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

# Webhooks

> Receive signed HTTP notifications when posts are created, scheduled, published, or fail.

Workspace webhooks POST a signed JSON payload to a URL you control whenever a subscribed post event fires. Use them to sync a CRM, trigger an internal job, or keep another system in step with TryPost.

They are **outgoing** event subscriptions for the workspace — not inbound Telegram/Stripe webhooks.

## Who can manage webhooks

Only the **account owner** and workspace **admins** can create, edit, test, rotate, replay, or delete webhooks. Members and viewers do not see **Webhooks** in the sidebar.

See [Team & roles](/knowledge-base/team).

## Create a webhook

<Steps>
  <Step title="Open Webhooks">
    In the sidebar, click **Webhooks**.
  </Step>

  <Step title="Create webhook">
    Click **Create webhook**.
  </Step>

  <Step title="Set the endpoint">
    Enter a public `http://` or `https://` URL (max 255 characters). Private, loopback, and link-local addresses are rejected.
  </Step>

  <Step title="Choose events">
    Select at least one event. There is no wildcard — you must list the events you want.
  </Step>

  <Step title="Copy the signing secret">
    TryPost opens the webhook detail page. Copy the **signing secret**. You can reveal it again later, or [rotate](#rotate-the-signing-secret) it.
  </Step>
</Steps>

Creating a webhook does **not** send a request. Use **Send test event** when you want to ping the URL. New webhooks start **enabled** — you cannot create them disabled.

There is no cap on how many webhooks a workspace can have. Two rows can share the same URL. Webhooks have no name — identify them by URL and events.

You can also manage webhooks through the [REST API](/api-reference/endpoint/list-webhooks) and [MCP tools](/ai/tools-reference#webhooks).

## Events

| Event                      | When it fires                              | `data`                             |
| -------------------------- | ------------------------------------------ | ---------------------------------- |
| `post.created`             | A post is created                          | Full [post payload](#post-payload) |
| `post.scheduled`           | A post moves to `scheduled`                | Full post payload                  |
| `post.unscheduled`         | A scheduled post returns to `draft`        | Full post payload                  |
| `post.published`           | A post published on every enabled platform | Full post payload                  |
| `post.partially_published` | Some platforms published, others failed    | Full post payload                  |
| `post.failed`              | Publishing failed on the enabled platforms | Full post payload                  |
| `post.deleted`             | A post is deleted                          | `{ "id", "workspace_id" }` only    |

`post.created` does not fire again when you edit a draft. Status events fire on the transition, not on every save.

There is **no** event for `publishing`. A post moving into that state is silent. `post.unscheduled` fires only when a **scheduled** post returns to `draft` — moving a failed post back to draft does not send it.

A new post is always created as `draft`, even if you pass `scheduled_at`. Scheduling is a later status change, so create-then-schedule produces `post.created` and then `post.scheduled`.

Publish now (dashboard **Post now**, REST `PUT` with `status=publishing`, or `publish-post-tool` without `scheduled_at`) goes `draft` → `publishing` (silent) → `published` / `partially_published` / `failed`. You do **not** get `post.scheduled`. A scheduled post that later becomes due follows the same silent `publishing` step, then the outcome event.

Edits that do **not** change status send nothing: content, media, labels, platforms, or a new `scheduled_at` on a post that is already `scheduled`. [Duplicate](/knowledge-base/posts#duplicating-a-post) is a new post, so it fires `post.created`. A failed or published post cannot be republished in place — duplicate it and you will get a fresh lifecycle.

There are no events for comments, mentions, social-account disconnects, or team changes — those stay in [notifications](/knowledge-base/notifications). AI Create has no dedicated `post.ready` event; when the draft is saved you get `post.created` (`created_via` is `web`). Deleting a [workspace](/knowledge-base/workspaces) removes the webhooks with it and does **not** send `post.deleted` for each post.

## Receive a delivery

Each delivery is `POST` with `Content-Type: application/json`. TryPost does not follow redirects — `3xx` counts as a failure. Respond with `2xx` (including `204`) as soon as you have accepted the payload. Dedupe on envelope `id`: retries of the same attempt reuse it, but they refresh `created_at` and therefore the signature. Do not treat the full body as byte-identical across retries.

### Headers

| Header                | Value                                       |
| --------------------- | ------------------------------------------- |
| `Content-Type`        | `application/json`                          |
| `X-Webhook-Signature` | Hex HMAC-SHA256 of the **raw request body** |
| `User-Agent`          | `TryPost.it/1.0 (+https://trypost.it)`      |

<Note>
  Self-hosting? Override the User-Agent with `TRYPOST_USER_AGENT`. Private endpoints stay rejected unless you set `TRYPOST_ALLOW_PRIVATE_NETWORK=true` — see [Configuration](/self-hosting/configuration#advanced).
</Note>

### Envelope

Every event — including the test ping — uses the same envelope:

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

| Field        | Description                                                                                                   |
| ------------ | ------------------------------------------------------------------------------------------------------------- |
| `id`         | Delivery log UUID. Stable across retries of the same attempt; a [replay](#replay-a-delivery) creates a new id |
| `type`       | Event name, or `webhook.test` for a test ping                                                                 |
| `data`       | Event payload. Empty object for `webhook.test`                                                                |
| `created_at` | ISO 8601 timestamp of **this attempt**. Retries keep the same `id` and write a new `created_at`               |

### Verify the signature

Compute HMAC-SHA256 of the **raw body bytes** with the signing secret. Compare the hex digest to `X-Webhook-Signature` with a constant-time comparison. Do not re-serialize the JSON — key order and escaping must match the bytes TryPost signed.

<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>
  Frameworks that parse JSON before you see the request often discard the raw body. Read the unparsed body (for example Express `express.raw({ type: 'application/json' })`) and only then `JSON.parse`.
</Warning>

The secret is stored encrypted. It looks like `whsec_` plus 32 random characters. You can read it from the dashboard, [`GET /webhooks/{id}`](/api-reference/endpoint/get-webhook), or `get-webhook-tool`.

## Post payload

For every post event except `post.deleted`, `data` is the post at the moment of the event:

| Field                                                         | Description                                                                                                    |
| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `id`                                                          | Post UUID                                                                                                      |
| `workspace_id`                                                | Workspace UUID                                                                                                 |
| `user_id`                                                     | Author user UUID                                                                                               |
| `status`                                                      | `draft`, `scheduled`, `publishing`, `published`, `partially_published`, or `failed`                            |
| `created_via`                                                 | `web`, `api`, `mcp`, or `null`                                                                                 |
| `content`                                                     | Post body. May include HTML from the editor (e.g. `<p>…</p>`), or be an empty string                           |
| `scheduled_at` / `published_at` / `created_at` / `updated_at` | ISO 8601, or `null` — not the `Y-m-d H:i:s` strings used by [`GET /posts`](/api-reference/endpoint/list-posts) |
| `author`                                                      | `{ id, name }` only — no email — or `null`                                                                     |
| `workspace`                                                   | `{ id, name }`                                                                                                 |
| `labels`                                                      | `{ id, name, color }[]`                                                                                        |
| `media`                                                       | See [Media](#media)                                                                                            |
| `platforms`                                                   | See [Platforms](#platforms)                                                                                    |

`post.deleted` is only `{ "id": "…", "workspace_id": "…" }` — the post is already gone.

### Media

Each `media[]` item:

| Field               | Description                                                                 |
| ------------------- | --------------------------------------------------------------------------- |
| `id`                | Media id                                                                    |
| `path`              | Stored path, e.g. `medias/{uuid}.jpg`                                       |
| `url`               | Public file URL                                                             |
| `type`              | `image`, `video`, `document`, or `null` if the file could not be classified |
| `mime_type`         | Stored MIME type                                                            |
| `original_filename` | Filename as uploaded or imported                                            |
| `source`            | `ai`, `unsplash`, `giphy`, or `null` (direct upload)                        |
| `source_meta`       | Extra source data, or `null`                                                |
| `meta`              | File metadata (`alt_text`, dimensions, etc.)                                |

### Platforms

One entry per `post_platform` row — including accounts that are **not** enabled for this publish (`enabled: false`). Create/sync writes a row for every connected account; filter on `enabled` if you only care about the ones that will go out.

| Field                                                  | Description                                                                                                          |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `id`                                                   | Post-platform UUID                                                                                                   |
| `social_account_id`                                    | Connected account UUID                                                                                               |
| `platform`                                             | e.g. `linkedin`, `x`, `instagram-facebook`                                                                           |
| `content_type`                                         | e.g. `linkedin_post`, `instagram_reel`                                                                               |
| `enabled`                                              | Whether this account is included in the publish                                                                      |
| `status`                                               | `pending`, `publishing`, `published`, or `failed`                                                                    |
| `platform_post_id`                                     | Id on the social network, or `null`                                                                                  |
| `platform_url`                                         | Live post URL, or `null`                                                                                             |
| `published_at`                                         | ISO 8601, or `null`                                                                                                  |
| `error_message` / `error_context`                      | Present when that platform failed                                                                                    |
| `display_name` / `display_username` / `display_avatar` | Snapshot of the account at publish time                                                                              |
| `meta`                                                 | Per-platform settings (same keys as the [REST meta contract](/api-reference/endpoint/update-post#per-platform-meta)) |
| `social_account`                                       | `{ id, platform, display_name, username, is_active, status }`, or `null`. Never includes tokens                      |

## Test event

On the webhook detail page, open the actions menu and click **Send test event**. That POSTs a signed `webhook.test` ping with an empty `data` object. A `2xx` response is required. Timeouts, connection errors, and non-2xx statuses fail the test.

The test is **synchronous**: the dashboard, API, and MCP wait for the HTTP response (5 second timeout). It does **not** write a delivery log, so you cannot replay it. It does not change `status`, `consecutive_failures`, or `last_sent_at`.

The ping runs even if the webhook is **paused** or **disabled** — status only gates real post events.

## Status

| Status       | Meaning                                                                                                                                                                                                                   |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Enabled**  | Subscribed events are delivered                                                                                                                                                                                           |
| **Disabled** | You turned it off. Events are skipped                                                                                                                                                                                     |
| **Paused**   | TryPost paused it after **5** consecutive failed deliveries. The account owner gets a transactional email with a link to the webhook (not an in-app notification — there is no toggle under **Settings → Notifications**) |

You can set **enabled** or **disabled** from the actions menu (**Enable endpoint** / **Disable endpoint**). You cannot set **paused** yourself — only TryPost does that after failures.

Re-enabling a paused webhook clears `consecutive_failures` and `paused_at`. Saving an already-enabled webhook with `status: enabled` does not reset the counter.

## Delivery, retries, and pause

Deliveries run on the `webhooks` Horizon queue.

* **Timeout:** 10 seconds (test ping: 5 seconds)
* **Retries:** 3 attempts, 60 seconds apart
* **Success:** HTTP `2xx`. `last_sent_at` updates and the failure counter resets
* **Failure:** After 3 failed attempts, the consecutive-failure count goes up by one
* **Pause:** At 5 consecutive failed deliveries, the webhook is paused and the account owner is emailed

A replay still delivers if the webhook is paused or disabled. Regular events do not. A successful replay on a paused webhook updates `last_sent_at` and resets the failure counter, but **does not re-enable** it — you still have to set status back to **enabled**.

Private or local endpoints are rejected at create/update and again at delivery time.

<Note>
  Self-hosting? Horizon must be running. It already includes a `webhooks` supervisor — you do not add a separate worker. See [Production](/self-hosting/production).
</Note>

## Delivery logs

Open a webhook to see logs, newest first: event type, HTTP status, attempts, payload, and response body (first 2,000 characters). New deliveries appear live over WebSockets — no refresh. Self-hosting that live view needs [Reverb](/self-hosting/production) running.

The dashboard loads more rows as you scroll (25 at a time). The [REST list](/api-reference/endpoint/list-webhook-logs) paginates at **15**. MCP returns the first `limit` rows (default 50, max 100) with no cursor.

Logs older than **7 days** are deleted daily. Deleting a webhook deletes its logs immediately. Test pings never appear here.

<Note>
  Self-hosting? The prune job is `app:prune-webhook-logs`, scheduled daily. Your cron must run `php artisan schedule:run` every minute — see [Scheduled tasks](/self-hosting/production#scheduled-tasks-cron).
</Note>

## Replay a delivery

**Replay** queues a new signed POST with the original `data`, a new envelope `id`, and a new log row. The dashboard flash, API `{ "replayed": true }`, and MCP result mean the job was queued — not that the receiver already answered. Watch the new log row (it appears live) for the outcome.

Replay works even when the webhook is paused or disabled. You can replay a successful log as well as a failed one.

## Manage from the dashboard

On the webhook detail page, the actions menu also lets you:

* **Edit endpoint** — change the URL and subscribed events
* **Enable endpoint** / **Disable endpoint** — toggle delivery without deleting the row
* **Rotate signing secret** — issue a new `whsec_…` value
* **Send test event** — ping the URL
* **Copy webhook ID** — paste into API or MCP calls
* **Delete** — remove the webhook and its logs

The overview card shows the signing secret (masked). Reveal or copy it at any time — unlike API keys, the secret stays readable until you rotate it.

## Rotate the signing secret

**Rotate signing secret** issues a new `whsec_…` value. The previous secret stops working immediately — including the next retry of an in-flight delivery, which signs with the new secret. Update the receiver before you rotate, or deliveries will fail signature checks.

## Delete a webhook

Delete from the list or the detail page. Delivery to that URL stops at once, remaining logs are removed, and queued jobs for that webhook are discarded. This cannot be undone.

## Via the API and MCP

| Task                  | REST                                                                                  | MCP                          |
| --------------------- | ------------------------------------------------------------------------------------- | ---------------------------- |
| List                  | [`GET /webhooks`](/api-reference/endpoint/list-webhooks)                              | `list-webhooks-tool`         |
| Create                | [`POST /webhooks`](/api-reference/endpoint/create-webhook)                            | `create-webhook-tool`        |
| Get (includes secret) | [`GET /webhooks/{id}`](/api-reference/endpoint/get-webhook)                           | `get-webhook-tool`           |
| Update                | [`PUT /webhooks/{id}`](/api-reference/endpoint/update-webhook)                        | `update-webhook-tool`        |
| Test                  | [`POST /webhooks/{id}/send-test`](/api-reference/endpoint/send-webhook-test)          | `send-webhook-test-tool`     |
| Rotate secret         | [`POST /webhooks/{id}/rotate-secret`](/api-reference/endpoint/rotate-webhook-secret)  | `rotate-webhook-secret-tool` |
| Logs                  | [`GET /webhooks/{id}/logs`](/api-reference/endpoint/list-webhook-logs)                | `list-webhook-logs-tool`     |
| Replay                | [`POST /webhooks/{id}/logs/{log}/replay`](/api-reference/endpoint/replay-webhook-log) | `replay-webhook-log-tool`    |
| Delete                | [`DELETE /webhooks/{id}`](/api-reference/endpoint/delete-webhook)                     | `delete-webhook-tool`        |

List and update responses omit `signing_secret`. Create, get, and rotate return it. REST delete is `204`; MCP delete returns `{ "deleted": true }`.

## FAQ

<AccordionGroup>
  <Accordion title="Can I subscribe to every event with a wildcard?">
    No. Send an explicit `events` array. `*` and unknown names are rejected.
  </Accordion>

  <Accordion title="Does creating or updating a webhook ping the URL?">
    No. Only **Send test event** (or the matching API / MCP call) sends a request.
  </Accordion>

  <Accordion title="Why is my webhook paused?">
    Five deliveries in a row exhausted their retries. Fix the endpoint, then set status back to **enabled**.
  </Accordion>

  <Accordion title="How long are logs kept?">
    Seven days. Older rows are pruned daily. Deleting the webhook removes the remaining logs.
  </Accordion>

  <Accordion title="Does a test work when the webhook is paused?">
    Yes. Tests and replays ignore status. Only subscribed post events are skipped while paused or disabled.
  </Accordion>

  <Accordion title="Is there an event when a post starts publishing?">
    No. `publishing` is silent. Subscribe to `post.published`, `post.partially_published`, and `post.failed` for the outcome.
  </Accordion>

  <Accordion title="Must the endpoint be HTTPS?">
    No. Public `http://` URLs are accepted. Private and loopback addresses are not, unless a self-hosted instance sets `TRYPOST_ALLOW_PRIVATE_NETWORK=true`.
  </Accordion>

  <Accordion title="Does a successful replay re-enable a paused webhook?">
    No. It can reset the failure counter, but you still have to set status to **enabled** before subscribed events fire again.
  </Accordion>

  <Accordion title="Does Publish now fire post.scheduled?">
    No. Immediate publish never enters `scheduled`. Subscribe to `post.created` plus the outcome events (`post.published`, `post.partially_published`, `post.failed`).
  </Accordion>

  <Accordion title="Does changing the schedule time fire post.scheduled again?">
    No. `post.scheduled` only fires when status becomes `scheduled`. Editing `scheduled_at` on an already-scheduled post is silent.
  </Accordion>

  <Accordion title="Can I add custom headers or pick the HTTP method?">
    No. Workspace webhooks are always `POST` with `Content-Type`, `User-Agent`, and `X-Webhook-Signature`.
  </Accordion>

  <Accordion title="Is there a webhook for comments or a disconnected account?">
    No. Only the seven post lifecycle events. Comments, mentions, and account disconnects stay in [notifications](/knowledge-base/notifications).
  </Accordion>
</AccordionGroup>
