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

# Receive webhooks

> Register an endpoint, verify signed deliveries, filter what you receive, and read the delivery log. Includes the retry policy and the auto-disable rule.

Webhooks live on the Orchestration service and cover events from the whole platform, so one subscription endpoint serves every service.

<Tip>
  **Give this to your AI agent** with your endpoint URL:

  "On gtm-api (MCP connector at [https://mcp.gtm-api.com/mcp](https://mcp.gtm-api.com/mcp), or REST at app.gtm-api.com with my key): register a webhook for my endpoint URL with the events I name. The event vocabulary is the enum on the create-webhook schema, so read it there rather than looking for a catalog call, and use `events: [\"*\"]` if I say I want everything. Then watch the delivery log and show me the first delivery's status and response code."
</Tip>

## 1. Register an endpoint

`POST /api/webhooks` takes a `name`, your `target_url` and the `events[]` you want:

```json POST /orchestration/v4/api/webhooks theme={null}
{
  "name": "Mass action outcomes",
  "target_url": "https://example.com/hooks/gtm",
  "events": ["mass-actions.settled", "mass-actions.paused"]
}
```

* `name` is required (1 to 255 characters). Omitting it fails validation.
* `target_url` must be `https://` and must not resolve to a loopback, private or link-local address. DNS names are resolved and checked too, not just IP literals.
* Each `events[]` value is validated against the platform event catalog, which currently carries 105 types across the services.
* The same `target_url` cannot be registered twice in one team.
* `events: ["*"]` subscribes to the entire catalog, including event types added later.

The response returns the webhook's `secret`, a 32-character hex string, exactly once. Store it: every later read masks it, and it is what you verify signatures with.

<Note>
  Webhooks are capped per plan. Creating one past the cap answers `402` with `webhook_limit_reached` and a body carrying `used`, `limit` and a suggested action. The free Sandbox plan's cap is 0, so webhooks need a paid plan. See [billing and plans](/kb/billing-and-plans).
</Note>

### Filter what you receive

Two optional filters narrow deliveries at the source, so your endpoint is not woken by events it would discard anyway:

* `filters.account_sid` restricts deliveries to one account (`ln_ac_...` for LinkedIn). The sid is matched verbatim, so channels added later work here without a change.
* `filters.where` takes a small filter expression over the event payload. It is validated strictly at write time: nesting depth is capped at 5 and the whole expression at 20 leaf conditions, answering `filter_grammar_invalid` or `filter_leaves_limit_exceeded` when it exceeds either.

## 2. Verify deliveries

Every real delivery is an HTTPS `POST` with a JSON body and these headers:

| Header | Content |
| - | - |
| `X-Webhook-Event` | The event type, for example `mass-actions.settled` |
| `X-Webhook-Signature` | `t={unix_seconds},v1={hex_hmac}` |
| `X-Webhook-Timestamp` | The same unix timestamp as `t` |
| `X-Webhook-Id` | The subscription's sid |
| `X-Webhook-Log-Id` | This delivery's sid in the delivery log |
| `X-Trace-Id` | Trace id, also present in the body |

The body is a fixed envelope, with the event's own data nested under `payload`:

```json theme={null}
{
  "webhook_log_sid": "wh_lg_...",
  "type": "mass-actions.settled",
  "emitted_at": "2026-08-13T09:12:44Z",
  "occurred_at": "2026-08-13T09:12:41Z",
  "team_sid": "ts_tm_...",
  "trace_id": "019ffa3d-...",
  "payload": {}
}
```

The signature is `HMAC-SHA256(secret, "{t}.{raw_body}")` over the raw request body, with the timestamp mixed in to block replays. Verify before parsing, and reject signatures older than about 5 minutes:

