# Webhook events

Signed HTTP callbacks for post.scheduled, post.published, post.partial and post.failed, and how to verify them.


Relaycast can `POST` a signed JSON event to your server when a post is scheduled, published, partially published or failed. Webhooks are configured in the dashboard, not through the API.

## Set up a webhook

1. Open **Settings → Notifications** in the [dashboard](https://social-publish-kappa.vercel.app/dashboard/settings).
2. Add a rule with the **Webhook** channel.
3. Enter your endpoint URL. It must be `https` and resolve to a public address.
4. Copy the signing secret (it starts with `whsec_`). You need it to verify signatures.
5. Pick the events, and optionally limit the rule to some accounts or to scheduled posts only.
6. Use **Send test** to deliver a sample `post.published` event.

See [Notifications & Webhooks](/docs/notifications) for email notifications and rule options.

## Events

| Event | Sent when |
| --- | --- |
| `post.scheduled` | A post is created with `scheduledFor` (not sent for `publishNow` posts). |
| `post.published` | Every target of the post was published. |
| `post.partial` | The post finished with some targets published and some failed. |
| `post.failed` | Every target of the post failed. |

Each event is sent at most once per rule and post. A rule limited to certain accounts fires when the post targets at least one of them. A rule with **only scheduled posts** ignores posts sent with `publishNow`.

## Request

```http
POST https://your-server.example.com/webhooks/relaycast
content-type: application/json
x-webhook-id: 2f6c0b1e-7d4a-4f3e-9b8c-1a2d3e4f5a6b_a3f8c2d1-9b7e-4c6a-8f5d-2e1b0c9a8d7f_post.published
x-webhook-timestamp: 1793631606
x-signature: 5b1f0c3e9a7d2b8e4f6a1c0d9e8b7a6f5c4d3e2b1a0f9e8d7c6b5a4f3e2d1c0b
```

### Headers

| Header | Description |
| --- | --- |
| `x-webhook-id` | Unique delivery ID. It is the same on every retry of the same delivery, so use it to ignore duplicates. Test deliveries use `test_<random>`. |
| `x-webhook-timestamp` | Unix time in seconds when the request was signed. |
| `x-signature` | Hex-encoded HMAC-SHA256 of `` `${timestamp}.${rawBody}` `` with your signing secret. |
| `content-type` | `application/json` |

### Body

```json title="post.published"
{
  "id": "2f6c0b1e-7d4a-4f3e-9b8c-1a2d3e4f5a6b_a3f8c2d1-9b7e-4c6a-8f5d-2e1b0c9a8d7f_post.published",
  "event": "post.published",
  "data": {
    "postId": "a3f8c2d1-9b7e-4c6a-8f5d-2e1b0c9a8d7f",
    "status": "published",
    "scheduledFor": "2026-11-02T15:00:00.000Z",
    "scheduled": true,
    "content": "Our new release is live. Faster uploads, a calendar view and more.",
    "dashboardUrl": "https://social-publish-kappa.vercel.app/dashboard/posts",
    "targets": [
      {
        "id": "e1d2c3b4-a596-4877-8695-a4b3c2d1e0f9",
        "accountId": "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f",
        "platform": "twitter",
        "handle": "acme",
        "status": "published",
        "url": "https://x.com/acme/status/1852700000000000000",
        "error": null
      },
      {
        "id": "f0e9d8c7-b6a5-4493-8281-706f5e4d3c2b",
        "accountId": "3c8d1e2f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
        "platform": "linkedin",
        "handle": "acme-inc",
        "status": "published",
        "url": "https://www.linkedin.com/feed/update/urn:li:share:7260000000000000000/",
        "error": null
      }
    ]
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Delivery ID (same as `x-webhook-id`). |
| `event` | string | `post.scheduled`, `post.published`, `post.partial` or `post.failed`. |
| `test` | boolean | `true` for **Send test** deliveries; omitted otherwise. |
| `data.postId` | string | The post ID. Fetch the full post with [`GET /v1/posts/{id}`](/docs/api/get-post). |
| `data.status` | string | The post status: `scheduled`, `publishing`, `published`, `partial` or `failed`. |
| `data.scheduledFor` | string | When the post was or will be sent (ISO 8601). |
| `data.scheduled` | boolean | `false` when the post was sent with `publishNow`. |
| `data.content` | string | The shared post text. |
| `data.dashboardUrl` | string | Link to the Posts page in the dashboard. |
| `data.targets[].id` | string | Target ID. |
| `data.targets[].accountId` | string | Account ID. |
| `data.targets[].platform` | string | Platform ID. |
| `data.targets[].handle` | string | Account handle. |
| `data.targets[].status` | string | `queued`, `publishing`, `published` or `failed`. |
| `data.targets[].url` | string \| null | Public URL of the published post, if any. |
| `data.targets[].error` | string \| null | Error message for a failed target. |

The `data` object is read when the delivery is sent, so a retried delivery carries the post's current state.

For `post.scheduled`, `data.status` is `scheduled`, targets are `queued`, and `url` is `null`. For `post.partial` and `post.failed`, check `targets[].error`:

```json title="post.partial (data.targets)"
[
  { "id": "e1d2c3b4-a596-4877-8695-a4b3c2d1e0f9", "accountId": "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f", "platform": "twitter", "handle": "acme", "status": "published", "url": "https://x.com/acme/status/1852700000000000000", "error": null },
  { "id": "c9b8a7f6-e5d4-4c3b-a291-807f6e5d4c3b", "accountId": "1d7e3f52-8c4a-4b9e-a0f1-7e6d5c4b3a29", "platform": "instagram", "handle": "acme", "status": "failed", "url": null, "error": "Instagram only accepts JPEG images" }
]
```

## Verify the signature

Always verify the signature before trusting a webhook:

1. Read the **raw** request body, before any JSON parsing.
2. Reject the request if `x-webhook-timestamp` is more than a few minutes from your clock (5 minutes is a good tolerance). This blocks replayed requests.
3. Compute `HMAC-SHA256(secret, timestamp + "." + rawBody)` as hex.
4. Compare it with `x-signature` using a constant-time comparison.

```ts tab="Node.js"
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const SECRET = process.env.RELAYCAST_WEBHOOK_SECRET!; // whsec_...
const TOLERANCE_SECONDS = 5 * 60;

function verify(rawBody: Buffer, timestamp: string | undefined, signature: string | undefined): boolean {
  if (!timestamp || !signature) return false;
  const ts = Number(timestamp);
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", SECRET).update(`${timestamp}.`).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signature, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

const app = express();
const seen = new Set<string>(); // use a database or cache in production

app.post("/webhooks/relaycast", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.header("x-webhook-timestamp"), req.header("x-signature"))) {
    return res.status(401).send("invalid signature");
  }
  const deliveryId = req.header("x-webhook-id")!;
  if (seen.has(deliveryId)) return res.sendStatus(200); // duplicate retry
  seen.add(deliveryId);

  const event = JSON.parse(req.body.toString("utf8"));
  if (event.event === "post.failed" || event.event === "post.partial") {
    for (const t of event.data.targets) if (t.status === "failed") console.warn(t.platform, t.error);
  }
  res.sendStatus(200); // respond quickly; do slow work asynchronously
});

