Webhooks
Receive signed platform events with platform-owned endpoints and idempotent handlers.
Receive signed platform events with platform-owned endpoints and idempotent handlers.
Outbound webhooks deliver business events to your HTTPS receiver. Delivery is
at least once: verify the signature over the raw body, deduplicate on the
delivery id (webhook-id), return success quickly, and process asynchronously.
Webhook endpoints are operated records — created, edited, rotated and deleted
through the dashboard's Developer tab, the CLI, or the core API. They are not
part of the business program: fs.business() has no webhooks option, and the
repository never holds a receiver URL or a signing secret. Where an event is
produced is contract; where it is delivered is operations.
farthershore webhook create <business> \
--url https://hooks.example.com/farthershore \
--idempotency-key <persisted-webhook-create-attempt-key> \
--events subscription.created,payment.failed
farthershore webhook list <business>
farthershore webhook test <business> <webhookId> --idempotency-key <persisted-test-attempt-key>
farthershore webhook trigger <business> <webhookId> --type payment.failed --idempotency-key <persisted-trigger-attempt-key>
farthershore webhook deliveries <business> <webhookId>
The signing secret from API creation is one-time material. Store it directly in
the receiver's secret store and never print or commit it. In human (non-JSON)
mode webhook create and webhook rotate print it as an FS_WEBHOOK_SECRET=
line followed by a three-line createWebhookHandler snippet, so the receiver
can be wired in one paste.
webhook listen gives you the Stripe-CLI-style loop for a receiver running on
your machine: it opens a Cloudflare quick tunnel to the local URL, creates a
temporary endpoint on the tunnel, prints the endpoint id, the tunnel URL and
(with --print-secret) the FS_WEBHOOK_SECRET= line, then tails the deliveries
log until you press Ctrl-C — at which point the temporary endpoint is deleted
and the tunnel stops.
farthershore webhook listen <business> \
--forward-to http://localhost:3000/webhooks/farthershore \
--print-secret --trigger payment.failed
Each delivery prints as time type status responseStatus id; add
--format json for one JSON object per line. --events a,b narrows the
subscription (default: the whole catalog) and --trigger <event> fires a
signed sample as soon as the tunnel is up. The tunnel needs cloudflared: the
CLI bundles it as an optional @farthershore/cloudflared-<platform> dependency
and falls back to a cloudflared on your PATH. A listener that is killed hard
cannot clean up; the leftover is recognisable by its *.trycloudflare.com URL
in webhook list, and webhook delete --yes removes it.
webhook trigger --type <event> works against any endpoint, not just a
listener's: Core signs and sends a realistic sample of that catalog event
(data for payment.failed carries an invoice id, amount, currency and
card_declined), and the deliveries log records it under that event type, so
every branch of your handler map can be exercised before a real subscription
exists. webhook test stays the plain webhook.test ping.
Endpoints subscribe to seven event names:
subscription.created, subscription.updated, subscription.canceled,
payment.succeeded, payment.failed, entitlement.changed, and
usage.threshold_reached. Every name has a producer — an event exists on the
wire only when something emits it. A test send arrives as webhook.test; it is
not subscribable and every endpoint receives it when you ask for one.
Every delivery is a JSON envelope signed with the open
Standard Webhooks format, so you can verify
it with @farthershore/backend or with any Standard Webhooks library:
POST https://hooks.example.com/farthershore
Content-Type: application/json
webhook-id: 6d3a… # the delivery id — stable across retries
webhook-timestamp: 1788000000 # unix seconds at send time
webhook-signature: v1,MdgW… # base64 HMAC-SHA256 over `${id}.${timestamp}.${body}`
x-fs-webhook-event: subscription.updated
{
"id": "6d3a…",
"type": "subscription.updated",
"createdAt": "2026-09-05T10:00:00.000Z",
"businessId": "biz_…",
"environmentId": null,
"data": { "subscriptionId": "sub_…", "reason": "plan_changed" }
}
environmentId is null for production events. data carries the
event-specific fields; the body is authoritative and the
x-fs-webhook-event header is a convenience copy of type.
Compute HMAC-SHA256 with the secret's key bytes (the base64 after the fswh_
prefix) over id.timestamp.body, compare in constant time against every
v1, entry in the header, and reject timestamps more than 5 minutes from now
in either direction. Parse JSON only after verification.
import { verifyWebhook } from "@farthershore/backend/webhooks";
const raw = await request.text();
const result = verifyWebhook({
body: raw,
headers: request.headers,
secrets: [process.env.FS_WEBHOOK_SECRET],
});
if (!result.ok) return new Response(result.reason, { status: 401 });
const event = JSON.parse(raw);
Persist the delivery id in the same transaction as the business effect so a retry cannot apply it twice.
farthershore webhook rotate <business> <webhookId> (or the dashboard action)
issues a new fswh_ secret and returns it once. For 24 hours every delivery
carries two v1, signatures — the new secret first, then the retired one — so
you can roll the receiver at your own pace. Pass secrets: [current, previous]
to the SDK if you also want to keep the old one on your side during the roll.
Retry-After is
honoured on 429/503). Five consecutive failures disable the endpoint.webhook-id is
mandatory.environmentId.Use farthershore webhook test after every receiver or secret change (and
webhook trigger --type <event> for each event the receiver handles), then
inspect webhook deliveries for response and timing evidence.