Workflows
Webhooks
Accept signed webhook deliveries, verify their HMAC signature and freshness, and start work exactly once per delivery.
Many systems announce events with webhooks: a ticket was created, a payment settled, a build finished. Senders retry,
so the same delivery can arrive several times, and anyone who finds your URL can post to it. mayura/workstream/webhooks
handles both problems. It checks an HMAC-SHA256 signature and a timestamp window, validates the JSON body against your
schema, records the delivery durably, and runs your dispatch function once per delivery id. A repeat of a delivery
gets the recorded answer instead of starting anything new.
It is transport-neutral: you receive the HTTP request with your own server and hand Mayura the raw bytes. The usual
dispatch submits a durable workflow run.
A complete example
import { createServer } from 'node:http';
import { createSqliteStore } from 'mayura/storage-sqlite';
import { createWebhookRuntime, defineWebhookTrigger } from 'mayura/workstream/webhooks';
import { z } from 'mayura';
const store = createSqliteStore({ filename: 'webhooks.sqlite' });
await store.initialize();
const ticketCreated = z.object({ ticket: z.object({ id: z.string(), title: z.string() }) });
const trigger = defineWebhookTrigger({
id: 'tickets.created',
version: '1',
secretId: 'tracker',
schemaId: 'tickets.created.v1',
input: ticketCreated, // its JSON Schema's digest is pinned into every stored delivery
// Runs once per new delivery, after verification. `commandId` is stable for the delivery.
dispatch: async (event, { commandId }) => {
console.log('new ticket', event.ticket.id);
return { accepted: true, key: commandId };
},
});
const webhooks = createWebhookRuntime({
store,
scope: { principalId: 'tracker-ingress', projectId: 'support' },
resolveSecret: async () => new TextEncoder().encode(process.env.WEBHOOK_SECRET ?? ''),
});
createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(chunk as Buffer);
try {
const delivery = await webhooks.receive(trigger, {
deliveryId: String(request.headers['x-delivery-id']),
timestampMs: Number(request.headers['x-timestamp']),
signature: String(request.headers['x-signature']), // sha256=<hex>
body: new Uint8Array(Buffer.concat(chunks)),
});
response.writeHead(delivery.status === 'succeeded' ? 202 : 500).end();
} catch {
response.writeHead(401).end(); // map error codes properly in production; see below
}
}).listen(8081);Set WEBHOOK_SECRET to at least 16 bytes, and configure the same secret in the sending system. Size limits, error
mapping and concurrency limits are up to your HTTP handler; the
event-automation starter's ingress
is a production version.
The signature contract
The sender signs these bytes with HMAC-SHA256 and the shared secret:
<timestamp in ms>.<delivery id>.<raw body bytes>and sends the signature as sha256=<64 lowercase hex characters>. The timestamp and delivery id are inside the
signature, so neither can be changed without the secret. Header names are yours to choose; Mayura only sees the four
fields of receive. If your provider signs a different format, verify its signature yourself first; receive always
checks this format.
Pass the exact bytes you received. Parsing and re-serializing the JSON before verification changes the bytes and breaks the signature.
What receive checks, in order
- The request is well formed: a simple delivery id, a timestamp, a
sha256=signature, a body withinmaxBodyBytes. - The timestamp is within
maxClockSkewMsof the runtime's clock (5 minutes by default), so a captured request goes stale. - The signature matches, compared in constant time, using the secret from
resolveSecret. - The body is UTF-8 JSON that passes the trigger's
inputschema. - The delivery is recorded in the store, keyed by trigger and delivery id, and only then dispatched.
A request refused at any of these steps changes nothing, not even the delivery record.
Duplicates and retries
- A repeat of a delivery that already succeeded returns the same snapshot, with the same
output, and does not calldispatchagain. Answer it with the same success status so the sender stops retrying. - A retry may carry a fresh timestamp and signature. That is fine as long as the body is identical.
- The same delivery id with a different body is refused with
CONFLICT. - Pass
commandIdto whateverdispatchstarts, as its idempotency key. For a workflow, use it in theidempotencyKeyofsubmit, so even a dispatch retried after a crash finds the run it already started:
import type { WebhookDispatchContext } from 'mayura/workstream/webhooks';
// Use as the trigger's `dispatch`. `workflows` is a lifecycle fleet runtime, `intake` a workflow definition.
const dispatch = async (event: TicketCreated, { commandId }: WebhookDispatchContext) => {
const run = await workflows.submit(intake, { input: event, idempotencyKey: `delivery:${commandId}` });
return { runId: run.id };
};Delivery statuses
receive returns a snapshot with status:
| Status | Meaning | Typical HTTP answer |
|---|---|---|
succeeded |
dispatch returned; output holds its JSON result. |
202 |
outcome_unknown |
dispatch threw or timed out part-way. It is never retried automatically. |
500 |
dispatching |
Another request or process is dispatching this delivery right now, or crashed while doing so. | 503 with retry-after |
Errors thrown by receive carry a code: PERMISSION_DENIED (bad signature, stale timestamp or no secret),
INVALID_INPUT (malformed request or body that fails the schema), CONFLICT (delivery id reused with a different
body), LIMIT_EXCEEDED (too many callbacks in flight) and STORAGE_UNAVAILABLE. Map them to 401, 400, 409, 503 and
503.
A delivery stuck in dispatching because a process died stays that way. After checking whether its work happened,
call webhooks.recoverAbandoned(snapshot.id) to mark it outcome_unknown. inspect(id) and events(id) read a
delivery by the id in its snapshot.
Options
defineWebhookTrigger:
| Option | Meaning |
|---|---|
id, version |
The trigger's identity. Change version when the payload contract changes. |
secretId |
Passed to resolveSecret, so one runtime can serve several senders. |
schemaId, schemaDigest |
A name and a 64-hex SHA-256 that pin the payload contract into every stored delivery. Leave schemaDigest out to derive it from input when that validator can describe itself as JSON Schema (Zod 4.2 and later can); schemaDigest(jsonSchema) from mayura/workstream/webhooks computes it for any other. |
input |
The schema the parsed body must pass. dispatch receives the validated value. |
dispatch |
Your handler. Receives the input and { deliveryId, commandId, signal }, returns JSON. |
createWebhookRuntime:
| Option | Default | Meaning |
|---|---|---|
store, scope |
required | An initialized store and the scope deliveries are recorded under. |
resolveSecret |
required | Returns the secret bytes (16 to 4,096) for { triggerId, secretId, signal }. Read your secret manager here to rotate without a restart. |
maxClockSkewMs |
300,000 | Accepted distance between the delivery timestamp and now. At most 1 hour. |
maxBodyBytes |
1 MiB | Largest body accepted. At most 1 MiB. |
callbackTimeoutMs |
30,000 | Time limit for resolveSecret, schema validation and dispatch. |
maxPendingCallbacks |
32 | Callbacks in flight at once; beyond it, receive fails with LIMIT_EXCEEDED. |
now |
Date.now |
The clock, for tests. |
Good to know
- Deduplication is by delivery id. If a sender sends the same event under two ids,
dispatchruns twice; make its effects idempotent on a business key too. - Keep
dispatchshort: record the event or submit a workflow, and do the real work in the workflow. Anything that fails part-way insidedispatchbecomesoutcome_unknown. - Rate limiting per sender, TLS and routing belong in your server or proxy. Choose the trigger and scope from your route, never from the request body.