FerrymanFerryman

REST API v1

Ferryman API

Publish and schedule posts to every platform you have connected to Ferryman from your own code, and get a signed webhook when each one goes out. Available on the Creator plan, trials included.

Quickstart

  1. Open Settings, expand AI Tools and generate an API key. Copy it: it is shown once.
  2. List the platforms you can post to:
export FERRYMAN_API_KEY=fky_live_...
curl https://ferryman.io/api/v1/connections -H "Authorization: Bearer $FERRYMAN_API_KEY"
  1. Schedule a post. Leave out scheduledAt to publish right away, or add "dryRun": true to see exactly what would happen without posting.
curl -X POST https://ferryman.io/api/v1/posts \
  -H "Authorization: Bearer $FERRYMAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text":"New guide is live","platforms":["BLUESKY","THREADS"],"scheduledAt":"2026-11-02T14:00:00Z"}'

Authentication

Every request sends an API key as a bearer token: Authorization: Bearer fky_live_.... Keys are created and revoked in Settings, and work for both this API and the Ferryman MCP server.

A key can post to every connected account as you. Keep it on your server, never in browser code or a public repo. If one leaks, revoke it in Settings and generate a new one.

Rate limits

  • 120 requests per minute per API key. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining.
  • 60 publishes and schedules per hour per account, shared with the MCP server.
  • 60 media uploads per hour per account.

Over a limit the API answers 429 with a Retry-After header in seconds.

Errors

Every error has the same JSON shape, with an HTTP status that matches its type:

{
  "error": {
    "type": "invalid_request",
    "message": "scheduledAt must be in the future."
  }
}
400invalid_requestA field is missing or invalid, or the post can't go to a selected platform. details lists field problems.
401unauthorizedThe Authorization header is missing, or the key is unknown or revoked.
403forbiddenThis account can't use the API (Creator plan required).
404not_foundNo scheduled post, platform group or webhook with that id on this account.
429rate_limitedToo many requests. Wait for the Retry-After header's seconds.
500internal_errorSomething went wrong on our side. Safe to retry reads; check GET /scheduled-posts before retrying a publish.

When a publish reaches some platforms but not others, the call still succeeds: each platform's outcome is in results.

Endpoints

Base URL https://ferryman.io/api/v1. Bodies are JSON. The full machine-readable description is the OpenAPI spec.

Connections

The platforms and accounts you can post to.

get/connections

List connected platforms

Every supported platform with whether it is connected, its accounts, character and image limits, and whether it takes threads.

curl https://ferryman.io/api/v1/connections \
  -H "Authorization: Bearer $FERRYMAN_API_KEY"

Media

Upload local files to post them.

post/media

Get a one-time upload URL

Returns a one-time PUT recipe valid for 10 minutes. PUT the raw file bytes to upload.url with upload.headers, then pass mediaHandle as imageUrls or videoUrl (the field named in `field`). Supported: jpeg, png, gif and webp images, mp4, mov and webm video.

FieldTypeDescription
filenamerequiredstringLocal file name, e.g. clip.mp4 (used for the extension).
contentTyperequiredstringMIME type, e.g. video/mp4 or image/png.
curl -X POST https://ferryman.io/api/v1/media \
  -H "Authorization: Bearer $FERRYMAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filename":"clip.mp4","contentType":"video/mp4"}'

Posts

Publish now or schedule for later.

post/posts

Publish or schedule a post

Without scheduledAt or useNextPostingSlot the post publishes now and the response lists each platform's result. With either, it is scheduled. Platforms that fail are listed in results; the call itself still succeeds.

