Skip to content
Mayura

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

ts
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

  1. The request is well formed: a simple delivery id, a timestamp, a sha256= signature, a body within maxBodyBytes.
  2. The timestamp is within maxClockSkewMs of the runtime's clock (5 minutes by default), so a captured request goes stale.
  3. The signature matches, compared in constant time, using the secret from resolveSecret.
  4. The body is UTF-8 JSON that passes the trigger's input schema.
  5. 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 call dispatch again. 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 commandId to whatever dispatch starts, as its idempotency key. For a workflow, use it in the idempotencyKey of submit, so even a dispatch retried after a crash finds the run it already started:
ts
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, dispatch runs twice; make its effects idempotent on a business key too.
  • Keep dispatch short: record the event or submit a workflow, and do the real work in the workflow. Anything that fails part-way inside dispatch becomes outcome_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.