Webhooks
Subscribe to publish, destination, comment, engagement, and connection lifecycle events.
Webhooks push events to your HTTPS endpoint so you do not need to poll GET /v1/posts/:id. Onepostly signs every delivery; verify the signature before trusting the body.
Quick start
- Create an endpoint (
POST /v1/webhooks) and store the returnedsecret(shown once). - Subscribe to the event types you need.
- Respond with
2xxwithin 10 seconds. - Verify
Onepostly-Signatureon every request.
curl -X POST https://api.onepostly.com/v1/webhooks \
-H "x-api-key: op_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Production",
"url": "https://example.com/webhooks/onepostly",
"events": [
"destination.published",
"destination.failed",
"post.published",
"connection.expired",
"connection.revoked"
],
"enabled": true
}'{
"webhook": {
"id": "WEBHOOK_ID",
"name": "Production",
"url": "https://example.com/webhooks/onepostly",
"events": [
"destination.published",
"destination.failed",
"post.published",
"connection.expired",
"connection.revoked"
],
"enabled": true,
"secret": "whsec_…",
"createdAt": "2026-07-15T12:00:00.000Z",
"updatedAt": "2026-07-15T12:00:00.000Z"
}
}secret is returned only on create and rotate. List/get responses never include it.
Manage endpoints
| Method | Path | Notes |
|---|---|---|
GET | /v1/webhooks/events | Catalog of event types + groups |
GET | /v1/webhooks | List endpoints |
POST | /v1/webhooks | Create (201) |
GET | /v1/webhooks/:id | Get one |
PATCH | /v1/webhooks/:id | Update name, url, events, enabled |
DELETE | /v1/webhooks/:id | Remove (204) |
POST | /v1/webhooks/:id/rotate-secret | New secret (plaintext once) |
POST | /v1/webhooks/:id/test | Sends webhook.test |
GET | /v1/webhooks/:id/deliveries | Recent delivery attempts |
URL rules
- Must be HTTPS in production
http://localhostandhttp://127.0.0.1allowed for local development- Optional custom
secreton create (min 16 chars); otherwise Onepostly generateswhsec_…
Event types
Event names are platform-agnostic. Platform details (x, instagram, facebook, threads, …) live in each event’s data.platform field.
Posts
| Event | When |
|---|---|
post.scheduled | Post accepted with a future scheduledFor |
post.published | All destinations finished successfully |
post.failed | Post ended in a failed state |
post.partial | Some destinations published, others failed |
post.cancelled | Scheduled/queued post cancelled before publish |
Destinations
| Event | When |
|---|---|
destination.published | A destination went live on the platform |
destination.failed | A destination failed (after retries / permanent error) |
destination.deleted | Remote-delete succeeded |
Comments
| Event | When |
|---|---|
comment.created | A reply/comment was created via the API |
comment.deleted | A comment was deleted via the API |
List/read comment endpoints do not emit webhooks.
Engagement
| Event | When |
|---|---|
engagement.liked | Destination liked |
engagement.unliked | Like removed |
engagement.bookmarked | Destination bookmarked |
engagement.unbookmarked | Bookmark removed |
engagement.retweeted | Destination retweeted |
engagement.unretweeted | Retweet removed |
engagement.quoted | Quote post created (also emits destination.published for the new quote) |
Connections
| Event | When |
|---|---|
connection.connected | Account connected (or reconnected) |
connection.disconnected | Account removed |
connection.expired | Access token expired (time-based / refresh failed after expiry) |
connection.revoked | Token rejected by the platform (revoked / invalid grant / TOKEN_INVALID) |
System
| Event | When |
|---|---|
webhook.test | Triggered by POST …/test |
Envelope
Every delivery body is a JSON envelope:
{
"id": "event_…",
"type": "destination.published",
"createdAt": "2026-07-15T12:00:00.000Z",
"organizationId": "WORKSPACE_ID",
"data": {}
}| Field | Meaning |
|---|---|
id | Event id (idempotent per endpoint — duplicate deliveries share the same id) |
type | Event type string |
createdAt | ISO-8601 timestamp |
organizationId | Your workspace |
data | Event-specific payload |
Payload reference
Every event below is wrapped in the envelope above. Only id, type, and data differ.
Posts
post.scheduled
{
"id": "post.scheduled:POST_ID",
"type": "post.scheduled",
"createdAt": "2026-07-15T12:00:00.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"postId": "POST_ID",
"scheduledFor": "2026-07-16T12:00:00",
"timezone": "America/New_York",
"destinationIds": ["DEST_ID_1", "DEST_ID_2"]
}
}post.published
{
"id": "post.published:POST_ID",
"type": "post.published",
"createdAt": "2026-07-15T12:00:02.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"postId": "POST_ID",
"status": "published",
"previousStatus": "processing"
}
}post.failed
Same data shape as post.published, with "status": "failed".
post.partial
Same data shape as post.published, with "status": "partial".
post.cancelled
{
"id": "post.cancelled:POST_ID",
"type": "post.cancelled",
"createdAt": "2026-07-15T12:00:03.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"postId": "POST_ID",
"previousStatus": "scheduled"
}
}Destinations
destination.published
{
"id": "destination.published:DEST_ID",
"type": "destination.published",
"createdAt": "2026-07-15T12:00:01.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"postId": "POST_ID",
"destinationId": "DEST_ID",
"connectionId": "CONNECTION_ID",
"platform": "x",
"externalPostId": "2077…",
"externalUrl": "https://x.com/i/web/status/2077…",
"publishedAt": "2026-07-15T12:00:01.000Z"
}
}destination.failed
{
"id": "destination.failed:DEST_ID:TOKEN_INVALID",
"type": "destination.failed",
"createdAt": "2026-07-15T12:00:01.500Z",
"organizationId": "WORKSPACE_ID",
"data": {
"postId": "POST_ID",
"destinationId": "DEST_ID",
"connectionId": "CONNECTION_ID",
"platform": "instagram",
"errorCode": "TOKEN_INVALID",
"errorMessage": "Access token is invalid or expired. Reconnect the account."
}
}destination.deleted
{
"id": "destination.deleted:DEST_ID",
"type": "destination.deleted",
"createdAt": "2026-07-15T12:00:02.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"postId": "POST_ID",
"destinationId": "DEST_ID",
"connectionId": "CONNECTION_ID",
"platform": "x",
"externalPostId": "2077…"
}
}Comments
comment.created
{
"id": "comment.created:DEST_ID:COMMENT_ID",
"type": "comment.created",
"createdAt": "2026-07-15T12:00:03.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"postId": "POST_ID",
"destinationId": "DEST_ID",
"connectionId": "CONNECTION_ID",
"platform": "x",
"externalPostId": "2077…",
"parentCommentId": null,
"comment": {
"id": "COMMENT_ID",
"text": "Thanks!",
"username": "acme",
"likeCount": 0,
"timestamp": "2026-07-15T12:00:03.000Z",
"parentId": null,
"replies": []
}
}
}comment.deleted
{
"id": "comment.deleted:DEST_ID:COMMENT_ID",
"type": "comment.deleted",
"createdAt": "2026-07-15T12:00:04.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"postId": "POST_ID",
"destinationId": "DEST_ID",
"connectionId": "CONNECTION_ID",
"platform": "x",
"externalPostId": "2077…",
"commentId": "COMMENT_ID"
}
}Engagement
engagement.liked
{
"id": "engagement.liked:DEST_ID:1721044800000",
"type": "engagement.liked",
"createdAt": "2026-07-15T12:00:05.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"postId": "POST_ID",
"destinationId": "DEST_ID",
"connectionId": "CONNECTION_ID",
"platform": "x",
"externalPostId": "2077…",
"active": true
}
}engagement.unliked
Same data shape as engagement.liked, with "active": false.
engagement.bookmarked
Same data shape as engagement.liked, with "active": true.
engagement.unbookmarked
Same data shape as engagement.liked, with "active": false.
engagement.retweeted
{
"id": "engagement.retweeted:DEST_ID:RETWEET_ID",
"type": "engagement.retweeted",
"createdAt": "2026-07-15T12:00:06.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"postId": "POST_ID",
"destinationId": "DEST_ID",
"connectionId": "CONNECTION_ID",
"platform": "x",
"externalPostId": "2077…",
"retweetId": "RETWEET_ID"
}
}engagement.unretweeted
{
"id": "engagement.unretweeted:DEST_ID:1721044801000",
"type": "engagement.unretweeted",
"createdAt": "2026-07-15T12:00:07.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"postId": "POST_ID",
"destinationId": "DEST_ID",
"connectionId": "CONNECTION_ID",
"platform": "x",
"externalPostId": "2077…"
}
}engagement.quoted
Also emits a separate destination.published for the new quote post.
{
"id": "engagement.quoted:QUOTE_DEST_ID",
"type": "engagement.quoted",
"createdAt": "2026-07-15T12:00:08.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"sourcePostId": "SOURCE_POST_ID",
"sourceDestinationId": "SOURCE_DEST_ID",
"quotePostId": "QUOTE_POST_ID",
"quoteDestinationId": "QUOTE_DEST_ID",
"connectionId": "CONNECTION_ID",
"platform": "x",
"externalPostId": "2088…",
"externalUrl": "https://x.com/i/web/status/2088…",
"text": "Adding context…"
}
}Connections
connection.connected
{
"id": "connection.connected:CONNECTION_ID:2026-07-15T12:00:09.000Z",
"type": "connection.connected",
"createdAt": "2026-07-15T12:00:09.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"connectionId": "CONNECTION_ID",
"platform": "facebook",
"handle": "acme",
"displayName": "Acme Inc",
"status": "active"
}
}connection.disconnected
{
"id": "connection.disconnected:CONNECTION_ID:1721044802000",
"type": "connection.disconnected",
"createdAt": "2026-07-15T12:00:10.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"connectionId": "CONNECTION_ID",
"platform": "threads",
"handle": "acme"
}
}connection.expired
{
"id": "connection.expired:CONNECTION_ID:2026-07-15T12:00:11.000Z",
"type": "connection.expired",
"createdAt": "2026-07-15T12:00:11.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"connectionId": "CONNECTION_ID",
"platform": "instagram",
"handle": "acme",
"status": "expired",
"reason": "refresh_failed",
"tokenExpiresAt": "2026-07-14T12:00:00.000Z",
"previousStatus": "active"
}
}reason is "expired" or "refresh_failed".
connection.revoked
{
"id": "connection.revoked:CONNECTION_ID:2026-07-15T12:00:12.000Z",
"type": "connection.revoked",
"createdAt": "2026-07-15T12:00:12.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"connectionId": "CONNECTION_ID",
"platform": "x",
"handle": "acme",
"status": "revoked",
"reason": "token_invalid",
"tokenExpiresAt": "2026-08-01T12:00:00.000Z",
"previousStatus": "active"
}
}reason is "refresh_revoked" or "token_invalid".
System
webhook.test
{
"id": "EVENT_UUID",
"type": "webhook.test",
"createdAt": "2026-07-15T12:00:13.000Z",
"organizationId": "WORKSPACE_ID",
"data": {
"message": "Onepostly webhook test event",
"endpointId": "WEBHOOK_ID"
}
}Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Onepostly-Webhooks/1.0 |
Onepostly-Signature | t=<unix>,v1=<hex> |
Onepostly-Event | Event type (e.g. destination.published) |
Onepostly-Delivery | Delivery attempt id |
Onepostly-Event-Id | Envelope id |
Verify signatures
Signature is Stripe-style HMAC-SHA256 over the string timestamp + "." + rawBody using your endpoint secret.
- Read the raw request body (do not re-serialize JSON before verifying).
- Parse
Onepostly-Signature→tandv1. - Reject if
|now - t| > 300seconds (replay window). - Compute HMAC-SHA256 of the string
t+.+ raw body, using your endpoint secret, as hex. - Compare to
v1with a constant-time equality check.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyOnepostlySignature({ secret, rawBody, header, toleranceSec = 300 }) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=").map((s) => s.trim())),
);
const timestamp = Number(parts.t);
const v1 = parts.v1;
if (!timestamp || !v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSec) {
return false;
}
const signedPayload = timestamp + "." + rawBody;
const expected = createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest("hex");
const a = Buffer.from(v1, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Delivery & retries
| Behavior | Detail |
|---|---|
| Method | POST |
| Timeout | 10 seconds |
| Success | HTTP 2xx |
| Max attempts | 5 |
| Retryable | 408, 429, 5xx, network errors |
| Non-retryable | Most other 4xx (after first response) |
Publishing never fails because a webhook endpoint is down — events are best-effort with retries.
Inspect deliveries
curl "https://api.onepostly.com/v1/webhooks/WEBHOOK_ID/deliveries?limit=20" \
-H "x-api-key: op_YOUR_KEY"Each row includes status (pending | success | failed), attemptCount, responseStatus, and lastError.
Test
curl -X POST https://api.onepostly.com/v1/webhooks/WEBHOOK_ID/test \
-H "x-api-key: op_YOUR_KEY"Sends a webhook.test event to that endpoint only (forced), regardless of the endpoint’s subscribed events list.
Rotate secret
curl -X POST https://api.onepostly.com/v1/webhooks/WEBHOOK_ID/rotate-secret \
-H "x-api-key: op_YOUR_KEY"Response includes a new plaintext secret. Update your verifier immediately; the old secret stops working.
Errors
| Code | HTTP | Meaning |
|---|---|---|
UNAUTHORIZED | 401 | Missing/invalid API key |
FORBIDDEN | 403 | Read-only key, or session user is not owner/admin |
VALIDATION_ERROR | 400 | Invalid URL, events, or body |
NOT_FOUND | 404 | Unknown webhook id |
CREATE_FAILED | 500 | Could not persist endpoint |
Best practices
- Prefer webhooks over polling for publish status (X → Posts)
- Always verify signatures; reject unsigned or stale requests
- Treat
id(event id) as an idempotency key - Keep handlers fast — queue work and return
200quickly - Use HTTPS endpoints in production
- Subscribe to
connection.expired/connection.revokedso you can prompt users to reconnect
See also Quickstart and OpenAPI.