> ## Documentation index
> Fetch the complete documentation index at: https://dash.robopost.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Changes and webhooks

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](https://dash.robopost.app/docs/api/authentication.md). For webhooks, a public `https://` URL.

## Follow the change feed

```bash
curl "https://public-api.robopost.app/v1/scheduled_posts/changes?limit=100" \
  -H "Authorization: Bearer $ROBOPOST_API_KEY"
```

```json
{ "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](https://dash.robopost.app/docs/api/analytics.md).

```python
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:

```bash
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.

2. Send a test event and check your endpoint answered:

```bash
curl -X POST https://public-api.robopost.app/v1/webhooks/<id>/test -H "Authorization: Bearer $ROBOPOST_API_KEY"
```

3. From then on, each change to a post sends a `POST` to your URL:

```json
{
  "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.

```python
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
```

```javascript
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](https://dash.robopost.app/docs/api/media.md)
