Sign in Create account
Guides

Receive email (Inbound)

Receive email in your app: turn receiving on for the domain, create addresses or a catch-all, handle the email.received webhook and fetch the content, attachments and original through the API.

#How it works

Inbound means receiving and processing email in your own system: an address of yours receives the message, Postal stores the text, the HTML, the attachments and the original, tells your code through the email.received webhook and hands over the content through the API. There is no mail server to run. See the Inbound page.

  1. The sender writes to an address of yours (on your domain, with the MX pointed to mx.postal.so, or on an address generated under in.postal.so).
  2. Postal's inbound server accepts the message only if there is an inbox for that address.
  3. Postal stores the message and its attachments and creates a received_email, one for each inbox it was addressed to.
  4. Every webhook of the team that subscribes to email.received gets a signed POST with the summary.
  5. Your code fetches the content with GET /emails/receiving/{id} and does what it needs: opens a ticket, stores an attachment, replies.

Inbound and Inboxes

It is the same feature. The messages your code processes also show up in Inboxes, in the dashboard, organized into threads. Use Inbound when the work is done by your code, and Inboxes when people on the team read and reply. Both work at the same time.

#Plans and availability

The plan defines how many inboxes the team can have and whether they can use a domain of yours. On the Free plan, up to 3 inboxes, all with an address under in.postal.so. Addresses on your own domain, including the catch-all, and turning receiving on for the domain are on paid plans (custom_inbound_domains).

FreeProScaleEnterprise
Inboxes310100Unlimited
Inboxes on your own domainNoYesYesYes

Receiving does not use the plan's sending quota. Replies and forwards go out as regular sends and count against the quota. Going over the number of inboxes returns 403 plan_limit_reached; see Plan limits and Features by plan.

#Receive on your domain

Turn receiving on for the domain in the dashboard (Domains, settings tab, Receiving (inboxes)) or through the API, with capabilities.receiving in PATCH /domains/{id} (or when adding it, in POST /domains):

PATCH /domains/{id}

cURL

curl -X PATCH https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "capabilities": {
      "receiving": "enabled"
    }
  }'

Node.js

const response = await fetch('https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    capabilities: {
      receiving: 'enabled',
    },
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.patch(
    "https://api.postal.so/domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "capabilities": {
            "receiving": "enabled",
        },
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->patch('domains/0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'capabilities' => [
            'receiving' => 'enabled',
        ],
    ],
]);

$data = json_decode($response->getBody(), true);

Then create, in the domain's DNS, the record Postal asks for:

TypeNameValuePriority
MX@mx.postal.so10

The name @ is the domain you added itself: for replies.acmestore.com, the MX goes on replies.acmestore.com. Receiving does not depend on the sending verification (DKIM and SPF): messages arrive as soon as the MX points to Postal. A domain can even be receive-only, with capabilities.sending set to disabled. Replying and forwarding from the domain, on the other hand, require the domain to be verified for sending.

Use a subdomain if the domain already receives email

The MX decides where all of the domain's email goes. If acmestore.com already receives in Google Workspace or Microsoft 365, changing the MX takes the messages away from there. Add a subdomain, such as replies.acmestore.com, and turn receiving on for it. To find out whether the domain already has MX records from another provider, call GET /domains/{id}/receiving-check: it only informs, it does not block, and the dashboard uses the answer to warn you before turning it on.

More about DNS in Domains and DNS.

#Addresses and catch-all

Every address that receives is an inbox. Create it in the dashboard, under Inboxes, or with POST /inboxes. There are three kinds:

KindHow to create itExample
Exact addressdomain_id and local_part[email protected]
Catch-alldomain_id and local_part set to **@replies.acmestore.com
Generated addressWithout domain_id: Postal generates the address (Free plan included)[email protected]
Create a catch-all

cURL

curl -X POST https://api.postal.so/inboxes \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Replies",
    "domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
    "local_part": "*"
  }'

Node.js

const response = await fetch('https://api.postal.so/inboxes', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'Replies',
    domain_id: '0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f',
    local_part: '*',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/inboxes",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "name": "Replies",
        "domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
        "local_part": "*",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('inboxes', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'name' => 'Replies',
        'domain_id' => '0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f',
        'local_part' => '*',
    ],
]);

$data = json_decode($response->getBody(), true);

