Docs
Docs/API Reference/Webhooks

Webhooks

Get webhook events from Octopost when a post publishes, partly fails or fails: which events exist, which publishes send them, payloads, signatures and retries.

A webhook is a URL of yours that Octopost sends a POST to when a post finishes publishing. Register one in the dashboard at https://octopost.ink/dashboard/webhooks or with the API below.

Events#

EventSent when
post.publishedThe post went out on every network.
post.partial_successThe post went out on some networks and failed on others.
post.failedThe post failed on every network.

Approvals add three more, sent whichever way the post was made: post.pending_approval (a post is held for approval), post.approved and post.rejected. Their data carries an approval object (state, createdBy, decidedBy, decidedAt, edited, autoApproved) and an empty results. See Approvals. There are no account events. Webhooks are included on every plan.

Which publishes send webhooks#

Every publish sends one, however it was started: POST /api/posts with published: true, POST /api/posts/{id}/publish, POST /api/posts/{id}/retry, a dead-letter retry (PATCH /api/failed-posts/{id}), the MCP tools (create_post, retry_failed and the others that publish), the scheduler and the queue. data.source says which one.

  • Scheduled and queued posts retry temporary errors on their own (up to 3 times, about 1, 5 and 15 minutes apart). The webhook is sent once the outcome is final, not for each automatic retry.
  • A retry only republishes the networks that aren't live yet, but the webhook describes the whole post: results has every network, including ones that were already live.
  • A call that publishes nothing (for example a retry when every network is already live) sends no webhook.

Payload#

Every delivery is a JSON body like this:

{
  "event": "post.partial_success",
  "timestamp": "2026-11-04T15:00:04.512Z",
  "data": {
    "postId": "6f1c2d3e-8a9b-4c5d-9e0f-112233445566",
    "status": "partial",
    "source": "scheduler",
    "content": "Shipping v2 today.",
    "platforms": ["twitter", "bluesky"],
    "publishedAt": "2026-11-04T15:00:04.512Z",
    "results": [
      { "platform": "twitter", "success": true, "status": "published", "postId": "1234567890", "postUrl": "https://x.com/you/status/1234567890" },
      { "platform": "bluesky", "success": false, "status": "failed", "error": "Bluesky session expired. Reconnect the account.", "errorCode": "AUTH_FAILED" }
    ],
    "poll": null
  }
}
FieldDescription
eventpost.published, post.partial_success or post.failed.
timestampWhen the event was sent.
data.postIdThe Octopost post id.
data.statusThe post's overall outcome: published (live everywhere), partial (live on some networks, or a thread that stopped part way) or failed (live nowhere). Matches event.
data.sourceWhat published it: rest (POST /api/posts), publish_now (/publish), retry (/retry), dlq (failed-post retry), mcp, scheduler or queue.
data.contentThe main post text. Networks with their own version (variant: true in results) got that instead.
data.platformsEvery network of the post, lowercase ids (twitter, bluesky, ...).
data.publishedAtWhen at least one network is live.
data.results[]One per network: platform (lowercase id), success (true when that network is fully live), status (published, partial for a thread that is only partly live, or failed), postId + postUrl when live, error + errorCode when not, and variant (true when that network got its own version of the post through variants).
data.mediaUrlsThe post's media URLs, when it has any.
data.pollThe poll, or null.

errorCode is one of the codes in Errors, for example AUTH_FAILED (reconnect the account), RATE_LIMIT, DUPLICATE_CONTENT, MEDIA_UPLOAD_FAILED, TIMEOUT or PLATFORM_ERROR. The same errors are on the post itself (GET /api/posts/{id}, results) and on its entry in GET /api/failed-posts.

Headers#

HeaderValue
Content-Typeapplication/json
User-AgentOctopost-Webhooks/1.0
X-Webhook-IDThe webhook's id.
X-Webhook-EventThe event name.
X-Webhook-Delivery-IDA new random id for each delivery attempt.
X-Webhook-TimestampUnix time (seconds) the attempt was signed.
X-Octopost-Signaturet=<timestamp>,v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>">. Use this one.
X-Webhook-Signaturesha256= followed by the hex HMAC-SHA256 of the raw body. The original signature, still sent so existing receivers keep working. It doesn't cover the timestamp, so it can't tell a replayed request from a fresh one.

Verifying the signature#

Use X-Octopost-Signature. It signs the timestamp together with the exact bytes of the body, using your webhook's secret (HMAC-SHA256, hex):

  1. Split the header on , into t and v1.
  2. Reject the request if t is more than 5 minutes (300 seconds) from your clock. This is what stops someone who captured a delivery from sending it to you again later.
  3. Compute HMAC-SHA256 of `${t}.${rawBody}` with your secret and compare it to v1 in constant time.

