# Webhooks

Rather than polling for a story that breaks or a gap that opens, have us post it to you. Webhooks are an Enterprise scope.

## Setting one up

In the [console](/developers/console#webhooks), give a public HTTPS URL, the topics you care about (leave it empty for all of them), and whether you want `breaking`, `blindspot`, or both. You get a signing secret once, at creation.

The destination must be a public HTTPS hostname on port 443. Private addresses, link-local ranges, credentials in the URL, fragments and non-standard ports are rejected when you save, and the hostname is resolved again before every delivery, so a domain that later points inside a private network stops being delivered to.

Five enabled endpoints are included. Disable one before adding a sixth.

## The delivery

```http Delivery
POST /your/receiver HTTP/1.1
Content-Type: application/json
User-Agent: baba-News-Webhooks/1.0
X-Baba-Event-Id: 3a1e6c88-5b1d-4a2f-9d43-0b7c6e0a11f2
X-Baba-Signature: t=1788350400,v1=4c1b...9ea3

{
  "id": "3a1e6c88-5b1d-4a2f-9d43-0b7c6e0a11f2",
  "type": "story.blindspot",
  "storyId": "44",
  "state": "blindspot",
  "topic": "politics",
  "headline": "Cabinet approves the deal",
  "url": "https://news.itsbaba.com/812/cabinet-approves-the-deal",
  "observedAt": "2026-09-01T08:14:22.000Z"
}
```

`type` is `story.breaking` or `story.blindspot`. `storyId` is the cluster id, so the [story detail endpoint](/developers/docs/endpoints#stories) gives you the full record.

## Verifying the signature

`X-Baba-Signature` is `t=<unix seconds>,v1=<hex>`, where the hex is `HMAC-SHA256(secret, "<t>.<raw body>")`. Verify against the raw request bytes, before any JSON parsing, and compare in constant time. Reject a timestamp more than five minutes from now.

```javascript Verification
import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody is the exact request bytes, before JSON parsing.
function verifyBabaWebhook(rawBody, header, secret) {
  const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header ?? '');
  if (!match || Math.abs(Date.now() / 1000 - Number(match[1])) > 300) return false;
  const expected = createHmac('sha256', secret)
    .update(match[1] + '.').update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(match[2], 'hex'));
}
// After verification: persist event.id with a unique constraint, enqueue work,
// and return 2xx. A duplicate id must return 2xx without repeating the action.
```

## Retries

Answer `2xx` within ten seconds. Anything else, including a timeout, is retried on a fixed ladder.

| Attempt | When |
| --- | --- |
| 1 | Immediately |
| 2 | 1 minute later |
| 3 | 5 minutes later |
| 4 | 15 minutes later |
| 5 | 1 hour later |
| 6 | 4 hours later |

A `4xx` other than `408` or `429` stops the ladder at once: it says the request itself is wrong, and repeating it will not help. After six attempts the delivery is marked failed and is not retried again.

Delivery is at-least-once. Make your handler idempotent on `id`.

> Redirects are not followed. Point the endpoint at the final URL.
