Webhooks
Webhooks
Register an endpoint, verify signed deliveries, and handle retries for Onepostly events.
Register an HTTPS endpoint, and Onepostly sends it a signed POST for each subscribed event, such as a published post, a new comment or an account to reconnect.
Set Up an Endpoint
Create the Endpoint
Create it under Webhooks in the dashboard or with the API. Send the URL and events from the Event Catalog, or ["*"] for every event, even future ones.
import { Configuration, WebhooksApi } from "@onepostly/sdk";
const webhooksApi = new WebhooksApi(
new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);
const response = await webhooksApi.createWebhook({
createWebhookBody: {
"name": "Production",
"url": "https://example.com/hooks/onepostly",
"events": [
"post.published",
"post.failed",
"account.expired",
"account.revoked"
]
},
});
console.log(response);{
"id": "whk_e5c7a3f18b2d4e69a04c3f9d1b7e2c58",
"name": "Production",
"url": "https://example.com/hooks/onepostly",
"enabled": true,
"events": [
"post.published",
"post.failed",
"account.expired",
"account.revoked"
],
"createdAt": "2027-03-14T16:20:00.000Z",
"updatedAt": "2027-03-14T16:20:00.000Z",
"secret": "q3Vt8kRz1mXo5bJw7nLc2yHd9sAf4gPe"
}Store the Secret
Only this 201 and Rotate the Secret return secret. Store it right away, because it cannot be read again. To pick your own, send secret on create.
Verify and Acknowledge Each Delivery
Verify Onepostly-Signature against the raw body. Reply with any 2xx within 10 seconds, and do any slow work after you reply.
Send a Test Event
Send a webhook.test event, whatever the endpoint subscribes to.
import { Configuration, WebhooksApi } from "@onepostly/sdk";
const webhooksApi = new WebhooksApi(
new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);
const response = await webhooksApi.testWebhook({
id: "whk_e5c7a3f18b2d4e69a04c3f9d1b7e2c58",
});
console.log(response);{
"sent": true
}sent: true means the delivery started. See its result in the delivery log. On sent: false, send it again. A paused endpoint records the test as failed.
The test event's data looks like this.
{
"object": {
"message": "Onepostly webhook test event",
"webhookId": "whk_e5c7a3f18b2d4e69a04c3f9d1b7e2c58"
}
}Endpoint Rules
| Field | Rule |
|---|---|
url | Public HTTPS, no credentials, up to 2,048 characters |
name | 1 to 120 characters |
events | Event names, or ["*"] alone |
secret | Optional, 16 to 128 characters. Generated if left out |
enabled | Optional, default true. false creates it paused |
Every write, even a test, rotation or retry, needs a read_write API key.
Manage Endpoints
| Method | Path | Description |
|---|---|---|
GET | /v1/webhooks/events | Lists every event name |
GET | /v1/webhooks | Lists webhooks, newest first |
POST | /v1/webhooks | Creates a webhook, with its secret |
GET | /v1/webhooks/{id} | Gets one webhook |
PATCH | /v1/webhooks/{id} | Changes name, url, events or enabled |
DELETE | /v1/webhooks/{id} | Deletes it and its delivery log |
POST | /v1/webhooks/{id}/rotate-secret | Replaces the secret |
POST | /v1/webhooks/{id}/test | Sends a webhook.test event |
GET | /v1/webhooks/{id}/deliveries | Lists its deliveries |
GET | /v1/webhooks/{id}/deliveries/{deliveryId} | Gets one delivery with its body |
POST | /v1/webhooks/{id}/deliveries/{deliveryId}/retry | Sends a finished delivery again |
PATCH changes only the fields you send, and replaces events whole. It cannot change the secret. enabled: false pauses an endpoint. Events from that time are never sent, and pending deliveries end as failed.
Payload
Every delivery body has these fields. data.object is the event's object as it was at that time. A post event carries the whole post, as GET /v1/posts/{id} returns it. Objects can gain fields, so ignore unknown ones.
Prop
Type
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Onepostly-Webhooks/1.0 |
Onepostly-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Onepostly-Event | The event name, same as type |
Onepostly-Event-Id | The event id, same as id |
Onepostly-Delivery | The delivery id, the same on every attempt |
Sample Delivery
POST /hooks/onepostly HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Onepostly-Webhooks/1.0
Onepostly-Signature: t=1805101204,v1=48267c482b923179ab3b38f14e593896bfa7fde216b400b4ad697749d3ba95ef
Onepostly-Event: post.published
Onepostly-Event-Id: evt_7c41e09a5b3d4f2e8a6c1b9d0e3f5a72
Onepostly-Delivery: dlv_9f3e6b2a4c1d4a8eb7f51d2c8e0a6b49
{
"id": "evt_7c41e09a5b3d4f2e8a6c1b9d0e3f5a72",
"type": "post.published",
"createdAt": "2027-03-15T09:00:04.412Z",
"organizationId": "5b0e7c1a-2f4d-4c8e-9a3b-6d1f0e2c4a7b",
"data": {
"object": {
"id": "pst_3c8a2d6e91b44f7a8e250d6b4c1f9a73",
"groupId": "grp_7d2e5b1a9c4f4a68b3e07f1c2d9a6e45",
"accountId": "acc_YOUR_X_ACCOUNT_ID",
"platform": "x",
"source": "onepostly",
"status": "published",
"text": "New release notes are up.",
"mediaKind": "text",
"media": [],
"thread": [],
"scheduledFor": null,
"timezone": null,
"options": {},
"platformPostId": "1900876543210987654",
"url": "https://x.com/i/web/status/1900876543210987654",
"error": null,
"publishedAt": "2027-03-15T09:00:04.000Z",
"metrics": null,
"createdAt": "2027-03-15T09:00:00.000Z",
"updatedAt": "2027-03-15T09:00:04.000Z"
}
}
}The real body is compact JSON. Always verify the exact bytes you receive.
Verify Signatures
Each delivery is signed with the endpoint's secret. Before you trust the body, verify the signature in these four steps.
Split Onepostly-Signature on , into t, the Unix seconds, and v1, the hex signature.
Reject it if t is more than 300 seconds from your clock.
Compute HMAC-SHA256 of t, . and the raw body, keyed with the UTF-8 secret.
Compare your hex digest with v1 in constant time.
Timestamp Tolerance
The timestamp is signed, so the age check stops replayed deliveries. Each attempt gets a fresh t, so real retries pass. Keep your clock synced with NTP.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyOnepostlySignature(
secret: string,
rawBody: string | Buffer,
header: string | null | undefined,
): boolean {
if (!header) return false;
const parts = new Map(
header.split(",").map((part) => {
const index = part.indexOf("=");
return [part.slice(0, index).trim(), part.slice(index + 1).trim()] as const;
}),
);
const timestamp = Number(parts.get("t"));
const signature = parts.get("v1");
if (!Number.isInteger(timestamp) || !signature) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
const received = Buffer.from(signature, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}In Express, use express.raw, not express.json. With a global express.json(), register the webhook route before it.
import express from "express";
import { verifyOnepostlySignature } from "./verify-onepostly";
const app = express();
app.post(
"/hooks/onepostly",
express.raw({ type: "application/json" }),
(req, res) => {
const valid = verifyOnepostlySignature(
process.env.ONEPOSTLY_WEBHOOK_SECRET!,
req.body,
req.get("Onepostly-Signature"),
);
if (!valid) {
res.status(401).end();
return;
}
const event = JSON.parse(req.body.toString("utf8"));
// Hand `event` to your own background processing, then acknowledge.
res.status(200).end();
},
);In Next.js, read the body with await req.text() first.
import { verifyOnepostlySignature } from "@/lib/verify-onepostly";
export async function POST(req: Request) {
const rawBody = await req.text();
const valid = verifyOnepostlySignature(
process.env.ONEPOSTLY_WEBHOOK_SECRET!,
rawBody,
req.headers.get("onepostly-signature"),
);
if (!valid) {
return new Response(null, { status: 401 });
}
const event = JSON.parse(rawBody);
// Hand `event` to your own background processing, then acknowledge.
return new Response(null, { status: 200 });
}Respond to Failures
| Situation | Respond with | What happens |
|---|---|---|
| The signature is not valid | 401 | Not retried. The delivery ends as failed |
| A temporary problem on your side | Any 5xx, such as 503 | The delivery is retried |
You already processed this id | 200 | Nothing. Skip the event |
Never return a 4xx for a temporary problem. The only 4xx statuses that are retried are 408 and 429, so reply with a 5xx instead.
Rotate the Secret
A secret cannot be read again. To replace or recover it, rotate it. The response shows the new secret once, so store it right away.
import { Configuration, WebhooksApi } from "@onepostly/sdk";
const webhooksApi = new WebhooksApi(
new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);
const response = await webhooksApi.rotateWebhookSecret({
id: "whk_e5c7a3f18b2d4e69a04c3f9d1b7e2c58",
});
console.log(response);{
"id": "whk_e5c7a3f18b2d4e69a04c3f9d1b7e2c58",
"name": "Production",
"url": "https://example.com/hooks/onepostly",
"enabled": true,
"events": [
"post.published",
"post.failed",
"account.expired",
"account.revoked"
],
"createdAt": "2027-03-14T16:20:00.000Z",
"updatedAt": "2027-03-15T12:00:00.000Z",
"secret": "Lw4nQ8xTb2Rk7pZc1vHs9mYd3fJe6gAu"
}Later attempts use the new secret, so update your handler right away. Deliveries it rejected in between end as failed, so retry them.
Delivery and Retries
| Behavior | Detail |
|---|---|
| Method | POST |
| Timeout | 10 seconds per attempt |
| Success | Any 2xx |
| Retried | 408, 429, 5xx, timeouts and network errors |
| Not retried | Any other status. Redirects are not followed |
| Attempts | 5, the first included |
| Signature | A new t each attempt, the same body |
| Attempt | Sent |
|---|---|
| 1 | When the event happens |
| 2 | 1 minute later |
| 3 | 2 minutes later |
| 4 | 3 minutes later |
| 5 | 4 minutes later |
After 5 failed attempts, about 10 minutes in, the delivery ends as failed. Retry it, or read the current state from the API.
| Case | What to do |
|---|---|
| An event arrives twice | Skip an id you already handled |
| Events arrive out of order | Compare createdAt, or read the current state |
A failing endpoint never affects publishing or other API calls.
Delivery Log
List a webhook's deliveries, newest first.
import { Configuration, WebhooksApi } from "@onepostly/sdk";
const webhooksApi = new WebhooksApi(
new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);
const response = await webhooksApi.listWebhookDeliveries({
id: "whk_e5c7a3f18b2d4e69a04c3f9d1b7e2c58",
limit: 20,
});
console.log(response);{
"data": [
{
"id": "dlv_9f3e6b2a4c1d4a8eb7f51d2c8e0a6b03",
"webhookId": "whk_e5c7a3f18b2d4e69a04c3f9d1b7e2c58",
"eventId": "evt_7c41e09a5b3d4f2e8a6c1b9d0e3f5a03",
"eventType": "account.revoked",
"status": "failed",
"attempts": 5,
"responseStatus": 503,
"lastError": "HTTP 503",
"createdAt": "2027-03-15T11:00:00.318Z",
"deliveredAt": null
},
{
"id": "dlv_9f3e6b2a4c1d4a8eb7f51d2c8e0a6b49",
"webhookId": "whk_e5c7a3f18b2d4e69a04c3f9d1b7e2c58",
"eventId": "evt_7c41e09a5b3d4f2e8a6c1b9d0e3f5a72",
"eventType": "post.published",
"status": "success",
"attempts": 1,
"responseStatus": 200,
"lastError": null,
"createdAt": "2027-03-15T09:00:04.412Z",
"deliveredAt": "2027-03-15T09:00:04.987Z"
},
{
"id": "dlv_9f3e6b2a4c1d4a8eb7f51d2c8e0a6b02",
"webhookId": "whk_e5c7a3f18b2d4e69a04c3f9d1b7e2c58",
"eventId": "evt_7c41e09a5b3d4f2e8a6c1b9d0e3f5a02",
"eventType": "post.failed",
"status": "success",
"attempts": 2,
"responseStatus": 200,
"lastError": null,
"createdAt": "2027-03-15T09:00:02.205Z",
"deliveredAt": "2027-03-15T09:01:02.640Z"
}
],
"nextCursor": null,
"hasMore": false
}| Parameter | Effect |
|---|---|
status | pending, success or failed, comma-separated |
since, until | A time window, default the last 7 days |
q | The start of an event name or id |
limit | 1 to 100, default 50 |
cursor | The previous page's nextCursor |
Prop
Type
include=facets adds counts per status, and include=histogram adds results over time. Getting one delivery adds payload, the exact body sent.
Retry a Delivery
Send a finished delivery again. The body and event id stay the same, so your duplicate check catches it. The delivery goes back to pending with attempts reset. A pending one returns 409 DELIVERY_IN_PROGRESS.
import { Configuration, WebhooksApi } from "@onepostly/sdk";
const webhooksApi = new WebhooksApi(
new Configuration({ accessToken: process.env.ONEPOSTLY_API_KEY })
);
const response = await webhooksApi.retryWebhookDelivery({
id: "whk_e5c7a3f18b2d4e69a04c3f9d1b7e2c58",
deliveryId: "dlv_9f3e6b2a4c1d4a8eb7f51d2c8e0a6b03",
});
console.log(response);{
"id": "dlv_9f3e6b2a4c1d4a8eb7f51d2c8e0a6b03",
"webhookId": "whk_e5c7a3f18b2d4e69a04c3f9d1b7e2c58",
"eventId": "evt_7c41e09a5b3d4f2e8a6c1b9d0e3f5a03",
"eventType": "account.revoked",
"status": "pending",
"attempts": 0,
"responseStatus": null,
"lastError": null,
"createdAt": "2027-03-15T11:00:00.318Z",
"deliveredAt": null,
"payload": {
"id": "evt_7c41e09a5b3d4f2e8a6c1b9d0e3f5a03",
"type": "account.revoked",
"createdAt": "2027-03-15T11:00:00.318Z",
"organizationId": "5b0e7c1a-2f4d-4c8e-9a3b-6d1f0e2c4a7b",
"data": {
"object": {
"id": "acc_YOUR_TIKTOK_ACCOUNT_ID",
"platform": "tiktok",
"handle": "yourbrand",
"status": "revoked",
"reason": "token_invalid",
"tokenExpiresAt": "2027-03-16T09:00:00.000Z",
"previousStatus": "active"
}
}
}
}Event Catalog
Event names are the same on every platform. data.object.platform names the platform. Each page shows a sample data per event.
Post Events
Schedules, results, edits, cancels, deletes and outside posts.
Comment Events
Your API comments, and new Facebook and Instagram comments.
Inbox Events
Facebook and Instagram messages, receipts and reactions.
Review Events
New recommendations on Facebook Pages.
Engagement Events
Likes, bookmarks and reposts made through the API.
Account Events
Connects, disconnects, expiry and revoked access.
Only Send a Test Event sends webhook.test. Subscribing does nothing.
Every event schema is also in the API Reference and the OpenAPI spec. Use them to generate types for your webhook handler.
Errors
| Code | HTTP | Cause | Fix |
|---|---|---|---|
VALIDATION_ERROR | 400 | A field breaks the Endpoint Rules | Fix the field in param |
FORBIDDEN | 403 | A read_only key tried a write | Use a read_write key |
NOT_FOUND | 404 | No webhook or delivery has this id | Use a current id |
DELIVERY_IN_PROGRESS | 409 | The delivery is still pending | Retry once it finishes |
Other codes are in Errors.