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.
- Register your receiver with
POST /webhook-endpointsand store thesecretfrom the response: it is only returned that once. - Send yourself a sample with
POST /webhook-endpoints/{id}/testand the body{ "sampleEvent": "post.published" }. - In your receiver, verify the signature on the raw body, deduplicate on
id, and answer2xxwithin 15 seconds.
Events
| Event | When PostFast sends it | data |
|---|---|---|
post.published | A post reaches PUBLISHED. | { post } |
post.failed | A 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.connected | An account is connected or reconnected, through OAuth, a connect link or the page selection step. | { account, reconnected, externalId? } |
account.disconnected | PostFast pauses an account: a revoked or expired token, a suspension, an action block. | { account } |
webhook.test | Only 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.
mediaItemsindata.postcarry storage keys, not URLs.post.failed:lastError.codeisMISSED_DISCONNECTEDwhen the post's time passed while its account was disconnected, andMISSED_NOT_PUBLISHEDwhen 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 nocodeat all.post.publishedon TikTok:platformPostIdcan still benullwhen the event is sent, and no second event follows when it arrives, so read it later fromGET /social-posts.- First comments post after publishing, so
firstCommentErrorisnullinpost.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.reconnectedistruewhen PostFast already had the account, andexternalIdis present only when the connect link carried one.account.connectedon Facebook: the login sends one event for the personal profile before the Pages send theirs. That profile cannot be posted to and is not inGET /social-media/my-social-accounts, so check an account against that list before you treat it as usable.account.disconnected:data.account.disabledReasonsays 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 latesttimestampfor a post, or read it back withGET /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.
| Header | Value |
|---|---|
webhook-id | The event's id (evt_<uuid>), the same as in the body. It stays the same on every retry. |
webhook-timestamp | When this attempt was sent, in unix seconds. It is new on every attempt. |
webhook-signature | v1,<base64>. It is new on every attempt, because the timestamp is part of what is signed. |
content-type | application/json |
user-agent | PostFast-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 "", 204These 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
2xxwithin 15 seconds. The response body is ignored. - Redirects are not followed: a
3xxcounts 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:
| Attempt | Wait before it |
|---|---|
| 1 | None |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 4 hours |
| 7 | 6 hours |
| 8 | 7 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 Goneturns the endpoint off at once, withactive: falseanddisabledReason: 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
2xxends this. - Once a failing endpoint's streak is 5 days old with no success since it began, PostFast disables it, with
active: falseanddisabledReason: 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}showsactive,disabledReason,lastSuccessAtandlastFailureAt.- 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
idif 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
| Route | What it does | Rate limit |
|---|---|---|
POST /webhook-endpoints | Registers 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-endpoints | Lists 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}/test | Sends 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.localor.internal. - The hostname must resolve to public addresses.
- At most 2,048 characters.
Errors
| Status | message | When |
|---|---|---|
400 | webhook.invalidUrl | The url is not a URL PostFast can read, or it is null on an update. |
400 | webhook.urlNotHttps | The url is not https. |
400 | webhook.urlHostNotAllowed | The hostname is an IP address, localhost, has no dot, or uses an internal suffix such as .local or .internal, or the URL carries credentials. |
400 | webhook.urlPortNotAllowed | The url uses a port other than 443. |
400 | webhook.urlPrivateAddress | The hostname resolves to an address that is not public. |
400 | webhook.urlResolutionFailed | The hostname does not resolve. |
400 | webhook.invalidEvents | events is null on an update. |
400 | webhook.limitReached | The workspace already has 5 active endpoints. Applies when creating an endpoint and when re-enabling one. |
400 | webhook.disabled | Test route only: webhooks are switched off across PostFast for the moment. |
404 | webhook.notFound | No 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
sampleEventthe 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 imageimage/00000000-0000-4000-8000-000000000000.jpg, platformX,platformPostIdsample-platform-post-idwhen published, alastErrorwith codeSAMPLEwhen failed, and the account@sampleonX. No customer data is read for it. - The sample account's
followerCountis the number0. On a real event it is a numeric string, as onGET /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
410turns 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
error | What happened |
|---|---|
null | The receiver answered 2xx. |
timeout | No answer within 15 seconds. |
connection | PostFast could not connect to the receiver. |
tls | The receiver's certificate or TLS setup was refused. |
redirect | The receiver answered 3xx. Redirects are not followed. |
http_4xx | The receiver answered 4xx, other than 410. |
gone | The receiver answered 410, which also turns the endpoint off. |
http_5xx | The receiver answered with any other status, typically 5xx. |
blocked_address | The 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.