Webhooks

PostFast sends a signed HTTPS POST to your server when a post publishes or fails for good, or when an account is connected, reconnected or disconnected, so your integration can react without polling the API.

Webhooks are on every plan, including the 7-day trial. You register endpoints through the REST API with your workspace API key.

  1. Register your receiver with POST /webhook-endpoints and store the secret from the response: it is only returned that once.
  2. Send yourself a sample with POST /webhook-endpoints/{id}/test and the body { "sampleEvent": "post.published" }.
  3. In your receiver, verify the signature on the raw body, deduplicate on id, and answer 2xx within 15 seconds.

Events

EventWhen PostFast sends itdata
post.publishedA post reaches PUBLISHED.{ post }
post.failedA post fails for good: after its last automatic retry, on an error a retry cannot fix, or when PostFast gives up on a post that missed its time. Never for a failure that will still be retried.{ post }
account.connectedAn account is connected or reconnected, through OAuth, a connect link or the page selection step.{ account, reconnected, externalId? }
account.disconnectedPostFast pauses an account: a revoked or expired token, a suspension, an action block.{ account }
webhook.testOnly from the test route. You cannot subscribe to it.{ webhook: { id, url }, message }

data.post is the same object as an item of GET /social-posts, and data.account the same object as an item of GET /social-media/my-social-accounts. Post events carry no data.account.

An endpoint receives the events it subscribed to for the whole workspace, however the post was created. To act on one account, check data.post.socialMediaId or data.post.platform, or data.account.id, in your receiver.

  • mediaItems in data.post carry storage keys, not URLs.
  • post.failed: lastError.code is MISSED_DISCONNECTED when the post's time passed while its account was disconnected, and MISSED_NOT_PUBLISHED when it went more than 2 hours past its time without publishing. Other failures carry the platform's own code when there is one, and some have no code at all.
  • post.published on TikTok: platformPostId can still be null when the event is sent, and no second event follows when it arrives, so read it later from GET /social-posts.
  • First comments post after publishing, so firstCommentError is null in post.published, and a first comment that fails sends no event.
  • Posts imported from a platform when an account connects send no events.
  • account.connected: one event per account, so a Facebook or LinkedIn page selection can send several. reconnected is true when PostFast already had the account, and externalId is present only when the connect link carried one.
  • account.connected on Facebook: the login sends one event for the personal profile before the Pages send theirs. That profile cannot be posted to and is not in GET /social-media/my-social-accounts, so check an account against that list before you treat it as usable.
  • account.disconnected: data.account.disabledReason says why. Removing an account from PostFast sends no event.
  • A post can change after its event. TikTok can still publish a post PostFast marked as failed, a TikTok draft can fail after post.published, and a post someone retries can fail again. Go by the latest timestamp for a post, or read it back with GET /social-posts.

post.published

{
  "id": "evt_1f0b9c5e-7d4a-4b6e-9a2d-3c8e1f5a7b90",
  "type": "post.published",
  "timestamp": "2026-11-10T09:00:04.812Z",
  "workspaceId": "7b8f2a10-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
  "data": {
    "post": {
      "id": "9c2f7e40-3b1a-4d5e-8f6a-1b2c3d4e5f60",
      "content": "New drop is live.",
      "status": "PUBLISHED",
      "approvalStatus": "APPROVED",
      "socialMediaId": "4a1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "platform": "INSTAGRAM",
      "mediaItems": [
        {
          "key": "image/a1b2c3d4-e5f6-7890-1234-567890abcdef.png",
          "type": "IMAGE",
          "sortOrder": 0
        }
      ],
      "scheduledAt": "2026-11-10T09:00:00.000Z",
      "publishedAt": "2026-11-10T09:00:04.512Z",
      "failedAt": null,
      "platformPostId": "18061253087412345",
      "groupId": "c3d4e5f6-7890-1234-5678-90abcdef1234",
      "firstComment": null,
      "firstCommentError": null,
      "controls": {
        "threadsTopicTag": null,
        "instagramPublishType": "TIMELINE",
        "facebookContentType": "POST",
        "tiktokIsDraft": false,
        "youtubePrivacy": "PUBLIC"
      },
      "lastError": null
    }
  }
}

post.failed