app.listen(3001);
```

```python tab="Python"
import hashlib, hmac, json, os, time
from flask import Flask, abort, request

SECRET = os.environ["RELAYCAST_WEBHOOK_SECRET"].encode()  # whsec_...
TOLERANCE_SECONDS = 5 * 60

app = Flask(__name__)
seen = set()  # use a database or cache in production


def verify(raw_body: bytes, timestamp: str | None, signature: str | None) -> bool:
    if not timestamp or not signature:
        return False
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/webhooks/relaycast")
def social_publish_webhook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    if not verify(raw, request.headers.get("x-webhook-timestamp"), request.headers.get("x-signature")):
        abort(401)
    delivery_id = request.headers["x-webhook-id"]
    if delivery_id in seen:
        return "", 200  # duplicate retry
    seen.add(delivery_id)

    event = json.loads(raw)
    if event["event"] in ("post.failed", "post.partial"):
        for t in event["data"]["targets"]:
            if t["status"] == "failed":
                print(t["platform"], t["error"])
    return "", 200
```

> [!WARNING]
> Compute the signature over the exact bytes you received. Re-serializing parsed JSON changes whitespace and key order, and the signature won't match.

## Responding and retries

- Return any `2xx` status to acknowledge the delivery. Any other status, a connection error, or no response within **10 seconds** counts as a failure.
- Failed deliveries are retried up to **8 attempts** in total with exponential backoff starting at 10 seconds (10 s, 20 s, 40 s, … between attempts, about 20 minutes overall).
- Redirects are not followed. Point the rule at the final URL.
- If the URL starts resolving to a private or internal address, delivery stops without further retries.
- Deliveries for a rule that has been deleted or disabled are dropped.

Retries reuse the same `x-webhook-id`, with a new `x-webhook-timestamp` and `x-signature`, so make your handler idempotent on the delivery ID.
