# Webhooks: events, signatures and retries

> Praeko webhooks: register a URL, the post.sent, post.failed and post.scheduled-fail events, verify the HMAC-SHA256 signature and understand retries.

Source: https://praeko.app/docs/en/guides/webhooks

Praeko calls your URL when an item goes out, fails or goes back to the queue. You verify the signature, answer 2xx and deduplicate by delivery id.

> **Before you start** Registering a webhook needs a key with the `admin` scope. The URL must be `https://` with a public host; localhost, private IPs and internal names are rejected with `unsafe_url` (checked on create, on update and on every delivery). Up to 10 per workspace.

## How a delivery works

1. **Event**: The item goes out or fails. Praeko records the delivery.
2. **Send**: Every minute, a POST to your URL with a 10 s timeout.
3. **Your server**: Verifies the signature and answers 2xx.
4. **Result**: 2xx = delivered. Any other status = retry.

## Register

`POST /v1/webhooks` with `url` and, optionally, `events` (without `events`, the webhook gets `post.sent` and `post.failed`). The response contains the `whsec_…` secret **only once** (and again from `POST /v1/webhooks/:id/rotate-secret`, which takes effect immediately). The list shows only a hint of the secret.

## Events

| Event | When |
|---|---|
| `post.sent` | The item was published on the network. |
| `post.scheduled-fail` | An attempt failed and the item went back to the queue. Includes `attempts` and `nextAttemptAt`. |
| `post.failed` | The item gave up: 3 attempts or a permanent failure. |
| `ping.test` | Only when you call `POST /v1/webhooks/:id/test`. |

There is no new-comment event: comments are read live from the networks. Posts created outside Praeko do not trigger webhooks either.

**post.sent body**

```json
{
  "id": "evt_…",
  "type": "post.sent",
  "createdAt": "2026-11-07T14:02:11.482Z",
  "data": {
    "post": { "id": "01M6…", "title": "My video" },
    "item": { "id": "01M6…", "network": "youtube", "channel": "01KA…", "url": "https://youtu.be/…", "attempts": 1 }
  }
}
```

Each delivery also carries the `Praeko-Event`, `Praeko-Delivery` and `Praeko-Signature` headers. The `error` field, when present, goes through the same scrubbing as the logs (tokens become `***`).

## Verify the signature

`Praeko-Signature: t=<unix>,v1=<hex>` is the HMAC-SHA256 of `"<t>.<raw body>"` with the `whsec_…` secret. Use the raw text, before `JSON.parse`.

### 1. Read the raw body

In Express, use `express.text({ type: "*/*" })` on the route.

### 2. Recompute and compare

Compare with `timingSafeEqual` and reject if `t` is more than 300 s away from your clock.

**Node.js**

```js
import { createHmac, timingSafeEqual } from "node:crypto";

// body: the RAW text you received (before JSON.parse); header: req.headers["praeko-signature"]
export function assinaturaValida(secret, header, body, toleranceSec = 300) {
  const p = Object.fromEntries(String(header).split(",").map((x) => x.split("=")));
  if (!p.t || !p.v1 || Math.abs(Date.now() / 1000 - Number(p.t)) > toleranceSec) return false;
  const expected = createHmac("sha256", secret).update(`${p.t}.${body}`).digest("hex");
  return expected.length === p.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1));
}

// Express: app.post("/praeko", express.text({ type: "*/*" }), (req, res) => {
//   if (!assinaturaValida(process.env.PRAEKO_WEBHOOK_SECRET, req.get("praeko-signature"), req.body)) return res.sendStatus(401);
//   const event = JSON.parse(req.body); /* deduplicate by req.get("praeko-delivery") */ res.sendStatus(204);
// });
```

> **Answer fast** Taking longer than 10 s or answering with a redirect (3xx) counts as a failure. Do the heavy work after you answer 204.

## Retries

| Attempt | Wait after the failure |
|---|---|
| 2nd | 1 min |
| 3rd | 5 min |
| 4th | 25 min |
| 5th | about 2 h |
| 6th | 6 h. If it fails again, the delivery becomes `failed`. |

The `Praeko-Delivery` header is the same on every attempt: store it and ignore repeats. To resend by hand, use `POST /v1/webhooks/:id/deliveries/:delivery/retry`; `GET /v1/webhooks/:id/deliveries` lists the deliveries.

> **Automatic pause** After 15 consecutive failures the webhook pauses, with `disabledReason`, and pending deliveries are discarded. Reactivating resets the count (manual tests do not count). Deliveries are kept for 30 days.

Routes: [`/v1/webhooks`](https://praeko.app/docs/en/api/webhooks).