{
  "id": "evt_6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
  "type": "post.failed",
  "timestamp": "2026-11-10T11:05:00.214Z",
  "workspaceId": "7b8f2a10-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
  "data": {
    "post": {
      "id": "9c2f7e40-3b1a-4d5e-8f6a-1b2c3d4e5f60",
      "content": "New drop is live.",
      "status": "FAILED",
      "approvalStatus": "APPROVED",
      "socialMediaId": "4a1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "platform": "INSTAGRAM",
      "mediaItems": [
        {
          "key": "image/a1b2c3d4-e5f6-7890-1234-567890abcdef.png",
          "type": "IMAGE",
          "sortOrder": 0
        }
      ],
      "scheduledAt": "2026-11-10T09:00:00.000Z",
      "publishedAt": null,
      "failedAt": "2026-11-10T11:05:00.180Z",
      "platformPostId": null,
      "groupId": "c3d4e5f6-7890-1234-5678-90abcdef1234",
      "firstComment": null,
      "firstCommentError": null,
      "controls": {
        "threadsTopicTag": null,
        "instagramPublishType": "TIMELINE",
        "facebookContentType": "POST",
        "tiktokIsDraft": false,
        "youtubePrivacy": "PUBLIC"
      },
      "lastError": {
        "message": "This post was not published in time (the account may have been disconnected) and was marked failed. You can retry it.",
        "code": "MISSED_NOT_PUBLISHED"
      }
    }
  }
}

account.connected

{
  "id": "evt_2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
  "type": "account.connected",
  "timestamp": "2026-11-10T08:30:12.402Z",
  "workspaceId": "7b8f2a10-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
  "data": {
    "account": {
      "id": "4a1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "platform": "INSTAGRAM",
      "platformUsername": "postfast_app",
      "displayName": "PostFast Official",
      "connectionStatus": "CONNECTED",
      "inboxCapable": true,
      "disabledReason": null,
      "followerCount": "12480",
      "followerCountUpdatedAt": "2026-11-10T00:00:00.000Z"
    },
    "reconnected": false,
    "externalId": "client-42"
  }
}

account.disconnected

{
  "id": "evt_3c4d5e6f-7081-4923-ab4c-5d6e7f809102",
  "type": "account.disconnected",
  "timestamp": "2026-11-12T07:15:41.930Z",
  "workspaceId": "7b8f2a10-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
  "data": {
    "account": {
      "id": "4a1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "platform": "INSTAGRAM",
      "platformUsername": "postfast_app",
      "displayName": "PostFast Official",
      "connectionStatus": "DISABLED",
      "inboxCapable": true,
      "disabledReason": "TOKEN_REVOKED",
      "followerCount": "12480",
      "followerCountUpdatedAt": "2026-11-12T00:00:00.000Z"
    }
  }
}

webhook.test

{
  "id": "evt_4d5e6f70-8192-4a34-bc5d-6e7f80910213",
  "type": "webhook.test",
  "timestamp": "2026-11-10T08:59:30.118Z",
  "workspaceId": "7b8f2a10-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
  "data": {
    "webhook": {
      "id": "0d3c9d2e-5e4c-4c8a-9d2e-2b1f0a7c6e11",
      "url": "https://example.com/webhooks/postfast"
    },
    "message": "Test delivery from PostFast"
  }
}

Envelope and headers

Every delivery is a POST whose body has the same five fields: id (evt_<uuid>), type (the event name), timestamp (when the event was recorded, ISO 8601), workspaceId, and data. The body is byte-for-byte the same on every retry of an event.

HeaderValue
webhook-idThe event's id (evt_<uuid>), the same as in the body. It stays the same on every retry.
webhook-timestampWhen this attempt was sent, in unix seconds. It is new on every attempt.
webhook-signaturev1,<base64>. It is new on every attempt, because the timestamp is part of what is signed.
content-typeapplication/json
user-agentPostFast-Webhooks/1.0

Verify the signature

Deliveries are signed to the Standard Webhooks spec, so any Standard Webhooks library verifies them. Use standardwebhooks on npm or PyPI: pass it the endpoint's whsec_... secret, the raw request body and the request headers.

Verify the raw bytes, never a body you parsed and serialised again: the same JSON with different spacing or key order fails.

Node.js (npm install express standardwebhooks)

