Webhooks
Subscribe to mutation events and receive signed delivery payloads. Event catalog, payload envelope, and HMAC-SHA256 verification.
Updated 2026-06-14
Webhooks push mutation events to your endpoint as they happen, no polling. You register an HTTPS URL, pick the events you care about, and we POST a signed JSON payload to that URL every time a matching event fires.
Webhooks are a paid feature. Creating a webhook requires a Starter plan or above and an API key with the admin scope. Listing, updating, deleting, testing, and replaying stay available even after a downgrade, so a returning Free org can still manage its existing hooks.
Event catalog
Subscribe to any of these event types. The same list governs both what you can subscribe to and what we actually emit, so the two never drift.
| Event | Fires when |
|---|---|
question.created |
A question is created |
question.updated |
A question’s fields change |
question.published |
A question moves to published |
question.unpublished |
A published question is unpublished |
question.deleted |
A question is deleted |
category.created |
A category is created |
category.updated |
A category’s fields change |
category.deleted |
A category is deleted |
Create a webhook
curl -X POST https://api.thefaq.app/api/v1/acme/webhooks \
-H "Authorization: Bearer $FAQAPP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/faqapp",
"events": ["question.published", "question.updated"],
"description": "Sync published questions to our cache"
}'
Body:
url(string, required): your HTTPS endpoint. Must behttps://and public. Private, link-local, and metadata hosts are rejected with a400.events(string[], required): one or more event types from the catalog above.description(string, optional, max 500): a label for your own reference.
Required scope: admin. Required plan: STARTER or above.
The response includes a secret field. This is the only time it’s shown. Store it somewhere safe; you’ll need it to verify signatures. If you lose it, delete the webhook and create a new one.
{
"data": {
"id": "c...",
"url": "https://example.com/hooks/faqapp",
"events": ["question.published", "question.updated"],
"active": true,
"description": "Sync published questions to our cache",
"deliveryCount": 0,
"secret": "shown once, store it now",
"createdAt": "2026-06-14T10:00:00Z",
"updatedAt": "2026-06-14T10:00:00Z"
}
}
Payload envelope
Every delivery is a POST with this JSON body:
{
"id": "9f3c...",
"event": "question.published",
"data": { },
"timestamp": "2026-06-14T10:00:01.234Z"
}
id, the delivery id. It stays the same across automatic retries and is different for a manual replay. Use it to deduplicate.event, the event type that fired.data, the public resource shape, identical to what the matching APIGETreturns. Never raw database internals, never secrets.timestamp, ISO-8601, when the delivery was signed.
Each delivery also carries these headers:
| Header | Meaning |
|---|---|
X-Faqapp-Webhook-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">. Verify this one. |
X-Faqapp-Signature |
HMAC-SHA256 of the raw request body, hex, prefixed sha256=. Kept for receivers built before the timestamped signature. |
X-Faqapp-Event |
The event type, same value as event in the body |
X-Faqapp-Delivery-Id |
The delivery id, same value as id in the body |
Verify the signature
Read X-Faqapp-Webhook-Signature. Take t, join it to the raw request body as <t>.<body>, and compute the hex HMAC-SHA256 with your webhook secret. Accept the request only when it equals a v1 value and t is within five minutes of your clock. The timestamp is part of the signed message, so an attacker cannot replay an old delivery with a fresh time. Always verify on the raw bytes before parsing JSON. Re-serializing changes the bytes and breaks the comparison.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyFaqappWebhook(secrets: string[], rawBody: string, header: string): boolean {
const fields = header.split(",").map(part => part.trim().split("="));
const timestamp = Number(fields.find(([key]) => key === "t")?.[1]);
const signatures = fields.filter(([key]) => key === "v1").map(([, value]) => value ?? "");
if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
return secrets.some(secret => {
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return signatures.some(signature => {
const received = Buffer.from(signature, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
});
});
}
const raw = await request.text();
const ok = verifyFaqappWebhook(
[process.env.FAQAPP_WEBHOOK_SECRET!],
raw,
request.headers.get("X-Faqapp-Webhook-Signature") ?? ""
);
if (!ok) return new Response("invalid signature", { status: 401 });
const delivery = JSON.parse(raw);
Output for a valid delivery: ok === true. For a changed body, a wrong secret, or a delivery older than five minutes: ok === false.
v1 is a version tag. A later scheme will arrive as a new key next to it, so ignore keys you don’t recognise. X-Faqapp-Signature keeps working for existing receivers. It has no timestamp, so a receiver that only checks it must deduplicate on id to resist replays.
Rotate a secret
secrets takes a list so you can rotate without dropping deliveries:
- Create a new webhook for the same URL and events, and store its secret.
- Deploy your receiver with both secrets:
[newSecret, oldSecret]. - Delete the old webhook.
- Deploy your receiver with
[newSecret]only.
Deduplicate deliveries
Retries resend the same delivery with the same id. Record each id you have processed, with a time-to-live of at least 24 hours, and return 2xx without repeating side effects when you see one again:
const delivery = JSON.parse(raw);
if (await seen.has(delivery.id)) return new Response(null, { status: 204 });
await handle(delivery);
await seen.add(delivery.id, { ttlSeconds: 86_400 });
return new Response(null, { status: 204 });
Delivery and retries
Respond with a 2xx status to acknowledge a delivery. Anything else, or a timeout past 10 seconds, counts as a failure and is retried.
Failed deliveries retry up to 3 times with exponential backoff and jitter (~30s, ~1m, capped at 1h). Inspect the history at GET /api/v1/{org}/webhooks/{id}/deliveries, and re-fire a specific past delivery with POST /api/v1/{org}/webhooks/{id}/deliveries/{deliveryId}/replay.
Send a synthetic ping at any time to check wiring:
curl -X POST https://api.thefaq.app/api/v1/acme/webhooks/$WEBHOOK_ID/test \
-H "Authorization: Bearer $FAQAPP_API_KEY"
Manage webhooks
GET /api/v1/{org}/webhooks, list (paginated;readscope)GET /api/v1/{org}/webhooks/{id}, fetch one, no secret (readscope)PATCH /api/v1/{org}/webhooks/{id}, changeurl,events,active, ordescription(adminscope)DELETE /api/v1/{org}/webhooks/{id}, soft-delete; stops all future deliveries (adminscope)
Error codes
plan_required(403): creating a webhook on a plan below Startervalidation_error(400): bad body, an unknown event type, or a non-public / non-httpsURLnot_found(404): no webhook with that id in this orginvalid_json(400): body wasn’t valid JSON