Webhooks
Foir pushes events to your backend when content changes — a record is published, an operation finishes, and so on. It delivers those events by dispatching an operation that you attach to a model event as a lifecycle hook. Each delivery is an HTTP POST to an endpoint you control, authenticated with a short-lived, JWKS-verifiable scoped token rather than a classic shared-secret HMAC signature.
This page is the map: what events exist, how a delivery is wired and shaped, how you authenticate it, and how retries and redelivery work. The hooks, operations, and verification pieces are each documented in depth on their own pages — this ties them together for the “how do I receive events from Foir” question.
Two different things called “webhook.” Foir’s outbound event delivery is a scoped-token-authenticated operation dispatch (below). The classic HMAC pattern —
verifyWebhookSignature(body, signature, secret)— is for the reverse direction: your Foir app or extension receiving a webhook from a third-party service like Shopify or Stripe. See Inbound HMAC webhooks. Don’t reach for an HMAC secret to verify a Foir delivery — verify the token instead.
The event catalog
A hook fires on one model event. Two families are available.
Record events — content lifecycle:
| Event | Fires when |
|---|---|
RECORD_CREATED | A new record is created |
RECORD_UPDATED | A record is updated |
RECORD_DELETED | A record is deleted |
RECORD_PUBLISHED | A version is published (goes live) |
RECORD_UNPUBLISHED | A published version is unpublished |
Operation events — an operation execution reached a terminal state, for chaining follow-up work:
| Event | Fires when |
|---|---|
OPERATION_COMPLETED | Operation finished successfully (payload carries executionId, operationKey, status, typed result) |
OPERATION_FAILED | Operation finished with an error ({code, message, retryable}) |
OPERATION_CANCELLED | Operation was cancelled before completing |
OPERATION_TIMED_OUT | An async operation didn’t call back within its TTL and the watchdog reaped it |
Wiring a delivery
Receiving an event is three steps:
1. Deploy an HTTP endpoint (anywhere — a Worker, a Lambda, your API).
2. Register it as an Operation → endpoint URL + declared capabilities.
3. Attach that operation to a model event as a Hook → optional condition, sync/async.- The operation owns the endpoint URL and the capabilities its dispatch token should carry. See Operations → Creating an Operation or define it in config with
defineOperation. - The hook binds a model + event to that operation, with an optional condition and an
asyncflag. Define it in the admin (Settings → Models → Lifecycle Hooks), viafoir hooks create, or infoir.config.tswithdefineHook.
The delivery request
When the event fires, Foir POSTs to your endpoint:
| Method / URL | POST to the operation’s registered endpoint URL |
| Headers | X-Foir-Token — the scoped token to verify; X-Foir-Context — tenant/project/locale context |
| Body | an OperationPayload |
For a hook-fired delivery the payload’s trigger.type is lifecycle, and record carries the record’s id, model key, and data. The full request/response contract — including field-by-field payload shape — is documented under Operations → The HTTP Contract.
Verifying a delivery
Verify X-Foir-Token before you process anything. It’s a short-lived token scoped to this single dispatch; verify it against Foir’s JWKS at https://api.foir.dev/.well-known/jwks.json. The verified claims tell you which tenant, project, and app the delivery belongs to, and which capabilities it carries. The SDK’s verifyScopedToken does the fetch-and-verify for you — see Server API → verifyScopedToken.
import { verifyScopedToken, parseSubject } from '@foir/sdk/server';
app.post('/hooks/on-publish', async (c) => {
const token = c.req.header('x-foir-token');
if (!token) return c.json({ error: 'missing token' }, 401);
const claims = await verifyScopedToken(
token,
'https://api.foir.dev/.well-known/jwks.json',
);
const { appName } = parseSubject(claims.sub);
const payload = await c.req.json(); // OperationPayload
// ...react to payload.record, payload.trigger, etc.
return c.json({ success: true });
});There is no HMAC secret to configure for this path, and no X-Foir-Signature header — the token is the only credential.
Responding: sync vs async
The hook’s async flag decides whether the originating content operation waits for your endpoint:
- Sync (
async: false) — do the work and return anOperationResult({ success, result?, error? }) inline. The event waits for it. Use for validation or critical transforms. - Async (
async: true) — return202 Accepted, queue the work, and report back later using thecallbackblock in the payload (a bearer token plus the exactcomplete/fail/progress/cancelmutation names). Use for notifications and external syncs, so a slow endpoint never blocks publishing. See Operations → Response (async mode).
Reliability: retries, redelivery, dead letters
-
Automatic retries. Async deliveries run on a background job queue and retry automatically when your endpoint is temporarily unavailable.
-
Dead-letter queue. An async execution that exhausts its retries lands in the dead-letter queue, where you can inspect the error and retry or dismiss it.
-
Inspect and replay from the hook side:
foir hooks deliveries <hookId> --status failed --limit 20 # recent failures foir hooks retry-delivery <deliveryId> # replay one foir hooks test <hookId> --data '{"recordId":"rec_abc123"}' # synthetic delivery to smoke-test a receiver -
Make your handler idempotent. A retry after a network blip means your endpoint may receive the same delivery more than once. Key on
executionId(present on every payload) and treat repeats as no-ops.
Inbound HMAC webhooks (from third parties)
The reverse direction — a third-party service (Shopify, Stripe, GitHub) calling your Foir app or extension — uses the classic pattern: the sender HMAC-signs the raw body, and you verify it with a shared secret. This is unrelated to the scoped-token path above.
import { verifyWebhookSignature } from '@foir/sdk/server';
app.post('/webhooks/shopify', async (c) => {
const body = await c.req.text();
const signature = c.req.header('x-shopify-hmac-sha256') ?? '';
if (!(await verifyWebhookSignature(body, signature, process.env.SHOPIFY_WEBHOOK_SECRET!))) {
return c.text('Invalid signature', 401);
}
// Process webhook...
});verifyWebhookSignature is HMAC-SHA256 over the raw request body. See Server API → Webhook Verification.
Receivers Foir operates for you
Foir runs its own inbound receivers on the public host that you never call — they’re listed here so the surface is complete:
POST /v1/webhooks/stripe— Foir’s billing receiver for Stripe events (subscription lifecycle, invoice paid / payment failed), verified against theStripe-Signatureheader. Operated by Foir. See REST Endpoints → Stripe webhook./v1/email/unsubscribe— RFC 8058 one-click unsubscribe carried in notification emails, authenticated by a signed token in the URL. See REST Endpoints.
See also
- Lifecycle Hooks — binding events to operations
- Operations — endpoints, the HTTP contract, async callbacks, dead letters
- Server API Reference —
verifyScopedToken,verifyWebhookSignature,OperationPayload - Defining Hooks —
defineHookinfoir.config.ts - REST Endpoints — the receivers Foir operates