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 seven available events. To subscribe to all events, pass all seven 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": "whe_a1b2c3d4e5f6",
"url": "https://yourapp.com/webhooks/briefgate",
"events": ["item.submitted", "intake.completed", "intake.stalled"],
"format": "raw",
"active": true,
"created_at": "2026-07-18T09:11:00Z",
"secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}The secret is shown exactly once. Store it securely — you need it to verify signatures.
The optional format field decides what shape the request body takes. It defaults to raw, the signed BriefGate envelope described below. Set it to slack or discord to post a ready-made chat message instead — see Slack and Discord.
Managing endpoints
List every endpoint registered on the account:
GET /v1/webhooks{
"webhooks": [
{
"id": "whe_a1b2c3d4e5f6",
"url": "https://yourapp.com/webhooks/briefgate",
"events": ["item.submitted", "intake.completed", "intake.stalled"],
"format": "raw",
"active": true,
"created_at": "2026-07-18T09:11:00Z"
}
]
}The secret is never returned here — only at creation. For a slack or discord endpoint, url comes back as only the origin (see What a chat notification does not contain).
Remove an endpoint:
DELETE /v1/webhooks/:idReturns 204 No Content. Nothing more is sent to it afterwards, and there is no way to reactivate it — register a new one.
Creating an endpoint, deleting one, retrying a delivery and sending a test event are all restricted to the account owner. Any signed-in team member can list endpoints and view delivery history, but changing them takes the owner role. An API key is the account's own credential, so this restriction applies only to browser sessions.
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
Two more headers travel alongside it, though neither is part of the signature: X-BriefGate-Event carries the event name, so you can route before parsing JSON, and User-Agent: BriefGate-Webhook/1.0 identifies the request as ours.
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": "in_01J3K...",
"project_name": "Bella Cucina Website",
"item_key": "logo",
"item_label": "Company logo",
"item_type": "image",
"file_count": 1,
"timestamp": 1784212920
}file_count is present only on file, image and file_list items. timestamp is
Unix seconds and is the value the signature is computed over. The client's email
address is deliberately not included — a chat channel usually has more readers
than the intake does.
intake.completed
Fires when all required items have been submitted (optional items do not block completion).
{
"event": "intake.completed",
"intake_id": "in_01J3K...",
"project_name": "Bella Cucina Website",
"timestamp": 1784372060
}Call get_intake_results to collect the answers themselves.
client.viewed
Fires when the client opens their portal.
{
"event": "client.viewed",
"intake_id": "in_01J3K...",
"slug": "8f3kqmr2",
"project_name": "Bella Cucina Website"
}No IP address is sent. Views are deduplicated server-side, so a client refreshing the page does not fire the event repeatedly.
chase.bounced
Fires when the mail provider reports a bounce or a spam complaint for a reminder email. Whether BriefGate stops sending to the address depends on what was reported — see still_chasing below and Bounce handling.
{
"event": "chase.bounced",
"intake_id": "in_01J3K...",
"chase_id": "chs_01J4M...",
"provider_message_id": "01J4M8Z...",
"event_type": "bounced",
"recipient": "owner@bellacucina.cz",
"still_chasing": true,
"bounce_type": "Transient",
"delivered_before": true,
"timestamp": 1784361600
}event_type is what the mail provider reported — bounced or complained. recipient is the address that bounced. still_chasing tells you whether the intake still has other recipients being chased (true) or this was the last one, so future reminders for this intake are now cancelled (false).
bounce_type is the provider's classification (Permanent, Transient, Undetermined, or null for a complaint). delivered_before is true when the provider had already confirmed delivery of that very message — a late delivery-status notification from behind the recipient's server, not a dead address. A permanent bounce or a complaint always retires the address; a bounce after delivery never does; a temporary bounce with no delivery retires it on the third one in a row.
intake.stalled
Fires when the intake has used up its reminder allowance — max_reminders reminders have been sent (default 3) without the client completing it. The chase engine cancels the remaining reminders and hands the intake back to you.
{
"event": "intake.stalled",
"intake_id": "in_01J3K...",
"project_name": "Bella Cucina Website",
"missing_items": ["logo", "hero_copy", "wp_admin"],
"timestamp": 1784707200
}missing_items lists the required items still waiting on the client, by key.
When you receive intake.stalled, consider reaching out to the client by other means.
intake.overdue
Fires once, the first time a periodic sweep notices an intake has passed its due date with at least one required item still outstanding. It cannot fire twice for the same intake.
{
"event": "intake.overdue",
"intake_id": "in_01J3K...",
"project_name": "Bella Cucina Website",
"due_date": "2026-07-01T00:00:00.000Z",
"outstanding_items": 3,
"timestamp": 1784707200
}outstanding_items counts required items that are still pending or needs_revision — waived items don't count. This fires independently of the automatic reminder schedule and independently of whether owner-notification email is enabled for the account; it is the one event meant to tell you, the developer, rather than remind the client.
intake.archived
Fires when POST /v1/intakes/:id/archive closes an intake — from any status, including one that was never sent.
{
"event": "intake.archived",
"intake_id": "in_01J3K...",
"project_name": "Bella Cucina Website",
"timestamp": 1784707200
}The intake and everything on it (items, files, chase history) still exist — this is not a deletion event, and there is no corresponding intake.deleted webhook for DELETE /v1/intakes/:id today.
Slack and Discord
You do not have to run a server to receive BriefGate events. Point a webhook at a Slack or Discord incoming webhook and the notification arrives in the channel your team already watches.
This is the same delivery path as any other webhook — same events, same retries, same delivery log. Only the request body changes, because Slack and Discord accept their own message shape and answer anything else with a 400.
Slack
Create an incoming webhook in Slack (Your apps → your app → Incoming Webhooks → Add New Webhook to Workspace), pick the channel, and copy the URL. Then:
curl -X POST https://api.briefgate.dev/v1/webhooks \
-H "Authorization: Bearer bg_live_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.slack.com/services/T0000/B0000/xxxxxxxx",
"events": ["intake.completed", "intake.stalled", "chase.bounced"],
"format": "slack"
}'The channel receives plain sentences:
The client submitted "logo" for Bella Cucina Website.
Intake: in_01J3K...The sentence never names the client — item.submitted payloads deliberately omit the client's email (see above), so the chat formatter falls back to "The client" whenever no client_email is present, which for this event is always.
A Slack incoming webhook always posts to the channel it was created for, under the name and icon configured on the Slack app. That is Slack's rule, not ours: an incoming webhook cannot override the channel, the username or the icon, and it cannot send a direct message. If you want each developer notified privately, give each of them their own webhook created against a DM, or subscribe a channel per project.
Discord
Open Channel settings → Integrations → Webhooks → New Webhook, copy the URL, and register it with "format": "discord".
curl -X POST https://api.briefgate.dev/v1/webhooks \
-H "Authorization: Bearer bg_live_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://discord.com/api/webhooks/123456/xxxxxxxx",
"events": ["intake.completed"],
"format": "discord"
}'Mentions in the message are inert: a project or client name containing @everyone is delivered as text and pings nobody.
What a chat notification does not contain
The portal link is never included. It is a bearer link into your client's portal, and a channel usually has more readers than the intake does. Each notification carries the intake id, which is enough to look the intake up through the API and gives away nothing.
For the same reason, GET /v1/webhooks returns only the origin of a Slack or Discord URL (https://hooks.slack.com/…). The path of such a URL is the credential — anyone holding it can post into the channel — so it is treated like the signing secret and not shown again. A raw URL points at your own server and is returned in full.
Requests to Slack and Discord are still signed with X-BriefGate-Signature. Neither service checks it; it costs nothing and keeps one code path for every format.
Choosing between them
A chat notification is for a human who wants to know. The raw format is for an agent that will act — it carries the full payload, it is verifiable, and it is what intake.completed should trigger if the next step is code. Registering both is normal: one endpoint per format, subscribed to the events that matter to each.
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 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
After 6 failed attempts, the delivery is abandoned and marked failed in the delivery log. Redirects are not followed, so a 301 or 302 counts as a failure — register the final URL.
Return HTTP 410 Gone to deactivate the endpoint from your side. That delivery is marked failed without further retries and nothing more is sent to the endpoint until you register it again.
View delivery history — the last 100 attempts, newest first:
GET /v1/webhooks/:id/deliveries{
"deliveries": [
{
"id": "whd_9f8e7d6c5b4a",
"event": "intake.completed",
"status": "failed",
"attempts": 6,
"response_code": 500,
"error": "HTTP 500",
"next_retry_at": null,
"created_at": "2026-07-18T09:11:00Z",
"delivered_at": null
}
]
}The submitted payload is not returned here. This view exists to tell you why a delivery failed, and a payload can contain whatever your client uploaded.
Retry one delivery by hand:
POST /v1/webhooks/:id/deliveries/:deliveryId/retryThis re-queues the delivery to go out on the next scheduled tick, through the same path as any other delivery — the SSRF re-check, a freshly computed signature, logged like any other attempt. It works on any delivery that has not already succeeded (a 409 is returned for one that has, and for one on an endpoint you have since switched off). It does not reset the attempt count: attempts keeps climbing from where it left off, so a delivery that already used all 6 attempts gets exactly one more try, not a fresh ladder — fix the endpoint first if you want more than that. Response:
{
"delivery": {
"id": "whd_9f8e7d6c5b4a",
"event": "intake.completed",
"status": "pending",
"attempts": 6,
"response_code": 500,
"error": null,
"next_retry_at": "2026-07-18T09:12:00Z",
"created_at": "2026-07-18T09:11:00Z",
"delivered_at": null
}
}Testing
Send a test delivery to your endpoint without triggering a real intake:
POST /v1/webhooks/:id/testNo request body — there is no way to choose which event to simulate. BriefGate always sends a fixed, fictional item.submitted payload:
{
"event": "item.submitted",
"intake_id": "in_test",
"item_key": "test",
"message": "This is a test delivery from BriefGate.",
"timestamp": 1784212920
}Note the shape is deliberately minimal and does not match a real item.submitted payload field-for-field (no project_name, item_label, item_type) — it exists to check that your endpoint is reachable and your signature verification works, not to exercise payload parsing.
The delivery is queued and sent in the background, signed exactly like a real one, and shows up in the delivery log like any other attempt. The request returns immediately:
{
"queued": true,
"delivery_id": "whd_9f8e7d6c5b4a"
}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 10 seconds to return a 2xx response before the request is aborted and counted as a failed attempt. 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.