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

# Webhooks

> Signed webhook events when PostMesh schedules, publishes or fails a post, holds one, or needs an account reconnected, with retries and replay.

Webhooks tell your own app when something happens in PostMesh.

> [!NOTE]
>
> **In short:** In Settings, API & MCP, switch to Webhooks, add a public https\:// endpoint, pick its events and copy the signing secret, which is shown once. Each delivery is a signed JSON POST. Check the signature and answer with any 2xx within 10 seconds; PostMesh tries a failed one 6 times over about 9 hours.

## Add an endpoint

**1. Open Settings, API & MCP**

Switch the view at the top to **Webhooks**. The first time, it walks you through four steps.

**2. Paste your endpoint URL**

A public https\:// address on the standard port. Private, local and internal addresses are refused, and so is a redirect.

**3. Choose events**

Pick at least one. PostMesh sends that endpoint only the events you picked, and you can change them later.

**4. Copy the signing secret**

It starts with `whsec_` and is shown once. Keep it on your server, never in a browser or an app.

**5. Send a test**

**Send test** posts a sample of the endpoint's first event, with `"test": true` in its data, so you can see it arrive.

You can have up to 10 endpoints. Webhooks are signed in only: no API key can add, change or read them.

## Events

| Event | When it's sent | Data |
| - | - | - |
| `post.scheduled` | A post is saved to go out later. | `post_id`, `scheduled_for`, `text`, `accounts` |
| `post.published` | A post went live on one account. A post to three accounts sends three. | `post_id`, `account_id`, `network`, `username`, `url`, `network_post_id` |
| `post.failed` | A post didn't go out on one account. `will_retry` says whether PostMesh tries it again on its own. | `post_id`, `account_id`, `network`, `username`, `error`, `will_retry`, `next_attempt_at` |
| `post.needs_review` | A scheduled post was held, so it waits for you to post it or pick a new time. | `post_id`, `reason`, `networks` |
| `account.needs_reconnect` | An account's login stopped working. | `account_id`, `network`, `username`, `since`, `waiting_posts` |
| `clip.ready` | A Clip Factory run finished. | `job_id`, `source_title`, `clips` |

Networks are named by their ids, the same as the REST API: `twitter`, `instagram`, `linkedin`, `tiktok`, `youtube`, `threads`, `facebook`, `reddit` and the rest.

## What a delivery looks like

Each delivery is a POST with a JSON body and three headers. `webhook-id` is the event's id: it is the same on every try and every replay, so store it and skip an id you have already handled.

```http Headers theme={null}
POST /webhooks/postmesh HTTP/1.1
Content-Type: application/json
webhook-id: evt_4Wn8cT2bHqLs7Jd9xKp3RmZa
webhook-timestamp: 1793952720
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

```json Body theme={null}
{
  "id": "evt_4Wn8cT2bHqLs7Jd9xKp3RmZa",
  "type": "post.published",
  "created_at": "2026-11-04T10:12:00.000Z",
  "data": {
    "post_id": "3f1c2b8e-6a0d-4b7e-9c51-2d8f0a6e4b19",
    "account_id": "9b7e1d24-5c3a-4f86-8e02-7a1d6c9f3b50",
    "network": "instagram",
    "username": "lumen.coffee",
    "url": "https://www.instagram.com/p/DQ3kLw7Ef2a/",
    "network_post_id": "18034526711234567"
  }
}
```

## Verify the signature

Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com) signing scheme, so its libraries can check them. The signature is an HMAC-SHA256 of `webhook-id`, `webhook-timestamp` and the raw body joined by dots, keyed with the bytes after `whsec_` (base64), and sent as `v1,` plus the base64 result. Check the raw body before parsing it, and refuse a timestamp more than 5 minutes away from your clock.

```js Node.js theme={null}
import crypto from "node:crypto"

// body: the raw request body as a string
export function verifyPostMesh(secret, headers, body) {
  const id = headers["webhook-id"]
  const timestamp = headers["webhook-timestamp"]
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
  const expected = crypto.createHmac("sha256", key).update(id + "." + timestamp + "." + body).digest()
  return headers["webhook-signature"].split(" ").some((part) => {
    const [version, signature] = part.split(",")
    const given = Buffer.from(signature ?? "", "base64")
    return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected)
  })
}
```

```python Python theme={null}
import base64, hashlib, hmac, time

# body: the raw request body as a string
def verify_postmesh(secret, headers, body):
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = (msg_id + "." + timestamp + "." + body).encode()
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        hmac.compare_digest(part[3:], expected)
        for part in headers["webhook-signature"].split(" ")
        if part.startswith("v1,")
    )
```

> [!TIP]
>
> Rolling a secret gives you a new one at once, and the old one keeps signing for 24 hours. While both last, `webhook-signature` carries two signatures separated by a space; accept the delivery if either matches.

## Retries and replay

* Any 2xx answer within 10 seconds counts as delivered. Anything else, a redirect or no answer is a failure.
* A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours: 6 tries in all, over about 9 hours. A Retry-After header can make a wait longer.
* Each endpoint keeps its last 30 days of deliveries, with the status, the time it took and the start of your server's answer. A failed one has a **Replay** button that sends it again now, with the same `webhook-id`.
* An endpoint that fails every delivery for 3 days is turned off, and nothing is sent to it until you turn it back on from its row.
* Tests are sent once and never tried again.

> [!NOTE]
>
> Events can arrive more than once or out of order. Use `webhook-id` to skip repeats, and `created_at` or the data itself to decide what is newest.


## Related topics

- [Post to Discord with a channel webhook](https://postmesh.app/docs/discord.md)
- [Connect Instagram, TikTok, YouTube and more](https://postmesh.app/docs/connect-accounts.md)
- [PostMesh docs](https://postmesh.app/docs/index.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.