thefaqapp
Guides

Webhooks Setup

Subscribe to events, verify signatures, and handle webhook deliveries

Overview

Webhooks let you receive real-time HTTP notifications when content changes in your organization. Instead of polling the API, subscribe to events and your endpoint receives a POST request whenever something happens.

Available Events

EventTrigger
question.createdA new question is created
question.updatedA question is updated
question.publishedA question is published
question.deletedA question is deleted
category.createdA new category is created
category.updatedA category is updated
category.deletedA category is deleted
domain.createdA custom domain is added
domain.verifiedA custom domain is verified
domain.deletedA custom domain is removed
feedback.receivedFeedback is submitted on a question

Creating a Subscription

curl -X POST https://app.thefaq.app/api/v1/your-org/webhooks \
  -H "Authorization: Bearer faq_your-admin-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/thefaqapp",
    "events": ["question.created", "question.updated", "question.deleted"],
    "secret": "whsec_your-signing-secret"
  }'
const response = await fetch(
  'https://app.thefaq.app/api/v1/your-org/webhooks',
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer faq_your-admin-api-key',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url: 'https://your-app.com/webhooks/thefaqapp',
      events: ['question.created', 'question.updated', 'question.deleted'],
      secret: 'whsec_your-signing-secret',
    }),
  }
);

const { data } = await response.json();
console.log(data.id); // Webhook subscription ID

Response:

{
  "data": {
    "id": "clx...",
    "url": "https://your-app.com/webhooks/thefaqapp",
    "events": ["question.created", "question.updated", "question.deleted"],
    "active": true,
    "createdAt": "2026-02-28T10:00:00.000Z"
  }
}

See Webhooks API Reference for full endpoint details.

Receiving Events

When an event fires, your endpoint receives a POST request with these headers and a JSON body.

Headers

HeaderDescription
X-Webhook-EventThe event type (e.g. question.created)
X-Webhook-DeliveryUnique delivery ID for deduplication
X-Webhook-TimestampISO 8601 timestamp of when the event occurred
X-Webhook-SignatureHMAC-SHA256 signature for verification
Content-Typeapplication/json

Payload Structure

{
  "event": "question.created",
  "timestamp": "2026-02-28T10:00:00.000Z",
  "data": {
    "id": "clx...",
    "question": "How do I reset my password?",
    "slug": "how-do-i-reset-my-password",
    "published": true,
    "organizationId": "org_..."
  }
}

Your endpoint must respond with a 2xx status code within 30 seconds to acknowledge receipt.

Verifying Signatures

Always verify the X-Webhook-Signature header to ensure the request came from TheFAQApp and was not tampered with.

The signature is an HMAC-SHA256 hash of the raw request body using your webhook secret.

# Compute expected signature (for testing)
echo -n '{"event":"question.created","timestamp":"2026-02-28T10:00:00.000Z","data":{}}' \
  | openssl dgst -sha256 -hmac "whsec_your-signing-secret"
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyWebhookSignature(
  body: string,
  signature: string,
  secret: string
): boolean {
  const expected = createHmac('sha256', secret)
    .update(body)
    .digest('hex');

  return timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// In your webhook handler (e.g. Express, Next.js Route Handler)
export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('X-Webhook-Signature')!;
  const secret = process.env.WEBHOOK_SECRET!;

  if (!verifyWebhookSignature(body, signature, secret)) {
    return new Response('Invalid signature', { status: 401 });
  }

  const event = JSON.parse(body);

  switch (event.event) {
    case 'question.created':
      await handleQuestionCreated(event.data);
      break;
    case 'question.updated':
      await handleQuestionUpdated(event.data);
      break;
    case 'question.deleted':
      await handleQuestionDeleted(event.data);
      break;
  }

  return new Response('OK', { status: 200 });
}

Important: Use timingSafeEqual (not ===) for signature comparison to prevent timing attacks.

Delivery and Retries

Webhook deliveries are powered by QStash for reliable async delivery with automatic retries.

Retry Schedule

If your endpoint returns a non-2xx response, the delivery is retried with exponential backoff:

AttemptDelay
1Immediately
21 minute
35 minutes
430 minutes
52 hours

After 5 failed attempts, the delivery is marked as failed. You can replay failed deliveries from the dashboard or API.

Idempotency

Use the X-Webhook-Delivery header to deduplicate events. Store processed delivery IDs and skip duplicates:

const deliveryId = request.headers.get('X-Webhook-Delivery')!;

if (await isAlreadyProcessed(deliveryId)) {
  return new Response('Already processed', { status: 200 });
}

// Process the event...

await markAsProcessed(deliveryId);

Testing

Send a Test Ping

Trigger a test delivery to verify your endpoint is working:

curl -X POST https://app.thefaq.app/api/v1/your-org/webhooks/clx_webhook_id/test \
  -H "Authorization: Bearer faq_your-admin-api-key"
await fetch(
  'https://app.thefaq.app/api/v1/your-org/webhooks/clx_webhook_id/test',
  {
    method: 'POST',
    headers: { 'Authorization': 'Bearer faq_your-admin-api-key' },
  }
);

Replay Failed Deliveries

If a delivery failed, you can replay it:

curl -X POST https://app.thefaq.app/api/v1/your-org/webhooks/clx_webhook_id/replay \
  -H "Authorization: Bearer faq_your-admin-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "deliveryId": "clx_delivery_id" }'
await fetch(
  'https://app.thefaq.app/api/v1/your-org/webhooks/clx_webhook_id/replay',
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer faq_your-admin-api-key',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ deliveryId: 'clx_delivery_id' }),
  }
);

Local Development

For local development, use a tunneling service like ngrok to expose your local endpoint:

ngrok http 3000
# Use the https URL as your webhook endpoint

On this page