Webhooks
PostWire calls your server the moment a post goes out or fails on a network, and when a connected account has to be signed in again. No polling. Every plan, including Free.
post.published and one post.failed.code.Events
| Event | When |
|---|---|
post.published | A network accepted the post, from the API, the dashboard, n8n, MCP or the scheduler. For an Instagram video that is still processing, or a large YouTube/TikTok upload that continues on the next run, the event is sent when it finishes, not before. |
post.failed | A network refused the post or it could not be sent: not connected, media missing or unreachable, a plan limit, or the network's own error. status: "unknown" with code: "timed_out" means the network did not answer in time. Check the network before you post again. |
connection.reauth_required | A network said its sign-in expired or was revoked. LinkedIn sign-ins expire about every 60 days. The event includes reconnect_url. |
webhook.test | Only when you ask for it with the test call below. |
On the Free plan, networks over the 2 per post are held: they were never sent, so they send no event.
Add an endpoint
In the dashboard, go to API & MCP and then Webhooks. Or use the API. The response contains the signing secret, and it is shown only once.
curl -X POST https://postwire.io/api/webhooks \
-H "Authorization: Bearer $POSTWIRE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/postwire","events":["post.published","post.failed","connection.reauth_required"]}'
# → 201 { "webhook": { "id": "…", "url": "…", "events": […] }, "secret": "whsec_…" }
curl -X POST https://postwire.io/api/webhooks/<id>/test -H "Authorization: Bearer $POSTWIRE_API_KEY"
curl https://postwire.io/api/webhooks/<id>/deliveries -H "Authorization: Bearer $POSTWIRE_API_KEY"
curl -X DELETE https://postwire.io/api/webhooks/<id> -H "Authorization: Bearer $POSTWIRE_API_KEY"
Rules: the URL must use https and point to a public address. PostWire does not follow redirects. You can add up to 5 endpoints per account. If you leave out events, the endpoint gets all three.
What arrives
POST /hooks/postwire
Content-Type: application/json
PostWire-Event: post.published
PostWire-Delivery: 3f6c2a0e-… (the same on every retry: dedupe on it)
PostWire-Signature: t=1759489200,v1=5d1c…
{
"id": "evt_9b0f…",
"type": "post.published",
"created_at": "2026-10-03T14:00:02.118Z",
"data": {
"platform": "youtube",
"status": "published",
"post_id": "dQw4w9WgXcQ",
"url": "https://youtube.com/shorts/dQw4w9WgXcQ",
"error": null,
"code": null,
"account": { "platform": "youtube", "handle": "@acme", "brand_id": "…", "brand": "Acme" },
"brand_id": "…",
"source": "schedule",
"schedule_id": "8a1d…",
"idempotency_key": null,
"label": "Monday launch"
}
}
source is api for POST /api/post, which the dashboard, n8n and MCP also use, and schedule for a scheduled post. If you send an idempotency_key with a publish, it comes back in the event, so you can match the event to your request. The full schema is WebhookEvent in openapi.json.
Verify the signature
Compute HMAC-SHA256 with your secret over <t>.<raw body> and compare the result with v1. Use the raw bytes of the body, not re-serialized JSON. Reject a t that is more than 5 minutes old.
// Node.js (Express)
import crypto from "node:crypto";
app.post("/hooks/postwire", express.raw({ type: "application/json" }), (req, res) => {
const parts = Object.fromEntries(req.get("PostWire-Signature").split(",").map((p) => p.split("=")));
const want = crypto.createHmac("sha256", process.env.POSTWIRE_WEBHOOK_SECRET).update(`${parts.t}.${req.body}`).digest("hex");
const a = Buffer.from(want), b = Buffer.from(parts.v1 || "");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (!fresh || a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(400);
const event = JSON.parse(req.body);
// … handle event.type, dedupe on req.get("PostWire-Delivery") …
res.sendStatus(200);
});
# Python (Flask)
import hmac, hashlib, time, os
@app.post("/hooks/postwire")
def postwire_hook():
parts = dict(p.split("=", 1) for p in request.headers["PostWire-Signature"].split(","))
body = request.get_data()
want = hmac.new(os.environ["POSTWIRE_WEBHOOK_SECRET"].encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest()
if abs(time.time() - int(parts["t"])) > 300 or not hmac.compare_digest(want, parts.get("v1", "")):
return "", 400
event = request.get_json()
return "", 200
Delivery and retries
- Answer with any 2xx within 5 seconds. Do slow work after you answer.
- Anything else (a timeout, a 4xx or 5xx, a redirect) is retried after 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h and 24 h. After the 9th try the delivery is marked failed.
- A retry keeps the same
PostWire-Deliveryid. Deliveries can arrive more than once and out of order, so dedupe on that id. GET /api/webhooks/<id>/deliverieslists the last deliveries. Each entry shows its status, how many tries it took, your endpoint's last answer and the payload. The dashboard shows the same list.- Sending a webhook never delays or fails a publish.
With n8n, Make or Zapier
Add a Webhook trigger node (n8n) or a Custom webhook (Make, Zapier). Copy its production URL, add it as an endpoint, then send a test. Then branch on {{ $json.body.type }}. For example, send post.failed to Slack and connection.reauth_required to the client who has to sign in again.
Related: live status and delivery rates by network · changelog · OpenAPI · MCP server