Webhooks
Receive real-time notifications when briefings complete, fail, or subscriptions change.
Event types
| Event | Description |
|---|---|
briefing.generated |
An AI briefing has been generated and its audio URL is ready. |
briefing.failed |
A briefing job encountered an unrecoverable error. |
subscription.upgraded |
The account was upgraded to a higher subscription tier. |
subscription.cancelled |
The account's subscription was cancelled or expired. |
Creating a webhook
Register an endpoint by sending a POST to /api/v1/webhooks.
Specify the HTTPS URL to deliver events to and the list of event types you want to receive.
Requires the webhooks:write scope.
curl -X POST https://listenbrief.com/api/v1/webhooks \
-H "Authorization: Bearer lb_api_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhook",
"events": ["briefing.generated", "briefing.failed"]
}'
The response includes a secret field. Store this value immediately — it is used
to verify incoming webhook signatures and is never shown again.
Payload format
Every webhook delivery is an HTTP POST to your URL with
Content-Type: application/json. The body follows this shape:
{
"event": "briefing.generated",
"timestamp": 1717200000,
"data": {
"job_id": "job_abc123",
"status": "generated"
}
}
The timestamp field is a Unix epoch integer (seconds). The data
object is event-specific; for briefing.generated it includes the job ID and
final status.
Signature verification
Each delivery includes two headers that you must check before processing the payload:
X-ListenBrief-Timestamp— Unix timestamp of the delivery attemptX-ListenBrief-Signature— HMAC-SHA256 of{timestamp}.{raw body}, formatted asv1={hex}
Construct the signed message by concatenating the timestamp, a literal period, and the raw request body string. Compute an HMAC-SHA256 using your webhook secret, then compare using a constant-time equality function.
const crypto = require('crypto');
function verifyWebhook(secret, signature, timestamp, body) {
const message = `${timestamp}.${body}`;
const expected = 'v1=' + crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
// Express example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-listenbrief-signature'];
const ts = req.headers['x-listenbrief-timestamp'];
if (!verifyWebhook(process.env.WEBHOOK_SECRET, sig, ts, req.body.toString())) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// process event...
res.sendStatus(200);
});
Replay protection
The X-ListenBrief-Timestamp header lets you reject replayed requests.
After verifying the signature, check that the timestamp is within 5 minutes
of your server's current time. Reject any delivery whose timestamp falls outside this window.
const MAX_AGE_SECONDS = 5 * 60; // 5 minutes
const ts = parseInt(req.headers['x-listenbrief-timestamp'], 10);
if (Math.abs(Date.now() / 1000 - ts) > MAX_AGE_SECONDS) {
return res.status(400).send('Timestamp too old');
}
Retry semantics
ListenBrief considers a delivery successful when your endpoint returns any 2xx HTTP status code within 10 seconds. If your endpoint returns a non-2xx status, closes the connection early, or times out, the delivery is retried with exponential backoff:
- Attempt 1 — immediate
- Attempt 2 — ~1 minute later
- Attempt 3 — ~5 minutes later
- Attempt 4 — ~15 minutes later
- Attempt 5 — ~30 minutes later
Maximum 5 attempts over approximately 30 minutes. Return 200 OK quickly — do
any heavy processing asynchronously in a background queue.
Dead-letter handling
After all retry attempts are exhausted, the event is marked as failed and logged to your webhook delivery log. Visit your dashboard under Settings → Webhooks to inspect failed deliveries, view request/response details, and manually replay individual events if needed.