Received emails and inboxes
Inboxes that receive email, and the endpoints to read, forward and organize the messages you receive.
Receive email with Postal: create inboxes on a domain of yours (with receiving enabled) or on an address generated under in.postal.so, read the messages through the API and get notified right away by the email.received webhook. See how to point the MX record in Domains and DNS. Postal accepts messages of up to 25 MB. The endpoints on this page require a full-access key, and the plan defines how many inboxes, and which features, the team has (see Plan limits).
#List inboxes
GET/inboxes
Lists the team's inboxes with their unread message count. It accepts limit, after and before.
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.
cURL
curl https://api.postal.so/inboxes \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/inboxes', {
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/inboxes",
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('inboxes', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "inbox",
"id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"paused_by_plan": false,
"name": "Support",
"address": "[email protected]",
"domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
"receiving_address": "[email protected]",
"catch_all": false,
"forward_to": ["[email protected]"],
"can_forward": true,
"unread_count": 3,
"last_received_at": "2026-10-24T12:41:09.000000Z",
"created_at": "2026-10-02T18:20:00.000000Z",
"reply_name": "Ada from Support",
"view": "list",
"agent": {
"enabled": false,
"instructions": null,
"actions": [],
"tone": {
"formality": 0.3,
"length": 0.3
}
}
}
]
}
#Create an inbox
POST/inboxes
With domain_id, the inbox receives at local_part@your-domain (use * to receive any address of the domain, a catch-all). Without domain_id, Postal generates an address under in.postal.so, such as [email protected]. Inboxes on your own domain are available on paid plans.
Request body
namestringrequiredInbox name, up to 255 characters.domain_idstringA domain of yours with receiving enabled (capabilities.receiving = "enabled").local_partstringThe part before the @, up to 64 characters: letters, numbers,.,+,-and_, or*. Required withdomain_id. It must start with a letter or number (or be just*).forward_tostring[]Up to 5 addresses that each message is automatically forwarded to (not available for catch-all or forin.postal.so).
Errors
- 422
validation_errorInvalid field, domain without receiving enabled, or address already in use. - 403
plan_limit_reachedThe plan does not allow more inboxes, or inboxes on your own domain.
cURL
curl -X POST https://api.postal.so/inboxes \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Support",
"domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
"local_part": "support",
"forward_to": ["[email protected]"]
}'
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: 'Support',
domain_id: '0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f',
local_part: 'support',
forward_to: ['[email protected]'],
}),
});
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": "Support",
"domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
"local_part": "support",
"forward_to": ["[email protected]"],
},
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' => 'Support',
'domain_id' => '0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f',
'local_part' => 'support',
'forward_to' => ['[email protected]'],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "inbox",
"id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"paused_by_plan": false,
"name": "Support",
"address": "[email protected]",
"domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
"receiving_address": "[email protected]",
"catch_all": false,
"forward_to": ["[email protected]"],
"can_forward": true,
"unread_count": 0,
"last_received_at": null,
"created_at": "2026-10-02T18:20:00.000000Z",
"reply_name": "Ada from Support",
"view": "list",
"agent": {
"enabled": false,
"instructions": null,
"actions": [],
"tone": {
"formality": 0.3,
"length": 0.3
}
}
}
#Retrieve an inbox
GET/inboxes/{id}
receiving_address is an address under in.postal.so that also delivers to the inbox: forward email from any provider to it, without pointing your domain's MX record (for inboxes under in.postal.so it is the address itself). Besides the inbox data, it includes folders with how many threads are in each folder (inbox, sent, drafts, archive, spam and trash) and how many of them are unread.
Path parameters
idstringrequiredInbox ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f', {
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/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
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('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "inbox",
"id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"paused_by_plan": false,
"name": "Support",
"address": "[email protected]",
"domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
"receiving_address": "[email protected]",
"catch_all": false,
"forward_to": ["[email protected]"],
"can_forward": true,
"unread_count": 3,
"last_received_at": "2026-10-24T12:41:09.000000Z",
"created_at": "2026-10-02T18:20:00.000000Z",
"reply_name": "Ada from Support",
"view": "list",
"agent": {
"enabled": false,
"instructions": null,
"actions": [],
"tone": {
"formality": 0.3,
"length": 0.3
}
},
"folders": {
"inbox": {
"threads": 12,
"unread": 3
},
"sent": {
"threads": 5
},
"drafts": {
"threads": 1
},
"archive": {
"threads": 40,
"unread": 0
},
"spam": {
"threads": 2,
"unread": 2
},
"trash": {
"threads": 0,
"unread": 0
}
}
}
#Update an inbox
PATCH/inboxes/{id}
Changes the name, the forwarding addresses, the reply name, the layout and the AI agent. The inbox address does not change.
Path parameters
idstringrequiredInbox ID.
Request body
namestringNew name.forward_tostring[] | nullUp to 5 addresses.nullor[]turns forwarding off.reply_namestring | nullName of whoever replies: replies go out as"Ada from Support" <support@…>. Up to 100 characters.viewstringLayout of the inbox in the dashboard:list(default),compact,board(board by labels),split(list and reading pane side by side) orcards.agentobjectThe inbox's AI agent (see AI agent).enabledbooleanTurns the agent on or off.instructionsstring | nullWhat the agent should do, up to 5,000 characters.actionsstring[]draft_reply,forward_thread,add_labels,assign_thread,mark_as_spam,archiveandmove_to_trash.toneobjectformality(0 professional, 1 casual) andlength(0 concise, 1 detailed), from 0 to 1.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
plan_limit_reachedThe plan does not include the inbox AI agent (when turning onagent.enabled). - 403
paused_by_planThe inbox is paused because it is above the plan's inbox limit. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X PATCH https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"forward_to": ["[email protected]", "[email protected]"]
}'
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
forward_to: ['[email protected]', '[email protected]'],
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"forward_to": ["[email protected]", "[email protected]"],
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'forward_to' => ['[email protected]', '[email protected]'],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "inbox",
"id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"paused_by_plan": false,
"name": "Support",
"address": "[email protected]",
"domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
"receiving_address": "[email protected]",
"catch_all": false,
"forward_to": ["[email protected]", "[email protected]"],
"can_forward": true,
"unread_count": 3,
"last_received_at": "2026-10-24T12:41:09.000000Z",
"created_at": "2026-10-02T18:20:00.000000Z",
"reply_name": "Ada from Support",
"view": "list",
"agent": {
"enabled": false,
"instructions": null,
"actions": [],
"tone": {
"formality": 0.3,
"length": 0.3
}
}
}
#Delete an inbox
DELETE/inboxes/{id}
Deletes the inbox and all the messages received in it.
Path parameters
idstringrequiredInbox ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X DELETE https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
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->delete('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "inbox",
"id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"deleted": true
}
#List received emails
GET/emails/receiving
Lists the received messages, newest first. The list has the summary; the content comes from GET /emails/receiving/{id}.
Each message has otp_token: {"code", "origin"} when the email has a valid OTP-Token header, or null. See the OTP-Token header.
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.inbox_idstringOnly messages of one inbox.unreadbooleanWithtrue, only unread ones.searchstringSearches sender, recipients and subject.
Errors
- 422
validation_errorA field is invalid. The message says which one.
cURL
curl 'https://api.postal.so/emails/receiving?inbox_id=0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f&unread=true' \
-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', {
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",
},
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',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "received_email",
"id": "019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"created_at": "2026-10-24T12:41:09.000000Z",
"from": "Carla Souza <[email protected]>",
"to": ["[email protected]"],
"cc": [],
"subject": "Size exchange for order #10482",
"message_id": "<[email protected]>",
"read": false,
"size": 18342,
"has_attachments": true,
"otp_token": null
}
]
}
#Retrieve a received email
GET/emails/receiving/{id}
Returns the message with its HTML, text, headers and attachments. The attachment download links are valid for 1 hour.
otp_token is {"code", "origin"} when the message has exactly one valid OTP-Token header in the top header section, and null in any other case (including emails received before this feature). The code is not included in the email.received webhook. See the OTP-Token header.
Path parameters
idstringrequiredReceived email ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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-10-24T12:41:09.000000Z",
"from": "Carla Souza <[email protected]>",
"to": ["[email protected]"],
"cc": [],
"subject": "Size exchange for order #10482",
"message_id": "<[email protected]>",
"read": false,
"size": 18342,
"has_attachments": true,
"otp_token": null,
"reply_to": [],
"html": "<p>Hi! I would like to exchange the T-shirt from order #10482 for size M.</p>",
"text": "Hi! I would like to exchange the T-shirt from order #10482 for size M.",
"headers": {
"date": "Fri, 24 Oct 2026 09:41:07 -0300",
"mime-version": "1.0"
},
"attachments": [
{
"id": "019a1c00-3e50-7b6c-8d7e-9f0a1b2c3d4e",
"filename": "label-photo.jpg",
"content_type": "image/jpeg",
"content_id": null,
"size": 15620,
"download_url": "https://api.postal.so/received-attachments/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/019a1c00-3e50-7b6c-8d7e-9f0a1b2c3d4e?expires=1761313269&signature=c0de…"
}
]
}
#Mark as read or unread
PATCH/emails/receiving/{id}
Path parameters
idstringrequiredReceived email ID.
Request body
readbooleanrequiredtruemarks it as read;false, as unread.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X PATCH https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"read": true
}'
Node.js
const response = await fetch('https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
read: true,
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"read": True,
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'read' => true,
],
]);
$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-10-24T12:41:09.000000Z",
"from": "Carla Souza <[email protected]>",
"to": ["[email protected]"],
"cc": [],
"subject": "Size exchange for order #10482",
"message_id": "<[email protected]>",
"read": true,
"size": 18342,
"has_attachments": true,
"otp_token": null
}
#Delete a received email
DELETE/emails/receiving/{id}
Path parameters
idstringrequiredReceived email ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X DELETE 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', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"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->delete('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",
"deleted": true
}
#Raw received message
GET/emails/receiving/{id}/raw
The message exactly as it reached the server (RFC 5322, text/plain).
Path parameters
idstringrequiredReceived email ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/raw \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/raw', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const message = await response.text();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/raw",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
message = response.text
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/raw', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$message = (string) $response->getBody();
Received: from mail.example.com (mail.example.com [203.0.113.24])
From: Carla Souza <[email protected]>
To: [email protected]
Subject: Size exchange for order #10482
Message-ID: <[email protected]>
…
#Forward a received email
POST/emails/receiving/{id}/forward
Forwards the message to up to 5 addresses, in the background. It only works for inboxes on one of your verified domains (not catch-all), because the forwarded email is sent from that domain.
Path parameters
idstringrequiredReceived email ID.
Request body
tostring[]requiredFrom 1 to 5 addresses.
Errors
- 422
validation_errorInvalid address, or the inbox cannot forward. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/forward \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": ["[email protected]"]
}'
Node.js
const response = await fetch('https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/forward', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: ['[email protected]'],
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/forward",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"to": ["[email protected]"],
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('emails/receiving/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/forward', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'to' => ['[email protected]'],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "received_email",
"id": "019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
"forwarded": true
}
#Threads, folders and labels
Each received email goes into a thread: it joins the thread it replies to, using the In-Reply-To and References headers, matched against the received emails and the replies sent from the inbox. With no reference, it opens a new thread.
- Folders:
inbox,archive,spamandtrash(thefolderfield) andsent(threads with a reply sent from the inbox, not in the trash). A new message in an archived thread brings it back to the Inbox; in Spam and Trash it stays where it is. - Labels are per inbox, have a color, and group threads. A thread can have several.
- Assignment: a thread can be assigned to a team member (
assigned_to, the useridin your account). - Sending: replying and starting new threads go through the same path as
POST /emails: the domain must be verified and the email counts toward the quota.
#Attachments when writing from the inbox
Replying, sending a new message, creating and updating a draft, and sending a draft accept attachments: [{"filename", "content", "content_type"}], with the content in Base64.
- Limits: up to 10 files per message or draft, a
filenameof up to 255 characters and 40 MB in total. The validation is the same asPOST /emails: invalid Base64 or a total above the limit returns422 validation_error. - Drafts keep the files until they are sent or deleted (deleting the draft, or the thread for good, deletes the files). The content never comes back from the API: the draft lists
attachments: [{"id", "filename", "content_type", "size"}]. - Updating a draft: if the
PATCHincludesattachments, the list is replaced. To keep an attachment the draft already has, send just itsid({"id": "…"}); items withfilenameandcontentare added, and whatever is left out is removed. Without the field, the attachments do not change;[]removes them all. - Sent messages list their attachments in
attachments(id,filename,content_type,content_id,sizeanddownload_url, a signed link valid for 1 hour), in the same format as received attachments.
#List threads
GET/inboxes/{id}/threads
Lists the threads of a folder, from the most recent activity to the oldest.
Path parameters
idstringrequiredInbox ID.
Query parameters
folderstringinbox(default),sent,archive,spamortrash.labelstringOnly threads with this label.unlabeledbooleanWithtrue, only threads without a label.assigned_tostringA member ID,me(you) ornone(unassigned).unreadbooleanWithtrue, only unread ones.searchstringSearches subject, snippet and senders.limitintegerFrom 1 to 100. Default: 20.afterstringThread ID for the next page.beforestringThread ID for the previous page.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl 'https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads?folder=inbox&unread=true' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads?folder=inbox&unread=true', {
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/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"folder": "inbox",
"unread": "true",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'folder' => 'inbox',
'unread' => 'true',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "thread",
"id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"subject": "Size exchange for order #10482",
"folder": "inbox",
"read": false,
"unread_count": 1,
"message_count": 2,
"snippet": "Hi! I would like to exchange the T-shirt from order #10482 for size M.",
"participants": [
{
"name": "Carla Souza",
"email": "[email protected]",
"is_me": false
},
{
"name": "Ada from Support",
"email": "[email protected]",
"is_me": true
}
],
"has_attachments": true,
"has_draft": false,
"has_sent": true,
"last_message_at": "2026-10-24T13:02:11.000000Z",
"created_at": "2026-10-24T12:41:09.000000Z",
"assigned_to": {
"id": 12,
"name": "Carl Mason",
"email": "[email protected]",
"avatar_url": null
},
"labels": [
{
"id": "019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f",
"name": "Important",
"color": "orange"
}
]
}
]
}
#Retrieve a thread
GET/inboxes/{id}/threads/{thread_id}
Returns the thread with all its messages, oldest first (received and sent, in the same format) and its drafts. The download links of the attachments, received and sent, are valid for 1 hour.
Received messages (direction: "received") include otp_token, with the code from the OTP-Token header or null, as in GET /emails/receiving/{id}. See the OTP-Token header.
Path parameters
idstringrequiredInbox ID.thread_idstringrequiredThread ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e', {
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/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
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('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "thread",
"id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"subject": "Size exchange for order #10482",
"folder": "inbox",
"read": false,
"unread_count": 1,
"message_count": 2,
"snippet": "Hi! I would like to exchange the T-shirt from order #10482 for size M.",
"participants": [
{
"name": "Carla Souza",
"email": "[email protected]",
"is_me": false
},
{
"name": "Ada from Support",
"email": "[email protected]",
"is_me": true
}
],
"has_attachments": true,
"has_draft": false,
"has_sent": true,
"last_message_at": "2026-10-24T13:02:11.000000Z",
"created_at": "2026-10-24T12:41:09.000000Z",
"assigned_to": {
"id": 12,
"name": "Carl Mason",
"email": "[email protected]",
"avatar_url": null
},
"labels": [
{
"id": "019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f",
"name": "Important",
"color": "orange"
}
],
"messages": [
{
"object": "thread_message",
"id": "019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"direction": "received",
"from": "Carla Souza <[email protected]>",
"to": ["[email protected]"],
"cc": [],
"bcc": [],
"subject": "Size exchange for order #10482",
"message_id": "<[email protected]>",
"created_at": "2026-10-24T12:41:09.000000Z",
"read": false,
"status": null,
"html": "<p>Hi! I would like to exchange the T-shirt from order #10482 for size M.</p>",
"text": "Hi! I would like to exchange the T-shirt from order #10482 for size M.",
"otp_token": null,
"attachments": []
},
{
"object": "thread_message",
"id": "019a1c03-2d43-7a54-9b65-3c4d5e6f7081",
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"direction": "sent",
"from": "\"Ada from Support\" <[email protected]>",
"to": ["[email protected]"],
"cc": [],
"bcc": [],
"subject": "Re: Size exchange for order #10482",
"message_id": "<[email protected]>",
"created_at": "2026-10-24T13:02:11.000000Z",
"read": true,
"status": "queued",
"html": null,
"text": "Hi, Carla! We have set aside size M for you.",
"attachments": [
{
"id": "019a1c05-4f65-7c76-9d87-5e6f70819203",
"filename": "size-chart.pdf",
"content_type": "application/pdf",
"content_id": null,
"size": 48213,
"download_url": "https://api.postal.so/attachments/019a1c03-2d43-7a54-9b65-3c4d5e6f7081/019a1c05-4f65-7c76-9d87-5e6f70819203?expires=1761314531&signature=9a7e…"
}
]
}
],
"drafts": []
}
#Update a thread
PATCH/inboxes/{id}/threads/{thread_id}
Moves it to another folder, marks it read or unread, assigns it to a member and changes its labels. All fields are optional.
Path parameters
idstringrequiredInbox ID.thread_idstringrequiredThread ID.
Request body
folderstringinbox,archive,spamortrash.readbooleantruemarks all received messages as read;falsemarks the last one as unread.assigned_tointeger | nullID of a team member, ornullto remove the assignee.labelsstring[]Replaces the thread's labels.add_labelsstring[]Adds labels.remove_labelsstring[]Removes labels.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X PATCH https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"folder": "archive",
"add_labels": ["019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f"],
"read": true
}'
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
folder: 'archive',
add_labels: ['019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f'],
read: true,
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"folder": "archive",
"add_labels": ["019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f"],
"read": True,
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'folder' => 'archive',
'add_labels' => ['019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f'],
'read' => true,
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "thread",
"id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"subject": "Size exchange for order #10482",
"folder": "archive",
"read": true,
"unread_count": 0,
"message_count": 2,
"snippet": "Hi! I would like to exchange the T-shirt from order #10482 for size M.",
"participants": [
{
"name": "Carla Souza",
"email": "[email protected]",
"is_me": false
},
{
"name": "Ada from Support",
"email": "[email protected]",
"is_me": true
}
],
"has_attachments": true,
"has_draft": false,
"has_sent": true,
"last_message_at": "2026-10-24T13:02:11.000000Z",
"created_at": "2026-10-24T12:41:09.000000Z",
"assigned_to": {
"id": 12,
"name": "Carl Mason",
"email": "[email protected]",
"avatar_url": null
},
"labels": [
{
"id": "019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f",
"name": "Important",
"color": "orange"
}
]
}
#Delete a thread
DELETE/inboxes/{id}/threads/{thread_id}
Moves the thread to the trash. If it is already in the trash, it deletes it for good, along with the received messages (the replies you sent stay in the email records).
Path parameters
idstringrequiredInbox ID.thread_idstringrequiredThread ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X DELETE https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
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->delete('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "thread",
"id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"deleted": true
}
#Act on several threads
POST/inboxes/{id}/threads/batch
Path parameters
idstringrequiredInbox ID.
Request body
idsstring[]requiredFrom 1 to 100 thread IDs.actionstringrequiredread,unread,archive,spam,trash,inbox,delete,add_label,remove_labelorassign.label_idstringRequired foradd_labelandremove_label.assigned_tointeger | nullForassign: the member, ornull.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/batch \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"ids": ["019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e"],
"action": "archive"
}'
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/batch', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
ids: ['019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e'],
action: 'archive',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/batch",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"ids": ["019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e"],
"action": "archive",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/batch', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'ids' => ['019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e'],
'action' => 'archive',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "batch",
"updated": 1
}
#Reply to a thread
POST/inboxes/{id}/threads/{thread_id}/reply
Sends a reply from the inbox address (with the reply_name), in the same thread, with the right In-Reply-To and References. Without to, it replies to the Reply-To (or the sender) of the last received message; without subject, it uses Re: …. It needs text or html and a verified domain. It accepts attachments.
Path parameters
idstringrequiredInbox ID.thread_idstringrequiredThread ID.
Request body
textstringText of the reply.htmlstringHTML of the reply.tostring[]Recipients.ccstring[]Carbon copy.bccstring[]Blind carbon copy.subjectstringSubject.draft_idstringDraft that this reply sends (it is deleted).attachmentsobject[]Attachments, up to 10 files and 40 MB in total, with the same rules asPOST /emails. Here there are only files sent in the body itself (nopathorcontent_id).filenamestringrequiredFile name, up to 255 characters.contentstringrequiredFile content in Base64.content_typestringMIME type, such asapplication/pdf. Without it, it is inferred from the extension.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team. - 403
validation_errorThe inbox's domain is not verified. - 403
sending_pausedSending is paused for your team because of a high bounce or complaint rate. - 403
paused_by_planThe inbox's domain is paused by the plan. - 403
plan_limit_reachedThe attachments go over the plan limit. - 429
daily_quota_exceededThe plan's sending quota is used up (alsomonthly_quota_exceeded).
cURL
curl -X POST https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e/reply \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Hi, Carla! We have set aside size M for you.",
"attachments": [
{
"filename": "size-chart.pdf",
"content": "JVBERi0xLjQK…",
"content_type": "application/pdf"
}
]
}'
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e/reply', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: 'Hi, Carla! We have set aside size M for you.',
attachments: [
{
filename: 'size-chart.pdf',
content: 'JVBERi0xLjQK…',
content_type: 'application/pdf',
},
],
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e/reply",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"text": "Hi, Carla! We have set aside size M for you.",
"attachments": [
{
"filename": "size-chart.pdf",
"content": "JVBERi0xLjQK…",
"content_type": "application/pdf",
},
],
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e/reply', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'text' => 'Hi, Carla! We have set aside size M for you.',
'attachments' => [
[
'filename' => 'size-chart.pdf',
'content' => 'JVBERi0xLjQK…',
'content_type' => 'application/pdf',
],
],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "thread_message",
"id": "019a1c03-2d43-7a54-9b65-3c4d5e6f7081",
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"direction": "sent",
"from": "\"Ada from Support\" <[email protected]>",
"to": ["[email protected]"],
"cc": [],
"bcc": [],
"subject": "Re: Size exchange for order #10482",
"message_id": "<[email protected]>",
"created_at": "2026-10-24T13:02:11.000000Z",
"read": true,
"status": "queued",
"html": null,
"text": "Hi, Carla! We have set aside size M for you.",
"attachments": [
{
"id": "019a1c05-4f65-7c76-9d87-5e6f70819203",
"filename": "size-chart.pdf",
"content_type": "application/pdf",
"content_id": null,
"size": 48213,
"download_url": "https://api.postal.so/attachments/019a1c03-2d43-7a54-9b65-3c4d5e6f7081/019a1c05-4f65-7c76-9d87-5e6f70819203?expires=1761314531&signature=9a7e…"
}
]
}
#Send a new message
POST/inboxes/{id}/messages
Sends a new message from the inbox. It opens a thread (thread_id in the response), which shows up in the sent folder. It accepts attachments.
Path parameters
idstringrequiredInbox ID.
Request body
tostring[]requiredRecipients.subjectstringrequiredSubject.textstringText.htmlstringHTML.ccstring[]Carbon copy.bccstring[]Blind carbon copy.attachmentsobject[]Attachments, up to 10 files and 40 MB in total, with the same rules asPOST /emails. Here there are only files sent in the body itself (nopathorcontent_id).filenamestringrequiredFile name, up to 255 characters.contentstringrequiredFile content in Base64.content_typestringMIME type, such asapplication/pdf. Without it, it is inferred from the extension.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team. - 403
validation_errorThe inbox's domain is not verified. - 403
sending_pausedSending is paused for your team. - 403
paused_by_planThe inbox's domain is paused by the plan. - 429
daily_quota_exceededThe plan's sending quota is used up (alsomonthly_quota_exceeded).
cURL
curl -X POST https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/messages \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": ["[email protected]"],
"subject": "Your order has shipped",
"text": "Hi, Carla! Your order is on its way."
}'
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: ['[email protected]'],
subject: 'Your order has shipped',
text: 'Hi, Carla! Your order is on its way.',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/messages",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"to": ["[email protected]"],
"subject": "Your order has shipped",
"text": "Hi, Carla! Your order is on its way.",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/messages', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'to' => ['[email protected]'],
'subject' => 'Your order has shipped',
'text' => 'Hi, Carla! Your order is on its way.',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "thread_message",
"id": "019a1c03-2d43-7a54-9b65-3c4d5e6f7081",
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"direction": "sent",
"from": "\"Ada from Support\" <[email protected]>",
"to": ["[email protected]"],
"cc": [],
"bcc": [],
"subject": "Re: Size exchange for order #10482",
"message_id": "<[email protected]>",
"created_at": "2026-10-24T13:02:11.000000Z",
"read": true,
"status": "queued",
"html": null,
"text": "Hi, Carla! We have set aside size M for you.",
"attachments": [
{
"id": "019a1c05-4f65-7c76-9d87-5e6f70819203",
"filename": "size-chart.pdf",
"content_type": "application/pdf",
"content_id": null,
"size": 48213,
"download_url": "https://api.postal.so/attachments/019a1c03-2d43-7a54-9b65-3c4d5e6f7081/019a1c05-4f65-7c76-9d87-5e6f70819203?expires=1761314531&signature=9a7e…"
}
]
}
#Translate a message
POST/inboxes/{id}/threads/{thread_id}/messages/{message_id}/translate
Translates a message of the thread (received or sent). It is asynchronous, like the other AI requests: the response is a task; poll GET /ai/tasks/{id} until it is ready. It uses 1 AI credit. The result is {"object": "ai_translation", "text", "subject", "from_language", "to_language", "credits"}.
Path parameters
idstringrequiredInbox ID.thread_idstringrequiredThread ID.message_idstringrequiredMessage ID.
Request body
tostringrequiredTarget language:pt-BR,en,es,fr,de,it,ja,zh,ko,ru,nlorar.fromstringSource language. Without it, the AI detects it.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team. - 429
ai_credits_exhaustedThe month's AI credits are used up.
cURL
curl -X POST https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e/messages/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/translate \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "en"
}'
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e/messages/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/translate', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: 'en',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e/messages/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/translate",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"to": "en",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/threads/019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e/messages/019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d/translate', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'to' => 'en',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "ai_task",
"id": "019a1c04-3e54-7b65-8c76-4d5e6f708192",
"status": "pending"
}
#List labels
GET/inboxes/{id}/labels
Path parameters
idstringrequiredInbox ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels', {
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/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels",
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('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "inbox_label",
"id": "019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"name": "Important",
"color": "orange",
"thread_count": 2
}
]
}
#Create a label
POST/inboxes/{id}/labels
Path parameters
idstringrequiredInbox ID.
Request body
namestringrequiredUp to 50 characters, unique in the inbox.colorstringcyan,teal,green,lime,yellow,orange,indigo,purple,pink,brownorgray(default).
Errors
- 422
validation_errorDuplicate name or invalid color. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Important",
"color": "orange"
}'
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Important',
color: 'orange',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"name": "Important",
"color": "orange",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'name' => 'Important',
'color' => 'orange',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "inbox_label",
"id": "019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"name": "Important",
"color": "orange",
"thread_count": 0
}
#Update a label
PATCH/inboxes/{id}/labels/{label_id}
Path parameters
idstringrequiredInbox ID.label_idstringrequiredLabel ID.
Request body
namestringNew name.colorstringNew color.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X PATCH https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels/019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"color": "pink"
}'
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels/019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
color: 'pink',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels/019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"color": "pink",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels/019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'color' => 'pink',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "inbox_label",
"id": "019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"name": "Important",
"color": "pink",
"thread_count": 2
}
#Delete a label
DELETE/inboxes/{id}/labels/{label_id}
Removes the label from all threads. The threads stay.
Path parameters
idstringrequiredInbox ID.label_idstringrequiredLabel ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X DELETE https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels/019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels/019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels/019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f",
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->delete('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/labels/019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "inbox_label",
"id": "019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f",
"deleted": true
}
#List drafts
GET/inboxes/{id}/drafts
The inbox's drafts, newest first, with each one's list of attachments (without the content). The ones suggested by the AI agent have source: "agent".
Path parameters
idstringrequiredInbox ID.
Query parameters
thread_idstringOnly drafts of one thread.limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl 'https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts?thread_id=019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts?thread_id=019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e', {
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/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'thread_id' => '019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "draft",
"id": "019a1c02-1c32-7f43-8a54-2b3c4d5e6f70",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"source": "user",
"to": ["[email protected]"],
"cc": [],
"bcc": [],
"subject": "Re: Size exchange for order #10482",
"text": "Hi, Carla! We have set aside size M for you.",
"html": null,
"attachments": [
{
"id": "019a1c05-4f65-7c76-9d87-5e6f70819203",
"filename": "size-chart.pdf",
"content_type": "application/pdf",
"size": 48213
}
],
"created_by": {
"id": 12,
"name": "Carl Mason"
},
"created_at": "2026-10-24T13:00:00.000000Z",
"updated_at": "2026-10-24T13:01:30.000000Z"
}
]
}
#Create a draft
POST/inboxes/{id}/drafts
Path parameters
idstringrequiredInbox ID.
Request body
thread_idstringThread that the draft replies to. Without it, it is a new message.tostring[]Recipients.ccstring[]Carbon copy.bccstring[]Blind carbon copy.subjectstringSubject.textstringText.htmlstringHTML.attachmentsobject[]Attachments of the draft, up to 10 files and 40 MB in total. See Attachments.filenamestringFile name, up to 255 characters. Required withoutid.contentstringFile content in Base64. Required withoutid.content_typestringMIME type. Without it, it is inferred from the extension.idstringOnly when updating: the ID of an attachment the draft already has, to keep it (without sending the content again).
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"to": ["[email protected]"],
"text": "Hi, Carla!"
}'
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
thread_id: '019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e',
to: ['[email protected]'],
text: 'Hi, Carla!',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"to": ["[email protected]"],
"text": "Hi, Carla!",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'thread_id' => '019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e',
'to' => ['[email protected]'],
'text' => 'Hi, Carla!',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "draft",
"id": "019a1c02-1c32-7f43-8a54-2b3c4d5e6f70",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"source": "user",
"to": ["[email protected]"],
"cc": [],
"bcc": [],
"subject": "Re: Size exchange for order #10482",
"text": "Hi, Carla! We have set aside size M for you.",
"html": null,
"attachments": [
{
"id": "019a1c05-4f65-7c76-9d87-5e6f70819203",
"filename": "size-chart.pdf",
"content_type": "application/pdf",
"size": 48213
}
],
"created_by": {
"id": 12,
"name": "Carl Mason"
},
"created_at": "2026-10-24T13:00:00.000000Z",
"updated_at": "2026-10-24T13:01:30.000000Z"
}
#Retrieve a draft
GET/inboxes/{id}/drafts/{draft_id}
Returns the draft with its text, HTML and the list of attachments (without the file content). created_by is the person who wrote it, or null when the AI agent did (source: "agent").
Path parameters
idstringrequiredInbox ID.draft_idstringrequiredDraft ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70 \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70', {
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/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70",
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('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "draft",
"id": "019a1c02-1c32-7f43-8a54-2b3c4d5e6f70",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"source": "user",
"to": ["[email protected]"],
"cc": [],
"bcc": [],
"subject": "Re: Size exchange for order #10482",
"text": "Hi, Carla! We have set aside size M for you.",
"html": null,
"attachments": [
{
"id": "019a1c05-4f65-7c76-9d87-5e6f70819203",
"filename": "size-chart.pdf",
"content_type": "application/pdf",
"size": 48213
}
],
"created_by": {
"id": 12,
"name": "Carl Mason"
},
"created_at": "2026-10-24T13:00:00.000000Z",
"updated_at": "2026-10-24T13:01:30.000000Z"
}
#Update a draft
PATCH/inboxes/{id}/drafts/{draft_id}
Accepts the same fields as creation, except thread_id. With attachments, the list of attachments is replaced: send {"id"} to keep an attachment the draft already has, and filename with content to add another (see Attachments).
Path parameters
idstringrequiredInbox ID.draft_idstringrequiredDraft ID.
Request body
tostring[]Recipients.ccstring[]Carbon copy.bccstring[]Blind carbon copy.subjectstringSubject.textstringText.htmlstringHTML.attachmentsobject[]Attachments of the draft, up to 10 files and 40 MB in total. See Attachments.filenamestringFile name, up to 255 characters. Required withoutid.contentstringFile content in Base64. Required withoutid.content_typestringMIME type. Without it, it is inferred from the extension.idstringOnly when updating: the ID of an attachment the draft already has, to keep it (without sending the content again).
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X PATCH https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70 \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Hi, Carla! We have set aside size M."
}'
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: 'Hi, Carla! We have set aside size M.',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"text": "Hi, Carla! We have set aside size M.",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'text' => 'Hi, Carla! We have set aside size M.',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "draft",
"id": "019a1c02-1c32-7f43-8a54-2b3c4d5e6f70",
"inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"source": "user",
"to": ["[email protected]"],
"cc": [],
"bcc": [],
"subject": "Re: Size exchange for order #10482",
"text": "Hi, Carla! We have set aside size M.",
"html": null,
"attachments": [
{
"id": "019a1c05-4f65-7c76-9d87-5e6f70819203",
"filename": "size-chart.pdf",
"content_type": "application/pdf",
"size": 48213
}
],
"created_by": {
"id": 12,
"name": "Carl Mason"
},
"created_at": "2026-10-24T13:00:00.000000Z",
"updated_at": "2026-10-24T13:01:30.000000Z"
}
#Send a draft
POST/inboxes/{id}/drafts/{draft_id}/send
Sends the draft (as a reply if it belongs to a thread; as a new message if not) with the draft's attachments, then deletes it along with its files.
Path parameters
idstringrequiredInbox ID.draft_idstringrequiredDraft ID.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team. - 403
validation_errorThe inbox's domain is not verified. - 403
sending_pausedSending is paused for your team. - 429
daily_quota_exceededThe plan's sending quota is used up (alsomonthly_quota_exceeded).
cURL
curl -X POST https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70/send \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70/send",
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->post('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70/send', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "thread_message",
"id": "019a1c03-2d43-7a54-9b65-3c4d5e6f7081",
"thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
"direction": "sent",
"from": "\"Ada from Support\" <[email protected]>",
"to": ["[email protected]"],
"cc": [],
"bcc": [],
"subject": "Re: Size exchange for order #10482",
"message_id": "<[email protected]>",
"created_at": "2026-10-24T13:02:11.000000Z",
"read": true,
"status": "queued",
"html": null,
"text": "Hi, Carla! We have set aside size M for you.",
"attachments": [
{
"id": "019a1c05-4f65-7c76-9d87-5e6f70819203",
"filename": "size-chart.pdf",
"content_type": "application/pdf",
"content_id": null,
"size": 48213,
"download_url": "https://api.postal.so/attachments/019a1c03-2d43-7a54-9b65-3c4d5e6f7081/019a1c05-4f65-7c76-9d87-5e6f70819203?expires=1761314531&signature=9a7e…"
}
]
}
#Delete a draft
DELETE/inboxes/{id}/drafts/{draft_id}
Path parameters
idstringrequiredInbox ID.draft_idstringrequiredDraft ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X DELETE https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70 \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70",
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->delete('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/drafts/019a1c02-1c32-7f43-8a54-2b3c4d5e6f70', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "draft",
"id": "019a1c02-1c32-7f43-8a54-2b3c4d5e6f70",
"deleted": true
}
#AI agent
With the agent on (agent.enabled in PATCH /inboxes/{id}), every new email is read by an AI that follows your instructions and tone, and can only do what is in actions: suggest a reply (draft_reply), apply labels and create the missing ones (add_labels), assign (assign_thread), archive (archive), mark as spam (mark_as_spam), move to the trash (move_to_trash) and forward (forward_thread).
- It never sends anything on its own: its replies become drafts (
source: "agent") for a person to review and send. - It only forwards to addresses written in the instructions.
- It uses 1 AI credit per email. It does nothing without credits, with the inbox or the agent turned off, on automatic emails (
Auto-Submitted) or on emails sent by the inbox itself. - To write good
instructions, usePOST /inboxes/{id}/agent/improve-instructions, which rewrites your draft more clearly.
#Improve the agent instructions
POST/inboxes/{id}/agent/improve-instructions
Rewrites the draft of the agent instructions to be clearer and more specific, in the same language and with the same intent, without inventing facts: email addresses, prices, times and rules stay as you wrote them. It saves nothing: store the result with PATCH /inboxes/{id} (agent.instructions) after reviewing it.
It is asynchronous, like the other AI requests: the response is a task (ai_task) and the result arrives from GET /ai/tasks/{id} as {"object": "agent_instructions", "instructions", "task_id", "status": "completed", "credits"}. It uses 1 AI credit.
Path parameters
idstringrequiredInbox ID.
Request body
instructionsstringrequiredThe draft of the instructions, from 1 to 4,000 characters.
Errors
- 404
not_foundThe resource does not exist or belongs to another team. - 422
validation_errorA field is missing or out of range. - 429
ai_credits_exhaustedThe month's AI credits are used up. They renew on the 1st (UTC). - 503
ai_not_configuredThe AI assistant is not available.
cURL
curl -X POST https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/agent/improve-instructions \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"instructions": "answer customer questions and send billing issues to [email protected]"
}'
Node.js
const response = await fetch('https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/agent/improve-instructions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
instructions: 'answer customer questions and send billing issues to [email protected]',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/agent/improve-instructions",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"instructions": "answer customer questions and send billing issues to [email protected]",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('inboxes/0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f/agent/improve-instructions', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'instructions' => 'answer customer questions and send billing issues to [email protected]',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "ai_task",
"id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"status": "pending"
}
{
"object": "agent_instructions",
"instructions": "Answer customer questions about orders, exchanges and deliveries politely.\nForward billing matters to [email protected].",
"task_id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"status": "completed",
"credits": {
"used": 13,
"limit": 100,
"remaining": 87,
"resets_at": "2026-11-01T00:00:00Z"
}
}
#Inbox webhooks
Besides email.received, four events follow the threads. Subscribe to them in POST /webhooks; the formats are in Webhooks.
| Event | When |
|---|---|
inbox.thread.created | A new thread was opened, by a received email or by a message sent from the inbox. |
inbox.thread.updated | The folder, the read state, the assignee or the labels changed (through PATCH, a batch action, moving to the trash or the agent), or a new email took the thread out of the archive. One event per change, and one per changed thread in batch actions. |
inbox.thread.deleted | The thread was deleted for good. |
inbox.draft.created | The AI agent created a draft (source: "agent"). Drafts created by people do not generate an event. |
#Plan limits
The plan defines how many inboxes the team has and which of their features it can use. Going over a limit returns 403 plan_limit_reached, with a message that says what is missing. The values of each plan are in Limits and in GET /plans.
| Plan feature | Key | What changes in the API |
|---|---|---|
| Inboxes | inboxes | How many inboxes the team can have. Above it, POST /inboxes returns 403. |
| Inboxes on your domain | custom_inbound_domains | Without it, only inboxes under in.postal.so exist: creating one with domain_id returns 403, and the domain cannot turn receiving on. |
| Inbox AI agent | inbox_agent | Turning on agent.enabled in PATCH /inboxes/{id} returns 403 without it. |
| AI credits | ai_credits | The agent and the translation use credits each month; without credits, the translation returns 429 ai_credits_exhausted and the agent does not act. |
If the plan shrinks and the team is left with more inboxes than the new limit, Postal deletes none: the oldest ones, up to the limit, stay active and the newest ones are paused (paused_by_plan: true on the inbox). A paused inbox stays in the list and can be read, but PATCH /inboxes/{id} returns 403 paused_by_plan and its AI agent does not act. It returns to normal when the plan allows it again or when you delete other inboxes. See also Plan limits in Emails.