import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const webhook = new Webhook(process.env.POSTFAST_WEBHOOK_SECRET); // the whsec_... value

// express.raw keeps the exact bytes the signature was made over.
// Do not put express.json() in front of this route.
app.post("/webhooks/postfast", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body, req.headers);
  } catch {
    return res.status(400).send("Invalid signature");
  }

  // Deduplicate on event.id, queue the slow work, and answer right away.
  console.log(event.type, event.id);
  res.sendStatus(204);
});

app.listen(3000);

Python (pip install flask standardwebhooks)

import os

from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
webhook = Webhook(os.environ["POSTFAST_WEBHOOK_SECRET"])  # the whsec_... value


@app.post("/webhooks/postfast")
def postfast_webhook():
    # get_data() returns the exact bytes the signature was made over.
    try:
        event = webhook.verify(request.get_data(), request.headers)
    except WebhookVerificationError:
        return "Invalid signature", 400

    # Deduplicate on event["id"], queue the slow work, and answer right away.
    print(event["type"], event["id"])
    return "", 204

These libraries also refuse a webhook-timestamp more than 5 minutes away from your server's clock, which protects against replays, so keep that clock in sync.

To check by hand: take the secret without whsec_ and base64-decode it, compute an HMAC-SHA256 with it over <webhook-id>.<webhook-timestamp>.<raw body>, base64-encode the result, and compare it with the part of webhook-signature after v1,.

Delivery and retries

  • A delivery succeeds when your receiver answers any 2xx within 15 seconds. The response body is ignored.
  • Redirects are not followed: a 3xx counts as a failure, like any other status or a connection error.
  • Delivery is at-least-once, so the same event can arrive twice: deduplicate on id. Order is not guaranteed, and there is no promise about how quickly an event arrives.
  • PostFast sends at most 2 deliveries at a time to one endpoint.

A failed delivery is retried, up to 8 attempts in all:

AttemptWait before it
1None
25 seconds
35 minutes
430 minutes
52 hours
64 hours
76 hours
87 hours

Each wait varies by up to 20%, so the last attempt lands within about a day. After attempt 8, PostFast stops trying that event.

A Retry-After header on a 429 or 503 replaces the next wait, up to 1 hour.

When PostFast turns an endpoint off

  • A 410 Gone turns the endpoint off at once, with active: false and disabledReason: GONE, and the delivery is not retried. No email goes out for this one.
  • After 20 failures in a row the endpoint counts as failing: new deliveries wait 5 minutes and go one at a time. Any 2xx ends this.
  • Once a failing endpoint's streak is 5 days old with no success since it began, PostFast disables it, with active: false and disabledReason: FAILING. The workspace owner gets one email with the endpoint's host, when the failures started, the last status code and error, and the command to re-enable it.

Turning it back on

Fix the receiver, then send { "active": true } to PATCH /webhook-endpoints/{id}. That re-enables the endpoint and resets its failure state. Events that happened while it was off are not replayed, and retries that came due while it was off are dropped.

What you can and cannot look up

  • GET /webhook-endpoints/{id} shows active, disabledReason, lastSuccessAt and lastFailureAt.
  • PostFast keeps its record of each delivery for 30 days. The API has no route to read those records or to resend an event, so log what your receiver gets, and quote the event's id if you contact support.
  • Deliveries come from PostFast's servers, and their addresses are not fixed, so there are no IP addresses to allow. Trust the signature instead.

Register an endpoint

RouteWhat it doesRate limit
POST /webhook-endpointsRegisters an HTTPS endpoint to receive signed webhook events. The response carries the signing secret, the only time it is returned.180 requests per hour
GET /webhook-endpointsLists the workspace's webhook endpoints, oldest first, disabled ones included.6,000 requests per day, 600 per hour
GET /webhook-endpoints/{id}Fetches one webhook endpoint. Returns 404 once it has been deleted.6,000 requests per day, 600 per hour
PATCH /webhook-endpoints/{id}Changes an endpoint's URL, events or description, or turns it off and on. active: true re-enables a disabled endpoint and resets its failure state.180 requests per hour
DELETE /webhook-endpoints/{id}Deletes a webhook endpoint. Deliveries still waiting for a retry are dropped.180 requests per hour
POST /webhook-endpoints/{id}/testSends one signed delivery to the endpoint and returns the result: webhook.test, or a sample of any event with sampleEvent.600 requests per day, 60 per hour, 10 per minute

