Docs

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

  1. Create an endpoint (POST /v1/webhooks) and store the returned secret (shown once).
  2. Subscribe to the event types you need.
  3. Respond with 2xx within 10 seconds.
  4. Verify Onepostly-Signature on 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

MethodPathNotes
GET/v1/webhooks/eventsCatalog of event types + groups
GET/v1/webhooksList endpoints
POST/v1/webhooksCreate (201)
GET/v1/webhooks/:idGet one
PATCH/v1/webhooks/:idUpdate name, url, events, enabled
DELETE/v1/webhooks/:idRemove (204)
POST/v1/webhooks/:id/rotate-secretNew secret (plaintext once)
POST/v1/webhooks/:id/testSends webhook.test
GET/v1/webhooks/:id/deliveriesRecent delivery attempts

URL rules

  • Must be HTTPS in production
  • http://localhost and http://127.0.0.1 allowed for local development
  • Optional custom secret on create (min 16 chars); otherwise Onepostly generates whsec_…

Event types

Event names are platform-agnostic. Platform details (x, instagram, facebook, threads, …) live in each event’s data.platform field.

Posts

EventWhen
post.scheduledPost accepted with a future scheduledFor
post.publishedAll destinations finished successfully
post.failedPost ended in a failed state
post.partialSome destinations published, others failed
post.cancelledScheduled/queued post cancelled before publish

Destinations

EventWhen
destination.publishedA destination went live on the platform
destination.failedA destination failed (after retries / permanent error)
destination.deletedRemote-delete succeeded

Comments

EventWhen
comment.createdA reply/comment was created via the API
comment.deletedA comment was deleted via the API

List/read comment endpoints do not emit webhooks.

Engagement

EventWhen
engagement.likedDestination liked
engagement.unlikedLike removed
engagement.bookmarkedDestination bookmarked
engagement.unbookmarkedBookmark removed
engagement.retweetedDestination retweeted
engagement.unretweetedRetweet removed
engagement.quotedQuote post created (also emits destination.published for the new quote)

Connections

EventWhen
connection.connectedAccount connected (or reconnected)
connection.disconnectedAccount removed
connection.expiredAccess token expired (time-based / refresh failed after expiry)
connection.revokedToken rejected by the platform (revoked / invalid grant / TOKEN_INVALID)

System

EventWhen
webhook.testTriggered 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": {}
}
FieldMeaning
idEvent id (idempotent per endpoint — duplicate deliveries share the same id)
typeEvent type string
createdAtISO-8601 timestamp
organizationIdYour workspace
dataEvent-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

HeaderValue
Content-Typeapplication/json
User-AgentOnepostly-Webhooks/1.0
Onepostly-Signaturet=<unix>,v1=<hex>
Onepostly-EventEvent type (e.g. destination.published)
Onepostly-DeliveryDelivery attempt id
Onepostly-Event-IdEnvelope id

Verify signatures

Signature is Stripe-style HMAC-SHA256 over the string timestamp + "." + rawBody using your endpoint secret.

  1. Read the raw request body (do not re-serialize JSON before verifying).
  2. Parse Onepostly-Signaturet and v1.
  3. Reject if |now - t| > 300 seconds (replay window).
  4. Compute HMAC-SHA256 of the string t + . + raw body, using your endpoint secret, as hex.
  5. Compare to v1 with 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

BehaviorDetail
MethodPOST
Timeout10 seconds
SuccessHTTP 2xx
Max attempts5
Retryable408, 429, 5xx, network errors
Non-retryableMost 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

CodeHTTPMeaning
UNAUTHORIZED401Missing/invalid API key
FORBIDDEN403Read-only key, or session user is not owner/admin
VALIDATION_ERROR400Invalid URL, events, or body
NOT_FOUND404Unknown webhook id
CREATE_FAILED500Could 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 200 quickly
  • Use HTTPS endpoints in production
  • Subscribe to connection.expired / connection.revoked so you can prompt users to reconnect

See also Quickstart and OpenAPI.