When a message arrives, Postal looks for the inbox in this order: an inbox's receiving address under in.postal.so (receiving_address), the exact address and, last, the domain's catch-all. The exact address wins over the catch-all. Addresses are compared case-insensitively and are unique across Postal.

  • No special handling for +: support+order@ only arrives if there is an inbox with that exact address or a catch-all on the domain. For one address per order or per thread, use the catch-all.
  • Forward from another provider without changing the MX: every exact-address inbox on your domain also gets a receiving_address under in.postal.so. Set your current provider to forward to it and the messages reach the inbox. The catch-all has no such address.
  • One message to two inboxes becomes two received emails and two events, one for each inbox. The same inbox twice (for example, in to and in cc) gives just one.

#The email.received webhook

Register your system's URL under Webhooks, in the dashboard, and select Received, or create it with POST /webhooks. The URL must be HTTPS and public. Keep the signing_secret (whsec_…) from the response: it checks the signature.

POST /webhooks

cURL

curl -X POST https://api.postal.so/webhooks \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://acmestore.com/webhooks/postal",
    "events": ["email.received"]
  }'

Node.js

const response = await fetch('https://api.postal.so/webhooks', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    endpoint: 'https://acmestore.com/webhooks/postal',
    events: ['email.received'],
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/webhooks",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "endpoint": "https://acmestore.com/webhooks/postal",
        "events": ["email.received"],
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('webhooks', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'endpoint' => 'https://acmestore.com/webhooks/postal',
        'events' => ['email.received'],
    ],
]);

$data = json_decode($response->getBody(), true);

As soon as it stores the message, Postal sends a POST with this body:

email.received
{
  "type": "email.received",
  "created_at": "2026-11-04T12:41:09.880Z",
  "data": {
    "email_id": "019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
    "inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
    "thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
    "created_at": "2026-11-04T12:41:09.000Z",
    "from": "Bruno Lee <[email protected]>",
    "to": ["[email protected]"],
    "cc": [],
    "subject": "Invoice for order #10475",
    "message_id": "[email protected]",
    "attachments": [
      {
        "id": "019a1c00-3e50-7b6c-8d7e-9f0a1b2c3d4e",
        "filename": "receipt-10475.pdf",
        "content_type": "application/pdf",
        "size": 48213
      }
    ]
  }
}
Field of dataWhat it is
email_idID of the received email. Use it in GET /emails/receiving/{id}.
inbox_idThe inbox that received it. A webhook covers every inbox of the team: filter by this field to handle only one.
thread_idThe thread: messages that reply to each other (through the In-Reply-To and References headers) share it.
created_atWhen the message was received (ISO 8601, UTC).
fromThe sender, as it came in the From header (with the name, if any).
to, ccThe recipients from the To and Cc headers. An inbox in blind copy does not show up here: use inbox_id.
subjectThe subject.
message_idThe message's Message-ID, without the < > signs, or null.
attachmentsid, filename (may be null), content_type and size (bytes) of each attachment. Without the content.

The event does not carry the body or the otp_token: a webhook goes through queues, logs and retries, and the content stays behind your API key. Respond with 2xx within 15 seconds; otherwise Postal tries again up to 8 times over about 27 hours, always with the same webhook-id (use it to discard repeats). See Responses and retries and the delivery history.

#Verify the signature and read the email

Every delivery carries the webhook-id, webhook-timestamp and webhook-signature headers (Standard Webhooks, HMAC-SHA256 with the signing_secret; the same values come in svix-*). The SDKs check the signature, reject messages more than 5 minutes off and return the event. The example below checks it, answers right away and then fetches the content and the attachments:

Received email webhook

Node.js

import express from 'express';
import { verifyWebhook, PostalWebhookVerificationError } from 'postal-so';

const app = express();
const api = { Authorization: `Bearer ${process.env.POSTAL_API_KEY}` }; // full-access key

// The body must arrive raw (a Buffer) for the signature to match.
app.post('/webhooks/postal', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;

  try {
    event = verifyWebhook(req.body, req.headers, process.env.POSTAL_WEBHOOK_SECRET);
  } catch (error) {
    if (error instanceof PostalWebhookVerificationError) return res.sendStatus(400);
    throw error;
  }

  // Answer right away (within 15 s) and process in a queue; webhook-id stays the same on retries.
  res.sendStatus(200);

  if (event.type !== 'email.received') return;

  const { email_id, inbox_id, from, subject } = event.data;
  const email = await fetch(`https://api.postal.so/emails/receiving/${email_id}`, { headers: api })
    .then((response) => response.json());

  for (const attachment of email.attachments) {
    const file = await fetch(attachment.download_url); // signed link, valid for 1 hour
    await saveAttachment(attachment.filename ?? attachment.id, Buffer.from(await file.arrayBuffer()));
  }

  await openTicket({ inbox_id, from, subject, text: email.text, html: email.html });
});

