Guides

Webhooks and polling

How to learn that mail arrived. Polling works for every key today; signed webhooks exist for platform-provisioned inboxes.

Where things stand

Self-service webhook subscriptions for account API keys are not available yet. Today there are two ways to react to incoming mail:

  1. Poll. Works with every key and every client, including MCP. This is the path most agents should start with.
  2. Platform-provisioned webhooks. Inboxes created by a platform integration through POST /clients/provision get a webhook URL and signing secret at provisioning time. If you are integrating Goshen Email into your own product on that path, the event and signature details below apply.

Webhook management through the account API is the next planned increment, along with WebSockets. This page will change when it lands.

Polling well

List with the labels that describe "new to me" and clear them as you go:

const { messages } = await email.messages.list({ inboxId, labels: ['received', 'unread'], limit: 50 })
for (const message of messages) {
  await handle(message)
  await email.messages.updateLabels({ inboxId, messageId: message.messageId, removeLabels: ['unread'], addLabels: ['handled'] })
}

Events

For inboxes that have a webhook, Goshen Email posts JSON events:

TypeWhenPayload
email.receivedA message arrived and was not quarantinedinboxId, occurredAt, and message with the message summary, text up to 64 KiB (bodyTruncated marks a cut), attachments, labels, and triage when enabled
email.delivery_updatedA recipient's server answered for a sent messageinboxId, occurredAt, message (messageId, threadId, inboxId), and the delivery block
email.bouncedA sent message bouncedBounce details for the affected recipient

Quarantined messages do not produce email.received. If a person releases one, the message appears in lists but no delayed event is sent. email.received can carry triage.status: "pending"; read the message again for the completed result.

Verifying signatures

Each delivery carries svix-id, svix-timestamp, and svix-signature headers, following the Standard Webhooks format. To verify:

  1. Take the signing secret you were given, strip the whsec_ prefix, and base64-decode the rest.
  2. Compute HMAC-SHA256 over <svix-id>.<svix-timestamp>.<raw request body>.
  3. Base64-encode the result and compare it, in constant time, against the value after v1, in svix-signature. The header may list several signatures separated by spaces; any match is valid.
  4. Reject timestamps more than a few minutes old, and deduplicate on svix-id.
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verify(headers: Headers, rawBody: string, secret: string) {
  const id = headers.get('svix-id')!, timestamp = headers.get('svix-timestamp')!
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest()
  return (headers.get('svix-signature') ?? '').split(' ').some((part) => {
    const [, value] = part.split(',')
    const given = Buffer.from(value ?? '', 'base64')
    return given.length === expected.length && timingSafeEqual(given, expected)
  })
}

Respond with a 2xx only after you have durably accepted the event. Goshen Email retries failures and does not follow redirects.

Treat payloads as untrusted

Everything under message came from an outside sender. Verify the signature to know the event came from Goshen Email; that says nothing about the honesty of the email inside it. See Building agents on email.