Inbound webhooks
Verify a delivery before writing anything, commit the work with its tier-C idempotency record, and
answer 2xx quickly. Send heavy work through one of the side-effect paths below.
Verify before any work
Verify the provider’s signature over the raw bytes, before JSON parsing or validation
middleware. Use crypto.subtle.verify for cryptographic comparison rather than comparing signature
strings. Verify a signed timestamp against a tolerance too (five minutes here): an old captured
request must be refused even after its idempotency record has been purged. Never log the secret or
signature. The provider’s actual signing format takes precedence over this illustrative protocol.
This example protocol signs the ASCII timestamp in seconds, a dot, and the exact request bytes with
HMAC-SHA256. It carries a hex signature in webhook-signature and the timestamp in
webhook-timestamp. These are example protocol headers, not a particular provider’s API.
IRequest.bytes() supplies the raw body; no JSON or body-validation middleware should precede this
route. Import a verification-only HMAC key at startup using the secret from your secret manager and
runtime.subtle.importKey.
Bound the body read with RuntimePlugin({ maxBodyBytes }); the example uses 1 MiB and also refuses
an oversized declared Content-Length before bytes(). The runtime bound is still required when
the header is absent or understates the body size.
If global CSRF protection is enabled, exclude only /webhooks/provider from both session-plugin
form CSRF (csrf.exclude: ['/webhooks/provider']) and http-security’s
csrfMiddleware({ exclude: ['/webhooks/provider'] }). The verified signature is this route’s CSRF
defence. Keep CSRF enabled for the rest of the application; never disable it wholesale.
import { CAPABILITIES } from '@setu-ts/common';
import type { IIdempotencyService, IRuntimeServices } from '@setu-ts/common';
import type { IKernelApplication } from '@setu-ts/kernel';
import type { IUnitOfWork } from '@setu-ts/database-plugin';
import { RuntimePlugin } from '@setu-ts/runtime';
interface Delivery { id: string; type: string; }
const maxBodyBytes = 1024 * 1024;
export const webhookRuntime = RuntimePlugin({ maxBodyBytes });
export function registerWebhook(app: IKernelApplication, key: CryptoKey): void {
app.router.post('/webhooks/provider', async (ctx) => {
const runtime = ctx.services.get<IRuntimeServices>(CAPABILITIES.RUNTIME);
const declaredLength = ctx.request.headers.get('content-length');
if (declaredLength !== null && Number(declaredLength) > maxBodyBytes) {
return ctx.response.status(413).json({ error: 'Delivery too large' });
}
const stamp = ctx.request.headers.get('webhook-timestamp') ?? '';
const signature = ctx.request.headers.get('webhook-signature') ?? '';
const signedAt = Number(stamp) * 1000;
if (!/^\d{1,12}$/.test(stamp) || !/^[a-fA-F0-9]{64}$/.test(signature) ||
Math.abs(runtime.now() - signedAt) > 5 * 60 * 1000) {
return ctx.response.status(401).json({ error: 'Invalid delivery' });
}
const raw = await ctx.request.bytes();
const prefix = new TextEncoder().encode(`${stamp}.`);
const signed = new Uint8Array(prefix.length + raw.length);
signed.set(prefix);
signed.set(raw, prefix.length);
const mac = new Uint8Array(32);
for (let i = 0; i < mac.length; i++) mac[i] = Number.parseInt(signature.slice(i * 2, i * 2 + 2), 16);
if (!await runtime.subtle.verify('HMAC', key, mac, signed)) {
return ctx.response.status(401).json({ error: 'Invalid delivery' });
}
let parsed: unknown;
try { parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)); }
catch { return ctx.response.status(400).json({ error: 'Invalid event' }); }
if (typeof parsed !== 'object' || parsed === null ||
!('id' in parsed) || typeof parsed.id !== 'string' ||
!/^[\x21\x23-\x7e]{1,255}$/.test(parsed.id) ||
!('type' in parsed) || typeof parsed.type !== 'string') {
return ctx.response.status(400).json({ error: 'Invalid event' });
}
const event: Delivery = { id: parsed.id, type: parsed.type };
const idempotency = ctx.services.get<IIdempotencyService>(CAPABILITIES.IDEMPOTENCY);
const { value } = await idempotency.within<{ accepted: true }, IUnitOfWork>({
key: event.id,
namespace: 'webhooks.receive',
scope: 'webhook:provider',
fingerprint: { id: event.id, type: event.type },
}, async (uow) => {
// Apply this event's business changes through this same unit of work.
await uow.getRepository<Delivery>('WebhookReceipt').create(event);
return { accepted: true };
});
return ctx.response.status(200).json(value);
});
}
Register the bounded webhookRuntime above, DatabasePlugin, SchedulerPlugin and
IdempotencyPlugin({ transactional: { store: createDatabaseIdempotencyStore() } }), then start the
application and call registerWebhook with the verification key. The
tier-C README example
shows that composition; Workers instead need manual purge from a Cron Trigger. Install the outer
error handler so a retryable refusal reaches the provider as an
HTTP error.
De-duplicate with tier C
Use the provider’s event id as key; it must be 1–255 characters in 0x21–0x7E, excluding ",
so the example refuses invalid ids before within. scope is required and normally comes from
authenticated identity. Webhooks have no signed-in principal: the fixed webhook:provider scope
deliberately shares the verified provider’s events across deliveries. Use a distinct scope for each
provider/account whose event-id space is independent, and derive it from trusted endpoint
configuration rather than an unsigned request field. A concurrent duplicate can reject with a
retryable 409 before the winner commits; the provider should retry that non-2xx response.
Keep within’s ttlMs greater than twice the timestamp tolerance plus the allowed clock skew: the
same signed timestamp can be accepted both before and after its stated time. With this five-minute
tolerance, retain records for more than ten minutes plus skew. The default 86,400,000 ms (24 hours)
is sufficient here; the supported minimum of 60,000 ms is not.
Fingerprint stable semantic fields, here { id, type }, rather than the entire parsed body. Retry
metadata can change without changing the event. A changed fingerprint under the same key is refused
with 422 fingerprint-mismatch; repeatedly retrying that changed request will not fix the mismatch.
Include every field that determines your own business operation in its fingerprint.
Tier B’s idempotent() can answer a concurrent pair [200, 409] and does not commit the business
write with the idempotency record. Tier C commits both together; a committed duplicate returns the
stored result without running the callback. See
store guarantees.
Side effects: integration events or queue jobs
For an integration event, write the outbox row inside the within callback using the supplied
unit of work. This is valid only when both stores use the same database; a store on another database
receives a row its relay cannot see. Dispatch after commit. The event definition below is supplied
by the application, as in the
integration-event guide:
import type { IIdempotencyService } from '@setu-ts/common';
import type { IUnitOfWork } from '@setu-ts/database-plugin';
import type { IOutbox, IntegrationEventDefinition } from '@setu-ts/messaging-plugin';
export async function acceptAndPublish(
idempotency: IIdempotencyService,
outbox: IOutbox,
definition: IntegrationEventDefinition<{ id: string }>,
event: { id: string },
): Promise<void> {
await idempotency.within({ key: event.id, namespace: 'webhooks.publish', scope: 'webhook:provider' },
async (uow: IUnitOfWork) => {
await uow.getRepository('WebhookReceipt').create(event);
await outbox.write(uow, definition, event);
return { accepted: true };
});
outbox.dispatch();
}
The M107 outbox targets integration events, not IQueue. For a queue job, enqueue after commit
on every accepted delivery, including a replay: a crash after commit but before enqueue must be
recoverable by the provider’s retry. Make the job itself idempotent with idempotentIngress. Use
the event id in ctx.payload.data.id as its explicit key: ctx.payload is the whole IJob, and
its top-level id changes on every enqueue. The built-in 'auto' and 'job-id' key sources also
read that job id, so this recipe requires a custom key reading the event data:
import type { IIdempotencyService, IQueue } from '@setu-ts/common';
import type { IUnitOfWork } from '@setu-ts/database-plugin';
import { idempotentIngress } from '@setu-ts/idempotency-plugin';
import { QueuePlugin } from '@setu-ts/queue-plugin';
export const queuePlugin = QueuePlugin({
behaviors: [idempotentIngress({
jobNames: ['process-webhook'],
key: (ctx) => {
const job = ctx.payload;
if (typeof job !== 'object' || job === null || !('data' in job)) return undefined;
const data = job.data;
return typeof data === 'object' && data !== null && 'id' in data &&
typeof data.id === 'string' ? data.id : undefined;
},
})],
});
export async function acceptAndEnqueue(
idempotency: IIdempotencyService, queue: IQueue, event: { id: string },
): Promise<void> {
await idempotency.within({ key: event.id, namespace: 'webhooks.queue', scope: 'webhook:provider' },
async (uow: IUnitOfWork) => {
await uow.getRepository('WebhookReceipt').create(event);
return { accepted: true };
});
await queue.add('process-webhook', event);
}
Do not suppress enqueue when replayed is true. A failed enqueue must yield a non-2xx response so
the provider retries. The ingress behavior needs a shared tier-B store for cross-replica
de-duplication, a lease long enough for the job, and a TTL covering redelivery. It prevents
duplicate processing within that store’s limits; external effects still need the provider’s own
idempotency key. See the
idempotency ingress contract.
Keep the verified request path short: commit, schedule durable follow-up work, and return 2xx.