Sign in Create account
API reference

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

Full-access key

Lists the team's inboxes with their unread message count. It accepts limit, after and before.

Query parameters

  • limitinteger
    From 1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
GET /inboxes

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);
200Response
{
  "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

Full-access key

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

  • namestringrequired
    Inbox name, up to 255 characters.
  • domain_idstring
    A domain of yours with receiving enabled (capabilities.receiving = "enabled").
  • local_partstring
    The part before the @, up to 64 characters: letters, numbers, ., +, - and _, or *. Required with domain_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 for in.postal.so).

Errors

  • 422validation_error
    Invalid field, domain without receiving enabled, or address already in use.
  • 403plan_limit_reached
    The plan does not allow more inboxes, or inboxes on your own domain.
POST /inboxes

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);
201Response
{
  "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}

Full-access key

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

  • idstringrequired
    Inbox ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /inboxes/{id}

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);
200Response
{
  "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}

Full-access key

Changes the name, the forwarding addresses, the reply name, the layout and the AI agent. The inbox address does not change.

Path parameters

  • idstringrequired
    Inbox ID.

Request body

  • namestring
    New name.
  • forward_tostring[] | null
    Up to 5 addresses. null or [] turns forwarding off.
  • reply_namestring | null
    Name of whoever replies: replies go out as "Ada from Support" <support@…>. Up to 100 characters.
  • viewstring
    Layout of the inbox in the dashboard: list (default), compact, board (board by labels), split (list and reading pane side by side) or cards.
  • agentobject
    The inbox's AI agent (see AI agent).
    • enabledboolean
      Turns the agent on or off.
    • instructionsstring | null
      What the agent should do, up to 5,000 characters.
    • actionsstring[]
      draft_reply, forward_thread, add_labels, assign_thread, mark_as_spam, archive and move_to_trash.
    • toneobject
      formality (0 professional, 1 casual) and length (0 concise, 1 detailed), from 0 to 1.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 403plan_limit_reached
    The plan does not include the inbox AI agent (when turning on agent.enabled).
  • 403paused_by_plan
    The inbox is paused because it is above the plan's inbox limit.
  • 404not_found
    The resource does not exist or belongs to another team.
PATCH /inboxes/{id}

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);
200Response
{
  "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}

Full-access key

Deletes the inbox and all the messages received in it.

Path parameters

  • idstringrequired
    Inbox ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /inboxes/{id}

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);
200Response
{
  "object": "inbox",
  "id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
  "deleted": true
}

#List received emails

GET/emails/receiving

Full-access key

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

  • limitinteger
    From 1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
  • inbox_idstring
    Only messages of one inbox.
  • unreadboolean
    With true, only unread ones.
  • searchstring
    Searches sender, recipients and subject.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
GET /emails/receiving

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);
200Response
{
  "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}

Full-access key

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

  • idstringrequired
    Received email ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /emails/receiving/{id}

cURL

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

Node.js

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

const data = await response.json();

Python

import os
import requests

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

data = response.json()

PHP

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

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

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "received_email",
  "id": "019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
  "inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
  "created_at": "2026-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}

Full-access key

Path parameters

  • idstringrequired
    Received email ID.

Request body

  • readbooleanrequired
    true marks it as read; false, as unread.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
PATCH /emails/receiving/{id}

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);
200Response
{
  "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}

Full-access key

Path parameters

  • idstringrequired
    Received email ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /emails/receiving/{id}

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);
200Response
{
  "object": "received_email",
  "id": "019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
  "deleted": true
}

#Raw received message

GET/emails/receiving/{id}/raw

Full-access key

The message exactly as it reached the server (RFC 5322, text/plain).

Path parameters

  • idstringrequired
    Received email ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /emails/receiving/{id}/raw

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();
200Response (text/plain)
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

Full-access key

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

  • idstringrequired
    Received email ID.

Request body

  • tostring[]required
    From 1 to 5 addresses.

