Inbound
Receive email in your app.
Every message that reaches an address of yours becomes a JSON event on your webhook, with the sender, subject and attachments. Read the text, the HTML and the files through the API, without running a mail server.
On the Free plan, receive at an address under in.postal.so
{
"type": "email.received",
"data": {
"email_id": "019a1c00-3e4f-7a5b-…",
"from": "Bruno <[email protected]>",
"to": ["[email protected]"],
"subject": "Invoice for order #10475",
"attachments": [{
"filename": "nota-fiscal-10475.pdf",
"content_type": "application/pdf",
"size": 48213
}]
}
}Email as an interface
Email becomes an input to your system
People write with the email they always use. Your app gets each message as data and decides what to do: open a ticket, store an attachment, reply.
How it works
Set it up in three steps
app.post('/webhooks/postal', express.raw({ type: 'application/json' }), async (req, res) => {
const event = verifyWebhook(req.body, req.headers, process.env.POSTAL_WEBHOOK_SECRET);
if (event.type === 'email.received') {
await queue.add(event.data.email_id); // process it outside the request
}
res.sendStatus(200);
});Code samples
From notification to content, in a few lines
Check the signature with the official SDK, fetch the whole message and download the attachments.
Webhook in Node.js
With the postal-so package, verifyWebhook checks the signature and returns the event. The body must arrive raw, without going through the JSON parser.
- Rejects notifications more than 5 minutes off
- The whole message comes from GET /emails/receiving/{id}
- Respond 2xx within 15 seconds
import express from 'express';
import { verifyWebhook } from 'postal-so';
const app = express();
const api = { Authorization: `Bearer ${process.env.POSTAL_API_KEY}` };
// The body must arrive raw 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 {
return res.sendStatus(400);
}
if (event.type === 'email.received') {
const { email_id, from, subject } = event.data;
const email = await fetch(`https://api.postal.so/emails/receiving/${email_id}`, { headers: api })
.then((response) => response.json());
// email.text, email.html and email.attachments[].download_url
await openTicket({ from, subject, text: email.text, attachments: email.attachments });
}
res.sendStatus(200);
});Webhook in PHP
With the postal-so/postal-php package, Webhook::verify checks the signature and returns the event as an array. Then the content and the attachments come from the API.
- Works in plain PHP and in Laravel
- Each attachment has a signed download link
- The file name may be empty: fall back to the id
use Postal\Exceptions\WebhookVerificationException;
use Postal\Webhook;
try {
$event = Webhook::verify(file_get_contents('php://input'), 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')]]);
$email = json_decode(file_get_contents("https://api.postal.so/emails/receiving/{$id}", false, $api), true);
// $email['text'], $email['html'] and the attachments, with the download link
foreach ($email['attachments'] as $attachment) {
$name = basename($attachment['filename'] ?? $attachment['id']);
file_put_contents("/var/attachments/{$name}", file_get_contents($attachment['download_url']));
}
}
http_response_code(200);From the command line
To test, or to fetch messages without a webhook: the whole message, an attachment and the original, with every header.
- Requires a full-access API key
- The attachment link is valid for 1 hour; fetch the message again for another
- List with filters: inbox_id, unread and search
# The whole message: text, HTML, headers and attachments
curl https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d \
-H "Authorization: Bearer $POSTAL_API_KEY"
# An attachment: download_url is signed and valid for 1 hour (no key needed)
curl -o nota-fiscal-10475.pdf "$DOWNLOAD_URL"
# The original message, as it reached the server
curl https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/raw \
-H "Authorization: Bearer $POSTAL_API_KEY"Format
What arrives and what you fetch
The webhook carries the summary. The API carries the whole content, with the attachment links.
{
"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 <[email protected]>",
"to": ["[email protected]"],
"cc": [],
"subject": "Invoice for order #10475",
"message_id": "[email protected]",
"attachments": [
{
"id": "019a1c00-3e50-7b6c-8d7e-9f0a1b2c3d4e",
"filename": "nota-fiscal-10475.pdf",
"content_type": "application/pdf",
"size": 48213
}
]
}
}{
"object": "received_email",
"id": "019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"from": "Bruno <[email protected]>",
"to": ["[email protected]"],
"subject": "Invoice for order #10475",
"size": 66103,
"otp_token": null,
"text": "Could you resend the invoice? Order attached.",
"html": "<p>Could you resend the invoice? Order attached.</p>",
"headers": { "date": ["Wed, 4 Nov 2026 09:41:07 -0300"] },
"attachments": [
{
"filename": "nota-fiscal-10475.pdf",
"content_type": "application/pdf",
"size": 48213,
"download_url": "https://api.postal.so/received-attachments/…"
}
]
}- thread_id groups the replies of the same conversation, through the In-Reply-To and References headers
- otp_token carries the code and the origin when the email has a valid OTP-Token header; it is not sent in the webhook
- The original, as it reached the server, comes from GET /emails/receiving/{id}/raw
Use cases
What you can build
Cases that work today with the webhook, the received emails API and the sending API.
AI agent with email
The agent reads each message through the webhook and the API and replies with POST /emails, passing In-Reply-To and References so the reply lands in the same conversation.
Support and tickets
Every email to suporte@ opens or updates a ticket in your system. thread_id groups the replies of the same conversation.
Reply by email
With a catch-all, each order or comment gets its own address, such as [email protected]. The reply arrives with that address in to.
CRM logging
Copy or forward customer conversations to a Postal address and log each message in the contact's history, by sender.
Reading attachments
Invoices, résumés and spreadsheets arrive with their name, type and size. Your code downloads the file through a signed link and processes it.
Tests with access codes
In automated tests, the sign-in email reaches a test inbox and the API returns the code from the OTP-Token header, ready to type.
Security
Secure end to end
What Postal does so that only what came from it reaches your system.
Signed notification
Every POST carries webhook-id, webhook-timestamp and webhook-signature (HMAC-SHA256, the Standard Webhooks format). The SDKs check it and reject notifications more than 5 minutes old.
Only mail with a destination gets in
The inbound server only accepts messages for addresses that have an inbox; the rest are refused on the spot, with no bounces later. So are messages over 25 MB.
Content behind your key
The webhook carries only the summary. Text, HTML, attachments and the original require a full-access API key, and attachment links expire after 1 hour.
Comparison
Inbound or Inboxes?
It is the same inbox, used in two ways. You can use both at the same time.
| Inbound | Inboxes | |
|---|---|---|
| Who does the work | Your code | People on the team, in the dashboard |
| How it arrives | email.received webhook and API | Conversations in the dashboard, with folders, labels and an assignee |
| Reply | From your system, with POST /emails | In the conversation, from the dashboard or the API |
| Addresses | Exact, catch-all or under in.postal.so | The same |
| What for | Processing what arrives: tickets, attachments, replies, tests | Serving customers and organizing the team's inbox |
Need someone on the team to read and reply? See Inboxes.
Scope
Scope and limits
What applies today, with no fine print.
| Item | How it works |
|---|---|
| Plans | On the Free plan, up to 3 inboxes with an address under in.postal.so. Addresses on your own domain, catch-all included, are on paid plans. See plans and pricing. |
| Size | Up to 25 MB per message, attachments included. Above that, the server refuses it on delivery. |
| Addresses | Only messages for addresses that have an inbox get in. An email to another address of the domain is refused, unless there is a catch-all. |
| Pace | Each sending server delivers up to 60 messages per minute; above that, it gets a temporary refusal and tries again. |
| Webhook | Respond 2xx within 15 seconds. Otherwise Postal tries again up to 8 times, over about 27 hours. |
| Storage | Messages, attachments and the original are kept for the plan's retention period, 30 days by default, and then deleted. Keep in your system whatever you need to hold on to. |
| Sending | Receiving does not use the sending quota. Replies and forwards go out as regular sends, from a verified domain. |
| What it does not do | Postal does not filter spam or viruses and does not record SPF, DKIM or DMARC for received messages. The original, with every header, is available for your code to check. |
Frequently asked questions
Do I need my own domain?
Not to get started. Postal generates an address under in.postal.so that receives right away, on the Free plan too. To receive at addresses on your own domain, catch-all included, use a paid plan, turn on receiving for the domain and point its MX to mx.postal.so.
How do I know when an email arrives?
Register a webhook with the Received event (email.received): Postal sends a signed POST as soon as it stores the message. If you would rather poll, list the messages with GET /emails/receiving, filtering by inbox or by unread.
Can I reply through the API?
Yes. Send the reply with POST /emails, from a verified domain, with the In-Reply-To and References headers pointing to the received message_id, so it lands in the same conversation. On an inbox of a verified domain of yours, you can also reply in the conversation itself, through the inboxes API.
How do I get the attachments?
The webhook lists the attachments with their name, type and size. GET /emails/receiving/{id} returns each one with a download_url, a signed link that is valid for 1 hour and needs no key. The original message comes from GET /emails/receiving/{id}/raw.
What security checks are done?
Every webhook notification is signed, and the inbound server only accepts messages of up to 25 MB for addresses that have an inbox. Postal does not filter spam or viruses and does not record SPF, DKIM or DMARC for received messages: if your flow depends on that, check it in your code from the original message.
What is the difference between Inbound and Inboxes?
It is the same inbox, used in two ways. With Inbound, your code processes each message through the webhook and the API. With Inboxes, the team reads and replies in the dashboard, with conversations, labels and an assignee.
Start receiving email in your app
Create your free account and an address under in.postal.so in a few minutes.