PHP

use Postal\Exceptions\WebhookVerificationException;
use Postal\Webhook;

try {
    $event = Webhook::verify(
        file_get_contents('php://input'), // raw body
        getallheaders(),
        getenv('POSTAL_WEBHOOK_SECRET'),
    );
} catch (WebhookVerificationException $e) {
    http_response_code(400);
    exit;
}

if ($event['type'] === 'email.received') {
    $id = $event['data']['email_id'];
    $api = stream_context_create(['http' => [
        'header' => 'Authorization: Bearer ' . getenv('POSTAL_API_KEY'), // full-access key
    ]]);
    $email = json_decode(file_get_contents("https://api.postal.so/emails/receiving/{$id}", false, $api), true);

    foreach ($email['attachments'] as $attachment) {
        $name = basename($attachment['filename'] ?? $attachment['id']);
        file_put_contents("/var/attachments/{$id}-{$name}", file_get_contents($attachment['download_url']));
    }

    openTicket($event['data']['from'], $event['data']['subject'], $email['text'], $email['html']);
}

http_response_code(200);

Install with npm install postal-so or composer require postal-so/postal-php (see SDKs). Without an SDK, or in another language, check the signature as in Verify the signature, always over the body exactly as it arrived.

#Content, attachments and original

GET /emails/receiving/{id} returns the whole message: text, html, reply_to, the stored headers and the attachments. It needs a full-access API key; a sending-only key gets 401 restricted_api_key.

GET /emails/receiving/{id}

cURL

curl https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "received_email",
  "id": "019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
  "inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
  "created_at": "2026-11-04T12:41:09.000000Z",
  "from": "Bruno Lee <[email protected]>",
  "to": ["[email protected]"],
  "cc": [],
  "subject": "Invoice for order #10475",
  "message_id": "[email protected]",
  "read": false,
  "size": 66103,
  "has_attachments": true,
  "otp_token": null,
  "reply_to": [],
  "html": "<p>Hi! Could you resend the invoice for order #10475? The receipt is attached.</p>",
  "text": "Hi! Could you resend the invoice for order #10475? The receipt is attached.",
  "headers": {
    "message-id": ["<[email protected]>"],
    "date": ["Wed, 4 Nov 2026 09:41:07 -0300"]
  },
  "attachments": [
    {
      "id": "019a1c00-3e50-7b6c-8d7e-9f0a1b2c3d4e",
      "filename": "receipt-10475.pdf",
      "content_type": "application/pdf",
      "content_id": null,
      "size": 48213,
      "download_url": "https://api.postal.so/received-attachments/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/019a1c00-3e50-7b6c-8d7e-9f0a1b2c3d4e?expires=1762263669&signature=5f1c…"
    }
  ]
}
  • Attachments: each one has a download_url, a signed link that is valid for 1 hour and needs no key. After that the link returns 403: fetch the message again for a new link. Images embedded in the HTML have a content_id.
  • Headers: headers keeps, as lists, message-id, in-reply-to, references, date, list-unsubscribe, auto-submitted, x-postal-forwarded and otp-token, when the message has them. The others are in the original.
  • Original: GET /emails/receiving/{id}/raw returns the message exactly as it reached the server (text/plain, RFC 5322), with every header and MIME part.
  • Access codes: otp_token is {"code", "origin"} when the message has a valid OTP-Token header, and null otherwise. See the OTP-Token header, with an end-to-end sign-in test.
  • Read and unread: mark it with PATCH /emails/receiving/{id} ({"read": true}) to keep track of what your code has already processed.

Without a webhook: poll the list

If your system cannot receive webhooks, call GET /emails/receiving from time to time, filtering by inbox and by unread, and mark each processed message as read. The list has the summary, newest first.

GET /emails/receiving

cURL

