Skip to Content

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:

EventFires when
RECORD_CREATEDA new record is created
RECORD_UPDATEDA record is updated
RECORD_DELETEDA record is deleted
RECORD_PUBLISHEDA version is published (goes live)
RECORD_UNPUBLISHEDA published version is unpublished

Operation events — an operation execution reached a terminal state, for chaining follow-up work:

EventFires when
OPERATION_COMPLETEDOperation finished successfully (payload carries executionId, operationKey, status, typed result)
OPERATION_FAILEDOperation finished with an error ({code, message, retryable})
OPERATION_CANCELLEDOperation was cancelled before completing
OPERATION_TIMED_OUTAn 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 async flag. Define it in the admin (Settings → Models → Lifecycle Hooks), via foir hooks create, or in foir.config.ts with defineHook.

The delivery request

When the event fires, Foir POSTs to your endpoint:

Method / URLPOST to the operation’s registered endpoint URL
HeadersX-Foir-Token — the scoped token to verify; X-Foir-Context — tenant/project/locale context
Bodyan 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 an OperationResult ({ success, result?, error? }) inline. The event waits for it. Use for validation or critical transforms.
  • Async (async: true) — return 202 Accepted, queue the work, and report back later using the callback block in the payload (a bearer token plus the exact complete / fail / progress / cancel mutation 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 the Stripe-Signature header. 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

Last updated on