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

# Webhooks

> How Nylon tells you about the outcomes nobody is waiting on.

Publishing is synchronous, so `POST /v1/posts` already tells you what happened — webhooks exist for the outcomes nobody is holding a request open for: a scheduled post that failed at 09:00 while everyone was asleep, and a profile whose token expired between one post and the next.

That is why the event list is short and all of it is about outcomes. A feed of every state change would be more events and no more information.

## Events

| Event                      | When                                                                      |
| -------------------------- | ------------------------------------------------------------------------- |
| `post.published`           | Every target on a post published.                                         |
| `post.partially_published` | Some targets published and at least one failed.                           |
| `post.failed`              | No target published.                                                      |
| `profile.connected`        | An account was connected, or reconnected after being disconnected.        |
| `profile.disconnected`     | An account was disconnected and its stored credentials destroyed.         |
| `profile.needs_attention`  | A profile's stored credentials stopped working and it needs reconnecting. |

`post.published` and `post.partially_published` are separate events rather than one event with a status field, so an endpoint can subscribe to just the bad news:

```bash theme={null}
curl -X POST https://api.nylon.dev/v1/webhooks \
  -H "Authorization: Bearer nylon_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/nylon/webhooks",
    "events": ["post.failed", "post.partially_published", "profile.needs_attention", "profile.disconnected"]
  }'
```

The three profile events are one account's life story — `connected` when it becomes publishable, `needs_attention` when its credentials stop working, `disconnected` when it is taken away — so an integration that mirrors accounts into its own UI can keep that list correct without polling [`GET /v1/profiles`](/api-reference/list-profiles).

<Note>
  All three fire on the *transition*. `profile.needs_attention` is sent once when an account breaks, not once per post until you fix it; `profile.connected` is sent when an account starts being publishable, and a reconnection of an account that is already live is a credential refresh rather than a new connection, so it is silent.
</Note>

## Set one up

<Steps>
  <Step title="Register the endpoint">
    [`POST /v1/webhooks`](/api-reference/create-webhook) with an `https` URL. Omit `events` to receive everything.

    The response contains the signing secret. **It is in that response and no other** — store it before you move on. If you lose it, [rotate](/api-reference/rotate-webhook-secret) rather than asking for it back: an endpoint that hands out signing secrets would turn a leaked API key into forged events.
  </Step>

  <Step title="Verify signatures">
    Every request carries `Nylon-Signature`. Check it before you trust the body — see below.
  </Step>

  <Step title="Test it">
    [`POST /v1/webhooks/{webhookId}/test`](/api-reference/test-webhook) delivers a signed `webhook.test` event and waits for your endpoint to answer, returning the status code and body it gave. It is the only delivery that is not queued, because wiring up a receiver should be one round trip.
  </Step>
</Steps>

## The request

```http theme={null}
POST /nylon/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Nylon/1.0 (+https://docs.nylon.dev/webhooks)
Nylon-Event: post.partially_published
Nylon-Delivery: cly7w4c5d0002x8b3n1q3r0aa
Nylon-Webhook-Id: cly7w1a2b0001x8b3k9m2p0zz
Nylon-Signature: t=1772024400,v1=5f8d1c...
```

```json theme={null}
{
  "id": "cly7w4c5d0002x8b3n1q3r0aa",
  "type": "post.partially_published",
  "created_at": "2026-03-01T09:00:04Z",
  "data": {
    "post": {
      "id": "cly7q9v2m0001x8b3a1c4d0dd",
      "status": "partially_published",
      "text": "Shipping day.",
      "targets": [
        { "profile_id": "cly7p2q4k0001x8b3f2n9d0aa", "network": "linkedin", "status": "published", "url": "https://www.linkedin.com/feed/update/..." },
        { "profile_id": "cly7p2q4k0003x8b3j6k2f0cc", "network": "instagram", "status": "failed", "error": { "code": "reauthentication_required", "message": "..." } }
      ]
    }
  }
}
```

The object under `data.post` is the same shape [`GET /v1/posts/{postId}`](/api-reference/get-post) returns, and `data.profile` is the same shape as a profile. There is nothing to learn twice.

<Note>
  `id` is the delivery id, and it is also the `Nylon-Delivery` header. A retry reuses it, so deduplicate on it and you can treat delivery as at-least-once without processing anything twice.
</Note>