<CodeGroup>
  ```typescript Node theme={null}
  import crypto from "node:crypto";

  function verify(rawBody: string, header: string, secret: string): boolean {
    const parts = Object.fromEntries(
      header.split(",").map((p) => p.split("=") as [string, string]),
    );
    if (!parts.t || !parts.v1) return false;
    const age = Math.abs(Date.now() / 1000 - Number(parts.t));
    if (age > 300) return false;
    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${parts.t}.${rawBody}`)
      .digest("hex");
    const received = Buffer.from(parts.v1);
    const digest = Buffer.from(expected);
    // timingSafeEqual throws on a length mismatch, so check that first.
    return (
      received.length === digest.length && crypto.timingSafeEqual(digest, received)
    );
  }
  ```

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

  def verify(raw_body: bytes, header: str, secret: str) -> bool:
      # Parse defensively: a malformed header must return False, not raise.
      parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
      if "t" not in parts or "v1" not in parts:
          return False
      try:
          ts = int(parts["t"])
      except ValueError:
          return False
      if abs(time.time() - ts) > 300:
          return False
      expected = hmac.new(
          secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(expected, parts["v1"])
  ```
</CodeGroup>

Compute the HMAC over the raw bytes you received, before any JSON re-serialization: parsing and re-encoding the body can reorder keys and break the signature.

<Warning>
  Redirects are not followed, deliberately. A `302` from your endpoint counts as a failed delivery, not a hop. Deliveries also time out at 30 seconds total (5 seconds to connect), so acknowledge fast and do the work asynchronously.
</Warning>

## 3. Test before relying on it

`POST /api/webhooks/{sid}/test` fires a synthetic delivery at your endpoint, so you can confirm reachability, signature handling and parsing without waiting for a real event.

Three things to know about it: it is rate-limited to 10 calls per minute per caller (the budget is shared across all your webhooks), it is rejected with `invalid_transition` while the webhook's status is `off`, and it writes no row to the delivery log. It also sets a subset of the headers above, so build your verifier on the signature, timestamp, event and id headers rather than requiring all six.

## 4. Retries and auto-disable

A delivery is attempted up to **5 times**, with waits of 1 minute, 5 minutes, 30 minutes and 2 hours between attempts. The fifth failure is terminal, so a dead endpoint settles about 2.5 hours after the first attempt.

| Response | What happens |
| - | - |
| `2xx` | Delivered, done |
| `5xx`, `408`, `429`, network or TLS timeout | Retried on the schedule above |
| Any other `4xx` | Terminal, no retry: the request was understood and refused |
| `410 Gone` | Terminal, and it disables the subscription immediately |

After **20 consecutive failed attempts**, or a single `410 Gone`, the subscription flips to `failed` and stops delivering. Its deliveries still waiting for a retry are cancelled with `cancel_reason: "webhook_failed"`, and the failure counter stops moving from that point. That transition itself emits `webhooks.failed`, so a second webhook can page you when the first one dies; the failing endpoint also gets that one event, a single attempt with no retries.

Bringing it back is three calls: fix the endpoint, `PATCH /api/webhooks/{sid}` with `{"status": "on"}` (this resets the counter), then replay what was lost:

```json theme={null}
POST /api/webhooks/{sid}/replay
{
  "from": "2026-09-04T16:00:00Z"
}
```

Replay puts every delivery of that webhook created since `from` whose status is `failed` or `cancelled` (by the platform, never by your own cancel) back into the queue, oldest first, one per second, so your endpoint is not hit by the whole backlog at once; the response says how many rows it re-armed and when the last one is due. `to` defaults to now, `statuses` to `["failed", "cancelled"]` (pass `["success"]` to re-fire delivered events into a rebuilt downstream), `event_types` to every type. The window spans at most 30 days and one call re-arms at most 5000 rows; beyond either cap the call is refused with the cap named, so narrow the window and call again. Replayed rows keep their `webhook_log_sid`, so a receiver that deduplicates on it sees the same delivery, not a new one.

## 5. Use the delivery log

The log holds **one row per event per subscription**, not one per attempt: a retry updates that row in place, bumping `retry_count` and overwriting the response code. The row is the delivery, and its history is the counter.

