Shared building blocks · 6 of 15
Build a webhook receiver
Receive Snapcaster's signed events, answer each one correctly, and release the hold on sold units.
Every subscription needs an HTTP endpoint that receives Snapcaster’s webhook events. Your receiver verifies each delivery, stores each event once, moves stock for the events your subscription names, and answers so that Snapcaster retries only the deliveries you did not save. The page ends with a complete Node.js receiver.
Check that Snapcaster has your endpoint
Section titled “Check that Snapcaster has your endpoint”Snapcaster registers one webhook endpoint for your platform in each environment, with its own signing secret.
Events for every seller on your platform go to that URL, and data.seller_id names the seller.
To register or change it, follow Onboard your platform.
To check whether an endpoint is registered, call POST /webhooks/test with a seller’s key and a new UUID Idempotency-Key, because a reused key returns the earlier event_id and sends nothing.
HTTP 202 means it is, and a webhook.test is on its way.
HTTP 409 WEBHOOK_ENDPOINT_NOT_REGISTERED means it is not.
Events raised before Snapcaster registers your endpoint wait and go out once it does.
Use the signing secret as the HMAC key exactly as Snapcaster sent it, as UTF-8 text, without decoding it from hex or base64.
Answer each delivery
Section titled “Answer each delivery”Snapcaster sends only POST requests, each with a JSON body and two signature headers.
It counts any 2xx as delivered, and every other status, or no answer within 10 seconds, as a failed attempt to retry.
| What arrived | Answer | What Snapcaster does next |
|---|---|---|
| A valid new event | Save its effect and its event_id in one durable write, then answer 2xx | Marks it delivered. On an event that announced a sale, the 2xx acknowledges its stock |
A duplicate, or an event type you do not handle | Answer 2xx and change nothing | Marks it delivered |
| A correctly signed body that is not a usable event | Answer non-2xx, such as 400 | Retries it, so fix your parser and the next attempt or a replay delivers it |
| A missing, malformed or wrong signature, or a timestamp more than five minutes from your clock | Answer non-2xx, such as 401, and change nothing | Retries it |
| A valid event you could not save, such as when your database is down | Answer non-2xx, such as 500 | Retries it |
A body is not usable when it is not JSON, lacks a string event_id, a string type or an object data, or lacks a field your code needs.
Ignore fields you do not recognise.
The statuses 400, 401 and 500 are the sample’s choices, because Snapcaster treats every non-2xx the same.
Store each seller’s sellerId when you validate its key.
If an event names a seller you have not stored, answer non-2xx, so Snapcaster retries it instead of you acknowledging stock you did not move.
If real events fail verification, your receiver holds the wrong signing secret for this environment.
If they fail the timestamp check, sync your server’s clock with a time server.
Each retry is signed again, with a new X-Snapcaster-Timestamp over the same body.
Answer within 10 seconds. Save the event and its stock change, answer, and do slower work, such as printing labels, afterwards.
Retry schedule
Section titled “Retry schedule”Snapcaster retries a timeout or a non-2xx response for at least 24 hours.
The wait starts at 30 seconds and doubles up to one hour, with up to 20% random variation, so the last attempt comes 24 to about 30 hours after the first.
Time an event spends waiting for your endpoint to be registered does not count.
After the last attempt, Snapcaster holds the event.
Once your receiver works again, email info@snapcaster.gg to have the held events replayed.
A replay keeps the original event_id and payload, and gets a fresh 24-hour retry window.
Verify the raw body before parsing
Section titled “Verify the raw body before parsing”Verify the signature over the raw request body, and parse JSON only after it passes, because re-serializing a parsed body changes its bytes.
With node:http, join the request’s chunks with Buffer.concat, as the sample does.
With Express, mount express.raw({ type: 'application/json' }) on the webhook route, ahead of any express.json(), and read the bytes from req.body.
Webhook signing gives the headers, the signed message and the verification order.
Store each event once
Section titled “Store each event once”Snapcaster can deliver an event more than once, for example when your 2xx was lost on the way back.
Every retry and replay carries the same event_id, so deduplicate on it, never on a hash of the body, because a corrected guest email can change the body of an order.created or order.updated retry.
Save the event_id and the stock change it caused in one write, such as one database transaction, before you answer 2xx.
Saved separately, a crash between the two writes either applies the change again on the retry or loses it, depending on which write came first.
Answered before saving, a crash loses the event, and on a sale event the 2xx has already acknowledged stock you never deducted.
Events can arrive in any order, such as an order.cancelled before a retry of its order.created.
Apply each event’s units as it arrives, and the two net out once both are in.
Move stock from the right events
Section titled “Move stock from the right events”Stock change for each event gives the change each event makes for your subscription, which GET /me reports in subscription.
Store every other event and answer 2xx without changing stock.
Release the hold on sold units
Section titled “Release the hold on sold units”When a buyer buys, Snapcaster holds the sold units as committed, and the listing’s available stays lower until your platform reports stock that counts the sale.
These steps release the hold, and each one must finish before the next starts:
- Snapcaster sends the event that announced the sale, which Stock change for each event names for your subscription.
- Your platform saves the stock change and the
event_id, then answers2xx. - Snapcaster records the time your
2xxreached it as the acknowledgement of that sale. - Once your server has finished sending the response, your platform counts on-hand and sets
asOfto the time of that count. - Your platform pushes that listing snapshot, and Snapcaster releases the hold, because the snapshot’s
asOfis later than the acknowledgement.
A snapshot is a whole listing write, so send the SKU’s current price with its quantity, even when only the stock changed.
GET /listings returns the current price.
Snapcaster compares asOf with its own clock at the moment your 2xx arrived, so sync your server’s clock with a time server.
Your 2xx reaches Snapcaster a moment after you send it, and a snapshot stamped within that moment releases nothing.
After each push, read available and quantity with GET /listings.
If available is still below quantity and every sale event so far has your 2xx, count on-hand again and push a newer snapshot.
Each acknowledgement covers only the units its event announced.
Polling and reporting fulfillment acknowledge nothing, and a stale, conflicting or duplicate snapshot releases nothing.
A cancellation releases the hold on the units it cancels at once.
A refund releases none, with or without restock.
Without a registered endpoint nothing acknowledges a sale, so its hold stays until the events go out and the steps above run.
Run the sample receiver
Section titled “Run the sample receiver”The sample needs Node.js 18 or later and no packages, and answers each case the way the table in Answer each delivery says.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { open, readFile, rename } from 'node:fs/promises';
import { createServer } from 'node:http';
const SIGNING_SECRET = process.env.SNAPCASTER_SIGNING_SECRET;
const SUBSCRIPTION = process.env.SNAPCASTER_SUBSCRIPTION;
const PORT = Number(process.env.PORT ?? 3000);
const STATE_FILE = process.env.STATE_FILE ?? 'receiver-state.json';
const REPLAY_WINDOW_SECONDS = 300;
const API_VERSIONS = new Set(['2026-10']);
const moves = (stock, substitutions = []) => ({ stock, substitutions });
const adjusted = (data) => moves(data.lines.map((line) => [line.sku_id, line.delta]));
const substituted = (data, applied) => {
if (data.reason !== 'line_substituted') return moves([]);
if (!data.substitutions.every((substitution) => typeof substitution.id === 'string'))
throw new Error('substitution without an id');
const unapplied = data.substitutions.filter((substitution) => !applied[substitution.id]);
return moves(
unapplied.flatMap((substitution) =>
substitution.lines.flatMap((line) => [
[line.original_sku_id, line.quantity],
...(line.action === 'substitute' ? [[line.substitute_sku_id, -line.quantity]] : []),
])
),
unapplied.map((substitution) => substitution.id)
);
};
const STOCK_RULES = {
inventory: { 'inventory.adjusted': adjusted },
both: { 'inventory.adjusted': adjusted },
orders: {
'order.created': (data) => moves(data.lines.map((line) => [line.sku_id, -line.quantity])),
'order.cancelled': (data) => moves(data.lines.map((line) => [line.sku_id, line.quantity])),
'order.refunded': (data) => moves(data.restock ? data.lines.map((line) => [line.sku_id, line.quantity]) : []),
'order.updated': substituted,
},
};
if (!SIGNING_SECRET) throw new Error("Set SNAPCASTER_SIGNING_SECRET to your Platform's Signing secret");
const stockRules = STOCK_RULES[SUBSCRIPTION];
if (!stockRules) throw new Error('Set SNAPCASTER_SUBSCRIPTION to inventory, orders, or both, as GET /me reports');
async function loadState() {
try {
return JSON.parse(await readFile(STATE_FILE, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') return { stock: {}, events: {}, substitutions: {} };
throw error;
}
}
async function saveState(next) {
const temporary = `${STATE_FILE}.tmp`;
const file = await open(temporary, 'w');
try {
await file.writeFile(JSON.stringify(next, null, 2));
await file.sync();
} finally {
await file.close();
}
await rename(temporary, STATE_FILE);
}
function verify(rawBody, headers) {
const timestamp = headers['x-snapcaster-timestamp'];
const signature = headers['x-snapcaster-signature'];
if (typeof timestamp !== 'string' || !/^\d+$/.test(timestamp)) return 'missing or malformed timestamp';
if (typeof signature !== 'string' || !/^sha256=[0-9a-f]{64}$/.test(signature))
return 'missing or malformed signature';
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > REPLAY_WINDOW_SECONDS) return 'stale timestamp';
const expected = createHmac('sha256', SIGNING_SECRET).update(timestamp).update(rawBody).digest('hex');
if (!timingSafeEqual(Buffer.from(signature), Buffer.from(`sha256=${expected}`))) return 'signature mismatch';
return null;
}
function parseEvent(rawBody) {
let event;
try {
event = JSON.parse(rawBody.toString('utf8'));
} catch {
return null;
}
if (typeof event?.event_id !== 'string' || typeof event.type !== 'string') return null;
if (typeof event.data !== 'object' || event.data === null) return null;
if (!API_VERSIONS.has(event.api_version)) return null;
return event;
}
const isWhole = (value) => Number.isInteger(value);
function effectOf(event, applied) {
const rule = stockRules[event.type];
if (!rule) return moves([]);
try {
const effect = rule(event.data, applied);
return effect.stock.every(([skuId, delta]) => isWhole(skuId) && isWhole(delta)) ? effect : null;
} catch {
return null;
}
}
let state = await loadState();
async function record(event) {
if (state.events[event.event_id]) return [200, 'duplicate', event.event_id];
const applied = state.substitutions ?? {};
const effect = effectOf(event, applied);
if (!effect) return [400, 'signed body is not a usable event', event.event_id];
const stock = { ...state.stock };
for (const [skuId, delta] of effect.stock) stock[skuId] = (stock[skuId] ?? 0) + delta;
const substitutions = { ...applied, ...Object.fromEntries(effect.substitutions.map((id) => [id, event.event_id])) };
const received = { type: event.type, received_at: new Date().toISOString() };
const next = { stock, events: { ...state.events, [event.event_id]: received }, substitutions };
await saveState(next);
state = next;
return [200, effect.stock.length ? 'recorded' : 'recorded, no stock change', event.event_id];
}
let queue = Promise.resolve();
const serialized = (task) => {
const result = queue.then(task);
queue = result.catch(() => {});
return result;
};
async function handle(request) {
if (request.method !== 'POST') return [405, 'POST only'];
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const rawBody = Buffer.concat(chunks);
const refusal = verify(rawBody, request.headers);
if (refusal) return [401, refusal];
const event = parseEvent(rawBody);
if (!event) return [400, 'signed body is not a usable event'];
try {
return await serialized(() => record(event));
} catch (error) {
console.error(error);
return [500, 'could not save the event', event.event_id];
}
}
createServer(async (request, response) => {
const [status, outcome, eventId = null] = await handle(request);
console.log(JSON.stringify({ status, outcome, event_id: eventId }));
response.writeHead(status, { 'Content-Type': 'text/plain' }).end(outcome);
}).listen(PORT, () => console.log(`Listening on port ${PORT} for ${SUBSCRIPTION} events`));Start it with your platform’s signing secret, your subscription as GET /me reports it, and the port your registered URL reaches.
SNAPCASTER_SIGNING_SECRET='your-signing-secret' SNAPCASTER_SUBSCRIPTION=orders PORT=3000 node receiver.mjsThe sample keeps your stock and every saved event_id in receiver-state.json.
To start from your current stock, create the file before the first run with your on-hand quantity for each sku_id.
{ "stock": { "14602": 5 }, "events": {} }In your system, replace the file with one database transaction, and key stock by data.seller_id as well as sku_id when you serve several sellers.
Prove the receiver with a test event
Section titled “Prove the receiver with a test event”Call POST /webhooks/test with a seller’s key and a new UUID Idempotency-Key.
Snapcaster answers HTTP 202 with the event_id of the webhook.test it queued, which normally reaches your receiver within a few seconds.
The sample prints one line for each delivery it answers.
{"status":200,"outcome":"recorded, no stock change","event_id":"evt_5b0e7c2d9a4f41e8b3c6d1a07f2e9b58"}A matching event_id means your receiver verified the signature and answered 2xx.
If nothing arrives, check that the registered URL reaches your receiver from the internet and that the receiver holds this environment’s signing secret.
Snapcaster tries again about 30 seconds later.
Need help?
The email includes this page and selects sandbox as the environment. Update it if you need help with production.
Never include an API key or signing secret in the message.