Webhooks

Overview

BriefGate sends signed POST requests to your endpoint when intake events happen. Webhooks are the best way to react to client activity in real time — more efficient than polling get_intake_status in a loop, and they work while your agent is idle or sleeping between tasks.

Each request is signed so you can verify it came from BriefGate and was not tampered with.

Registering an endpoint

POST /v1/webhooks
json
{
  "url": "https://yourapp.com/webhooks/briefgate",
  "events": ["item.submitted", "intake.completed"]
}

You can subscribe to any subset of the five available events. To subscribe to all events, pass all five event names.

bash
curl -X POST https://api.briefgate.dev/v1/webhooks \
  -H "Authorization: Bearer bg_live_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://yourapp.com/webhooks/briefgate","events":["item.submitted","intake.completed","intake.stalled"]}'

Response:

json
{
  "id": "wh_01J3K...",
  "url": "https://yourapp.com/webhooks/briefgate",
  "events": ["item.submitted", "intake.completed", "intake.stalled"],
  "secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "created_at": "2026-07-16T10:00:00Z"
}

The secret is shown exactly once. Store it securely — you need it to verify signatures.

Signature verification

Every webhook request includes the header:

X-BriefGate-Signature: t=1721131200,v1=abc123...

To verify:

  1. Extract t and v1 from the header.
  2. Reject the request if |now - t| > 300 seconds (prevents replay attacks).
  3. Compute HMAC-SHA256(secret, "${t}.${rawBody}") where rawBody is the raw request body string.
  4. Compare with timingSafeEqual — never use === for signature comparison.

Complete Node.js verification function:

javascript
import { createHmac, timingSafeEqual } from 'crypto';

function verifyWebhook(rawBody, secret, signatureHeader) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('=', 2))
  );
  const t = parts['t'];
  const v1 = parts['v1'];
  if (!t || !v1) throw new Error('Missing signature components');

  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) {
    throw new Error('Timestamp out of tolerance window');
  }

  const expected = createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  if (!timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
    throw new Error('Signature mismatch');
  }
}

Call verifyWebhook before doing anything with the payload. If it throws, return HTTP 400 and discard the request.

Events

item.submitted

Fires when a client submits a single item in their portal.

json
{
  "event": "item.submitted",
  "intake_id": "int_01J3K...",
  "item_key": "logo",
  "item_type": "image",
  "status": "submitted",
  "submitted_at": "2026-07-16T14:22:00Z"
}

intake.completed

Fires when all required items have been submitted (optional items do not block completion).

json
{
  "event": "intake.completed",
  "intake_id": "int_01J3K...",
  "project_name": "Bella Cucina Website",
  "client_email": "[email protected]",
  "completed_at": "2026-07-18T09:11:00Z",
  "items_count": 8
}

client.viewed

Fires when the client opens their portal.

json
{
  "event": "client.viewed",
  "intake_id": "int_01J3K...",
  "client_email": "[email protected]",
  "viewed_at": "2026-07-16T13:55:00Z",
  "ip": "a3f2c1d4..." 
}

ip is a one-way hash — used for deduplication and fraud signals only, not reversible.

chase.bounced

Fires when a reminder email hard-bounces or is reported as spam. BriefGate stops sending to this address automatically.

json
{
  "event": "chase.bounced",
  "intake_id": "int_01J3K...",
  "chase_id": "ch_01J4M...",
  "channel": "email",
  "client_email": "[email protected]",
  "bounced_at": "2026-07-18T08:00:00Z",
  "reason": "hard_bounce"
}

intake.stalled

Fires after three consecutive bounced reminder emails, indicating the chase engine has given up on email delivery.

json
{
  "event": "intake.stalled",
  "intake_id": "int_01J3K...",
  "project_name": "Bella Cucina Website",
  "client_email": "[email protected]",
  "stalled_at": "2026-07-22T08:00:00Z",
  "missing_items": ["logo", "hero_copy", "wp_admin"]
}

When you receive intake.stalled, consider escalating via SMS (send_chase with channel: "sms") or reaching out to the client by other means.

Retries

If your endpoint does not return HTTP 2xx, BriefGate retries with exponential backoff:

Attempt Delay after previous
1 (immediate)
2 1 minute
3 5 minutes
4 25 minutes
5 2 hours
6 12 hours
7 12 hours

After 7 failed attempts, the delivery is abandoned and marked failed in the delivery log.

Return HTTP 410 Gone to permanently deregister the endpoint and stop all future deliveries to that URL.

View delivery history:

GET /v1/webhooks/:id/deliveries

Testing

Send a synthetic test payload to your endpoint without triggering a real intake:

POST /v1/webhooks/:id/test
json
{
  "event": "intake.completed"
}

BriefGate sends a realistic but fictional payload. Use this to verify your handler is reachable and your signature verification works before going live.

Best practices

Verify the signature first. Do not process the payload before calling your verification function. A request without a valid signature should be discarded immediately.

Use idempotency. Webhooks can be delivered more than once in rare cases (network retries, infrastructure restarts). Check intake_id + event combination to avoid processing the same event twice.

Log the raw body before parsing. If your JSON parser throws, you still want the raw body available for debugging. Store it before calling JSON.parse.

Respond quickly. Your endpoint has 30 seconds to return a 2xx response. If your handler does heavy work (calls external APIs, builds files), acknowledge immediately and process in a background queue.

Do not rely on delivery order. item.submitted events may arrive before or after intake.completed. Design your handler to handle events out of order.