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 oneemail.messagesorlinkedin.messagesrun each. This is the route for an agent, which has no server to receive a webhook; the MCP tool isevents.
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