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

# Webhooks

> Connect tells your URL when a post changes status or a social account connects.

An outgoing webhook is a public URL Connect `POST`s to. You subscribe to events, we sign the body, you answer quickly with `2xx` (including `204`).

Private, loopback, and link-local URLs are refused. Connect does not follow redirects — a `3xx` is a failure.

Create and manage endpoints in the [dashboard](https://connect.trypost.it/webhooks) or the [Webhooks API](/docs/api-reference/webhooks/create). Creating one does **not** ping the URL; send a test when you want to.

## Events

| Event                      | When                                    |
| -------------------------- | --------------------------------------- |
| `post.created`             | Draft saved                             |
| `post.scheduled`           | Status becomes scheduled                |
| `post.unscheduled`         | A scheduled post returns to draft       |
| `post.published`           | Every enabled network succeeded         |
| `post.partially_published` | Mixed success                           |
| `post.failed`              | Every enabled network failed            |
| `post.deleted`             | Post deleted                            |
| `account.connected`        | Social account connected or reconnected |
| `account.disconnected`     | Social account disconnected             |

There is no `publishing` event and no wildcard. Immediate publish goes draft → publishing (silent) → outcome. A due scheduled post does the same. Edits that do not change status send nothing.

## What arrives

A small envelope: an id, the event name, a `data` object, and a timestamp.

```json theme={null}
{
  "id": "9f1a2b3c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
  "type": "post.published",
  "data": {},
  "created_at": "2026-09-04T15:04:05+00:00"
}
```

* **id** is the delivery. Retries of the same attempt keep it. A replay gets a new one.
* **type** is the event, or `webhook.test` for a ping (`data` is empty).
* **data** on post events is the post (caption, media, per-network rows, author). On account events it is the social account.
* **created\_at** is **this attempt**. Retries keep the id and write a new timestamp — so the signature changes. Dedupe on `id`, not on the raw body.

## Verify it is us

Connect sends `X-Webhook-Signature`: hex HMAC-SHA256 of the **raw body**, plus `User-Agent: TryPost.it/1.0 (+https://trypost.it)`.

Hash the bytes you received (not a re-serialized JSON) with the signing secret. Compare with a constant-time check.

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'node:crypto';

  const expected = crypto
    .createHmac('sha256', process.env.TRYPOST_WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');

  const valid = crypto.timingSafeEqual(
    Buffer.from(expected, 'utf8'),
    Buffer.from(req.headers['x-webhook-signature'], 'utf8'),
  );
  ```

  ```php PHP theme={null}
  $expected = hash_hmac('sha256', $rawBody, $secret);
  $valid = hash_equals($expected, $request->header('X-Webhook-Signature'));
  ```

  ```python Python theme={null}
  import hmac
  import hashlib

  expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
  valid = hmac.compare_digest(expected, signature_header)
  ```
</CodeGroup>

<Warning>
  Read the unparsed body (for example Express `express.raw({ type: 'application/json' })`) and only then `JSON.parse`.
</Warning>

The secret is shown when you create, open, or rotate a webhook — not when you list them. Rotate invalidates the previous secret immediately, including the next retry of an in-flight delivery.

## If deliveries fail

Connect pauses the webhook after repeated failures. You turn it back on; you cannot set “paused” yourself. Replay still sends even while it is paused or disabled.
