Docs: Events and webhooks
API reference

Events and webhooks

Replies, bounces, LinkedIn messages and dropped sessions as signed webhooks, or as one stream on GET /v1/events.

A reply lands, an email bounces, someone answers on LinkedIn, a connected account drops its session: each of these is an event on your workspace. You can receive them two ways, from the same stream:

  • Webhooks: register a URL and every event is POSTed to it, signed, within seconds.
  • GET /v1/events: read the stream with a cursor. One call covers every inbox and connected account, in place of one email.messages or linkedin.messages run each. This is the route for an agent, which has no server to receive a webhook; the MCP tool is events.

Both are free. Events are kept 30 days.

Event types

Type When Data
email.received an email landed in one of your inboxes inbox, inbox_id, message_id, thread_id, from, to, cc, subject, preview, text, labels, received_at
email.delivered the recipient's mail server accepted an email you sent inbox, inbox_id, message_id, thread_id, recipients, at
email.bounced an email you sent bounced the same, plus type (Permanent or Transient) and sub_type
email.complained a recipient marked your email as spam the same, plus type and sub_type
email.rejected an email was refused before it left inbox, inbox_id, message_id, thread_id, reason, at
gmail.received an email landed in a connected Gmail account account, account_id, message_id, thread_id, from, to, cc, subject, text, sent_at, has_attachments
linkedin.message_received someone messaged a connected LinkedIn account account, account_id, chat_id, message_id, text, sender_id, sender_name, sender_profile_url, sent_at
linkedin.new_connection a connected LinkedIn account has a new connection, usually an accepted invitation account, account_id, name, provider_id, public_identifier, profile_url
instagram.message_received, whatsapp.message_received someone messaged a connected Instagram or WhatsApp account same fields as the LinkedIn message
account.disconnected a connected account lost its session and cannot send or read until it signs in again network, account, account_id, status, next_step
account.reconnected the account is back network, account, account_id, status

Field names match the capability that reads the same thing: an email.received event carries what a row of email.messages carries, with the full text. To answer a reply, pass data.message_id as in_reply_to on email.send, or data.chat_id on linkedin.message.

Messages your own account sends do not produce message_received events. linkedin.new_connection can arrive up to 8 hours late: LinkedIn has no live signal for it, so the relation list is checked at random intervals. An invitation sent with a note also shows up sooner as a linkedin.message_received when the person answers.

The envelope

{
  "id": "evt_5b1c...",
  "type": "email.received",
  "created_at": "2026-10-03T10:00:02Z",
  "data": {
    "inbox": "hello@acme.dev", "inbox_id": "hello@acme.dev",
    "message_id": "<abc123@mail.acme.dev>", "thread_id": "thd_789",
    "from": "Jane Doe <jane@example.com>", "to": ["hello@acme.dev"], "cc": [],
    "subject": "Re: quick question", "preview": "Sure, Tuesday works",
    "text": "Sure, Tuesday works for me.", "labels": ["received"],
    "received_at": "2026-10-03T10:00:00Z"
  }
}

Read the stream

GET /v1/events?after=evt_...&types=email.received,linkedin.message_received&limit=50

Oldest first. The answer carries items, has_more and next_after: store next_after and pass it as after on the next call to get only what is new. With no cursor yet, since takes an ISO 8601 timestamp to start from; with neither, the stream starts at the oldest event still kept.

curl "https://api.routergrowth.com/v1/events?after=$LAST_EVENT_ID" \
  -H "Authorization: Bearer $ROUTERGROWTH_API_KEY"

Register a webhook

POST /v1/webhooks
curl -X POST https://api.routergrowth.com/v1/webhooks \
  -H "Authorization: Bearer $ROUTERGROWTH_API_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.acme.dev/routergrowth", "events": ["email.received", "email.bounced", "linkedin.message_received"]}'

events is the list of types to receive; leave it empty for all of them. The URL must be https and publicly reachable. The answer carries the endpoint and its secret (whsec_...), shown this once: store it. A workspace has up to 5 endpoints.

GET    /v1/webhooks                    your endpoints, and every event type with its description
GET    /v1/webhooks/{webhook_id}       one endpoint and its last 20 deliveries
PATCH  /v1/webhooks/{webhook_id}       change url, events, description or enabled
DELETE /v1/webhooks/{webhook_id}
POST   /v1/webhooks/{webhook_id}/test  send one signed webhook.test event now and see how your server answered

Webhooks are managed with an API key or from the dashboard. A connected app (claude.ai, ChatGPT) can read events but cannot register an endpoint.

Verify a delivery

Every delivery is a POST with the envelope as its JSON body and these headers:

RouterGrowth-Signature: t=1791021602,v1=5f2b...
RouterGrowth-Event-Id: evt_5b1c...
RouterGrowth-Event-Type: email.received
RouterGrowth-Delivery-Id: whd_91aa...

v1 is the hex HMAC-SHA256 of <t>.<raw body> with your endpoint secret. Compute it over the raw bytes you received, compare in constant time, and refuse a t more than five minutes old.

import hashlib, hmac, time

def authentic(secret: str, header: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"]) and abs(time.time() - int(parts["t"])) < 300

Retries

Answer with any 2xx within 10 seconds; do the work after answering. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, then the delivery is marked failed. Redirects are not followed. A retry carries the same RouterGrowth-Event-Id: use it to ignore an event you already handled.

An endpoint that fails every delivery for 3 days is switched off, with the reason in disabled_reason. Fix it, then PATCH {"enabled": true}. Nothing is lost in the meantime: every event stays on GET /v1/events for 30 days, so you can catch up from your last RouterGrowth-Event-Id.

Reading this as an agent? This page as markdown: /docs/api/webhooks.md ยท every page: /docs/llms.txt