Inbound email

Configure receiving domains and routes, consume complete email payloads, verify signatures, and handle retries.

Sendbound's Postal-backed receiving flow delivers one email.received event per project and envelope recipient. It does not change your domain's existing outbound sending configuration.

1. Create a receiving route

First verify ownership of example.com in the same Sendbound project. You can then receive on that domain or a subdomain such as inbound.example.com. Ownership inherited from a verified parent is for receiving; it does not mark the subdomain's DKIM or outbound sending as verified.

You do not need a second ownership verification or separate receiving approval for that subdomain. Saving an inbound route automatically provisions its receiving domain with our mail provider. Your route must be enabled and the MX record for the exact receiving domain must be configured as described below.

In the dashboard, open Webhooks → Add Webhook → Inbound email. Enter the exact receiving domain, * for a catch-all (or a local part such as support), and your public HTTPS webhook URL.

The equivalent API operation uses your secret API key:

curl https://api.sendbound.com/webhooks \
  -H "Authorization: Bearer $SENDBOUND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/webhooks/email/inbound",
    "events": ["email.received"],
    "inboundDomain": "inbound.example.com",
    "inboundPattern": "*"
  }'

Save data.id and data.signingSecret from the creation response in your application's secret storage. Do not put the secret in frontend code or logs. The route is not saved successfully unless provider provisioning succeeds.

The route accepts only email.received. inboundPattern is either * or an exact local part; partial wildcards and regular expressions are not supported. Address matching is case-insensitive. Multiple matching webhooks each receive their own delivery.

Use PATCH /webhooks/{id} to change url, inboundDomain, inboundPattern, or enabled. Use DELETE /webhooks/{id} to delete the webhook and delivery history. Disabling a webhook stops its future HTTP deliveries; it does not alter DNS.

Webhooks without an inboundDomain remain project-wide event subscriptions. They receive mail admitted through an enabled receiving domain or matching route. Simply enabling a parent domain does not silently create routes for every possible subdomain: create a route for each receiving subdomain.

2. Add the MX record

TypeDNS namePriorityTarget
MXinbound.example.com10mail.sendbound.com

Most DNS panels expect just inbound as the name within the example.com zone. Use DNS-only mode where a provider offers proxying. TTL can be your provider's default.

Do not replace your root domain's existing MX records when receiving on a subdomain. An A record alone is not the required MX configuration. inbound.sendbound.com is not the documented receiving target.

A saved route is not a DNS verification result. Allow for DNS propagation, then send a real message from an external mailbox to test SMTP reception.

3. Handle the payload

The HTTP body is the following JSON object, not a data wrapper:

{
  "id": "c856112f-3a02-5402-a6f5-a702a637dc01",
  "from": "sender@example.net",
  "to": "someone@inbound.example.com",
  "recipients": ["someone@inbound.example.com"],
  "subject": "Example email",
  "text": "Hello!",
  "html": "<p>Hello!</p>",
  "messageId": "<original-message-id@example.net>",
  "receivedAt": "2026-10-16T12:00:00.000Z",
  "attachments": [
    {
      "id": "d8af3bc5-c5bf-5796-a312-4bdf603539c7",
      "filename": "example.txt",
      "contentType": "text/plain",
      "contentId": null,
      "size": 6,
      "url": "https://api.sendbound.com/emails/c856112f-3a02-5402-a6f5-a702a637dc01/attachments/d8af3bc5-c5bf-5796-a312-4bdf603539c7"
    }
  ]
}
  • id is Sendbound's stable email-record ID.
  • from is the envelope sender, which can be empty for a bounce.
  • to is one envelope recipient. recipients contains that recipient only, rather than exposing other projects' or BCC recipients.
  • text and html are strings; absent bodies are empty strings.
  • messageId is the original RFC Message-ID, or null if absent. It is not a reliable unique delivery identifier.
  • receivedAt is an ISO timestamp.
  • attachments is always an array; it is empty when there are none.
  • HTML and attachment contents are untrusted sender input. Sanitize HTML before display and scan attachments as required by your application.

The raw message and combined parsed content are limited to 20 MiB. Attachments are stored in a separate private object-storage bucket, not inline in webhook JSON or in the database. Missing private storage causes an explicit receiving failure instead of silently discarding attachments.

Download an attachment

curl "https://api.sendbound.com/emails/EMAIL_ID/attachments/ATTACHMENT_ID" \
  -H "Authorization: Bearer $SENDBOUND_API_KEY" \
  --output attachment.bin

Attachment URLs require a secret key for the owning project, or an authenticated project session. They are not public or expiring presigned URLs. Cross-project requests return 404. Downloads use attachment disposition and private/no-store caching. GET /emails/{id} also includes the parsed payload in inboundData.

4. Verify webhook signatures

Every delivery includes:

Sendbound-Event: email.received
Sendbound-Delivery-Id: <stable delivery UUID>
Sendbound-Timestamp: <Unix milliseconds>
Sendbound-Signature-V2: v2=<lowercase hex HMAC-SHA256>

The exact UTF-8 signing input is:

timestamp + "." + deliveryId + "." + rawRequestBody

The key is the signing-secret string exactly as returned, not hex-decoded bytes. Preserve raw request bytes before parsing JSON. Check timestamp freshness (for example, five minutes), compare signatures in constant time, and deduplicate using the delivery ID. A retry has the same delivery ID and payload, but a fresh timestamp and signature.

Example for Node.js/Express:

import express from 'express';
import {createHmac, timingSafeEqual} from 'node:crypto';