* `POST /api/webhook-logs/search` lists deliveries with status, response code and timing; filter by webhook or event type.
* `POST /api/webhook-logs/{sid}/retry` re-sends. Allowed from every state but `in_progress`: `pending`, `retrying`, `failed`, `success` (a manual re-send of a delivered event is legitimate) and `cancelled`, whatever cancelled it. It re-arms the row without counting as an attempt, so `retry_count` stays the number of attempts that actually went out (at most 5). Only while the parent subscription is still live: a deleted or `off` webhook rejects the retry.
* `POST /api/webhook-logs/{sid}/cancel` stops a delivery that has not settled. Allowed from `pending`, `retrying` and `in_progress`.
* `POST /api/webhook-logs/metrics` aggregates outcomes. `period` with `from` and `to` is required and may span at most 90 days.

Both `retry` and `cancel` answer `409` with `invalid_transition` when the row is not in a state that allows the verb, and the error names the state it found.

## 6. Read the payload

`payload` is the event's own row, under the field names the REST API uses for that entity, plus a few fields that exist only on the wire:

* `client_reference` on `linkedin-connection-requests.sent`, `.accepted`, `.withdrawn`, `.expired-detected` and on `linkedin-messages.sent` echoes the `client_reference` you passed to the send. Match on it instead of on a LinkedIn identifier. It is `null` on rows the sync picked up from LinkedIn's own UI. Every send verb accepts it: `send_linkedin_connection_request` and all six message sends (the basic message, voice, InMail, Sales Navigator, Recruiter and the group opener), and the comment, post and repost verbs, which emit no event of their own. A mass-action plan step's `args.client_reference` T reaches every row the run creates as `T:{item sid}:{step id}`, so match the `T:` prefix.
* `counterpart` on `linkedin-conversations.created`, `linkedin-messages.received` and `linkedin-messages.sent` is the other side of the thread: `{ "full_name", "headline", "picture_url" }`, or `null` when we hold no participant data for the thread yet. `picture_url` is LinkedIn's signed CDN link and expires within hours, so render it on arrival rather than storing it.
* `created_by`, where the row has one, is a reference to the actor: `{ "actor_type", "actor_sid", "actor_name", "oauth_client_sid", "reason" }`. `actor_name` and `oauth_client_sid` name the OAuth client that acted (an MCP client such as Claude, or n8n) and are `null` for a user, an API key or a system job. `reason` is set on system jobs, for example `snapshot_capture_job`.
* `nickname`, the profile's vanity URL slug, is present only where LinkedIn exposes it: the connections list and received invitations. Followers, conversations, messages and sent invitations name people by hash id, so those rows carry `nickname` only once the same person is also a 1st-degree connection of the account. Match on `ln_member_id` (stable) or `ln_id` (the `ACoAA...` profile id, present on every event) rather than on the slug.
* `linkedin-account-activity-log.task-failed` fires once for each call an account dispatched that ended `failed`. Its payload is the activity-log row (`sid`, `linkedin_account_sid`, `action_type`, `error_message`, `duration_ms`, `created_at`, `updated_at`, `trace_id`) with `pending_age_seconds`, how long the call was pending, and `failure_source`, what failed it: `plugin` (the call's own answer), `reconcile` (nobody finished the call in time) or `browser_deleted` (its browser was deleted while it ran). On a send's row, `send_outcome` and `send_outcome_reason` say what came of the send, and they are `null` on every other action. `not_sent` (`refused`) means nothing reached LinkedIn. `unknown` (`answer_lost`) means the send may be out: a failed call is not a send that did not go out, so ask check-sent before you send it again ([Sending once](/concepts/sending-once)). To receive only the sends in doubt, subscribe with `"filters": {"where": {"field": "payload.send_outcome", "op": {"eq": "unknown"}}}`.
* `deleted_at` on a terminal `linkedin-connection-requests.*` event (`accepted`, `withdrawn`, `expired-detected`) is the moment we detected the transition, not LinkedIn's own timestamp of it. Acceptance is detected by the next sync of the account's pending invitations, so it can trail the real acceptance by up to the sync interval. `removal_kind` says which transition it was.

## Rotating the secret

There is no self-service rotation endpoint. If a secret is exposed, delete the webhook and register a new one, then point your verifier at the new secret. Deleting and re-registering also gives you a clean delivery log boundary, which is easier to reason about than a rotation with no overlap window.


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