Skip to content

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.

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.

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.

A POST with a JSON body. This test event was captured as it arrived:

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:

{
"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.

Pickpost-Signature is t=<unix seconds>,v1=<hex>, where the hex is the HMAC-SHA256 of <t>.<body> 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.

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)
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.

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.

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.