Waymaker
WaymakerDocs
Developer Documentation
Documentation

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

typeWhen
thread.createdThe first message of a new thread arrives.
message.receivedAny message arrives, including the first.
thread.status_changedA thread's status changes: by a person, by a move to or from Archive, or by a reply reopening it.
thread.assignedA thread is assigned or unassigned, including automatic assignment when mail arrives.
thread.movedA thread is moved to a folder.
pingOnly 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_email is present only on thread.created and message.received.
  • status is set on thread.status_changed (open, pending, resolved or archived).
  • assigned_to is set on thread.assigned. It is a user id, or null when the thread was unassigned.
  • folder_id and folder_slug are set on thread.moved, and on other events when the folder is known (mail arriving is in inbox).
  • actor_user_id is the person who made the change, or null when Waymaker did (mail arriving, an automatic assignment, a reply reopening a thread).
  • A ping carries event_id, type, occurred_at, org_id and inbox_id only.

Headers

HeaderValue
Content-Typeapplication/json
User-AgentWaymaker-Events/1
Waymaker-Event-IdThe event's id, the same as event_id in the body.
Waymaker-Event-TypeThe event's type.
Waymaker-TimestampWhen this delivery was signed, in Unix seconds.
Waymaker-Signaturev1= followed by the hex HMAC-SHA256 described below.

Verifying an event

Check every request before you act on it:

  1. Read the raw request body as text. Do not parse and re-serialise it first.
  2. Compute HMAC-SHA256 over Waymaker-Timestamp + "." + rawBody. The key is the whole secret string, whsec_ included, as UTF-8.
  3. Compare v1=<hex> with Waymaker-Signature in constant time.
  4. Reject the request if the timestamp is more than 5 minutes from now.
  5. 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 2xx is 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 Gone to 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.
  • after is the next from your previous call. Leave it out to start from the oldest event kept.
  • next is where to resume, even when no events came back, so store it and poll with it. While has_more is true, call again straight away.
  • limit defaults to 100, with a maximum of 200.
  • Events are kept for 7 days. ping events 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_id across both.
  • Events are recorded only while the inbox has at least one subscription, so subscribe first.