Webhooks: events, signatures and retries
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.
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
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.
{
"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.
Read the raw body
In Express, use express.text({ type: "*/*" }) on the route.
Recompute and compare
Compare with timingSafeEqual and reject if t is more than 300 s away from your clock.
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);
// });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.
disabledReason, and pending deliveries are discarded. Reactivating resets the count (manual tests do not count). Deliveries are kept for 30 days.Routes: /v1/webhooks.