# Events and webhooks

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

```json
{
  "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

```text
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.

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

## Register a webhook

```text
POST /v1/webhooks
```

```bash
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.

```text
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:

```text
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.

```python
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`.
