# Receive webhooks A webhook is a URL of yours that Pickpost calls when something happens in a workspace. Use it to post in a chat channel when a post goes out, to open a ticket when one fails, or to keep another system in sync. ## Add one In the app, open **API & MCP** and find **Webhooks**. Paste an `https` URL, pick the events, and click **Add webhook**. Copy the signing secret that appears (`whsec_…`): it is shown once. Only owners and admins of a workspace can add, change or see webhooks. They are not available to API tokens, MCP clients or the assistant, because a webhook receives the workspace's posts wherever its URL points. The URL must be `https`, reachable on the public internet, and have no user name or password in it. A workspace has at most 10 webhooks; its plan may allow fewer (every plan allows 10 today). After a move to a plan with fewer, the oldest webhooks within the new limit keep working, and deliveries to the others fail with the reason until the workspace upgrades or deletes some. ## Events | Event | When | `data` | |---|---|---| | `post.published` | A post went out to every account it was for | `post`: the post, as `posts.get` returns it | | `post.failed` | Publishing a post ended, and at least one account failed or could not be confirmed | `post`: the post, with `results` per account | | `account.disconnected` | An account stopped working and needs to be reconnected | `account`: the account (no tokens) | | `ping` | You clicked **Send test** | `message` | Each event goes to every webhook that asked for it; a `ping` goes only to the webhook you tested. A failed post that is published again later sends a new event. ## What arrives A `POST` with a JSON body. This test event was captured as it arrived: ```http POST /pickpost-webhook HTTP/1.1 Content-Type: application/json User-Agent: Pickpost-Webhooks/1 Pickpost-Event: ping Pickpost-Delivery: whd_d5c6c46ff45c4c57 Pickpost-Signature: t=1790471647,v1=04237f94dd56a46b9b069830c005f41c73a66363418364ffaad74da3ff36259e Content-Length: 166 {"id":"evt_b0f6e369f33c4167","type":"ping","createdAt":"2026-09-27T01:14:02.435698Z","workspaceId":"20b9511c8bcb4203","data":{"message":"Webhook test from Pickpost"}} ``` Every event has the same envelope: `id` (the event), `type`, `createdAt`, `workspaceId` and `data`. A `post.published` body looks like this: ```json { "id": "evt_36a0f162588e4bff", "type": "post.published", "createdAt": "2026-09-27T00:48:51.682264Z", "workspaceId": "20b9511c8bcb4203", "data": { "post": { "id": "36a2e7fa9147424d", "status": "Published", "accountIds": ["fd3c012b05d54320"], "versions": [{ "content": [{ "body": "Our autumn menu is live.", "media": [] }], "options": {} }], "scheduledAt": "2026-09-27T00:48:51.662958Z", "results": [{ "accountId": "fd3c012b05d54320", "ok": true, "externalId": "dryrun-1790470131-d3ba1352", "uncertain": false }], "updatedAt": "2026-09-27T00:48:51.681385Z" } } } ``` The example comes from a dry-run server, so the `externalId` is made up. On a real server it is the network's post id. Answer with any `2xx` status within 10 seconds. Pickpost reads only the status, not what you send back. Do slow work after answering. ## Check the signature `Pickpost-Signature` is `t=,v1=`, where the hex is the HMAC-SHA256 of `.` with your signing secret. Compute it over the raw body, exactly as received, before parsing the JSON. Refuse a delivery whose signature does not match, or whose `t` is more than 5 minutes from your clock. ```python import hmac, hashlib, time def verify(secret: str, header: str, body: bytes, tolerance: int = 300, now=None) -> bool: """body: the raw request body, exactly as received (before any JSON parsing).""" parts = dict(p.split("=", 1) for p in header.split(",")) t, sent = parts.get("t", ""), parts.get("v1", "") if not t.isdigit() or abs((now or time.time()) - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sent) ``` ```js import { createHmac, timingSafeEqual } from "node:crypto"; // body: the raw request body (a Buffer), exactly as received, before JSON.parse. export function verify(secret, header, body, toleranceSeconds = 300, now = Date.now() / 1000) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const t = Number(parts.t); if (!Number.isInteger(t) || Math.abs(now - t) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${t}.`).update(body).digest(); const sent = Buffer.from(parts.v1 ?? "", "hex"); return sent.length === expected.length && timingSafeEqual(sent, expected); } ``` Both were checked against a real delivery: the delivery passes, and a changed body or a delivery older than 5 minutes fails. ## Retries and duplicates A delivery that gets no `2xx` is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, then it is given up. Timeouts, refused connections and redirects count as failures. Pickpost does not follow redirects, so use the final URL. Deliveries are sent at least once, so the same event can arrive twice, for example when Pickpost restarts during a delivery. Keep the `id` of the events you handled and skip repeats. `Pickpost-Delivery` names one delivery to one webhook and stays the same across its retries. After 10 deliveries in a row are given up, the webhook is paused and receives nothing. Events that happen while it is paused are not kept for later. Fix the endpoint, then click **Resume**. ## The delivery log **Deliveries** under each webhook shows the last 50: the event, the state (`pending`, `sending`, `delivered`, `failed`), the attempts, the last answer or error, and when the next try is due. **Send again** queues a delivery once more, from the first attempt. The log keeps 30 days.