Skip to main content
Webhooks live on the Orchestration service and cover events from the whole platform, so one subscription endpoint serves every service.
Give this to your AI agent with your endpoint URL:“On gtm-api (MCP connector at 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.”

1. Register an endpoint

POST /api/webhooks takes a name, your target_url and the events[] you want:
POST /orchestration/v4/api/webhooks
  • 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.
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.

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: The body is a fixed envelope, with the event’s own data nested under 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:
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.
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.

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. 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:
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). 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.