FieldTypeDescription
textrequiredstringPost text or media caption.
platformsTWITTER | BLUESKY | THREADS | MASTODON | LINKEDIN | INSTAGRAM | TIKTOK | YOUTUBE | FACEBOOK | SUBSTACK | PINTEREST | TELEGRAM | DISCORD | SLACK | TUMBLR[]Target platforms. Call GET /connections first. Omit when using platformGroupId to post to the group's platforms.
platformGroupIdstringA platform group id from GET /platform-groups. Posts to the group's platforms (unless platforms is given) with its accounts and saved settings; explicit fields win.
accountSelectionsobjectSocial account ids keyed by uppercase platform name, from GET /connections. Omit to use primary accounts. Requires multi-account access.
linkedinAsDocumentbooleanLinkedIn images: true (default) publishes a swipeable PDF document; false publishes a multi-image post.
useChainedPromobooleanAppend your configured promo reply on supported platforms.
forSuperFollowersOnlybooleanX: restrict to paid subscribers.
platformContentobjectText overrides keyed by uppercase platform name. Other targets use text.
imageUrlsstring[]Ordered public HTTPS image URLs. TikTok: up to 35 photos; Instagram: up to 10; other platforms: up to 4. For several targets the smallest limit applies. Cannot combine with videoUrl.
videoUrlstringPublic HTTPS video URL. Instagram publishes it as a Reel, YouTube as a Short.
tiktokMetadataobject
instagramTrialReelbooleanPublish an Instagram video as a Trial Reel. Requires videoUrl and INSTAGRAM.
videoCoverTimestampMsintegerVideo cover frame in milliseconds (Instagram Reels, TikTok direct video).
videoCoverImageUrlstringPublic HTTPS JPEG or PNG cover for Instagram Reels, Facebook and YouTube.
publishDestinationsobjectFeed, Story, or both. Stories take text and images only.
scheduledAtstringISO 8601 time in the future. Schedules the post instead of publishing now.
useNextPostingSlotbooleanQueue into the platform group's next open posting-schedule slot instead of passing scheduledAt. Requires platformGroupId.
postingScheduleIdstringBook a specific slot from GET /platform-groups/{id}/slots: pass it with that slot's scheduledAt and platformGroupId.
dryRunbooleanValidate and return a preview of exactly what would happen, without posting or saving anything.
curl -X POST https://ferryman.io/api/v1/posts \
  -H "Authorization: Bearer $FERRYMAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text":"New guide is live","platforms":["BLUESKY","THREADS"],"scheduledAt":"2026-11-02T14:00:00Z"}'
post/threads

Publish or schedule a thread

Two or more posts. Native threads on X, Bluesky, Threads and Mastodon; LinkedIn and Facebook merge the posts; Instagram makes a screenshot carousel (at most 10 posts); Discord and Slack post them in order.

FieldTypeDescription
postsrequiredobject[]Ordered thread posts.
platformsTWITTER | BLUESKY | THREADS | MASTODON | LINKEDIN | INSTAGRAM | TIKTOK | YOUTUBE | FACEBOOK | SUBSTACK | PINTEREST | TELEGRAM | DISCORD | SLACK | TUMBLR[]Target platforms. Call GET /connections first. Omit when using platformGroupId to post to the group's platforms.
platformGroupIdstringA platform group id from GET /platform-groups. Posts to the group's platforms (unless platforms is given) with its accounts and saved settings; explicit fields win.
accountSelectionsobjectSocial account ids keyed by uppercase platform name, from GET /connections. Omit to use primary accounts. Requires multi-account access.
linkedinAsDocumentbooleanLinkedIn images: true (default) publishes a swipeable PDF document; false publishes a multi-image post.
useChainedPromobooleanAppend your configured promo reply on supported platforms.
forSuperFollowersOnlybooleanX: restrict to paid subscribers.
scheduledAtstringISO 8601 time in the future. Schedules the post instead of publishing now.
useNextPostingSlotbooleanQueue into the platform group's next open posting-schedule slot instead of passing scheduledAt. Requires platformGroupId.
postingScheduleIdstringBook a specific slot from GET /platform-groups/{id}/slots: pass it with that slot's scheduledAt and platformGroupId.
dryRunbooleanValidate and return a preview of exactly what would happen, without posting or saving anything.
curl -X POST https://ferryman.io/api/v1/threads \
  -H "Authorization: Bearer $FERRYMAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"posts":[{"text":"1/ How we cut posting time in half"},{"text":"2/ Write once, schedule everywhere"}],"platforms":["TWITTER","BLUESKY"]}'

