New: campaigns, automations and product emails in the same account. Meet Postal
Sign in Create account

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

Signed POST to your webhook
email.received
{
  "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
    }]
  }
}
200Your system acknowledged it

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

notifies your system
webhook.js
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
SDK for Node.js
webhook.js
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
SDK for PHP
webhook.php
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
Received emails reference
terminal
# 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.

POST to your webhook
{
  "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
      }
    ]
  }
}
GET /emails/receiving/{id}
{
  "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.

InboundInboxes
Who does the workYour codePeople on the team, in the dashboard
How it arrivesemail.received webhook and APIConversations in the dashboard, with folders, labels and an assignee
ReplyFrom your system, with POST /emailsIn the conversation, from the dashboard or the API
AddressesExact, catch-all or under in.postal.soThe same
What forProcessing what arrives: tickets, attachments, replies, testsServing 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.

ItemHow it works
PlansOn 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.
SizeUp to 25 MB per message, attachments included. Above that, the server refuses it on delivery.
AddressesOnly 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.
PaceEach sending server delivers up to 60 messages per minute; above that, it gets a temporary refusal and tries again.
WebhookRespond 2xx within 15 seconds. Otherwise Postal tries again up to 8 times, over about 27 hours.
StorageMessages, 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.
SendingReceiving does not use the sending quota. Replies and forwards go out as regular sends, from a verified domain.
What it does not doPostal 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.