# Webhooks

> Get a signed JSON request at your own endpoint when posts publish, fail or need review.

> **Note:** Webhooks are part of the Pro and Advanced plans. Owners and admins manage them.

## Add an endpoint

Under **Settings → Webhooks**, choose **Add endpoint**:

- **Payload URL**: an HTTPS endpoint you control, such as `https://example.com/webhooks/orbit`.
- **Events**: which events to send.
- **Only for posts tagged** (optional): limit post events to one tag.

The **signing secret** is shown once, when the endpoint is created. Copy it then; **Regenerate** replaces it. Pause an
endpoint by turning off **Enabled**.

Endpoints can also be managed over the API (`/api/v1/webhook-endpoints`), which is how an integration registers its own
receiver.

## Events

| Event                     | Sent when                                     |
| ------------------------- | --------------------------------------------- |
| `post_published`          | A post goes live on a channel.                |
| `post_failed`             | A post couldn't be published.                 |
| `approval_requested`      | Someone asks for a post to be reviewed.       |
| `approval_decided`        | A review is approved or sent back.            |
| `account_needs_reconnect` | A channel stops accepting our sign-in.        |
| `daily_digest`            | Each morning, with the day's scheduled posts. |

## The request

A `POST` with a JSON body:

```json
{
  "event": "post_published",
  "organizationId": "org_123",
  "title": "Posted to LinkedIn",
  "body": "We shipped the new docs…",
  "url": "https://orbitwrite.com/brightloop/schedule?post=grp_123",
  "composerGroupIds": ["grp_123"],
  "post": {
    "platform": "linkedin_page",
    "platformLabel": "LinkedIn Page",
    "handle": "Brightloop",
    "platformUrl": "https://www.linkedin.com/feed/update/urn:li:share:…",
    "appUrl": "https://orbitwrite.com/brightloop/…",
    "excerpt": "We shipped the new docs…"
  },
  "timestamp": 1792400000000
}
```

`post` is included on `post_published` only. `timestamp` is milliseconds since the epoch.

The headers:

```http
Content-Type: application/json
User-Agent: Orbit-Webhook/1
X-Orbit-Timestamp: 1792400000000
X-Orbit-Signature: sha256=5d41402abc4b2a76b9719d911017c592…
```

## Verify the signature

The signature is an HMAC-SHA256, keyed with your signing secret, over the timestamp, a full stop, and the raw body.
Check it before trusting the request, and reject old timestamps to stop replays.

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

export function verifyOrbitwrite(rawBody: string, headers: Headers, secret: string): boolean {
  const timestamp = headers.get("x-orbit-timestamp") ?? "";
  const signature = headers.get("x-orbit-signature") ?? "";
  if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60_000) return false;

  const expected = "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Use the body exactly as received: parsing and re-serialising the JSON changes the bytes and breaks the signature.

## Delivery

- Each event is sent once, with a 10-second timeout. Respond with any 2xx quickly and do the work afterwards.
- Failed deliveries aren't retried, so treat webhooks as a prompt and read the current state from the API when it
  matters.
