Receiving Email
A mailbox is an address on your verified domain. Stack0 sends each email to it as a signed POST to your webhook.
DNS
Verify the domain for sending, then add an MX record with priority 10 and the value inbound-smtp.us-east-1.amazonaws.com. To keep your root domain's mail, use a subdomain such as mail.example.com.
Create a Mailbox
The result carries webhookSecret. Store it now: only create and rotateSecret return it.
import { Stack0 } from '@stack0/sdk'const stack0 = new Stack0({ apiKey: process.env.STACK0_API_KEY! })const mailbox = await stack0.mail.mailboxes.create({domain: 'mail.example.com',address: 'leads', // leads@mail.example.comwebhookUrl: 'https://example.com/api/inbound-email',maxInboundPerDay: 1000,})// Save mailbox.webhookSecret as STACK0_MAILBOX_SECRET.// Later, to replace it:const { webhookSecret } = await stack0.mail.mailboxes.rotateSecret(mailbox.id)
Plus Addresses
Mail to leads+4821@mail.example.com goes to the mailbox leads@mail.example.com, unless a mailbox exists for the exact address. The payload's to is the address the sender used, and tag is "4821". Put a record id in the Reply-To of the emails you send, and replies come back tagged with it. Addresses compare without case.
Receive and Verify
Each delivery carries Standard Webhooks headers: webhook-id, webhook-timestamp, and webhook-signature. verifyInboundWebhook checks the signature and refuses a timestamp more than five minutes from now. Pass the raw body, not parsed JSON.
import { verifyInboundWebhook, WebhookVerificationError } from '@stack0/sdk'export async function POST(request: Request) {let eventtry {event = await verifyInboundWebhook({payload: await request.text(),headers: request.headers,secret: process.env.STACK0_MAILBOX_SECRET!,})} catch (err) {if (err instanceof WebhookVerificationError) return new Response('bad signature', { status: 401 })throw err}// event.id is the same on every retry: skip ids you already handled.// event.tag, event.from, event.subject, event.text, event.html// event.messageId, event.inReplyTo, event.references, event.headers// event.attachments[i].url works until event.attachments[i].expiresAtreturn new Response('ok')}
Any Standard Webhooks library also verifies the signature, with the mailbox secret as the key. Deliveries also carry the older X-Stack0-Signature header (a hex HMAC-SHA256 of the body). That header does not cover a timestamp, so use webhook-signature in new code.
The Payload
{"id": "6f1c2d4e-9a1b-4d8e-8f2a-3c4b5d6e7f80","event": "email.inbound","mailbox": "leads@mail.example.com","mailboxId": "...","tag": "4821","from": { "email": "seller@example.org", "name": "Pat" },"to": "leads+4821@mail.example.com","cc": [], "bcc": [], "replyTo": null,"subject": "Re: Your offer","text": "Yes, let's talk.", "html": "<p>Yes, let's talk.</p>","messageId": "<r1@example.org>","inReplyTo": "<o1@mail.example.com>","references": ["<o1@mail.example.com>"],"headers": { "message-id": "<r1@example.org>", "received": ["from ...", "from ..."] },"attachments": [{ "filename": "photo.jpg", "contentType": "image/jpeg", "size": 48213,"url": "https://...", "expiresAt": "2026-09-30T13:00:00.000Z" }],"metadata": null,"receivedAt": "2026-09-30T12:00:00.000Z"}
headersholds every header, with lowercased names. A repeated header, such as Received, is an array.- An attachment
urlworks for one hour. Each retry and eachgetMessagecall makes a new one. - A failed delivery retries after 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours.
Daily Limit
A mailbox accepts maxInboundPerDay messages in any 24 hours. A message past the limit is stored and not delivered, and its webhookLastError starts with DAILY_LIMIT_REACHED.
Read Stored Messages
const { messages } = await stack0.mail.mailboxes.listMessages({ mailboxId: mailbox.id })const message = await stack0.mail.mailboxes.getMessage(messages[0].id)// message.headers, message.tag, message.webhookDelivered, message.webhookLastError// cc, bcc, and references are arrays, as in the webhook payload,// so mail.reply(message, { text }) threads a stored message too.