# Webhooks & events

> The X-Buddy event stream, webhook delivery and retries, verifying signatures, URL rules and test sends.

Source: https://x.builera.app/docs/en/webhooks-and-events
Last checked: 2026-10-24

X-Buddy records what happens in a workspace as **events**. Read them by polling, or receive them as **webhooks** on your own HTTPS endpoint.

## Polling {#polling}

`GET /events` (`events_list`) returns events in order. Pass the `cursor` of the last event you processed as `after` to get only newer ones. Event cursors are increasing numbers (as strings). Events are kept **30 days**. Polling is the reliable path; webhooks are the fast path.

## Event types {#event-types}

| Group | Types |
|---|---|
| Jobs | `job.succeeded`, `job.failed`, `job.unknown`, `job.held`, `job.cancelled`, `batch.completed` |
| Inbox & engagement | `dm.received`, `dm.request_received`, `reply.received`, `mention.received`, `follower.gained`, `follower.lost` |
| Posts | `post.published`, `post.failed`, `post.partial` |
| Campaigns | `campaign.person_entered`, `campaign.person_replied`, `campaign.person_completed`, `campaign.paused_auto` |
| X accounts | `account.session_lost`, `account.session_restored`, `account.safety_changed`, `account.x_rate_limited`, `account.chat_locked` |
| Infrastructure & other | `vps.offline`, `vps.online`, `proxy.failed`, `alert.opened`, `integration.paused`, `scan.completed`, `limit.reached` |

## The envelope {#the-envelope}

Events follow CloudEvents 1.0:

```json
{
  "specversion": "1.0",
  "id": "<event id>",
  "type": "job.succeeded",
  "source": "x-buddy:workspace:<workspace_id>",
  "subject": "<what the event is about>",
  "time": "2026-11-01T09:00:00Z",
  "observed_at": "2026-11-01T09:00:01Z",
  "cursor": "1042",
  "workspace_id": "<workspace_id>",
  "account_id": "<account_id>",
  "job_id": "<job_id>",
  "integration_id": null,
  "data": {}
}
```

When a connection may not read message text, content-bearing events carry `"text_withheld": true` instead of the text.

## Verify the signature {#verify-the-signature}

Deliveries follow Standard Webhooks and carry three headers: `webhook-id`, `webhook-timestamp` and `webhook-signature` (`v1,<base64>`).

1. Build the string `<webhook-id>.<webhook-timestamp>.<raw body>`.
2. Compute HMAC-SHA256 with your signing secret (base64-decode it after removing the `whsec_` prefix).
3. Compare with each `v1` signature in the header (there may be two during a secret rotation, which overlaps 24 hours).
4. Reject timestamps more than 5 minutes from your clock.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, id: string, ts: string, body: string, header: string): boolean {
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest();
  return header.split(" ").some((part) => {
    const [v, sig] = part.split(",");
    if (v !== "v1" || !sig) return false;
    const got = Buffer.from(sig, "base64");
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}
```

## Delivery and retries {#delivery-and-retries}

- `POST` with a 10-second timeout. Only a `2xx` answer counts as delivered; redirects are not followed.
- Retries after 30 s, 2 min, 10 min, 1 h, 6 h, 12 h, 24 h and 24 h (±20% jitter), for up to 72 hours.
- After **20 consecutive failures within 24 hours**, the endpoint is paused and an alert opens.

Use the event `id` to ignore duplicates: a retried delivery has the same id.

## URL rules {#url-rules}

Endpoints must use `https://`, on the default port, 443 or 8443. Addresses with credentials, and internal, loopback, link-local, private or cloud-metadata addresses are refused — including names that resolve to them. X-Buddy checks again just before sending.

## Test an endpoint {#test-an-endpoint}

`POST /webhooks/{endpoint_id}/test` (`webhook_test`) sends a signed `webhook.test` event at once and returns the HTTP status, the time taken and the start of your response. It does nothing on X.

## Related {#related}

- [REST API](/docs/en/rest-api)
- [Alerts & Telegram](/docs/en/alerts-and-telegram)