curl 'https://api.postal.so/emails/receiving?inbox_id=0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f&unread=true&limit=20' \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/receiving?inbox_id=0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f&unread=true&limit=20', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const data = await response.json();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/emails/receiving",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    params={
        "inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
        "unread": "true",
        "limit": "20",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('emails/receiving', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'query' => [
        'inbox_id' => '0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f',
        'unread' => 'true',
        'limit' => '20',
    ],
]);

$data = json_decode($response->getBody(), true);

#Reply and forward

To reply from your system, send with POST /emails, from a verified domain, and pass the In-Reply-To and References headers with the received message_id between < >. That way the sender's mail client shows the reply in the same thread. Use the message's reply_to, when there is one, as the recipient.

Reply (Node.js)
// email: the response of GET /emails/receiving/{id}
const original = `<${email.message_id}>`; // message_id comes without the < > signs

await fetch('https://api.postal.so/emails', {
  method: 'POST',
  headers: { ...api, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    from: 'Acme Store <[email protected]>',
    to: email.reply_to.length ? email.reply_to : [email.from],
    subject: email.subject.startsWith('Re:') ? email.subject : `Re: ${email.subject}`,
    text: 'Hi, Bruno! Here is the invoice for order #10475.',
    headers: { 'In-Reply-To': original, References: original },
  }),
});
  • Through the inbox thread: on an inbox of a verified domain of yours, POST /inboxes/{id}/threads/{thread_id}/reply replies and keeps the reply in the thread, visible in the dashboard. Inboxes under in.postal.so only receive.
  • Forward automatically: forward_to on the inbox (up to 5 addresses, in PATCH /inboxes/{id}) forwards each new message. Only on exact-address inboxes on a verified domain of yours.
  • Forward one message: POST /emails/receiving/{id}/forward, to up to 5 addresses, under the same conditions.

#Limits and retention

ItemLimitWhat happens
Message size25 MB, attachments includedThe server refuses it on delivery: 552 5.3.4.
Recipients per delivery50The next ones get 452 4.5.3, and the sending server delivers them in another connection.
Messages per sending server60 per minute, per IPTemporary 421 4.7.0; the sending server tries again.
Webhook2xx within 15 secondsUp to 8 attempts over about 27 hours, with the same webhook-id.
Attachment link1 hourFetch the message again for a new link.
Forwarding5 addressesOnly on exact-address inboxes on a verified domain.
RetentionThe plan's period (retention_days), 30 days by defaultThe message, its attachments and the original are deleted in the nightly cleanup.

What Postal does not do with incoming mail

Postal does not filter spam or viruses and does not check or record SPF, DKIM or DMARC for received messages. Your code gets everything that was sent to an address that has an inbox. If your flow depends on who sent it, check the original (/raw), treat attachments as untrusted files and keep in your system whatever you need beyond the retention period.

#Errors and troubleshooting

What happensLikely cause and what to do
The sender gets 550 5.1.1 Recipient address rejected: no such mailboxThere is no inbox for the address, or receiving is off for the domain. Create the inbox (or a catch-all) and check capabilities.receiving.
Messages keep arriving in Google or MicrosoftThe domain still has another MX, or the new record has not propagated. Call GET /domains/{id}/receiving-check and prefer a subdomain.
552 5.3.4 or 421 4.7.0 on deliveryThe message is over 25 MB, or more than 60 messages per minute came from the same server. The second case is temporary.
451 4.3.0 Temporary failure on deliveryA temporary failure while storing the message. The sending server tries again by itself.
403 plan_limit_reached when creating the inbox or turning receiving onThe plan does not include inboxes on your domain, or the team reached the plan's number of inboxes.
422 validation_error when creating the inboxThe domain does not have receiving enabled, the local_part is invalid or the address is already in use.
The webhook does not arriveCheck that the endpoint subscribes to email.received and is active: an endpoint that fails for 5 days is disabled. The attempts are in Webhooks (GET /webhooks/{id}/events), with the status and response of each one.
The signature does not matchThe body was parsed as JSON before the check, the secret belongs to another endpoint or the server clock is off (more than 5 minutes).
401 restricted_api_key when reading the messageThe key is sending-only. The received email endpoints need a full-access key.
403 on download_urlThe link is more than 1 hour old. Fetch the message again.
404 when fetching the messageThe message was deleted or is past retention, or the key belongs to another team.

See also Errors, the Received emails and inboxes reference and the Webhooks guide.