const app = express();
// Register before any global express.json() middleware.
app.post('/webhooks/email/inbound', express.raw({type: 'application/json', limit: '30mb'}), async (req, res) => {
  const timestamp = req.get('Sendbound-Timestamp') ?? '';
  const deliveryId = req.get('Sendbound-Delivery-Id') ?? '';
  const signature = req.get('Sendbound-Signature-V2') ?? '';
  if (!/^\d+$/.test(timestamp) || !deliveryId ||
      Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000 ||
      !/^v2=[0-9a-f]{64}$/.test(signature)) return res.sendStatus(401);
  const expected = createHmac('sha256', process.env.SENDBOUND_WEBHOOK_SECRET)
    .update(`${timestamp}.${deliveryId}.`).update(req.body).digest();
  const supplied = Buffer.from(signature.slice(3), 'hex');
  if (expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) return res.sendStatus(401);
  const email = JSON.parse(req.body.toString('utf8'));
  // Atomically persist/deduplicate by deliveryId and enqueue your processing.
  // Only acknowledge once that durable write succeeds.
  await saveAndEnqueueOnce(deliveryId, email);
  return res.sendStatus(204);
});

saveAndEnqueueOnce represents your own transactional storage/queue operation; it is not a Sendbound SDK function.

For existing integrations, Sendbound-Signature: sha256=<hex> is retained. That legacy signature authenticates only the raw body, not the timestamp or delivery ID. New integrations should verify V2.

Rotate the secret

PATCH /webhooks/WEBHOOK_ID
Content-Type: application/json
Authorization: Bearer YOUR_SECRET_API_KEY

{"rotateSecret": true}

Save data.signingSecret from the response. Rotation takes effect immediately, including future retry attempts; coordinate the receiver's secret update. The dashboard also exposes Rotate secret in the webhook editor. List/detail responses do not reveal the secret.

5. Retries and idempotency

Production webhook jobs use 8 total attempts with exponential retry delays: 30, 60, 120, 240, 480, 960, and 1920 seconds—about 63.5 minutes of scheduled waiting, plus request time and worker scheduling. Each request times out after 10 seconds. Network errors, timeouts, redirects, and non-2xx HTTP responses fail the attempt. Redirects are not followed.

Return 2xx only after durable acceptance. Inspect status, attempts, response code, and recent delivery history in Webhooks, or use GET /webhooks/{id}/deliveries. Delivery attempts retain the same Sendbound-Delivery-Id; deduplicate atomically on that ID. Use payload id if you also need email-level deduplication across multiple webhook subscriptions.

Retries are at-least-once delivery, not a promise of successful processing or exactly-once execution. After the final failed attempt, inspect the logs and send a new test after correcting your endpoint. Provider-to-Sendbound receiving failures are a separate retry mechanism, controlled by the mail provider.

6. Send a realistic test

curl -X POST "https://api.sendbound.com/webhooks/WEBHOOK_ID/test" \
  -H "Authorization: Bearer $SENDBOUND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event":"email.received","recipient":"your-org@inbound.example.com"}'

The webhook editor's Send test action sends the same request. It parses a synthetic MIME email and delivers the complete signed payload with test: true, text, HTML, Message-ID, recipient, and an empty attachments array. It returns a delivery ID and records the result in delivery logs. Tests are one-shot; failed tests return an error rather than scheduling production retries.

For a catch-all, choose a Test recipient your application recognizes, such as an existing organization's receiving address. The optional recipient must match the saved domain and address pattern; it never changes the route. If omitted, a catch-all test uses test@YOUR_RECEIVING_DOMAIN; an exact-address route uses its saved address. The synthetic sender is sender@example.com, not a real customer.

The receiving handler must verify the signature before inspecting test: true. It can then acknowledge tests with a 2xx response without creating business records, or process them against a known test organization. Never bypass signature verification for test payloads.

This does not send external email or prove MX, DNS propagation, SMTP acceptance, or real attachment storage. For the final end-to-end check, send an external email with an attachment to your configured receiving address and verify both webhook delivery and authenticated download.

Find the failing stage

  1. DNS: check MX for the exact receiving subdomain, not just the parent: dig MX inbound.example.com. It should point to mail.sendbound.com, priority 10. Do not replace your root domain's mailbox-provider MX records.
  2. Webhook test: use a recognized recipient, then open Delivery logs. A failed test returns HTTP 502 from Sendbound, with the destination's HTTP status in the error. The logs show its response as text.
    • 401/403: check receiver access requirements and signature verification.
    • 404: check the endpoint path and recipient/organization mapping. “Organization not found” is an application response, not an MX error.
    • 429/5xx: inspect the receiver's rate limits and server logs.
    • No HTTP status: inspect DNS resolution, connectivity, TLS and timeouts.
  3. Real receiving: send an external email to that recognized address and inspect Emails → Receiving, then webhook delivery history. No incoming record points to a DNS/SMTP/provider-ingestion problem, not the business handler. A received message plus a failed delivery points to webhook processing. A saved route or successful synthetic test is not proof of SMTP acceptance.

If SMTP accepts both the recipient and message data, but neither a received email nor a webhook delivery appears, contact Sendbound with the receiving address, route ID, approximate send time and original Message-ID. This is an ingestion investigation, not a reason to re-verify a working parent domain, recreate the route, or rotate your webhook secret. Provider rejections occur before customer delivery records exist, so they do not appear in the webhook delivery count. Emails → Receiving lists successfully ingested messages; the dashboard does not currently expose the mail provider's pre-ingestion rejection logs.

Errors to distinguish

  • Invalid/missing ownership proof: route creation fails.
  • Provider provisioning unavailable: route creation fails; it is not silently saved.
  • Missing/invalid provider authentication: callback is rejected.
  • Temporary processing/storage/queue failure: callback returns an error so the provider can retry; it is not acknowledged as successfully processed.
  • Invalid customer signatures: reject before processing.

Select an endpoint
to see request details