# Notifications & Webhooks

Get an email or a signed webhook when posts are scheduled, published, partially fail or fail.


Notification rules tell you when something happens to a post, so you don't have to poll `GET /v1/posts/{id}`. Each rule sends either an **email** or a **webhook** (an HTTPS `POST` signed with HMAC-SHA256).

## Events

| Event | Fires when |
| --- | --- |
| `post.scheduled` | A post is created with `scheduledFor`. Posts sent with `publishNow` don't fire this. |
| `post.published` | Every target of a post published. |
| `post.partial` | A post finished with some targets published and some failed. |
| `post.failed` | Every target of a post failed. |

The last three fire once per post, when it reaches its final state. A post that's retrying on one platform hasn't finished yet, so you won't get an event until it does.

## Create a rule

Rules are managed in the dashboard under **Settings → Notifications** at [https://social-publish-kappa.vercel.app/dashboard/settings?tab=notifications](https://social-publish-kappa.vercel.app/dashboard/settings?tab=notifications). Only project owners and admins can manage them.

1. Click **New notification**.
2. Choose the events.
3. Choose which profiles it applies to: all profiles, or specific accounts. A rule fires if the post targets at least one of the chosen accounts.
4. Optionally tick **Only scheduled posts** to skip posts sent with `publishNow`.
5. Pick the channel: email recipients (up to 20 addresses), or a webhook URL.
6. Use **Send test** to try it, then save.

Rules can be turned off and on without deleting them. There's no public API for managing rules.

## Email

Email notifications show the post text, each platform's result with a link to the live post or the error, and a button to open the post in the dashboard.

> [!NOTE]
> Email delivery needs an email provider configured on the deployment. If it isn't, the dashboard says so and email rules are skipped.

## Webhooks

Webhook URLs must be public `https://` URLs. URLs with credentials in them, `localhost`, and hosts that resolve to private, loopback or link-local addresses are rejected, both when you save the rule and again before every delivery. Redirects are not followed.

When you create a webhook rule, the dashboard shows its **signing secret** (it starts with `whsec_`). Copy it then: it's stored encrypted and can't be shown again.

### Request

Each delivery is a `POST` with a JSON body:

```http
POST /hooks/relaycast HTTP/1.1
Content-Type: application/json
User-Agent: SocialPublish-Webhooks/1.0
x-webhook-id: 9b1f..._5e6f7a8b-..._post.published
x-webhook-timestamp: 1791203000
x-signature: 3c5a0e9f4b...
```

```json
{
  "id": "9b1f..._5e6f7a8b-..._post.published",
  "event": "post.published",
  "data": {
    "postId": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
    "status": "published",
    "scheduledFor": "2026-10-05T14:00:00.000Z",
    "scheduled": true,
    "content": "We just shipped dark mode 🌙",
    "dashboardUrl": "https://social-publish-kappa.vercel.app/dashboard/posts",
    "targets": [
      {
        "id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
        "accountId": "8f1c2a4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b",
        "platform": "linkedin",
        "handle": "jane-doe",
        "status": "published",
        "url": "https://www.linkedin.com/feed/update/urn:li:share:...",
        "error": null
      }
    ]
  }
}
```

| Field | Description |
| --- | --- |
| `id` | Delivery ID, also sent as `x-webhook-id`. It's the same on every retry of this delivery, so use it to de-duplicate. |
| `event` | One of the events above. |
| `test` | `true` for deliveries sent with **Send test**; absent otherwise. |
| `data.status` | The post's status. |
| `data.scheduled` | `false` if the post was sent with `publishNow`. |
| `data.targets[]` | One entry per account, with its `status`, `url` of the live post, or `error` message. |

Test deliveries use a sample `post.published` payload with placeholder IDs.

### Verify the signature

The `x-signature` header is the hex-encoded HMAC-SHA256 of `{timestamp}.{raw body}`, keyed with your signing secret, where `timestamp` is the `x-webhook-timestamp` header (Unix seconds). Compute it over the **raw** request body, before any JSON parsing, and compare in constant time. Reject old timestamps to block replays.

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

export function verifyWebhook(rawBody: string, headers: Headers, secret: string) {
  const timestamp = headers.get("x-webhook-timestamp") ?? "";
  const signature = headers.get("x-signature") ?? "";
  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");

  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300; // 5 minutes
  const a = Buffer.from(signature, "hex");
  const b = Buffer.from(expected, "hex");
  return fresh && a.length === b.length && timingSafeEqual(a, b);
}
```

```python tab="Python" title="webhook.py"
import hashlib
import hmac
import time

def verify_webhook(raw_body: bytes, headers, secret: str) -> bool:
    timestamp = headers.get("x-webhook-timestamp", "")
    signature = headers.get("x-signature", "")
    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    try:
        fresh = abs(time.time() - int(timestamp)) < 300  # 5 minutes
    except ValueError:
        return False
    return fresh and hmac.compare_digest(signature, expected)
```

### Respond and retries

Return any `2xx` status within 10 seconds to acknowledge a delivery. Anything else, including timeouts and redirects, counts as a failure, and the delivery is retried with exponential backoff (starting at about 10 seconds), up to 8 attempts.

- Respond quickly and do slow work asynchronously.
- De-duplicate on `x-webhook-id`, since a delivery can arrive more than once.
- Don't rely on ordering. Use `data.status` and the target statuses as the source of truth, or fetch the post with `GET /v1/posts/{id}`.

See [Webhook events](/docs/api/webhook-events) for the payload reference.

## Credit usage emails

Separately from notification rules, project owners and admins get emails when the project's credits run low. Turn them on or off under **Settings → General**. See [Credits & Usage](/docs/credits#usage-emails).
