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{
"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.
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:
{
"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...t— Unix timestamp of the requestv1— HMAC-SHA256(webhook_secret,${t}.${rawBody}) as hex
To verify:
- Extract
tandv1from the header. - Reject the request if
|now - t| > 300 seconds(prevents replay attacks). - Compute
HMAC-SHA256(secret, "${t}.${rawBody}")whererawBodyis the raw request body string. - Compare with
timingSafeEqual— never use===for signature comparison.
Complete Node.js verification function:
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.
{
"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).
{
"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.
{
"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.
{
"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.
{
"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/deliveriesTesting
Send a synthetic test payload to your endpoint without triggering a real intake:
POST /v1/webhooks/:id/test{
"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.