Scheduled posts

Your queue of upcoming posts.

get/scheduled-posts

List upcoming scheduled posts

The next 50 pending posts, soonest first.

curl https://ferryman.io/api/v1/scheduled-posts \
  -H "Authorization: Bearer $FERRYMAN_API_KEY"
patch/scheduled-posts/{id}

Move a scheduled post

Changes only the time. Content, media and targets stay the same.

  • id (path) - The scheduled post id.
FieldTypeDescription
scheduledAtrequiredstringISO 8601 time in the future. Schedules the post instead of publishing now.
curl -X PATCH https://ferryman.io/api/v1/scheduled-posts/cmv8a1b2c0001 \
  -H "Authorization: Bearer $FERRYMAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"scheduledAt":"2026-11-03T09:30:00Z"}'
delete/scheduled-posts/{id}

Cancel a scheduled post

  • id (path) - The scheduled post id.
curl -X DELETE https://ferryman.io/api/v1/scheduled-posts/cmv8a1b2c0001 \
  -H "Authorization: Bearer $FERRYMAN_API_KEY"

Platform groups

Saved sets of platforms and their weekly posting schedules.

get/platform-groups

List platform groups

Saved sets of platforms with their accounts, post settings, weekly posting schedule and next open slot.

curl https://ferryman.io/api/v1/platform-groups \
  -H "Authorization: Bearer $FERRYMAN_API_KEY"
get/platform-groups/{id}/slots

List open posting slots

Upcoming times from the group's weekly schedule that no post has taken yet.

  • id (path) - The platform group id.
  • days (query) - How many days ahead to look.
  • limit (query) - Maximum slots to return.
curl https://ferryman.io/api/v1/platform-groups/cmv8a1b2c0001/slots \
  -H "Authorization: Bearer $FERRYMAN_API_KEY"
put/platform-groups/{id}/posting-schedule

Create or replace a posting schedule

Times are 24-hour HH:mm in the given IANA timezone, listed per weekday. Posts already scheduled keep their times.

  • id (path) - The platform group id.
FieldTypeDescription
timezonerequiredstringIANA timezone, e.g. America/New_York.
timesrequiredobjectPosting times per weekday. Omitted days have no slots.
namestring
dryRunbooleanValidate and return a preview of exactly what would happen, without posting or saving anything.
curl -X PUT https://ferryman.io/api/v1/platform-groups/cmv8a1b2c0001/posting-schedule \
  -H "Authorization: Bearer $FERRYMAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"timezone":"America/New_York","times":{"monday":["09:00","17:30"],"wednesday":["12:00"]}}'
delete/platform-groups/{id}/posting-schedule

Remove a posting schedule

Posts already scheduled into its slots stay scheduled.

  • id (path) - The platform group id.
curl -X DELETE https://ferryman.io/api/v1/platform-groups/cmv8a1b2c0001/posting-schedule \
  -H "Authorization: Bearer $FERRYMAN_API_KEY"

Webhooks

Endpoints that receive signed events when posts are scheduled, published or fail.

get/webhooks

List webhook endpoints

curl https://ferryman.io/api/v1/webhooks \
  -H "Authorization: Bearer $FERRYMAN_API_KEY"
post/webhooks

Add a webhook endpoint

The response carries the endpoint's signing secret. It is shown only once: store it to verify the Ferryman-Signature header.

FieldTypeDescription
urlrequiredstringPublic HTTPS URL that receives the events.
eventsrequiredpost.scheduled | post.published | post.failed[]
descriptionstring
curl -X POST https://ferryman.io/api/v1/webhooks \
  -H "Authorization: Bearer $FERRYMAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/ferryman/webhook","events":["post.published","post.failed"]}'
delete/webhooks/{id}

Delete a webhook endpoint

  • id (path) - The webhook endpoint id.
curl -X DELETE https://ferryman.io/api/v1/webhooks/cmv8a1b2c0001 \
  -H "Authorization: Bearer $FERRYMAN_API_KEY"

Webhooks

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.