Errors

  • 422validation_error
    Invalid address, or the inbox cannot forward.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /emails/receiving/{id}/forward

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);
200Response
{
  "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, spam and trash (the folder field) and sent (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 user id in 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 filename of up to 255 characters and 40 MB in total. The validation is the same as POST /emails: invalid Base64 or a total above the limit returns 422 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 PATCH includes attachments, the list is replaced. To keep an attachment the draft already has, send just its id ({"id": "…"}); items with filename and content are 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, size and download_url, a signed link valid for 1 hour), in the same format as received attachments.

#List threads

GET/inboxes/{id}/threads

Full-access key

Lists the threads of a folder, from the most recent activity to the oldest.

Path parameters

  • idstringrequired
    Inbox ID.

Query parameters

  • folderstring
    inbox (default), sent, archive, spam or trash.
  • labelstring
    Only threads with this label.
  • unlabeledboolean
    With true, only threads without a label.
  • assigned_tostring
    A member ID, me (you) or none (unassigned).
  • unreadboolean
    With true, only unread ones.
  • searchstring
    Searches subject, snippet and senders.
  • limitinteger
    From 1 to 100. Default: 20.
  • afterstring
    Thread ID for the next page.
  • beforestring
    Thread ID for the previous page.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
GET /inboxes/{id}/threads

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);
200Response
{
  "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}

Full-access key

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

  • idstringrequired
    Inbox ID.
  • thread_idstringrequired
    Thread ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /inboxes/{id}/threads/{thread_id}

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);
200Response
{
  "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}

Full-access key

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

  • idstringrequired
    Inbox ID.
  • thread_idstringrequired
    Thread ID.

Request body

  • folderstring
    inbox, archive, spam or trash.
  • readboolean
    true marks all received messages as read; false marks the last one as unread.
  • assigned_tointeger | null
    ID of a team member, or null to remove the assignee.
  • labelsstring[]
    Replaces the thread's labels.
  • add_labelsstring[]
    Adds labels.
  • remove_labelsstring[]
    Removes labels.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
PATCH /inboxes/{id}/threads/{thread_id}

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);
200Response
{
  "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}

Full-access key

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

  • idstringrequired
    Inbox ID.
  • thread_idstringrequired
    Thread ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /inboxes/{id}/threads/{thread_id}

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);
200Response
{
  "object": "thread",
  "id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
  "deleted": true
}

#Act on several threads

POST/inboxes/{id}/threads/batch

Full-access key

Path parameters

  • idstringrequired
    Inbox ID.

Request body

  • idsstring[]required
    From 1 to 100 thread IDs.
  • actionstringrequired
    read, unread, archive, spam, trash, inbox, delete, add_label, remove_label or assign.
  • label_idstring
    Required for add_label and remove_label.
  • assigned_tointeger | null
    For assign: the member, or null.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /inboxes/{id}/threads/batch

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);
200Response
{
  "object": "batch",
  "updated": 1
}

#Reply to a thread

POST/inboxes/{id}/threads/{thread_id}/reply

Full-access key

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

  • idstringrequired
    Inbox ID.
  • thread_idstringrequired
    Thread ID.

Request body

  • textstring
    Text of the reply.
  • htmlstring
    HTML of the reply.
  • tostring[]
    Recipients.
  • ccstring[]
    Carbon copy.
  • bccstring[]
    Blind carbon copy.
  • subjectstring
    Subject.
  • draft_idstring
    Draft that this reply sends (it is deleted).
  • attachmentsobject[]
    Attachments, up to 10 files and 40 MB in total, with the same rules as POST /emails. Here there are only files sent in the body itself (no path or content_id).
    • filenamestringrequired
      File name, up to 255 characters.
    • contentstringrequired
      File content in Base64.
    • content_typestring
      MIME type, such as application/pdf. Without it, it is inferred from the extension.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
  • 403validation_error
    The inbox's domain is not verified.
  • 403sending_paused
    Sending is paused for your team because of a high bounce or complaint rate.
  • 403paused_by_plan
    The inbox's domain is paused by the plan.
  • 403plan_limit_reached
    The attachments go over the plan limit.
  • 429daily_quota_exceeded
    The plan's sending quota is used up (also monthly_quota_exceeded).