Any window a route does not set uses the default limits, and each route has its own budget. See /docs#rate-limiting.

Each route has its own reference page with its request and response fields: create, list, get, update, delete and test.

Limit: 5 active endpoints per workspace. Disabled ones do not count. If you need more, ask support.

The secret: POST /webhook-endpoints returns it once, and no other route does. If you lose it, delete the endpoint and create a new one.

The same URL twice: a URL can be registered more than once. Each registration is a separate endpoint with its own secret and its own delivery of each event it subscribes to, so one endpoint's secret fails on the other's deliveries. If a create call fails without a response, check GET /webhook-endpoints before you try again, because the endpoint may already exist.

URL rules, checked when you save the URL and again each time PostFast connects:

  • https, on port 443.
  • A hostname with a dot in it. No IP addresses, no credentials in the URL, no localhost, and no internal suffixes such as .local or .internal.
  • The hostname must resolve to public addresses.
  • At most 2,048 characters.

Errors

StatusmessageWhen
400webhook.invalidUrlThe url is not a URL PostFast can read, or it is null on an update.
400webhook.urlNotHttpsThe url is not https.
400webhook.urlHostNotAllowedThe hostname is an IP address, localhost, has no dot, or uses an internal suffix such as .local or .internal, or the URL carries credentials.
400webhook.urlPortNotAllowedThe url uses a port other than 443.
400webhook.urlPrivateAddressThe hostname resolves to an address that is not public.
400webhook.urlResolutionFailedThe hostname does not resolve.
400webhook.invalidEventsevents is null on an update.
400webhook.limitReachedThe workspace already has 5 active endpoints. Applies when creating an endpoint and when re-enabling one.
400webhook.disabledTest route only: webhooks are switched off across PostFast for the moment.
404webhook.notFoundNo endpoint with this id in the workspace: it was deleted, it never existed, or it belongs to another workspace.

Other problems with the body (a missing url, an unknown or repeated event, a description that is too long) return the usual validation 400, with message as an array of strings.

A webhook.* error looks like this:

{
  "statusCode": 400,
  "message": "webhook.limitReached",
  "error": "BAD_REQUEST",
  "description": "Bad Request"
}

Test an endpoint

POST /webhook-endpoints/{id}/test sends one signed delivery while you wait and returns { eventId, type, delivered, statusCode, latencyMs, error }. The status is 200 even when your receiver failed, so read the result. The delivery is not retried.

Without a body it sends webhook.test. With { "sampleEvent": "<event>" } it sends a sample of any of the four events, whether or not the endpoint subscribes to it.

  • With sampleEvent the delivery is a fixed sample of that event in the real shape: ids of zeros (00000000-0000-4000-8000-000000000000), content "Sample post from PostFast", one image image/00000000-0000-4000-8000-000000000000.jpg, platform X, platformPostId sample-platform-post-id when published, a lastError with code SAMPLE when failed, and the account @sample on X. No customer data is read for it.
  • The sample account's followerCount is the number 0. On a real event it is a numeric string, as on GET /social-media/my-social-accounts.
  • A test counts like a real delivery: a failure adds to the endpoint's failure streak, a success clears it, and a 410 turns the endpoint off.
  • The route also works on a disabled endpoint, which is a way to check a fixed receiver before re-enabling it. A test never turns the endpoint back on.

Values of error

errorWhat happened
nullThe receiver answered 2xx.
timeoutNo answer within 15 seconds.
connectionPostFast could not connect to the receiver.
tlsThe receiver's certificate or TLS setup was refused.
redirectThe receiver answered 3xx. Redirects are not followed.
http_4xxThe receiver answered 4xx, other than 410.
goneThe receiver answered 410, which also turns the endpoint off.
http_5xxThe receiver answered with any other status, typically 5xx.
blocked_addressThe hostname now resolves to an address that is not public.

Integration notes

  • One endpoint per integration is the model. Each one receives its events for every account in the workspace.
  • Identify your client with X-PostFast-Client: <name>/<version> on your API requests. It is optional, and it lets support tell your integration apart from others.