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

FieldRule
urlPublic HTTPS, no credentials, up to 2,048 characters
name1 to 120 characters
eventsEvent names, or ["*"] alone
secretOptional, 16 to 128 characters. Generated if left out
enabledOptional, default true. false creates it paused

Every write, even a test, rotation or retry, needs a read_write API key.

Manage Endpoints

MethodPathDescription
GET/v1/webhooks/eventsLists every event name
GET/v1/webhooksLists webhooks, newest first
POST/v1/webhooksCreates 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-secretReplaces the secret
POST/v1/webhooks/{id}/testSends a webhook.test event
GET/v1/webhooks/{id}/deliveriesLists its deliveries
GET/v1/webhooks/{id}/deliveries/{deliveryId}Gets one delivery with its body
POST/v1/webhooks/{id}/deliveries/{deliveryId}/retrySends 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

HeaderValue
Content-Typeapplication/json
User-AgentOnepostly-Webhooks/1.0
Onepostly-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
Onepostly-EventThe event name, same as type
Onepostly-Event-IdThe event id, same as id
Onepostly-DeliveryThe 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.

verify-onepostly.ts
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.

server.ts
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.

app/hooks/onepostly/route.ts
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

SituationRespond withWhat happens
The signature is not valid401Not retried. The delivery ends as failed
A temporary problem on your sideAny 5xx, such as 503The delivery is retried
You already processed this id200Nothing. 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

BehaviorDetail
MethodPOST
Timeout10 seconds per attempt
SuccessAny 2xx
Retried408, 429, 5xx, timeouts and network errors
Not retriedAny other status. Redirects are not followed
Attempts5, the first included
SignatureA new t each attempt, the same body
AttemptSent
1When the event happens
21 minute later
32 minutes later
43 minutes later
54 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.

CaseWhat to do
An event arrives twiceSkip an id you already handled
Events arrive out of orderCompare 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
}
ParameterEffect
statuspending, success or failed, comma-separated
since, untilA time window, default the last 7 days
qThe start of an event name or id
limit1 to 100, default 50
cursorThe 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.

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

CodeHTTPCauseFix
VALIDATION_ERROR400A field breaks the Endpoint RulesFix the field in param
FORBIDDEN403A read_only key tried a writeUse a read_write key
NOT_FOUND404No webhook or delivery has this idUse a current id
DELIVERY_IN_PROGRESS409The delivery is still pendingRetry once it finishes

Other codes are in Errors.