Skip to content

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 | | feedback.created | Feedback is submitted (reserved, no public submission route yet) |

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 be https:// and public. Private, link-local, and metadata hosts are rejected with a 400.
  • 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, unique per attempt. Use it to deduplicate retries.
  • event, the event type that fired.
  • data, the public resource shape, identical to what the matching API GET returns. Never raw database internals, never secrets.
  • timestamp, ISO-8601, when the delivery was signed.

Each delivery also carries three headers:

| Header | Meaning | |---|---| | X-Faqapp-Signature | HMAC-SHA256 of the raw request body, hex, prefixed sha256= | | 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

The signature is sha256= followed by the hex HMAC-SHA256 of the raw request body keyed with your webhook secret. Compute the same HMAC on your side and compare. Always verify on the raw bytes before parsing JSON. Re-serializing changes the bytes and breaks the comparison.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyFaqappSignature(secret: string, rawBody: string, header: string): boolean {
  const received = header.startsWith("sha256=") ? header.slice(7) : header;
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(received, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

// In your handler, read the raw body first, then verify, then parse.
const raw = await request.text();
const ok = verifyFaqappSignature(
  process.env.FAQAPP_WEBHOOK_SECRET!,
  raw,
  request.headers.get("X-Faqapp-Signature") ?? ""
);
if (!ok) return new Response("invalid signature", { status: 401 });
const payload = JSON.parse(raw);

Reject any request whose signature doesn't match. Use a constant-time comparison so you don't leak timing information.

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; read scope)
  • GET /api/v1/{org}/webhooks/{id}, fetch one, no secret (read scope)
  • PATCH /api/v1/{org}/webhooks/{id}, change url, events, active, or description (admin scope)
  • DELETE /api/v1/{org}/webhooks/{id}, soft-delete; stops all future deliveries (admin scope)

Error codes

  • plan_required (403): creating a webhook on a plan below Starter
  • validation_error (400): bad body, an unknown event type, or a non-public / non-https URL
  • not_found (404): no webhook with that id in this org
  • invalid_json (400): body wasn't valid JSON