POST /inboxes/{id}/threads/{thread_id}/reply

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);
201Response
{
  "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

Full-access key

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

  • idstringrequired
    Inbox ID.

Request body

  • tostring[]required
    Recipients.
  • subjectstringrequired
    Subject.
  • textstring
    Text.
  • htmlstring
    HTML.
  • ccstring[]
    Carbon copy.
  • bccstring[]
    Blind carbon copy.
  • attachmentsobject[]
    Attachments, up to 10 files and 40 MB in total, with the same rules as POST /emails. Here there are only files sent in the body itself (no path or content_id).
    • filenamestringrequired
      File name, up to 255 characters.
    • contentstringrequired
      File content in Base64.
    • content_typestring
      MIME type, such as application/pdf. Without it, it is inferred from the extension.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
  • 403validation_error
    The inbox's domain is not verified.
  • 403sending_paused
    Sending is paused for your team.
  • 403paused_by_plan
    The inbox's domain is paused by the plan.
  • 429daily_quota_exceeded
    The plan's sending quota is used up (also monthly_quota_exceeded).
POST /inboxes/{id}/messages

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);
201Response
{
  "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

Full-access key

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

  • idstringrequired
    Inbox ID.
  • thread_idstringrequired
    Thread ID.
  • message_idstringrequired
    Message ID.

Request body

  • tostringrequired
    Target language: pt-BR, en, es, fr, de, it, ja, zh, ko, ru, nl or ar.
  • fromstring
    Source language. Without it, the AI detects it.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
  • 429ai_credits_exhausted
    The month's AI credits are used up.
POST /inboxes/{id}/threads/{thread_id}/messages/{message_id}/translate

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);
202Response
{
  "object": "ai_task",
  "id": "019a1c04-3e54-7b65-8c76-4d5e6f708192",
  "status": "pending"
}

#List labels

GET/inboxes/{id}/labels

Full-access key

Path parameters

  • idstringrequired
    Inbox ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /inboxes/{id}/labels

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);
200Response
{
  "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

Full-access key

Path parameters

  • idstringrequired
    Inbox ID.

Request body

  • namestringrequired
    Up to 50 characters, unique in the inbox.
  • colorstring
    cyan, teal, green, lime, yellow, orange, indigo, purple, pink, brown or gray (default).

Errors

  • 422validation_error
    Duplicate name or invalid color.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /inboxes/{id}/labels

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);
201Response
{
  "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}

Full-access key

Path parameters

  • idstringrequired
    Inbox ID.
  • label_idstringrequired
    Label ID.

Request body

  • namestring
    New name.
  • colorstring
    New color.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
PATCH /inboxes/{id}/labels/{label_id}

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);
200Response
{
  "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}

Full-access key

Removes the label from all threads. The threads stay.

Path parameters

  • idstringrequired
    Inbox ID.
  • label_idstringrequired
    Label ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /inboxes/{id}/labels/{label_id}

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);
200Response
{
  "object": "inbox_label",
  "id": "019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f",
  "deleted": true
}

#List drafts

GET/inboxes/{id}/drafts

Full-access key

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

  • idstringrequired
    Inbox ID.

