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 }- The first call (no
cursor) returns every post, oldest change first. Passsince=<time>to start later. - Save
next_cursor. It is returned even whenchangesis empty. - 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. - While
has_moreistrue, 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 cursorReceive webhooks
- 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.
- 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"- From then on, each change to a post sends a
POSTto 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"])) < 300import 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 eventid,"<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 itsurldoes 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.