Verify against the raw body, before parsing it:

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verifyOctopostWebhook(rawBody: string, header: string | null, secret: string): boolean {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((p) => {
    const i = p.indexOf("=");
    return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
  }));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; // too old (or replayed)
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
  const received = Buffer.from(parts.v1, "hex");
  return received.length === expected.length && timingSafeEqual(received, expected);
}

// Example with Bun.serve
Bun.serve({
  port: 3000,
  async fetch(req) {
    const raw = await req.text();
    if (!verifyOctopostWebhook(raw, req.headers.get("x-octopost-signature"), process.env.OCTOPOST_WEBHOOK_SECRET!)) {
      return new Response("bad signature", { status: 401 });
    }
    const event = JSON.parse(raw);
    if (event.event === "post.failed") {
      // alert someone
    }
    return new Response("ok");
  },
});

Every attempt, retries included, is signed with its own fresh timestamp, so a retry an hour later still verifies. Keep your server's clock in sync (NTP).

To also drop exact duplicates inside the 5-minute window, remember each X-Webhook-Delivery-ID you have handled for a few minutes.

The older body-only signature#

Receivers written before 2026-11-04 check X-Webhook-Signature: sha256= plus the hex HMAC-SHA256 of the raw body. It is still sent on every delivery and still valid:

const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
// compare with timingSafeEqual against req.headers.get("x-webhook-signature")

Switch to X-Octopost-Signature when you can; the old header can't detect replays.

The test delivery (POST /api/webhooks/{id}/test) is signed both ways, like a real delivery.

Delivery and retries#

  • Reply with any 2xx within 10 seconds. Octopost doesn't read the body.
  • If your server can't be reached, doesn't answer within 10 seconds, or answers 5xx, 429 or 408, Octopost tries again about 1 minute later, then 5 minutes, then 30 minutes after that: 4 attempts in total.
  • Any other reply (4xx) is recorded as failed and not retried.
  • Webhooks are never turned off automatically. Turn one off with PUT /api/webhooks/{id} and "active": false.
  • Use X-Webhook-ID + data.postId + event to ignore duplicates (a retried delivery has a new X-Webhook-Delivery-ID).

Managing webhooks#

The webhook object#

{
  "id": "c0ffee00-...",
  "url": "https://example.com/hooks/octopost",
  "events": ["post.published", "post.failed"],
  "active": true,
  "createdAt": "2026-11-04T15:00:00.000Z",
  "updatedAt": "2026-11-04T15:00:00.000Z"
}

Create#

POST /api/webhooks
FieldTypeRequiredDescription
urlstringYesWhere to send events. Can't point at localhost, private IP ranges or cloud metadata addresses.
eventsstring[]YesOne or more of post.published, post.partial_success, post.failed.
activebooleanNoDefault true.
curl -X POST https://octopost.ink/api/webhooks \
  -H "Authorization: Bearer op_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/hooks/octopost", "events": ["post.published", "post.partial_success", "post.failed"] }'
{
  "success": true,
  "webhook": {
    "id": "c0ffee00-...",
    "url": "https://example.com/hooks/octopost",
    "events": ["post.published", "post.partial_success", "post.failed"],
    "active": true,
    "secret": "5f2b...64 hex characters",
    "createdAt": "2026-11-04T15:00:00.000Z"
  }
}

The secret is only returned here. Store it: no other endpoint shows it again. To get a new secret, delete the webhook and create it again.

Validation errors and a body that isn't valid JSON return 400 { "error": "Invalid request", "details": ... }.

List#

GET /api/webhooks

{ "success": true, "webhooks": [ { "id", "url", "events", "active", "createdAt", "updatedAt" } ] }

Get one#

GET /api/webhooks/{id}

{ "success": true, "webhook": { ... } }, or 404 { "error": "Webhook not found" }.

Update#

PUT /api/webhooks/{id}

Send any of url, events, active. Returns { "success": true, "webhook": { "id", "url", "events", "active", "updatedAt" } }.

Delete#

DELETE /api/webhooks/{id}

{ "success": true, "message": "Webhook deleted successfully" }. Pending retries for it are dropped.

Send a test event#

POST /api/webhooks/{id}/test

Sends this body to the webhook URL right away, signed with the webhook's secret:

{
  "event": "webhook.test",
  "timestamp": "2026-11-04T15:00:00.000Z",
  "data": { "webhook_id": "c0ffee00-...", "user_id": "..." }
}

The test has the same headers and signature as a real delivery (X-Webhook-Event: webhook.test) and isn't retried. The response tells you what your server answered:

{
  "success": true,
  "message": "Test webhook sent",
  "result": { "status": 200, "ok": true, "error": null }
}

status is 0 and error is set when your server couldn't be reached.

Related guides