Changes and webhooks · Robopost Docs

Changes and webhooks

On this page

Posts change outside your integration: a teammate edits one in the dashboard, Robopost publishes it, a network rejects it, someone deletes it. Two ways to hear about all of it:

  • Change feed: you ask "what changed since last time?" whenever you like. Simplest and most robust.
  • Webhooks: Robopost calls your URL each time a post changes. Fastest.

Both cover every post of the team, every kind of change, and include removed posts.

You need: an API key. For webhooks, a public https:// URL.

Follow the change feed

curl "https://public-api.robopost.app/v1/scheduled_posts/changes?limit=100" \
  -H "Authorization: Bearer $ROBOPOST_API_KEY"
{ "changes": [ { "id": "…", "revision": 5, "status": "published", "…": "…" } ], "next_cursor": "WyIyMDI2…", "has_more": false }
  1. The first call (no cursor) returns every post, oldest change first. Pass since=<time> to start later.
  2. Save next_cursor. It is returned even when changes is empty.
  3. Next time, call with cursor=<saved cursor>: you get only posts that changed since, each once, in the order they changed, as they are now.
  4. While has_more is true, call again right away.

Each entry is the full post. Compare revision with the one you have to skip what you already processed. A change shows up about five seconds after it happens. Metric refreshes (impressions, likes…) are not changes; read them from analytics.

def sync(session, cursor, handle):
    while True:
        page = session.get("https://public-api.robopost.app/v1/scheduled_posts/changes",
                           params={"cursor": cursor} if cursor else {}).json()
        for post in page["changes"]:
            handle(post)            # store post["id"], post["revision"], post["status"], …
        cursor = page["next_cursor"]
        save_cursor(cursor)         # persist before the next call
        if not page["has_more"]:
            return cursor

Receive webhooks

  1. Create an endpoint:
curl -X POST https://public-api.robopost.app/v1/webhooks \
  -H "Authorization: Bearer $ROBOPOST_API_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/robopost-webhook", "description": "CRM sync"}'

The response contains secret (whsec_…). It's shown only now and when you rotate it: store it.

  1. Send a test event and check your endpoint answered:
curl -X POST https://public-api.robopost.app/v1/webhooks/<id>/test -H "Authorization: Bearer $ROBOPOST_API_KEY"
  1. From then on, each change to a post sends a POST to your URL:
{
  "id": "8c1f2a40-…:5",
  "type": "post.changed",
  "created_at": "2026-11-20T15:00:58.120Z",
  "team_id": "…",
  "data": { "post": { "id": "8c1f2a40-…", "revision": 5, "status": "published", "platform": { "post_url": "https://…" }, "…": "…" } }
}

Answer with any 2xx within 10 seconds. Do the work afterwards (queue it).

Verify the signature

Each request has Robopost-Signature: t=<unix time>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw request body>" with your secret. Reject requests whose signature doesn't match or whose t is more than five minutes old.

import hashlib, hmac, time

def verify(secret: str, header: str, body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", "")) and abs(time.time() - int(parts["t"])) < 300
import crypto from "node:crypto";

export function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
}

Delivery rules

  • In order, at least once. An event can arrive twice (after a timeout, for example): deduplicate on Robopost-Event-Id (the event id, "<post id>:<revision>").
  • Retries. If your endpoint fails, Robopost stops sending to it and retries the same event later, waiting longer each time (up to an hour), so nothing is skipped or reordered. After 30 failures in a row the endpoint is disabled; fix it and re-enable it with PATCH /v1/webhooks/<id> {"is_enabled": true}. Delivery resumes where it stopped.
  • Starting point. A new endpoint gets changes from the moment it's created. Send "start_from": "beginning" to first receive every existing post.
  • Tied to its API key. Deleting the API key that created an endpoint disables the endpoint, so revoking a leaked key also stops its webhooks. To keep the endpoint, re-enable it with another key (PATCH /v1/webhooks/<id> {"is_enabled": true}); it then belongs to that key. Changing its url does the same.
  • Large backlogs arrive over several passes. Each endpoint gets a short time slice per pass (about every 15 seconds), so a slow receiver only delays itself. Answering quickly gets you events faster.
  • Only public https:// addresses are called, and redirects are not followed.

Manage endpoints

Call Does
GET /v1/webhooks List endpoints with their last delivery and error
GET /v1/webhooks/{id} One endpoint
PATCH /v1/webhooks/{id} Change url or description, enable or disable
POST /v1/webhooks/{id}/rotate_secret New secret; the old one stops working at once
POST /v1/webhooks/{id}/test Send a webhook.test event now
DELETE /v1/webhooks/{id} Remove it

A team can have up to 10 endpoints.

Next

Media