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.
- The sender writes to an address of yours (on your domain, with the MX pointed to
mx.postal.so, or on an address generated underin.postal.so). - Postal's inbound server accepts the message only if there is an inbox for that address.
- Postal stores the message and its attachments and creates a
received_email, one for each inbox it was addressed to. - Every webhook of the team that subscribes to
email.receivedgets a signed POST with the summary. - Your code fetches the content with
GET /emails/receiving/{id}and does what it needs: opens a ticket, stores an attachment, replies.
#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).
| Free | Pro | Scale | Enterprise | |
|---|---|---|---|---|
| Inboxes | 3 | 10 | 100 | Unlimited |
| Inboxes on your own domain | No | Yes | Yes | Yes |
Values refreshed from GET /plans when the page opens.
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):
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:
| Type | Name | Value | Priority |
|---|---|---|---|
MX | @ | mx.postal.so | 10 |
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:
| Kind | How to create it | Example |
|---|---|---|
| Exact address | domain_id and local_part | [email protected] |
| Catch-all | domain_id and local_part set to * | *@replies.acmestore.com |
| Generated address | Without domain_id: Postal generates the address (Free plan included) | [email protected] |
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_addressunderin.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
toand incc) 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.
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:
{
"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 data | What it is |
|---|---|
email_id | ID of the received email. Use it in GET /emails/receiving/{id}. |
inbox_id | The inbox that received it. A webhook covers every inbox of the team: filter by this field to handle only one. |
thread_id | The thread: messages that reply to each other (through the In-Reply-To and References headers) share it. |
created_at | When the message was received (ISO 8601, UTC). |
from | The sender, as it came in the From header (with the name, if any). |
to, cc | The recipients from the To and Cc headers. An inbox in blind copy does not show up here: use inbox_id. |
subject | The subject. |
message_id | The message's Message-ID, without the < > signs, or null. |
attachments | id, 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:
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.
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);
{
"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 returns403: fetch the message again for a new link. Images embedded in the HTML have acontent_id. - Headers:
headerskeeps, as lists,message-id,in-reply-to,references,date,list-unsubscribe,auto-submitted,x-postal-forwardedandotp-token, when the message has them. The others are in the original. - Original:
GET /emails/receiving/{id}/rawreturns the message exactly as it reached the server (text/plain, RFC 5322), with every header and MIME part. - Access codes:
otp_tokenis{"code", "origin"}when the message has a validOTP-Tokenheader, andnullotherwise. 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.
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.
// 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}/replyreplies and keeps the reply in the thread, visible in the dashboard. Inboxes underin.postal.soonly receive. - Forward automatically:
forward_toon the inbox (up to 5 addresses, inPATCH /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
| Item | Limit | What happens |
|---|---|---|
| Message size | 25 MB, attachments included | The server refuses it on delivery: 552 5.3.4. |
| Recipients per delivery | 50 | The next ones get 452 4.5.3, and the sending server delivers them in another connection. |
| Messages per sending server | 60 per minute, per IP | Temporary 421 4.7.0; the sending server tries again. |
| Webhook | 2xx within 15 seconds | Up to 8 attempts over about 27 hours, with the same webhook-id. |
| Attachment link | 1 hour | Fetch the message again for a new link. |
| Forwarding | 5 addresses | Only on exact-address inboxes on a verified domain. |
| Retention | The plan's period (retention_days), 30 days by default | The 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 happens | Likely cause and what to do |
|---|---|
The sender gets 550 5.1.1 Recipient address rejected: no such mailbox | There 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 Microsoft | The 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 delivery | The 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 delivery | A temporary failure while storing the message. The sending server tries again by itself. |
403 plan_limit_reached when creating the inbox or turning receiving on | The plan does not include inboxes on your domain, or the team reached the plan's number of inboxes. |
422 validation_error when creating the inbox | The domain does not have receiving enabled, the local_part is invalid or the address is already in use. |
| The webhook does not arrive | Check 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 match | The 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 message | The key is sending-only. The received email endpoints need a full-access key. |
403 on download_url | The link is more than 1 hour old. Fetch the message again. |
404 when fetching the message | The 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.