Query parameters

  • thread_idstring
    Only drafts of one thread.
  • limitinteger
    From 1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /inboxes/{id}/drafts

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);
200Response
{
  "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

Full-access key

Path parameters

  • idstringrequired
    Inbox ID.

Request body

  • thread_idstring
    Thread that the draft replies to. Without it, it is a new message.
  • tostring[]
    Recipients.
  • ccstring[]
    Carbon copy.
  • bccstring[]
    Blind carbon copy.
  • subjectstring
    Subject.
  • textstring
    Text.
  • htmlstring
    HTML.
  • attachmentsobject[]
    Attachments of the draft, up to 10 files and 40 MB in total. See Attachments.
    • filenamestring
      File name, up to 255 characters. Required without id.
    • contentstring
      File content in Base64. Required without id.
    • content_typestring
      MIME type. Without it, it is inferred from the extension.
    • idstring
      Only when updating: the ID of an attachment the draft already has, to keep it (without sending the content again).

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /inboxes/{id}/drafts

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);
201Response
{
  "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}

Full-access key

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

  • idstringrequired
    Inbox ID.
  • draft_idstringrequired
    Draft ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /inboxes/{id}/drafts/{draft_id}

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);
200Response
{
  "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}

Full-access key

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

  • idstringrequired
    Inbox ID.
  • draft_idstringrequired
    Draft ID.

Request body

  • tostring[]
    Recipients.
  • ccstring[]
    Carbon copy.
  • bccstring[]
    Blind carbon copy.
  • subjectstring
    Subject.
  • textstring
    Text.
  • htmlstring
    HTML.
  • attachmentsobject[]
    Attachments of the draft, up to 10 files and 40 MB in total. See Attachments.
    • filenamestring
      File name, up to 255 characters. Required without id.
    • contentstring
      File content in Base64. Required without id.
    • content_typestring
      MIME type. Without it, it is inferred from the extension.
    • idstring
      Only when updating: the ID of an attachment the draft already has, to keep it (without sending the content again).

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
PATCH /inboxes/{id}/drafts/{draft_id}

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);
200Response
{
  "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

Full-access key

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

  • idstringrequired
    Inbox ID.
  • draft_idstringrequired
    Draft ID.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The resource does not exist or belongs to another team.
  • 403validation_error
    The inbox's domain is not verified.
  • 403sending_paused
    Sending is paused for your team.
  • 429daily_quota_exceeded
    The plan's sending quota is used up (also monthly_quota_exceeded).
POST /inboxes/{id}/drafts/{draft_id}/send

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);
200Response
{
  "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}

Full-access key

Path parameters

  • idstringrequired
    Inbox ID.
  • draft_idstringrequired
    Draft ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /inboxes/{id}/drafts/{draft_id}

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);
200Response
{
  "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, use POST /inboxes/{id}/agent/improve-instructions, which rewrites your draft more clearly.

#Improve the agent instructions

POST/inboxes/{id}/agent/improve-instructions

Full-access key

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

  • idstringrequired
    Inbox ID.

Request body

  • instructionsstringrequired
    The draft of the instructions, from 1 to 4,000 characters.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
  • 422validation_error
    A field is missing or out of range.
  • 429ai_credits_exhausted
    The month's AI credits are used up. They renew on the 1st (UTC).
  • 503ai_not_configured
    The AI assistant is not available.
POST /inboxes/{id}/agent/improve-instructions

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);
202Response (task created)
{
  "object": "ai_task",
  "id": "019a1d40-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "status": "pending"
}
200Final response in GET /ai/tasks/{id}
{
  "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.

EventWhen
inbox.thread.createdA new thread was opened, by a received email or by a message sent from the inbox.
inbox.thread.updatedThe 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.deletedThe thread was deleted for good.
inbox.draft.createdThe 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 featureKeyWhat changes in the API
InboxesinboxesHow many inboxes the team can have. Above it, POST /inboxes returns 403.
Inboxes on your domaincustom_inbound_domainsWithout it, only inboxes under in.postal.so exist: creating one with domain_id returns 403, and the domain cannot turn receiving on.
Inbox AI agentinbox_agentTurning on agent.enabled in PATCH /inboxes/{id} returns 403 without it.
AI creditsai_creditsThe 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.