Add an endpoint in Settings, under Webhooks, or with POST /webhooks. Ferryman sends it an HTTP POST with a JSON body for each event you subscribe to:
post.scheduled - a post was scheduled, from the API, an AI tool or the Ferryman app.post.published - a scheduled post, or a post published through the API or MCP, reached at least one platform. Platforms that failed are listed in results.post.failed - every platform failed.- A video published right away through the API or MCP uploads in the background: it is listed as
processing, and its final result is not sent as a webhook yet. Schedule a video, even a minute ahead, to get its outcome. webhook.test - sent by the Send test button in Settings.
post.scheduled
{
"id": "evt_3f9c2a",
"type": "post.scheduled",
"createdAt": "2026-11-01T18:00:00.000Z",
"data": {
"scheduledPostId": "cmv8a1b2c0001",
"platforms": [
"BLUESKY",
"THREADS"
],
"scheduledAt": "2026-11-02T14:00:00.000Z",
"text": "New guide is live",
"isThread": false,
"source": "api"
}
}
post.published
{
"id": "evt_7d41b0",
"type": "post.published",
"createdAt": "2026-11-02T14:00:07.000Z",
"data": {
"postId": "cmv8a1b2c0001",
"scheduled": true,
"platforms": [
"BLUESKY",
"THREADS"
],
"text": "New guide is live",
"results": [
{
"platform": "BLUESKY",
"success": true,
"status": "published",
"postId": "at://did:plc:abc/app.bsky.feed.post/3k",
"url": "https://bsky.app/profile/you.bsky.social/post/3k"
},
{
"platform": "THREADS",
"success": false,
"status": "failed",
"error": "Threads rejected the post"
}
]
}
}
Verify the signature
Each request carries Ferryman-Signature: t=<unix seconds>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <t>.<raw body> keyed with the endpoint's secret (whsec_..., shown once when you add the endpoint). Ferryman-Event-Id and Ferryman-Event-Type headers repeat the event's id and type.
Node.js
import crypto from "node:crypto";
// rawBody: the exact request body bytes, before JSON.parse
export function verifyFerrymanWebhook(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map(part => part.split("="))
);
const timestamp = Number(parts.t);
// Reject events older than 5 minutes (replay protection)
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const received = Buffer.from(parts.v1 ?? "", "hex");
const wanted = Buffer.from(expected, "hex");
return received.length === wanted.length && crypto.timingSafeEqual(received, wanted);
}
Retries
Answer with any 2xx status within 10 seconds. Otherwise Ferryman retries after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, 24 hours (7 attempts in total), then marks the delivery failed. Retries keep the same event id, so store ids you have handled and ignore repeats. Settings shows every endpoint's recent deliveries with their status and response code.