Webhooks
Receive signed sale, payment, and redelivery events from your store, verify them, and handle retries.
Webhooks let your service hear about events in your store as they happen. When a sale completes at one of your vendors or on the Second Life Marketplace, a payment terminal receives L$, or a redelivery of one of your products is recorded, Allomancy sends a signed JSON POST to a URL you register. These outbound webhooks are separate from Discord notifications, which post sale messages to a Discord channel.
You manage subscriptions through the API, as described in Managing webhooks with the API. In Allomancy Stores, the store owner can also manage them on the Webhooks tab of the Integrations page; see Integrations. This page covers the receiving side: what arrives, how to verify it, and how retries work.
Events
| Event type | Description | Selectable |
|---|---|---|
sale.completed | "A sale completed at one of your vendors." | Yes |
payment.received | "A payment terminal received L$." | Yes |
marketplace.sale.completed | "A sale completed on the Second Life Marketplace." | Yes |
redelivery.completed | "A product was redelivered to a customer." | Yes |
marketplace.redelivery.completed | "A Second Life Marketplace redelivery was recorded." | Yes |
test.ping | "A test sent from the dashboard or the API." | No |
sale.completed fires for inworld sales of the Purchase and Gacha types that were not imported. marketplace.sale.completed covers the Second Life Marketplace purchases that Allomancy records from your store's Marketplace notifications (ANS), free (L$0) purchases included; it is never sent for a redelivery or any other notification type. sale.completed never carries Marketplace sales, so a subscription receives them only if it selects marketplace.sale.completed.
redelivery.completed is sent when Allomancy records a redelivery of one of your products to a customer. That happens when:
- a customer redelivers the product from your customer page, including a customer page that uses a customer token from the Developer API;
- Allomancy support redelivers it to the customer;
- a customer buys, for themselves, a Copy product they already own while Refund duplicate purchases is on in Store Settings, and the product is redelivered automatically. When someone buys it as a gift for another resident, or a recent transaction for that product and customer is still pending, nothing is redelivered and no event is sent.
A redelivery request that is refused records nothing and sends nothing. Products you deliver yourself, from a customer's profile, the Sales page, or the Products page in Allomancy Stores, are deliveries, not redeliveries, so they do not send redelivery.completed; neither does a mass redelivery. Requesting a System Redelivery of the Allomancy system box does not send an event to your subscriptions.
marketplace.redelivery.completed is sent for each Marketplace notification Allomancy records for your store whose type is anything other than a purchase; such a notification is recorded as a redelivery. Both redelivery events are opt-in: no existing subscription receives them until it selects them.
A redelivery event means Allomancy recorded the redelivery. It does not confirm that the item arrived: the delivery can still fail, and the event can reach you before or after the item does.
test.ping cannot be selected on a subscription; it is sent only when you send a test event.
Which events a subscription receives:
- Only events that occur at or after the subscription was created. Earlier events are never sent to it.
- Only while the subscription is active. Allomancy checks that the subscription is active when it processes the event, and checks again before each attempt, each time against the subscription's current state.
- Only the event types it selects. The selection is checked at the same two points, also against the current state. A delivery waiting to be retried is dropped if, when its retry comes due, the subscription is still inactive or still does not select its event type; enable the subscription or select the type again before then, and the retry goes ahead. A
test.pingis exempt from these two checks and is delivered to an inactive subscription too. A delivery to a deleted subscription is always dropped, atest.pingincluded.
The event envelope
Every event is a JSON object with a metadata block and a data block, sent with Content-Type: application/json; charset=utf-8:
| Field | Type | Notes |
|---|---|---|
metadata.eventId | string (UUID) | The event's unique id, in lowercase. The same value arrives in the webhook-id header. |
metadata.eventType | string | The event type, such as sale.completed. |
metadata.version | string | The payload version, currently "1". |
metadata.timestamp | string | When the event occurred (UTC). |
data | object | The event's fields, described below for each event type. |
How payloads are written:
- Field names are camelCase.
- Enum values are strings, such as
"Purchase". This differs from the REST endpoints, which return enums as integers. - Money fields are integers in
L$. - Timestamps are ISO 8601 in UTC, ending in
Z, usually with fractional seconds. - A missing value is an explicit
null; a field is never left out. - New fields may be added to a payload over time without a version change. Ignore fields you do not recognize.
Names in a payload, such as the product name, the Marketplace listing name, and the residents' usernames, are the values at the time of the event. For a sale.completed event, if the product is renamed later, GET /v1/sales shows the current name, not the one in the event you received.
sale.completed
| Field | Type | Notes |
|---|---|---|
transactionId | string (UUID) | The sale's id. It matches id in GET /v1/sales. |
type | string | The sale type: Purchase or Gacha. |
date | string | When the sale completed (UTC). The same instant as metadata.timestamp. |
location | string or null | Where the sale happened, as reported inworld, or null. |
product.id | string (UUID) | The product's UUID. |
product.name | string | The product's name. |
product.image | string (UUID) or null | The product image texture UUID, or null when the product has none. |
payer.uuid | string (UUID) | The Second Life UUID of the resident who paid. |
payer.username | string | The Second Life username of the resident who paid. |
receiver.uuid | string (UUID) | The Second Life UUID of the resident who received the product. It differs from the payer for a gift. |
receiver.username | string | The Second Life username of the resident who received the product. |
payment | integer | The amount paid, in L$. |
net | integer | The sale's net income, in L$. |
usedCredit | integer | The store credit spent on the sale, in L$. |
coupon | string or null | The code of the coupon used, or null when no coupon was used. |
couponDiscount | integer | The coupon's discount percentage, or 0 when no coupon was used. |
customerDiscount | integer | The customer discount percentage applied to the sale. |
{
"metadata": {
"eventId": "0192a6f4-3b1c-7d2e-8f40-5a6b7c8d9e0f",
"eventType": "sale.completed",
"version": "1",
"timestamp": "2026-10-21T14:03:05.4182736Z"
},
"data": {
"transactionId": "6f1d2c3b-4a5e-4f60-8b7c-9d0e1f2a3b4c",
"type": "Purchase",
"date": "2026-10-21T14:03:05.4182736Z",
"location": "Allomancy Plaza",
"product": {
"id": "3a1f0c9b-7d2e-4c84-9a6f-1b5e8d0c2a37",
"name": "Copper Lantern",
"image": null
},
"payer": {
"uuid": "c7d8e9f0-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
"username": "resident.username"
},
"receiver": {
"uuid": "c7d8e9f0-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
"username": "resident.username"
},
"payment": 250,
"net": 225,
"usedCredit": 0,
"coupon": null,
"couponDiscount": 0,
"customerDiscount": 0
}
}payment.received
| Field | Type | Notes |
|---|---|---|
paymentId | string (UUID) | The payment's id. |
date | string | When the payment was received (UTC). The same instant as metadata.timestamp. |
location | string or null | Where the payment happened, as reported inworld, or null. |
vendorId | string (UUID) | The UUID of the payment terminal that received the payment. |
vendorName | string | The payment terminal's name. |
payer.uuid | string (UUID) | The Second Life UUID of the resident who paid. |
payer.username | string | The Second Life username of the resident who paid. |
amount | integer | The amount paid, in L$. |
net | integer | The payment's net amount, in L$. |
{
"metadata": {
"eventId": "0192a6f9-8e2d-7a41-b3c5-d6e7f8091a2b",
"eventType": "payment.received",
"version": "1",
"timestamp": "2026-10-21T15:20:41.0937514Z"
},
"data": {
"paymentId": "8b9c0d1e-2f3a-4b5c-8d6e-7f8091a2b3c4",
"date": "2026-10-21T15:20:41.0937514Z",
"location": "Allomancy Plaza",
"vendorId": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"vendorName": "Tip jar",
"payer": {
"uuid": "c7d8e9f0-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
"username": "resident.username"
},
"amount": 100,
"net": 100
}
}marketplace.sale.completed
| Field | Type | Notes |
|---|---|---|
transactionId | string (UUID) | Allomancy's id for this sale line. |
marketplaceTransactionId | string (int64) | Linden Lab's id for the Marketplace checkout: a 64-bit integer (long) sent as a JSON string. Every item of one Marketplace cart has the same value. |
type | string | Always Purchase. |
date | string | When Allomancy recorded the sale (UTC). The same instant as metadata.timestamp. |
listing | object | The Marketplace listing of the item sold. Always present, never null. |
listing.id | string (int64) | The Marketplace item id: a 64-bit integer (long) sent as a JSON string. The REST API returns the same id in the mpListing of the linked product, where it is an integer (int64), not a string. |
listing.name | string | The item name the Marketplace sent with this notification. |
product | object or null | The Allomancy product of your store that the listing is linked to at the time of the event, read after Allomancy's automatic name matching has run for this sale. A paused or deleted product that is still linked is included. null when the listing is not linked to a product of your store; written as null, never left out. |
product.id | string (UUID) | The product's UUID. |
product.name | string | The product's name. |
product.image | string (UUID) or null | The product image texture UUID, or null when the product has none. This differs from product itself being null. |
payer | object | Always present, never null. |
payer.uuid | string (UUID) | The Second Life UUID of the resident who made the purchase. |
payer.username | string | The Second Life username of the resident who made the purchase. |
receiver | object | Always present, never null. |
receiver.uuid | string (UUID) | The Second Life UUID of the resident who received the item. |
receiver.username | string | The Second Life username of the resident who received the item. |
payment | integer | The amount paid, in L$. 0 for a free purchase. |
fee | integer | The Marketplace fee, in L$. |
net | integer | payment minus fee, in L$. |
marketplaceTransactionId and listing.id are positive 64-bit integers (whole numbers; long in C# and Java), sent as JSON strings that contain only the digits 0 to 9. They are strings because a JavaScript Number cannot safely represent every integer above Number.MAX_SAFE_INTEGER (9007199254740991). Store them as strings. In JavaScript, keep the string, or use BigInt(id) when you need an integer; never Number(id) or parseInt(id). In C#, use long.Parse(id, CultureInfo.InvariantCulture).
The payload has no location, vendor, coupon, store credit, or discount fields.
{
"metadata": {
"eventId": "0192a712-6c4d-7e8f-9a0b-1c2d3e4f5a6b",
"eventType": "marketplace.sale.completed",
"version": "1",
"timestamp": "2026-10-21T16:12:47.2093581Z"
},
"data": {
"transactionId": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
"marketplaceTransactionId": "1182736455",
"type": "Purchase",
"date": "2026-10-21T16:12:47.2093581Z",
"listing": {
"id": "24519873",
"name": "Copper Lantern (boxed)"
},
"product": {
"id": "3a1f0c9b-7d2e-4c84-9a6f-1b5e8d0c2a37",
"name": "Copper Lantern",
"image": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
},
"payer": {
"uuid": "c7d8e9f0-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
"username": "resident.username"
},
"receiver": {
"uuid": "c7d8e9f0-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
"username": "resident.username"
},
"payment": 250,
"fee": 25,
"net": 225
}
}When the listing is not linked to a product of your store, the payload carries "product": null.
These three ids identify different things:
transactionIdidentifies this one sale line. It is Allomancy's id.marketplaceTransactionIdis Linden Lab's id for the Marketplace checkout. Every item of one Marketplace cart shares it, so several events can carry the same value.metadata.eventId, also sent as thewebhook-idheader, identifies the event. Every retry of an event keeps it, so deduplicate repeated deliveries of one event on it, as described in Deduplicating events.
marketplace.sale.completed and marketplace.redelivery.completed events report the Marketplace notifications that your store's Marketplace integration delivered to Allomancy, authenticated with the Notification Salt stored on your store when you set up the Marketplace in Store Settings. They are not an independent confirmation by Linden Lab of a payment or a redelivery.
redelivery.completed
| Field | Type | Notes |
|---|---|---|
transactionId | string (UUID) | Allomancy's id for the recorded redelivery. |
type | string | Always Redelivery. |
date | string | When the event occurred (UTC), taken just after Allomancy recorded the redelivery. The same instant as metadata.timestamp. |
location | string or null | Where the redelivery was requested: "* Web *" from a customer page, "* Support *" from Allomancy support, or, for an automatic redelivery, the location of the purchase as reported inworld, or null. |
product | object | The product being redelivered. Always present, never null. |
product.id | string (UUID) | The product's UUID. |
product.name | string | The product's name. |
product.image | string (UUID) or null | The product image texture UUID, or null when the product has none. |
receiver | object | Always present, never null. |
receiver.uuid | string (UUID) | The Second Life UUID of the resident the product is being redelivered to. |
receiver.username | string | The Second Life username of the resident the product is being redelivered to. |
location says where the request was made, not who made it: a redelivery from your customer page and one from a customer page built on the Developer API both carry "* Web *". The payload has no payer, money, coupon, store credit, or discount fields.
{
"metadata": {
"eventId": "0192a725-9d1e-7f2a-8b3c-4d5e6f7a8b9c",
"eventType": "redelivery.completed",
"version": "1",
"timestamp": "2026-10-21T17:05:12.6730148Z"
},
"data": {
"transactionId": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
"type": "Redelivery",
"date": "2026-10-21T17:05:12.6730148Z",
"location": "* Web *",
"product": {
"id": "3a1f0c9b-7d2e-4c84-9a6f-1b5e8d0c2a37",
"name": "Copper Lantern",
"image": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
},
"receiver": {
"uuid": "c7d8e9f0-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
"username": "resident.username"
}
}
}marketplace.redelivery.completed
The data object is the same as for marketplace.sale.completed, with the same fields and types, so one parser reads both. For a redelivery, these differ from that table:
typeis alwaysRedelivery.transactionIdis Allomancy's id for the recorded Marketplace redelivery.marketplaceTransactionIdis the Linden Lab transaction id the notification carries: a 64-bit integer (long) sent as a JSON string.dateis when Allomancy recorded the redelivery (UTC), the same instant asmetadata.timestamp.listingis the Marketplace listing the notification names, andproductis the product of your store that the listing is linked to at the time of the event, ornullwhen the listing is not linked to a product of your store.payerandreceiverare the residents the notification names in those roles, each with their Second Life UUID and username.payment,fee, andnetare always0.
marketplaceTransactionId and listing.id are 64-bit integers sent as JSON strings, just as for a sale; marketplace.sale.completed explains how to read them.
{
"metadata": {
"eventId": "0192a731-2b3c-7d4e-9f5a-6b7c8d9e0f1a",
"eventType": "marketplace.redelivery.completed",
"version": "1",
"timestamp": "2026-10-22T09:41:03.1172064Z"
},
"data": {
"transactionId": "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c",
"marketplaceTransactionId": "1190452318",
"type": "Redelivery",
"date": "2026-10-22T09:41:03.1172064Z",
"listing": {
"id": "24519873",
"name": "Copper Lantern (boxed)"
},
"product": {
"id": "3a1f0c9b-7d2e-4c84-9a6f-1b5e8d0c2a37",
"name": "Copper Lantern",
"image": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
},
"payer": {
"uuid": "c7d8e9f0-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
"username": "resident.username"
},
"receiver": {
"uuid": "c7d8e9f0-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
"username": "resident.username"
},
"payment": 0,
"fee": 0,
"net": 0
}
}Each notification Allomancy records sends its own event. Two notifications that carry the same Linden Lab transaction id send two events, so several events can carry the same marketplaceTransactionId. Deduplicate only on webhook-id, as described in Deduplicating events.
test.ping
A test event carries a fixed data block with a single message field set to "ping". It is signed like any other event.
{
"metadata": {
"eventId": "0192a700-1f2e-7d3c-8b4a-5968778695a4",
"eventType": "test.ping",
"version": "1",
"timestamp": "2026-10-21T15:30:00.482913Z"
},
"data": {
"message": "ping"
}
}Request headers
Each delivery carries these headers:
| Header | Value |
|---|---|
Content-Type | application/json; charset=utf-8 |
webhook-id | The event's id, the same value as metadata.eventId. It stays the same on every retry of the event. |
webhook-timestamp | When this attempt was signed, in Unix seconds. |
webhook-signature | The signature: v1, followed by a base64 HMAC-SHA256 (see Verifying a webhook). |
x-allomancy-event-type | The event type, the same value as metadata.eventType. |
User-Agent | Allomancy-Webhooks/1 |
CF-Worker | allomancy.com |
A retry sends the same webhook-id and the same body, with a fresh webhook-timestamp and webhook-signature.
Further headers are added on the way, such as CF-Ray and CF-Connecting-IP. The addresses in them, and in an X-Real-IP header if one arrives, are Cloudflare addresses, not a fixed Allomancy address (see Receivers behind Cloudflare).
Verifying a webhook
Allomancy signs every delivery with your subscription's signing secret, following the Standard Webhooks specification. Verify each request before you trust it:
- Verify the raw body. Check the signature over the request body exactly as it arrived, as bytes, before you parse it. Parsing and re-serializing the JSON changes the bytes and breaks the signature.
- The signed content is
{webhook-id}.{webhook-timestamp}.{body}: the two header values and the raw body, joined by dots. - The key is your secret with its
whsec_prefix removed, base64-decoded to bytes. - The signature is the base64 HMAC-SHA256 of the signed content under that key, prefixed with
v1,. Thewebhook-signatureheader may carry several signatures separated by spaces; accept the request if anyv1,signature matches, and compare in constant time. - Reject old or future timestamps. Refuse a request whose
webhook-timestampis more than 300 seconds away from your clock. - Deduplicate on
webhook-id. The same event can arrive more than once (see Responding and retries).
With an official library
Standard Webhooks publishes official libraries for many languages, and using one is the simplest route. Each receiver below is a complete file: it listens on port 3000, accepts POST /allomancy/webhooks, and reads your secrets from two environment variables. ALLOMANCY_WEBHOOK_SECRET holds the full current secret, whsec_ prefix included, and the optional ALLOMANCY_WEBHOOK_SECRET_PREVIOUS holds the previous one during a rotation. A request that does not verify gets 400; a verified one gets 204, and so does a repeat of an event already processed. None of the libraries takes a list of secrets, so each receiver tries one verifier per secret.
Uses the standardwebhooks package with Flask. The library raises ValueError for a malformed webhook-signature header, so the receiver catches that too.
pip install standardwebhooks flask
python receiver.pyimport os
from flask import Flask, request
from standardwebhooks import Webhook, WebhookVerificationError
secret = os.environ.get("ALLOMANCY_WEBHOOK_SECRET")
if not secret:
raise SystemExit("Set ALLOMANCY_WEBHOOK_SECRET")
webhooks = [Webhook(secret)]
previous = os.environ.get("ALLOMANCY_WEBHOOK_SECRET_PREVIOUS")
if previous:
webhooks.append(Webhook(previous))
processed_ids = set() # Use your database in production.
app = Flask(__name__)
def verify(raw_body, headers):
for webhook in webhooks:
try:
return webhook.verify(raw_body, headers)
except (WebhookVerificationError, ValueError):
continue
return None
@app.post("/allomancy/webhooks")
def allomancy_webhook():
event = verify(request.get_data(), request.headers)
if event is None:
return "", 400
event_id = request.headers["webhook-id"]
if event_id not in processed_ids:
# Store the event, then record its id: in production, in one step.
print(event_id, event["metadata"]["eventType"])
processed_ids.add(event_id)
return "", 204
if __name__ == "__main__":
app.run(port=3000)Uses the standardwebhooks package in a plain Node.js server. Save the file as receiver.mjs.
npm install standardwebhooks
node receiver.mjsimport { createServer } from "node:http";
import { Webhook } from "standardwebhooks";
const secret = process.env.ALLOMANCY_WEBHOOK_SECRET;
if (!secret) {
throw new Error("Set ALLOMANCY_WEBHOOK_SECRET");
}
const webhooks = [new Webhook(secret)];
const previous = process.env.ALLOMANCY_WEBHOOK_SECRET_PREVIOUS;
if (previous) {
webhooks.push(new Webhook(previous));
}
const processedIds = new Set(); // Use your database in production.
function verify(rawBody, headers) {
for (const webhook of webhooks) {
try {
return webhook.verify(rawBody, headers);
} catch {
// Try the next secret.
}
}
return undefined;
}
createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/allomancy/webhooks") {
res.writeHead(404).end();
return;
}
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const event = verify(Buffer.concat(chunks), req.headers);
if (event === undefined) {
res.writeHead(400).end();
return;
}
const eventId = req.headers["webhook-id"];
if (!processedIds.has(eventId)) {
// Store the event, then record its id: in production, in one step.
console.log(eventId, event.metadata.eventType);
processedIds.add(eventId);
}
res.writeHead(204).end();
});
}).listen(3000);Uses the standard-webhooks/standard-webhooks Composer package and PHP 8. PHP's built-in server runs the script for every request, and the script answers 404 to any path but /allomancy/webhooks. The library looks headers up by their lowercase names, so the receiver lowercases them first. It also accepts an empty key, so the receiver answers 500 to every request until a usable secret is set; Allomancy retries a 500.
composer require standard-webhooks/standard-webhooks
php -S 0.0.0.0:3000 receiver.php<?php
require __DIR__ . '/vendor/autoload.php';
use StandardWebhooks\Webhook;
// Keep PHP warnings out of the response.
ini_set('display_errors', '0');
$current = getenv('ALLOMANCY_WEBHOOK_SECRET');
$previous = getenv('ALLOMANCY_WEBHOOK_SECRET_PREVIOUS');
$secrets = ($previous === false || $previous === '') ? [$current] : [$current, $previous];
foreach ($secrets as $secret) {
if (!is_string($secret) || !str_starts_with($secret, 'whsec_')
|| strlen((string) base64_decode(substr($secret, strlen('whsec_')), true)) < 24) {
http_response_code(500);
exit;
}
}
$webhooks = array_map(fn ($secret) => new Webhook($secret), $secrets);
if (parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) !== '/allomancy/webhooks') {
http_response_code(404);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
$body = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$verified = false;
foreach ($webhooks as $webhook) {
try {
$webhook->verify($body, $headers);
$verified = true;
break;
} catch (\Exception | \TypeError $e) {
// Try the next secret.
}
}
$event = $verified ? json_decode($body, true) : null;
if (!is_array($event)) {
http_response_code(400);
exit;
}
$eventId = $headers['webhook-id'];
// The marker file claims the id atomically; use your database in production.
$marker = sys_get_temp_dir() . '/allomancy-webhook-' . hash('sha256', $eventId);
$handle = @fopen($marker, 'x');
if ($handle === false) {
// An existing marker is a repeat. Any other failure gets 500, so Allomancy retries.
http_response_code(file_exists($marker) ? 204 : 500);
exit;
}
fclose($handle);
// Store the event here; in production, record its id in the same step.
error_log($eventId . ' ' . $event['metadata']['eventType']);
http_response_code(204);Without a library
If you cannot use a library, the check takes a few lines of standard-library code. Each receiver below implements every rule above, reads the same two environment variables, and listens the same way. The Python and JavaScript receivers turn each whsec_ secret into its key once, at startup, and refuse to start with a malformed one. PHP runs the script anew for each request, so the PHP receiver checks its secrets on every request and answers 500 while one is malformed.
verify_webhook() accepts a list of keys and matches header names without regard to case. Flask is the only dependency.
pip install flask
python receiver.pyimport base64
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
TOLERANCE_SECONDS = 300
class InvalidWebhook(Exception):
pass
def signing_key(secret: str) -> bytes:
if not secret.startswith("whsec_"):
raise ValueError("A signing secret starts with whsec_")
encoded = secret[len("whsec_"):]
key = base64.b64decode(encoded, validate=True)
if base64.b64encode(key).decode() != encoded or not 24 <= len(key) <= 64:
raise ValueError("A signing secret is whsec_ and 24 to 64 bytes in padded base64")
return key
def verify_webhook(raw_body: bytes, headers, keys: list[bytes]) -> dict:
headers = {name.lower(): value for name, value in headers.items()}
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not msg_id or not timestamp or not signature_header:
raise InvalidWebhook("missing webhook headers")
try:
sent_at = int(timestamp)
except ValueError:
raise InvalidWebhook("invalid webhook-timestamp")
if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
raise InvalidWebhook("timestamp outside the tolerance")
signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
received = [
part[len("v1,"):].encode()
for part in signature_header.split(" ")
if part.startswith("v1,")
]
for key in keys:
digest = hmac.new(key, signed_content, hashlib.sha256).digest()
expected = base64.b64encode(digest)
for candidate in received:
if hmac.compare_digest(expected, candidate):
return json.loads(raw_body)
raise InvalidWebhook("no matching signature")
KEYS = [signing_key(os.environ.get("ALLOMANCY_WEBHOOK_SECRET", ""))]
if os.environ.get("ALLOMANCY_WEBHOOK_SECRET_PREVIOUS"):
KEYS.append(signing_key(os.environ["ALLOMANCY_WEBHOOK_SECRET_PREVIOUS"]))
processed_ids = set() # Use your database in production.
app = Flask(__name__)
@app.post("/allomancy/webhooks")
def allomancy_webhook():
try:
event = verify_webhook(request.get_data(), request.headers, KEYS)
except (InvalidWebhook, ValueError):
return "", 400
event_id = request.headers["webhook-id"]
if event_id not in processed_ids:
# Store the event, then record its id: in production, in one step.
print(event_id, event["metadata"]["eventType"])
processed_ids.add(event_id)
return "", 204
if __name__ == "__main__":
app.run(port=3000)Uses only node:http and node:crypto. Save the file as receiver.mjs.
node receiver.mjsimport { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
function signingKey(secret) {
if (typeof secret !== "string" || !secret.startsWith("whsec_")) {
throw new Error("A signing secret starts with whsec_");
}
const encoded = secret.slice("whsec_".length);
const key = Buffer.from(encoded, "base64");
if (key.toString("base64") !== encoded || key.length < 24 || key.length > 64) {
throw new Error("A signing secret is whsec_ and 24 to 64 bytes in padded base64");
}
return key;
}
const keys = [signingKey(process.env.ALLOMANCY_WEBHOOK_SECRET)];
if (process.env.ALLOMANCY_WEBHOOK_SECRET_PREVIOUS) {
keys.push(signingKey(process.env.ALLOMANCY_WEBHOOK_SECRET_PREVIOUS));
}
function verifyWebhook(rawBody, headers) {
const msgId = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatureHeader = headers["webhook-signature"];
if (!msgId || !timestamp || !signatureHeader || !/^\d+$/.test(timestamp)) {
return false;
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return false;
}
const received = signatureHeader
.split(" ")
.filter((part) => part.startsWith("v1,"))
.map((part) => Buffer.from(part.slice("v1,".length)));
for (const key of keys) {
const expected = Buffer.from(
createHmac("sha256", key)
.update(`${msgId}.${timestamp}.`)
.update(rawBody)
.digest("base64"),
);
for (const candidate of received) {
// timingSafeEqual throws on buffers of different lengths.
if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) {
return true;
}
}
}
return false;
}
const processedIds = new Set(); // Use your database in production.
createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/allomancy/webhooks") {
res.writeHead(404).end();
return;
}
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks);
let event;
try {
if (!verifyWebhook(rawBody, req.headers)) {
throw new Error("Invalid webhook");
}
event = JSON.parse(rawBody.toString("utf8"));
} catch {
res.writeHead(400).end();
return;
}
const eventId = req.headers["webhook-id"];
if (!processedIds.has(eventId)) {
// Store the event, then record its id: in production, in one step.
console.log(eventId, event.metadata.eventType);
processedIds.add(eventId);
}
res.writeHead(204).end();
});
}).listen(3000);Uses only PHP 8 itself, with no Composer package. PHP's built-in server runs the script for every request, and the script answers 404 to any path but /allomancy/webhooks.
php -S 0.0.0.0:3000 receiver.php<?php
// Keep PHP warnings out of the response.
ini_set('display_errors', '0');
const TOLERANCE_SECONDS = 300;
function signing_key(string|false $secret): ?string
{
if (!is_string($secret) || !str_starts_with($secret, 'whsec_')) {
return null;
}
$encoded = substr($secret, strlen('whsec_'));
$key = base64_decode($encoded, true);
if ($key === false || base64_encode($key) !== $encoded || strlen($key) < 24 || strlen($key) > 64) {
return null;
}
return $key;
}
function verify_webhook(string $body, array $headers, array $keys): bool
{
$msgId = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatureHeader = $headers['webhook-signature'] ?? '';
if ($msgId === '' || $signatureHeader === '' || !ctype_digit($timestamp)) {
return false;
}
if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$signedContent = "{$msgId}.{$timestamp}.{$body}";
foreach ($keys as $key) {
$expected = base64_encode(hash_hmac('sha256', $signedContent, $key, true));
foreach (explode(' ', $signatureHeader) as $part) {
if (str_starts_with($part, 'v1,') && hash_equals($expected, substr($part, strlen('v1,')))) {
return true;
}
}
}
return false;
}
$keys = [signing_key(getenv('ALLOMANCY_WEBHOOK_SECRET'))];
$previous = getenv('ALLOMANCY_WEBHOOK_SECRET_PREVIOUS');
if ($previous !== false && $previous !== '') {
$keys[] = signing_key($previous);
}
if (in_array(null, $keys, true)) {
http_response_code(500);
exit;
}
if (parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) !== '/allomancy/webhooks') {
http_response_code(404);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
$body = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$event = verify_webhook($body, $headers, $keys) ? json_decode($body, true) : null;
if (!is_array($event)) {
http_response_code(400);
exit;
}
$eventId = $headers['webhook-id'];
// The marker file claims the id atomically; use your database in production.
$marker = sys_get_temp_dir() . '/allomancy-webhook-' . hash('sha256', $eventId);
$handle = @fopen($marker, 'x');
if ($handle === false) {
// An existing marker is a repeat. Any other failure gets 500, so Allomancy retries.
http_response_code(file_exists($marker) ? 204 : 500);
exit;
}
fclose($handle);
// Store the event here; in production, record its id in the same step.
error_log($eventId . ' ' . $event['metadata']['eventType']);
http_response_code(204);Deduplicating events
Each receiver skips a webhook-id it has already processed, at the lines marked "record its id". In your own receiver, record the id in the same step that stores the event, then answer and do any slow work afterwards.
Every subscription that selects an event gets its own delivery of it, with the same webhook-id. If several subscriptions point at one receiver, include the subscription in the key you deduplicate on, for example by giving each subscription its own URL path and deduplicating on the path and the webhook-id together.
Responding and retries
Answer with any 2xx status to accept an event. Allomancy stops waiting less than 8 seconds after an attempt starts, and that time also covers its own checks before connecting to you, so verify the request, store the event, answer, and do any slow work afterwards.
Delivery is at least once. If your endpoint accepts an event but the acknowledgement is lost, or an attempt runs out of time after your endpoint already processed it, the event is sent again with the same webhook-id. Keep a record of the ids you have processed and skip repeats (see Deduplicating events).
Events can arrive in any order: deliveries to different subscriptions are sent at the same time, and each retry is scheduled on its own. Do not depend on the order in which events arrive.
How Allomancy treats each outcome:
| Outcome | Retried | Recorded as a failure (kind / code) |
|---|---|---|
2xx | No: delivered | Not a failure. Resets the subscription's failure count. |
408, 425, 429, or 5xx other than 501 and 505 | Yes, honoring Retry-After | http / http_status, once the retries run out |
3xx | No: redirects are not followed | http / redirect_refused, at once |
Any other 4xx, 501, or 505 | No | http / http_status, at once |
| No answer in time | Yes | timeout / timeout, once the retries run out |
| The connection could not be completed | Yes | network / connection_failed, once the retries run out |
| The host name could not be found | Yes | network / dns_failure, once the retries run out |
| The URL or its destination is not allowed | No | validation / invalid_url or destination_refused, at once |
| The signing secret is not valid | No | validation / invalid_secret, at once |
Each attempt is sent to the subscription's current URL, so a URL change also applies to deliveries waiting to be retried. Each delivery is signed with the secret that was in force when the event was queued for the subscription (see Signing secrets and rotation).
Problems on Allomancy's side, such as an outage of its delivery service or a failure of its own DNS lookups, are retried on the same schedule but never count against your subscription and never appear in its delivery failures.
Retry schedule
After a retryable outcome, the next attempt waits:
| After attempt | Next attempt in |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 30 minutes |
| 5 | 1 hour |
| 6 | 2 hours |
| 7 | 3 hours |
| 8 | 4 hours |
| 9 | 5 hours |
| 10 | 6 hours |
A valid Retry-After header on a retryable response (a number of seconds or an HTTP date) replaces the scheduled wait, kept between 1 second and 6 hours. A malformed or past value is ignored.
Retries stop, and the event is recorded as one failure, when either of these comes first:
- Attempt 11 fails.
- The next attempt would fall at or after the 20-hour deadline, which runs from the moment the event was queued for your subscription. No attempt is made after the deadline.
Automatic disabling
Each event that ends in a failure adds one to the subscription's consecutiveFailures, and a successful delivery resets it to 0. When the count reaches 10 on an active subscription, the subscription is disabled: its status becomes disabledByDeliveryFailures, disabledAt records when, and Allomancy tries once to tell the store owner in an inworld IM.
To resume deliveries, fix the endpoint and enable the subscription. Enabling resets consecutiveFailures to 0 and keeps the failure history. Enabling a subscription that is already active changes nothing and does not reset the count.
To backfill the sale.completed events a subscription did not receive while it was disabled, list that period with GET /v1/sales, using createdFrom and createdTo with includeDeletedSales=true and includeDeletedProducts=true, and skip any sale whose id you already stored as a transactionId.
Delivery failures
Failed events are recorded with their kind, the HTTP status your endpoint answered with (if any), a diagnostic code, and a short explanation. Test events and problems on Allomancy's side are never recorded. Records are kept for 30 days. The List delivery failures endpoint returns them, with the full list of codes.
The failure list is not a complete delivery log: in rare cases a failed event is not recorded. For sale.completed, Reconciling with GET /v1/sales finds the sales behind those events too.
Allomancy delivers from Cloudflare's network. When Cloudflare cannot reach your server, it can answer on your server's behalf with one of the statuses below, and that answer is what the failure records. The same statuses come back when your receiver sits behind your own Cloudflare zone and Cloudflare cannot reach your origin. Either way, Cloudflare answered because it could not reach your server, which is why an unreachable server can be recorded as an http failure rather than a network one. Such a failure has failureKind http, diagnosticCode http_status, and the status in responseStatus, for example 522 with the error The endpoint answered with HTTP 522.
responseStatus | What happened | What to check |
|---|---|---|
521 | Your server refused the connection. | The server is running, listening on the URL's port, and its firewall allows connections from Cloudflare. |
522 | The connection timed out. | The server is reachable on the URL's port, and no firewall or rate limit is dropping connections from Cloudflare. |
526 | Your server's TLS certificate was rejected: expired, self-signed, or issued for another host name. | The certificate is unexpired, issued for the URL's host name by a trusted certificate authority, and served with its full chain. |
530 | The host name could not be resolved, or it resolved to an address Cloudflare will not connect to, such as a private address. | The host name resolves, from anywhere, to a public address. |
These statuses follow the retryable 5xx row of the outcome table: retried, and recorded once the retries run out. Each recorded failure counts toward automatic disabling.
Signing secrets and rotation
You choose each subscription's signing secret and send it when you create the subscription. A secret is whsec_ followed by the standard base64 encoding, with padding, of 24 to 64 random bytes. To generate one with 32 bytes:
echo "whsec_$(openssl rand -base64 32)"Keep the secret on your receiver. The API never returns it, so store your copy safely.
To rotate a secret, update the subscription with a new signingSecret. Events queued after the update are signed with the new secret; deliveries already queued, including their retries, keep the old one. Accept both secrets for 24 hours after rotating, then drop the old one. Every receiver under Verifying a webhook already does this: set ALLOMANCY_WEBHOOK_SECRET to the new secret and ALLOMANCY_WEBHOOK_SECRET_PREVIOUS to the old one, and remove ALLOMANCY_WEBHOOK_SECRET_PREVIOUS after 24 hours.
Destination URL requirements
A subscription's URL is checked when you add the subscription or change its URL, and again before every attempt. Any other save, such as an update that keeps the URL, still checks every rule below except Public addresses only, which needs a host name lookup:
httpsorhttp. Anhttpsendpoint must present a valid certificate.- A host name, not an IP address. IPv4 and IPv6 addresses are refused in every spelling. See Hosting on a small server if your receiver has no domain name.
- Any port from 1 to 65535.
- At most 2048 characters, with no spaces or control characters, no
#fragment, and no user name or password. - A host name with at least one dot and no trailing dot. It cannot be, or end in,
allomancy.com,workers.dev,pages.dev,localhost,local,internal,test,invalid, orexample. - An ASCII host name. Internationalized host names are refused, whether written in Unicode or in punycode: any part of the host name that begins with
xn--is not accepted. - Public addresses only. Every IPv4 and IPv6 address the host name resolves to must be public. A host name that resolves to any address in these ranges is refused: IPv4
0.0.0.0/8,10.0.0.0/8,100.64.0.0/10,127.0.0.0/8,169.254.0.0/16,172.16.0.0/12,192.0.0.0/24,192.0.2.0/24,192.88.99.0/24,192.168.0.0/16,198.18.0.0/15,198.51.100.0/24,203.0.113.0/24, and224.0.0.0and above; IPv6 anything outside2000::/3, plus2001::/23,2001:db8::/32,2002::/16, and3fff::/20. An IPv4-mapped IPv6 address is judged as its IPv4 address.
When you add a subscription or change its URL, a host name that cannot be found is rejected with a 400. At delivery time it is retried and recorded as dns_failure if it never resolves.
An http URL is accepted, but the payloads, including sale details and residents' names, can be read in transit. A subscription with an http URL has insecureTransport set to true. Use https when you can.
Hosting on a small server
If your receiver runs on a server with a public IP address but no domain name, use a name that resolves to that address. sslip.io answers for names of the form <your-ip-with-dashes>.sslip.io, where <your-ip-with-dashes> is a placeholder for your server's own public IPv4 address written with dashes instead of dots. Get a Let's Encrypt certificate for that name and serve your receiver over https on it.
Another option is a Cloudflare Tunnel, which gives your receiver a host name on your own Cloudflare domain without opening a port. A receiver reached through Cloudflare also needs the settings in the next section.
Receivers behind Cloudflare
Allomancy delivers webhooks from Cloudflare's network. Deliveries have no fixed source IP address you could allowlist, and every request carries CF-Worker: allomancy.com.
If your receiver sits behind Cloudflare and your zone has a rule that blocks Worker subrequests, exempt requests whose cf.worker.upstream_zone equals allomancy.com ahead of that block. Match on this field rather than on the CF-Worker header, which Cloudflare adds after its rules run. For example, a custom rule that blocks Worker subrequests from every zone but your own:
(not cf.worker.upstream_zone in {"" "your-zone.com"})becomes:
(not cf.worker.upstream_zone in {"" "your-zone.com" "allomancy.com"})Cloudflare proxies only certain ports, so a receiver behind Cloudflare must use one of them:
- HTTPS: 443, 2053, 2083, 2087, 2096, 8443
- HTTP: 80, 8080, 8880, 2052, 2082, 2086, 2095
Redirects are not followed
A 3xx answer fails the event at once with redirect_refused; the redirect is never followed. A Google Apps Script web app that responds through its Content service answers with a redirect to a one-time script.googleusercontent.com URL, which Allomancy does not follow, so deliveries to it fail as redirect_refused. The script has already run by the time Google answers with that redirect, so it acts on the event even though the delivery is recorded as a counted failure; Apps Script web apps are therefore not supported as receivers.
Capture is best effort
Delivery is at least once from the moment an event is queued for delivery, but getting it into that queue is best effort. In rare cases, if Allomancy fails to queue an event right after the sale or payment completes or the redelivery is recorded, that event is lost: it is never delivered, never retried, and does not appear in your delivery failures.
To catch a sale.completed you never received, reconcile with GET /v1/sales. payment.received, marketplace.sale.completed, and marketplace.redelivery.completed have no equivalent listing in the API, so a lost one cannot be found through the API.