## Verifying the signature

`Nylon-Signature: t=<unix timestamp>,v1=<hex>`, where the hex is `HMAC-SHA256(secret, "<t>.<raw body>")`.

Three things matter: use the **raw** body — not a re-serialised object, because key order and whitespace change the hash — compare in constant time, and reject an old timestamp. Without the timestamp check, a captured delivery can be replayed at any point in the future.

<CodeGroup>
  ```javascript Node theme={null}
  import { createHmac, timingSafeEqual } from 'node:crypto'

  export const verify = (rawBody, header, secret, toleranceSeconds = 300) => {
    const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
    const timestamp = Number(parts.t)

    if (!Number.isFinite(timestamp)) return false
    if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false

    const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
    const provided = Buffer.from(parts.v1 ?? '')

    return provided.length === expected.length
      && timingSafeEqual(provided, Buffer.from(expected))
  }
  ```

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

  def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
      parts = dict(part.split("=", 1) for part in header.split(","))

      try:
          timestamp = int(parts["t"])
      except (KeyError, ValueError):
          return False

      if abs(time.time() - timestamp) > tolerance:
          return False

      expected = hmac.new(
          secret.encode(),
          f"{timestamp}.".encode() + raw_body,
          hashlib.sha256,
      ).hexdigest()

      return hmac.compare_digest(expected, parts.get("v1", ""))
  ```

  ```php PHP theme={null}
  function nylon_verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
  {
      $parts = [];
      foreach (explode(',', $header) as $part) {
          [$key, $value] = explode('=', $part, 2);
          $parts[$key] = $value;
      }

      if (!isset($parts['t'], $parts['v1'])) {
          return false;
      }

      if (abs(time() - (int) $parts['t']) > $tolerance) {
          return false;
      }

      $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);

      return hash_equals($expected, $parts['v1']);
  }
  ```
</CodeGroup>

<Warning>
  Frameworks that parse JSON for you usually discard the raw body. In Express, mount `express.raw({ type: 'application/json' })` on the webhook route; in Next.js App Router, read `await request.text()` before parsing.
</Warning>

## Delivery, retries and failure

<CardGroup cols={2}>
  <Card title="Its own durable run" icon="layers">
    The delivery is recorded inside the same operation that records the outcome and then handed to a workflow run of its own. Your endpoint being slow never slows down anyone's publish, and a delivery is never lost to a process that ended.
  </Card>

  <Card title="Answer with a 2xx" icon="check">
    Any 2xx settles the delivery. Anything else — a 500, a timeout, a DNS failure — is a retry. Ten seconds is the timeout, so acknowledge first and do the work afterwards.
  </Card>

  <Card title="Seven attempts over a day" icon="clock">
    Then `1m`, `5m`, `30m`, `2h`, `6h`, `24h` — the run sleeps between them, so a retry lands on the schedule rather than on the next sweep of a queue. Coarse on purpose: an endpoint is usually either fine or down for a deploy, and retries a second apart are a denial of service against the thing you are trying to reach.
  </Card>

  <Card title="Disabled after 20 in a row" icon="ban">
    Twenty consecutive failed deliveries disables the endpoint, with the reason on it. Any success resets the count. Fix the endpoint and `PATCH` `status` back to `active`.
  </Card>
</CardGroup>

`next_attempt_at` on a `pending` delivery is when that run will wake. [`GET /v1/webhooks/{webhookId}/deliveries`](/api-reference/list-webhook-deliveries) is the answer to "did you send it?". Every row carries the payload, the response status and the first 2000 characters of the response body, which is what lets you tell a signature your receiver rejected from an event that was never queued.

## Rotating the secret

[`POST /v1/webhooks/{webhookId}/secret`](/api-reference/rotate-webhook-secret) issues a new secret and returns it once. There is no overlap window — the next delivery is signed with the new secret — so deploy it to your receiver first, then rotate.

That is a deliberate simplification. Two valid secrets means a receiver that tries both and a window in which a leaked secret still works, which is more machinery than a webhook endpoint deserves.

## Not agent tools

The [MCP server](/mcp) exposes publishing, profiles and networks, and deliberately not these endpoints. Webhook configuration is infrastructure — where your servers listen and what signs the traffic — and an agent reconfiguring it is not a workflow anyone wants. Register endpoints from your own code or your deploy pipeline.
