Shared inbox events
A Host app can be told when something happens in a shared inbox it has been granted. Waymaker POSTs a small signed JSON event to an https endpoint of the app when:
- a new thread starts;
- a message arrives;
- a thread's status changes;
- a thread is assigned;
- a thread is moved to a folder.
An event carries ids, never content: no subject, no body, no names. When the app needs the mail
itself, it reads it with shared_inbox_get_email or shared_inbox_get_thread.
1. Grant the app the inbox
Events follow the same grant as reading: shared_inbox:<inbox id>, added by an org admin. See
the grant. Without it, subscribing is refused. If the grant is
later removed, events stop at once, including any already queued.
2. Subscribe
Only an organisation owner or admin can manage subscriptions.
waymaker host apps events subscribe <app-id> \
--inbox <inbox-id> \
--url https://your-app.example.com/hooks/inbox \
--events thread.created,message.received,thread.status_changed,thread.assigned,thread.moved
From an agent, use the MCP tool host_app_event_subscribe with app_id, shared_inbox_id, url and
event_types.
The response includes the signing secret (whsec_…). It is shown once. Store it in the app's
environment straight away, for example:
waymaker host apps env set <app-id> WAYMAKER_EVENTS_SECRET=whsec_...
Rules:
- The URL must be
https://and resolve to a public address. Private, internal and link-local addresses are refused, both when you subscribe and again before every delivery. - An app has one subscription per inbox. To change it, update it (below).
- An organisation can have at most 25 active subscriptions.
Then send a test:
waymaker host apps events test <subscription-id>
A ping event arrives within about a minute, signed like any other. waymaker host apps events list <app-id> shows whether it was delivered.
The events
type | When |
|---|---|
thread.created | The first message of a new thread arrives. |
message.received | Any message arrives, including the first. |
thread.status_changed | A thread's status changes: by a person, by a move to or from Archive, or by a reply reopening it. |
thread.assigned | A thread is assigned or unassigned, including automatic assignment when mail arrives. |
thread.moved | A thread is moved to a folder. |
ping | Only from events test. |
Every event has the same keys. A key that does not apply to the event is null:
{
"event_id": "5b0c4c0e-0d0e-4a43-9a7a-0e1b4f0a2f61",
"type": "message.received",
"occurred_at": "2026-10-31T01:23:45.678Z",
"org_id": "org_...",
"inbox_id": "a1b2c3d4-...",
"thread_id": "e5f6...",
"email_id": "9a8b...",
"folder_id": null,
"folder_slug": "inbox",
"status": null,
"assigned_to": null,
"actor_user_id": null,
"from_email": "customer@example.com"
}
from_emailis present only onthread.createdandmessage.received.statusis set onthread.status_changed(open,pending,resolvedorarchived).assigned_tois set onthread.assigned. It is a user id, ornullwhen the thread was unassigned.folder_idandfolder_slugare set onthread.moved, and on other events when the folder is known (mail arriving is ininbox).actor_user_idis the person who made the change, ornullwhen Waymaker did (mail arriving, an automatic assignment, a reply reopening a thread).- A
pingcarriesevent_id,type,occurred_at,org_idandinbox_idonly.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Waymaker-Events/1 |
Waymaker-Event-Id | The event's id, the same as event_id in the body. |
Waymaker-Event-Type | The event's type. |
Waymaker-Timestamp | When this delivery was signed, in Unix seconds. |
Waymaker-Signature | v1= followed by the hex HMAC-SHA256 described below. |
Verifying an event
Check every request before you act on it:
- Read the raw request body as text. Do not parse and re-serialise it first.
- Compute HMAC-SHA256 over
Waymaker-Timestamp + "." + rawBody. The key is the whole secret string,whsec_included, as UTF-8. - Compare
v1=<hex>withWaymaker-Signaturein constant time. - Reject the request if the timestamp is more than 5 minutes from now.
- De-duplicate on
Waymaker-Event-Id. A retry, or a delivery whose result was lost, can bring the same event twice.
Reply with any 2xx quickly, and do the work afterwards.
Node
import crypto from 'node:crypto'
// rawBody: the request body exactly as received, as a string.
export function verifyWaymakerEvent(rawBody, headers, secret) {
const ts = headers['waymaker-timestamp'] ?? ''
const sig = headers['waymaker-signature'] ?? ''
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const expected = 'v1=' + crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex')
const a = Buffer.from(sig)
const b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
Deno
// Returns the parsed event, or null when the request is not a genuine, fresh Waymaker event.
export async function verifyWaymakerEvent(req: Request, secret: string) {
const raw = await req.text()
const ts = req.headers.get('Waymaker-Timestamp') ?? ''
const sig = req.headers.get('Waymaker-Signature') ?? ''
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return null
const hex = sig.startsWith('v1=') ? sig.slice(3) : ''
if (!/^[0-9a-f]{64}$/.test(hex)) return null
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'])
const mac = new Uint8Array(hex.match(/../g)!.map((h) => parseInt(h, 16)))
// crypto.subtle.verify compares in constant time.
const ok = await crypto.subtle.verify('HMAC', key, mac, new TextEncoder().encode(`${ts}.${raw}`))
return ok ? JSON.parse(raw) : null
}
Delivery, retries and stopping
- Each delivery waits up to 10 seconds for a reply. The reply body is ignored.
- Redirects are not followed. Point the subscription at the final URL.
- Any
2xxis delivered. - Anything else (another status, a timeout, a network error) is retried after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours and 12 hours. That is eight attempts over about 22 hours. After the last attempt the event is dropped.
- Reply
410 Goneto stop. The subscription is disabled at once. - After 10 dropped events in a row, the subscription is disabled. A delivered event resets the count.
- Each organisation can make up to 1,000 delivery attempts an hour. Beyond that, deliveries wait and are not lost.
waymaker host apps events list <app-id> shows each subscription's status, why it was disabled, and
its delivery counts for the last 7 days. To turn one back on:
waymaker host apps events update <subscription-id> --enable
Enabling checks the grant again and resets the failure count. --url, --events and --disable
work the same way. From an agent, use host_app_event_update and host_app_event_list.
Rotating the secret
waymaker host apps events rotate <subscription-id> --confirm
The new secret is shown once, and the old one stops signing immediately. Put the new secret in the
app's environment and redeploy straight after. Events sent in between fail verification. If your
endpoint answers a failed check with an error status, as it should, they are retried and not lost. From an agent, use host_app_event_rotate_secret.
To stop events completely, run waymaker host apps events remove <subscription-id> --confirm
(host_app_event_unsubscribe).
Catching up: shared_inbox_events
If the endpoint was down, or the app wants to check it has everything, it reads the inbox's events from its context API. It calls and authenticates the same way as the inbox reads:
{ "action": "shared_inbox_events", "data": { "shared_inbox_id": "<inbox-id>", "after": "<next from the last call>", "limit": 100 } }
{
"shared_inbox_id": "<inbox-id>",
"events": [ { "event_id": "...", "type": "thread.assigned", "...": "..." } ],
"next": "MjAyNi0xMC0zMVQwMToyMzo0NS42NzgrMDA6MDB8...",
"has_more": false,
"retention_days": 7
}
- Events come oldest first. They are the same bodies that were delivered.
afteris thenextfrom your previous call. Leave it out to start from the oldest event kept.nextis where to resume, even when no events came back, so store it and poll with it. Whilehas_moreistrue, call again straight away.limitdefaults to 100, with a maximum of 200.- Events are kept for 7 days.
pingevents are not included. - An event's place in the order is the moment it was recorded. Two changes recorded within the same
instant can, rarely, become readable in the opposite order, so a cursor that has just passed one may
not show it. Delivery still sends it; treat the catch-up read as a safety net and de-duplicate on
event_idacross both. - Events are recorded only while the inbox has at least one subscription, so subscribe first.