Iniciar sesión Crear cuenta
Referencia de la API

E-mails

Envía, lista, programa y cancela correos electrónicos, con adjuntos, etiquetas, encabezados y plantillas.

Envía correos electrónicos, sigue el estado de cada mensaje, programa, cancela y consulta adjuntos. Los envíos por API y por SMTP aparecen en los mismos endpoints.

Quien recibe un correo enviado por aquí (en to y cc) queda guardado en la audiencia como contacto sin confirmar, que no recibe campañas ni automatizaciones hasta que lo confirmes. Se puede desactivar en Configuración › Equipo.

#Enviar un correo electrónico

POST/emails

Acceso total o solo envíoAcepta Idempotency-Key

Pone un correo electrónico en la cola de envío y devuelve el ID. El envío ocurre en segundos; sigue el resultado con GET /emails/{id} o con webhooks. Antes de enviar, Postal elimina los destinatarios que están en la lista de supresión. Para probar sin enviar de verdad, usa las direcciones de prueba.

Cabeceras

  • Idempotency-Keystring
    Clave de 1 a 256 caracteres para repetir la solicitud sin duplicar el envío. Consulta Idempotencia.

Cuerpo de la solicitud

  • fromstringobligatorio
    Remitente, con el formato [email protected] o Nome <[email protected]>. El dominio debe estar verificado y con envío activo. Hasta 998 caracteres.
  • tostring | string[]obligatorio
    Destinatarios. Una cadena o una lista de hasta 50 direcciones. Se puede omitir cuando el destinatario es un contacto: consulta user_id, external_id y contact_id.
  • user_idstring
    Alternativa a to: el ID de la persona en tu sistema, el external_id de un contacto (user_id es un alias). El correo va a la dirección de ese contacto. Se ignora cuando se envía to.
  • external_idstring
    Lo mismo que user_id. Si se envían los dos, vale user_id.
  • contact_idstring
    Alternativa a to: el ID del contacto en Postal (o su dirección de correo). Vale después de user_id y external_id.
  • subjectstringobligatorio
    Asunto, de hasta 998 caracteres.
  • htmlstring
    Cuerpo en HTML. Obligatorio cuando no hay text.
  • textstring
    Versión en texto plano. Recomendada incluso con html: mejora la entregabilidad.
  • ccstring | string[]
    Cópia.
  • bccstring | string[]
    Copia oculta.
  • reply_tostring | string[]
    Direcciones de respuesta (Reply-To).
  • headersobject
    Encabezados adicionales, como {"List-Unsubscribe": "<https://…>"}. Valores de hasta 998 caracteres y sin saltos de línea. No se pueden sobrescribir From, To, Cc, Bcc, Subject, Reply-To, Sender, Date, Message-ID, Return-Path, MIME-Version, Content-Type, Content-Transfer-Encoding ni DKIM-Signature. Para enviar un código de un solo uso que el cliente de correo pueda rellenar, usa OTP-Token: consulta Encabezado OTP-Token. Cuando el OTP-Token llega en la solicitud, la API comprueba el formato y responde 422 si el valor no es válido o si el encabezado aparece más de una vez, con cualquier combinación de mayúsculas y minúsculas (lo que valida Postal).
  • tagsobject[]
    Hasta 50 etiquetas para filtrar e identificar el envío en los webhooks.
    • namestringobligatorio
      Letras ASCII, números, _ o -, hasta 256 caracteres. No puede empezar por postal_.
    • valuestring
      Mismas reglas que el nombre. Opcional.
  • attachmentsobject[]
    Adjuntos, de hasta 40 MB en total. No se aceptan en lote. El plan puede permitir menos (o ningún adjunto): por encima del límite del plan, la respuesta es 403 plan_limit_reached. Un path que Postal no puede descargar en el momento del envío deja el correo con estado failed.
    • contentstring
      Contenido del archivo en Base64. Obligatorio sin path.
    • pathstring
      URL http(s) pública desde la que Postal descarga el archivo en el momento del envío. Obligatorio sin content.
    • filenamestring
      Nombre del archivo (hasta 255 caracteres). Con path, por defecto es el nombre que aparece al final de la URL.
    • content_typestring
      Tipo MIME, como application/pdf. Sin él, el tipo se deduce por la extensión.
    • content_idstring
      Para imágenes incrustadas: usa <img src="cid:logo"> en el HTML y "content_id": "logo" aquí.
  • scheduled_atstring
    Fecha y hora del envío en ISO 8601 (p. ej.: 2026-10-25T09:00:00-03:00). Debe estar en el futuro y a un máximo de 30 días. No se acepta en lote. Depende del recurso de programación del plan (403 plan_limit_reached si no lo incluye).
  • templateobject
    Envía con una plantilla publicada en lugar de html/text. Consulta Envío con plantilla.
    • idstringobligatorio
      ID o alias de la plantilla.
    • variablesobject
      Valores de las variables, como {"NAME": "Ana"}.

Envío con plantilla

Con template, Postal usa la última versión publicada: el from, el subject y el reply_to de la plantilla valen cuando no vienen en el cuerpo, y las variables {{{NAME}}} se rellenan con variables (en el HTML, los valores se escapan). Una variable sin valor y sin valor por defecto genera 422, y lo mismo ocurre con un valor que no sea texto ni número o, en una variable de tipo number, que no sea numérico. También se rellenan los valores de variables que la plantilla no declara. No envíes html ni text junto con template. Una plantilla pausada por el plan responde 403 paused_by_plan.

Cuerpo con plantilla
{
  "from": "Loja Exemplo <[email protected]>",
  "to": "[email protected]",
  "template": {
    "id": "order-shipped",
    "variables": {
      "NAME": "Ana",
      "ORDER": "10482"
    }
  }
}

Adjuntos e imágenes incrustadas

Envía el archivo en Base64 en content o una URL en path. Para una imagen dentro del HTML, usa el mismo content_id en el atributo src="cid:…".

Cuerpo con adjuntos
{
  "from": "Financeiro <[email protected]>",
  "to": ["[email protected]"],
  "subject": "Sua nota fiscal",
  "html": "<img src=\"cid:logo\" alt=\"Loja Exemplo\"><p>Segue a nota fiscal do pedido #10482.</p>",
  "attachments": [
    {
      "filename": "invoice-10482.pdf",
      "content": "JVBERi0xLjcKJcfsj6IKNSAwIG9iago8PC9MZW5ndGggNiAwIFI+PgpzdHJlYW0K…"
    },
    {
      "path": "https://cdn.lojaexemplo.com.br/logo.png",
      "content_id": "logo"
    }
  ]
}

Enviar a un contacto

Si guardas a las personas como contactos, no necesitas repetir la dirección: envía el user_id (el external_id del contacto) en lugar de to y Postal usa su correo. También vale en el lote. Un contacto que no existe devuelve 404. La lista de supresión sigue vigente: si la dirección está en ella, el correo no sale.

Cuerpo con un contacto
{
  "from": "Loja Exemplo <[email protected]>",
  "user_id": "user_42",
  "template": {
    "id": "order-shipped",
    "variables": {
      "NAME": "Ana",
      "ORDER": "10482"
    }
  }
}

Programación

Con scheduled_at, el correo queda con estado scheduled hasta la hora indicada. Puedes reprogramarlo o cancelarlo mientras no haya salido. La cuota se cuenta cuando se crea el correo, y un evento email.scheduled entra en el historial.

Errores

  • 422validation_error
    Algún campo no es válido. El mensaje indica cuál.
  • 403validation_error
    El dominio del from no está verificado o no es de tu equipo.
  • 403restricted_api_key
    La clave solo puede enviar desde otro dominio.
  • 403sending_paused
    Los envíos del equipo están pausados por una tasa alta de rebotes o quejas.
  • 403paused_by_plan
    El dominio del from, la template o la propia clave está pausada porque supera el límite del plan. Consulta Límites del plan.
  • 403plan_limit_reached
    El plan no incluye lo que pide el envío: scheduled_at, direcciones de prueba o adjuntos (o el total supera el límite del plan).
  • 404not_found
    La template no existe o no se ha publicado.
  • 404not_found
    Ningún contacto tiene el user_id, el external_id o el contact_id indicado.
  • 400invalid_idempotency_key
    La Idempotency-Key está vacía o tiene más de 256 caracteres.
  • 409concurrent_idempotent_requests
    Otra solicitud con la misma Idempotency-Key sigue en curso.
  • 409invalid_idempotent_request
    La misma Idempotency-Key se usó con otro cuerpo.
  • 429daily_quota_exceeded
    Se agotó la cuota diaria: el límite diario del plan o el tope de la reputación de la cuenta, el que sea menor. Se restablece a las 00:00 UTC. Cuando es la reputación la que limita, el mensaje indica el nivel y el tope.
  • 429monthly_quota_exceeded
    La cuota mensual del plan se agotó, y la reserva del Adicional de envíos también, si el equipo tiene una. El mensaje dice qué hacer, incluido dónde comprar el adicional cuando el plan lo ofrece.
POST /emails

cURL

curl -X POST https://api.postal.so/emails \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Idempotency-Key: order-10482-shipped" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Loja Exemplo <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Seu pedido #10482 saiu para entrega",
    "html": "<p>O pedido <b>#10482</b> está a caminho.</p>",
    "text": "O pedido #10482 está a caminho.",
    "tags": [
      {
        "name": "category",
        "value": "shipping"
      }
    ]
  }'

Node.js

const response = await fetch('https://api.postal.so/emails', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'order-10482-shipped',
  },
  body: JSON.stringify({
    from: 'Loja Exemplo <[email protected]>',
    to: ['[email protected]'],
    subject: 'Seu pedido #10482 saiu para entrega',
    html: '<p>O pedido <b>#10482</b> está a caminho.</p>',
    text: 'O pedido #10482 está a caminho.',
    tags: [
      {
        name: 'category',
        value: 'shipping',
      },
    ],
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/emails",
    headers={
        "Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}",
        "Idempotency-Key": "order-10482-shipped",
    },
    json={
        "from": "Loja Exemplo <[email protected]>",
        "to": ["[email protected]"],
        "subject": "Seu pedido #10482 saiu para entrega",
        "html": "<p>O pedido <b>#10482</b> está a caminho.</p>",
        "text": "O pedido #10482 está a caminho.",
        "tags": [
            {
                "name": "category",
                "value": "shipping",
            },
        ],
    },
    timeout=10,
)

data = response.json()

PHP

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

$response = $client->post('emails', [
    'headers' => [
        'Authorization' => 'Bearer '.getenv('POSTAL_API_KEY'),
        'Idempotency-Key' => 'order-10482-shipped',
    ],
    'json' => [
        'from' => 'Loja Exemplo <[email protected]>',
        'to' => ['[email protected]'],
        'subject' => 'Seu pedido #10482 saiu para entrega',
        'html' => '<p>O pedido <b>#10482</b> está a caminho.</p>',
        'text' => 'O pedido #10482 está a caminho.',
        'tags' => [
            [
                'name' => 'category',
                'value' => 'shipping',
            ],
        ],
    ],
]);

$data = json_decode($response->getBody(), true);
200Respuesta
{
  "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}

#Enviar en lote

POST/emails/batch

Acceso total o solo envíoAcepta Idempotency-Key

Envía hasta 100 correos electrónicos en una solicitud. El cuerpo es una lista de objetos con los mismos campos de POST /emails, excepto attachments y scheduled_at. Todo se valida antes: o todos los correos entran en la cola, o ninguno. Los errores de validación indican la posición, como [3] Missing `subject` field., y la cuota se comprueba para el lote entero (los elementos solo para direcciones de prueba no cuentan). El envío en lote es un recurso del plan.

Cuerpo de la solicitud (lista)

  • [ ]object[]obligatorio
    De 1 a 100 correos, cada uno con from, to, subject, html o text y, si quieres, cc, bcc, reply_to, headers, tags y template. En lugar de to, valen user_id, external_id o contact_id, como en POST /emails.

Errores

  • 422validation_error
    El cuerpo no es una lista, tiene más de 100 elementos, o algún elemento no es válido (con la posición en el mensaje).
  • 403validation_error
    Algún from usa un dominio no verificado.
  • 403plan_limit_reached
    El plan no incluye el envío en lote (ni las direcciones de prueba, si algún elemento las usa).
  • 403sending_paused
    Los envíos del equipo están pausados por una tasa alta de rebotes o quejas.
  • 403paused_by_plan
    Algún from usa un dominio pausado por el plan, o algún elemento usa una plantilla pausada.
  • 404not_found
    Algún elemento usa una template inexistente o no publicada, o un contacto que no existe (el mensaje indica la posición).
  • 429daily_quota_exceeded
    El lote superaría la cuota diaria o mensual.
POST /emails/batch

cURL

curl -X POST https://api.postal.so/emails/batch \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "from": "Loja Exemplo <[email protected]>",
      "to": ["[email protected]"],
      "subject": "Seu cupom chegou",
      "html": "<p>Olá, Ana! Use o cupom <b>BEMVINDA</b>.</p>"
    },
    {
      "from": "Loja Exemplo <[email protected]>",
      "to": ["[email protected]"],
      "subject": "Seu cupom chegou",
      "html": "<p>Olá, Bruno! Use o cupom <b>BEMVINDO</b>.</p>"
    }
  ]'

Node.js

const response = await fetch('https://api.postal.so/emails/batch', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify([
    {
      from: 'Loja Exemplo <[email protected]>',
      to: ['[email protected]'],
      subject: 'Seu cupom chegou',
      html: '<p>Olá, Ana! Use o cupom <b>BEMVINDA</b>.</p>',
    },
    {
      from: 'Loja Exemplo <[email protected]>',
      to: ['[email protected]'],
      subject: 'Seu cupom chegou',
      html: '<p>Olá, Bruno! Use o cupom <b>BEMVINDO</b>.</p>',
    },
  ]),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/emails/batch",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json=[
        {
            "from": "Loja Exemplo <[email protected]>",
            "to": ["[email protected]"],
            "subject": "Seu cupom chegou",
            "html": "<p>Olá, Ana! Use o cupom <b>BEMVINDA</b>.</p>",
        },
        {
            "from": "Loja Exemplo <[email protected]>",
            "to": ["[email protected]"],
            "subject": "Seu cupom chegou",
            "html": "<p>Olá, Bruno! Use o cupom <b>BEMVINDO</b>.</p>",
        },
    ],
    timeout=10,
)

data = response.json()

PHP

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

$response = $client->post('emails/batch', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        [
            'from' => 'Loja Exemplo <[email protected]>',
            'to' => ['[email protected]'],
            'subject' => 'Seu cupom chegou',
            'html' => '<p>Olá, Ana! Use o cupom <b>BEMVINDA</b>.</p>',
        ],
        [
            'from' => 'Loja Exemplo <[email protected]>',
            'to' => ['[email protected]'],
            'subject' => 'Seu cupom chegou',
            'html' => '<p>Olá, Bruno! Use o cupom <b>BEMVINDO</b>.</p>',
        ],
    ],
]);

$data = json_decode($response->getBody(), true);
200Respuesta
{
  "data": [
    {
      "id": "019a1b2c-51aa-7c21-9f02-6b7c8d9e0f1a"
    },
    {
      "id": "019a1b2c-51ab-7d30-8e11-2a3b4c5d6e7f"
    }
  ]
}

#Listar correos electrónicos

GET/emails

Clave de acceso total

Lista los correos electrónicos enviados, del más reciente al más antiguo, con paginación por cursor. La lista no incluye los eventos; usa GET /emails/{id} para ver el historial.

Parámetros de consulta

  • limitinteger
    Elementos por página, de 1 a 100. Por defecto: 20.
  • afterstring
    ID del último elemento recibido, para la página siguiente.
  • beforestring
    ID del primer elemento recibido, para la página anterior.
  • searchstring
    Busca en remitente, asunto y destinatarios. Un ID completo encuentra el correo exacto. Los destinatarios incluyen cc y bcc; la búsqueda admite hasta 200 caracteres.
  • statusstring
    Uno o más estados separados por comas, como bounced,complained. Consulta los estados. Un valor que no es un estado devuelve 422.
  • domain_idstring
    Solo correos de un dominio.
  • api_key_idstring
    Solo correos enviados con una clave.
  • start_datestring
    Creados a partir de esta fecha (ISO 8601). Una fecha sin hora, como 2026-10-01, empieza a la medianoche UTC.
  • end_datestring
    Creados hasta esta fecha (ISO 8601). Una fecha sin hora, como 2026-10-24, vale hasta el final de ese día (UTC). Si start_date es posterior a end_date, la respuesta es 422.

Errores

  • 422validation_error
    Filtro o cursor no válido, o after y before juntos. También cuando start_date es posterior a end_date.
GET /emails

cURL

curl 'https://api.postal.so/emails?limit=2&status=delivered,opened' \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails?limit=2&status=delivered,opened', {
  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",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    params={
        "limit": 2,
        "status": "delivered,opened",
    },
    timeout=10,
)

data = response.json()

PHP

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

$response = $client->get('emails', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'query' => [
        'limit' => 2,
        'status' => 'delivered,opened',
    ],
]);

$data = json_decode($response->getBody(), true);
200Respuesta
{
  "object": "list",
  "has_more": true,
  "data": [
    {
      "object": "email",
      "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
      "message_id": "<0103019a1b2c4d5e-6f7a8b9c-1d2e-4f3a-9b8c-7d6e5f4a3b2c-000000@sa-east-1.amazonses.com>",
      "to": ["[email protected]"],
      "from": "Loja Exemplo <[email protected]>",
      "created_at": "2026-10-24T13:02:11.000000Z",
      "subject": "Seu pedido #10482 saiu para entrega",
      "html": "<p>O pedido <b>#10482</b> está a caminho.</p>",
      "text": "O pedido #10482 está a caminho.",
      "bcc": [],
      "cc": [],
      "reply_to": ["[email protected]"],
      "last_event": "delivered",
      "scheduled_at": null,
      "simulated": false,
      "tags": [
        {
          "name": "category",
          "value": "shipping"
        }
      ]
    }
  ]
}

#Obtener un correo electrónico

GET/emails/{id}

Acceso total o solo envío

Devuelve el correo con el contenido, el estado actual (last_event) y la lista de eventos. En los correos a las direcciones de prueba, simulated es true. También indica de dónde vino: source es api o smtp, y log_id apunta al registro de la solicitud. Los campos están en El objeto correo electrónico y los tipos de evento en Eventos de un correo electrónico.

Qué claves pueden llamar: una clave de acceso total (o el panel) lee cualquier correo del equipo. Una clave sending_access lee solo los correos que ella misma envió, por API o por SMTP, y así sigue el estado de la entrega sin webhooks. Cualquier otro correo del equipo (de otra clave, del panel, de campañas o de automatizaciones) responde 404 not_found, igual que un correo que no existe. Las demás rutas de /emails siguen exigiendo acceso total.

Parámetros de ruta

  • idstringobligatorio
    ID del correo.

Errores

  • 404not_found
    El correo no existe, es de otro equipo o, con una clave sending_access, no lo envió ella.
GET /emails/{id}

cURL

curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b', {
  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/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    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/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Respuesta
{
  "object": "email",
  "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
  "message_id": "<0103019a1b2c4d5e-6f7a8b9c-1d2e-4f3a-9b8c-7d6e5f4a3b2c-000000@sa-east-1.amazonses.com>",
  "to": ["[email protected]"],
  "from": "Loja Exemplo <[email protected]>",
  "created_at": "2026-10-24T13:02:11.000000Z",
  "subject": "Seu pedido #10482 saiu para entrega",
  "html": "<p>O pedido <b>#10482</b> está a caminho.</p>",
  "text": "O pedido #10482 está a caminho.",
  "bcc": [],
  "cc": [],
  "reply_to": ["[email protected]"],
  "last_event": "delivered",
  "scheduled_at": null,
  "simulated": false,
  "tags": [
    {
      "name": "category",
      "value": "shipping"
    }
  ],
  "events": [
    {
      "type": "email.sent",
      "occurred_at": "2026-10-24T13:02:12.000000Z",
      "data": null
    },
    {
      "type": "email.delivered",
      "occurred_at": "2026-10-24T13:02:14.000000Z",
      "data": {
        "recipients": ["[email protected]"]
      }
    }
  ],
  "domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
  "api_key_id": "0199f0e2-7b8c-7d9e-8f0a-1b2c3d4e5f6a",
  "template_id": null,
  "source": "api",
  "log_id": "019a1b2c-4d60-7a11-b2c3-d4e5f6a7b8c9"
}

#Reprogramar un correo electrónico

PATCH/emails/{id}

Clave de acceso total

Cambia la fecha de envío de un correo programado. Solo funciona mientras el estado sea scheduled.

Parámetros de ruta

  • idstringobligatorio
    ID del correo.

Cuerpo de la solicitud

  • scheduled_atstringobligatorio
    Nueva fecha en ISO 8601, en el futuro y a un máximo de 30 días.

Errores

  • 422validation_error
    El correo no está programado o la fecha no es válida.
  • 404not_found
    El recurso no existe o es de otro equipo.
PATCH /emails/{id}

cURL

curl -X PATCH https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "scheduled_at": "2026-10-26T09:00:00-03:00"
  }'

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    scheduled_at: '2026-10-26T09:00:00-03:00',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.patch(
    "https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "scheduled_at": "2026-10-26T09:00:00-03:00",
    },
    timeout=10,
)

data = response.json()

PHP

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

$response = $client->patch('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'scheduled_at' => '2026-10-26T09:00:00-03:00',
    ],
]);

$data = json_decode($response->getBody(), true);
200Respuesta
{
  "object": "email",
  "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}

#Cancelar un correo electrónico programado

POST/emails/{id}/cancel

Clave de acceso total

Cancela el envío de un correo programado. El estado pasa a canceled y un evento email.canceled entra en el historial.

Parámetros de ruta

  • idstringobligatorio
    ID del correo.

Errores

  • 422validation_error
    El correo no está programado (ya salió o ya se canceló).
  • 404not_found
    El recurso no existe o es de otro equipo.
POST /emails/{id}/cancel

cURL

curl -X POST https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/cancel \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/cancel', {
  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/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/cancel",
    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('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/cancel', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Respuesta
{
  "object": "email",
  "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}

#Mensaje original

GET/emails/{id}/raw

Clave de acceso total

Devuelve el mensaje tal como se envió, en formato RFC 5322 (text/plain): el original, en los envíos por SMTP, o reconstruido, en los envíos por API.

Parámetros de ruta

  • idstringobligatorio
    ID del correo.

Errores

  • 404not_found
    El recurso no existe o es de otro equipo.
GET /emails/{id}/raw

cURL

curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/raw \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/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/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/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/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/raw', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$message = (string) $response->getBody();
200Respuesta (text/plain)
From: Loja Exemplo <[email protected]>
To: [email protected]
Subject: Seu pedido #10482 saiu para entrega
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="b1_Xk2p"

--b1_Xk2p
Content-Type: text/plain; charset=utf-8

O pedido #10482 está a caminho.
--b1_Xk2p--

#Diagnóstico de entregabilidad

GET/emails/{id}/insights

Clave de acceso total

Ejecuta comprobaciones de buenas prácticas en el correo e indica si cada una se superó. Los códigos son: has_text_version, no_link_shorteners, https_links, no_ip_links, images_have_alt, html_under_gmail_limit (HTML de hasta 102 KB), enough_text, reply_friendly_sender, subject_not_all_caps, unsubscribe_option y dmarc_configured (solo cuando el DNS responde). La IA explica cómo corregir los que fallaron.

Parámetros de ruta

  • idstringobligatorio
    ID del correo.

Errores

  • 404not_found
    El recurso no existe o es de otro equipo.
GET /emails/{id}/insights

cURL

curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/insights \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/insights', {
  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/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/insights",
    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/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/insights', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Respuesta
{
  "object": "list",
  "data": [
    {
      "code": "has_text_version",
      "passed": true,
      "data": {}
    },
    {
      "code": "https_links",
      "passed": false,
      "data": {
        "count": 1
      }
    },
    {
      "code": "dmarc_configured",
      "passed": true,
      "data": {
        "domain": "lojaexemplo.com.br"
      }
    }
  ]
}

#Crear un enlace de visualización

POST/emails/{id}/share

Clave de acceso total

Crea un enlace público y temporal para ver el correo en el navegador, útil para mostrarlo a alguien sin acceso al panel. El HTML se abre aislado, sin scripts.

Parámetros de ruta

  • idstringobligatorio
    ID del correo.

Cuerpo de la solicitud

  • expires_instring
    Validez, de 1 minuto a 48 horas: 10m, 2 hours, 1 day… Por defecto: 48h.

Errores

  • 422validation_error
    expires_in con un formato o intervalo no válido.
  • 403plan_limit_reached
    El plan no incluye los enlaces para compartir correos.
  • 404not_found
    El recurso no existe o es de otro equipo.
POST /emails/{id}/share

cURL

curl -X POST https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/share \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "expires_in": "2h"
  }'

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/share', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    expires_in: '2h',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/share",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "expires_in": "2h",
    },
    timeout=10,
)

data = response.json()

PHP

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

$response = $client->post('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/share', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'expires_in' => '2h',
    ],
]);

$data = json_decode($response->getBody(), true);
200Respuesta
{
  "object": "email",
  "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
  "url": "https://api.postal.so/shared/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b?expires=1761321731&signature=4f1c…"
}

#Listar adjuntos

GET/emails/{id}/attachments

Clave de acceso total

Lista los adjuntos de un correo enviado. Cada uno incluye un download_url firmado, válido durante 1 hora. En los adjuntos enviados por path, el enlace es la URL original y expires_at llega nulo. Acepta limit, after y before, como las demás listas.

Parámetros de ruta

  • idstringobligatorio
    ID del correo.

Errores

  • 422validation_error
    limit fuera del intervalo o after y before juntos.
  • 404not_found
    El recurso no existe o es de otro equipo.
GET /emails/{id}/attachments

cURL

curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments', {
  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/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments",
    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/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Respuesta
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b",
      "filename": "invoice-10482.pdf",
      "content_type": "application/pdf",
      "content_id": null,
      "content_disposition": "attachment",
      "download_url": "https://api.postal.so/attachments/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b?expires=1761314531&signature=9a7e…",
      "expires_at": "2026-10-24T14:02:11.000Z",
      "size": 48213
    }
  ]
}

#Obtener un adjunto

GET/emails/{id}/attachments/{attachment_id}

Clave de acceso total

Devuelve un adjunto con el enlace de descarga.

Parámetros de ruta

  • idstringobligatorio
    ID del correo.
  • attachment_idstringobligatorio
    ID del adjunto.

Errores

  • 404not_found
    El recurso no existe o es de otro equipo.
GET /emails/{id}/attachments/{attachment_id}

cURL

curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b', {
  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/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b",
    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/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Respuesta
{
  "object": "attachment",
  "id": "019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b",
  "filename": "invoice-10482.pdf",
  "content_type": "application/pdf",
  "content_id": null,
  "content_disposition": "attachment",
  "download_url": "https://api.postal.so/attachments/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b?expires=1761314531&signature=9a7e…",
  "expires_at": "2026-10-24T14:02:11.000Z",
  "size": 48213
}

#Estado de un correo electrónico

El campo last_event muestra el estado más avanzado del correo. Como los eventos pueden llegar fuera de orden, el estado solo avanza: un correo clicked no vuelve a delivered.

EstadoSignificado
queuedEn la cola, saliendo en instantes.
scheduledProgramado para scheduled_at.
sentAceptado por el proveedor de envío.
delivery_delayedEl servidor del destinatario aplazó la entrega; los reintentos se hacen solos.
deliveredEntregado al servidor del destinatario.
openedAbierto (con el seguimiento de aperturas activado en el dominio).
clickedSe hizo clic en un enlace (con el seguimiento de clics activado).
bouncedDevuelto. Los rebotes permanentes pasan a la lista de supresión.
complainedEl destinatario lo marcó como spam. La dirección pasa a la lista de supresión.
failedNo se pudo enviar (dominio no verificado, envío pausado, adjunto inaccesible…).
suppressedTodos los destinatarios, o parte de ellos, estaban en la lista de supresión.
canceledProgramación cancelada.

#El objeto correo electrónico

La lista (GET /emails) y la consulta de un correo (GET /emails/{id}) devuelven el mismo objeto. La consulta añade el historial y el origen. El envío (POST /emails) devuelve solo el id, y el envío en lote, una lista de id. Postal guarda los correos, los adjuntos y el historial durante 30 días (y los programados hasta que salgan); pasado ese tiempo, la consulta responde 404. Consulta Límites.

CampoTipoDescripción
object, idstringemail y el ID del correo.
message_idstring | nullEl Message-ID del correo. Es null hasta que el proveedor de envío lo acepta (correos en cola, programados o cancelados).
from, to, cc, bcc, reply_tostring | string[]Las direcciones, tal como se enviaron. cc, bcc y reply_to son listas vacías cuando no existen.
subject, html, textstring | nullEl contenido enviado. En un envío con plantilla, es el contenido ya rellenado con las variables.
created_atstringCuándo se creó el correo (ISO 8601, UTC).
last_eventstringEl estado actual: queued, scheduled, sent, delivery_delayed, delivered, opened, clicked, bounced, failed, suppressed, canceled o complained.
scheduled_atstring | nullLa fecha de envío indicada con scheduled_at, o null en un envío inmediato.
simulatedbooleantrue en los correos a las direcciones de prueba, que nunca se envían de verdad.
tagsobject[]Las etiquetas, como [{"name", "value"}].
eventsobject[]Solo en GET /emails/{id}: el historial, del más antiguo al más reciente, como [{"type", "occurred_at", "data"}]. Consulta Eventos de un correo electrónico.
domain_id, api_key_id, template_idstring | nullSolo en GET /emails/{id}: el dominio del remitente, la clave de API usada en el envío y la plantilla usada (null cuando no hubo clave o plantilla).
source, log_idstringSolo en GET /emails/{id}: api o smtp, y el ID del registro de la solicitud (null si no hay).

#Eventos de un correo electrónico

El campo events de GET /emails/{id} guarda todo lo que le ocurrió al correo, en orden. Cada evento tiene type, occurred_at y data (o null). Casi todos también se pueden suscribir como eventos de webhook (email.canceled y email.unsubscribed existen solo en el historial). Los eventos que involucran a varios destinatarios listan, en data.recipients, las direcciones a las que se aplican.

TipoCuándodata
email.scheduledEl correo se creó con scheduled_at.null
email.canceledLa programación se canceló.null
email.sentEl proveedor de envío aceptó el correo.null
email.deliveredEl servidor del destinatario recibió el correo.recipients
email.delivery_delayedLa entrega se aplazó; los reintentos se hacen solos.delay_type, recipients
email.bouncedEl correo rebotó. Los rebotes permanentes entran en la lista de supresión.bounce (type, subType, message, diagnosticCode), recipients
email.complainedEl destinatario lo marcó como spam. La dirección entra en la lista de supresión.recipients, feedback_type
email.failedNo se pudo enviar (dominio sin verificar, envío pausado, adjunto inaccesible…).reason
email.suppressedTodos los destinatarios, o parte de ellos, estaban en la lista de supresión y se descartaron en el envío. Los demás siguen con normalidad.recipients
email.openedEl destinatario abrió el correo (seguimiento de aperturas activado en el dominio).open (ipAddress, userAgent, timestamp)
email.clickedSe hizo clic en un enlace (seguimiento de clics activado).click (ipAddress, link, userAgent, timestamp)
email.repliedLlegó una respuesta al correo de una campaña o automatización que detecta respuestas.reply (from, subject, message_id, preview, attachments), auto
email.unsubscribedEl contacto canceló su suscripción desde el enlace de un correo de campaña.null

El last_event solo avanza, pero la lista de events guarda todos los eventos, incluso los que llegan fuera de orden. email.replied y email.unsubscribed no cambian el last_event.

#Direcciones de prueba

Para probar la integración y los webhooks de extremo a extremo, sin enviar correos de verdad y sin arriesgar la reputación de tu dominio, envía a las direcciones de prueba de Postal, en el dominio sandbox.postal.so. Es la misma idea que [email protected] de Resend.

El correo se acepta como cualquier otro: la misma validación, el from de un dominio verificado del equipo, adjuntos, plantillas, etiquetas, scheduled_at, Idempotency-Key, el envío en lote y el SMTP. Aparece en GET /emails y en el panel, con el historial de eventos, pero nunca se envía: en lugar del proveedor, Postal registra los eventos unos segundos después, en el orden de un envío real, y los webhooks salen con normalidad.

DirecciónEventos, en ordenEstado final
[email protected]email.sent, email.delivereddelivered
[email protected]email.sent, email.bounced (rebote permanente: type Permanent, subType General, message smtp; 550 5.1.1 user unknown)bounced
[email protected]email.sent, email.delivered, email.complainedcomplained
[email protected]email.suppressed (sin email.sent ni email.delivered)suppressed
[email protected]email.sent, email.delivery_delayeddelivery_delayed

Reglas

  • Las mayúsculas y minúsculas no importan, y se puede añadir una etiqueta después de +, de hasta 64 caracteres entre letras, números, ., _ y -: [email protected]. Usa la etiqueta para reconocer cada prueba en los eventos.
  • No los mezcles. Los destinatarios de un correo (to, cc y bcc) son todos direcciones de prueba o todos reales; mezclarlos devuelve 422 validation_error. Una dirección del dominio de prueba con otro nombre, como [email protected], también devuelve 422, con la lista de los nombres válidos.
  • Se permiten varias direcciones de prueba en el mismo correo: cada resultado se convierte en su propio evento, con la lista recipients de las direcciones de ese resultado. El estado final sigue el evento más avanzado (complained vale más que bounced y suppressed, que valen más que delivered).
  • No cuentan en nada que afecte a tu envío real: no usan la cuota de envío (diaria, mensual o de envío por encima de la cuota), no entran en la lista de supresión (un bounced@ o complained@ no bloquea nada), no entran en las tasas de rebote y de queja que pueden pausar los envíos, no aparecen en las métricas ni en el resumen semanal, y no activan el aviso de primer correo.
  • Lo demás sigue vigente: el límite de solicitudes por segundo, las claves de API restringidas a un dominio y la pausa de envíos del equipo (403 sending_paused).
  • Recurso del plan. Las direcciones de prueba dependen del recurso test_addresses del plan (consulta Límites del plan); sin él, el envío responde 403 plan_limit_reached.
  • El correo trae "simulated": true en GET /emails/{id}. No hace falta configurar DNS: no se entrega nada a ese dominio.
Cuerpo de POST /emails
{
  "from": "Loja Exemplo <[email protected]>",
  "to": ["[email protected]"],
  "subject": "Seu pedido #10482 saiu para entrega",
  "html": "<p>O pedido <b>#10482</b> está a caminho.</p>",
  "tags": [
    {
      "name": "category",
      "value": "shipping"
    }
  ]
}

Unos segundos después, tu webhook recibe el mismo evento de un rebote real:

Webhook email.bounced recibido
{
  "type": "email.bounced",
  "created_at": "2026-10-24T13:02:15.004Z",
  "data": {
    "email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    "created_at": "2026-10-24T13:02:11.000Z",
    "from": "Loja Exemplo <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Seu pedido #10482 saiu para entrega",
    "tags": {
      "category": "shipping"
    },
    "bounce": {
      "diagnosticCode": ["smtp; 550 5.1.1 user unknown"],
      "message": "smtp; 550 5.1.1 user unknown",
      "subType": "General",
      "type": "Permanent"
    }
  }
}

#Límites del plan

Algunos recursos de envío dependen del plan. Cuando el plan no incluye uno de ellos, la API responde 403 plan_limit_reached y el mensaje indica qué recurso falta. Lo que incluye cada plan está en Límites y en GET /plans.

Recurso del planClaveDónde vale
Envío en lotebatch_sendingPOST /emails/batch
Programación de envíosscheduled_sendingEl campo scheduled_at de POST /emails.
Direcciones de prueba simuladastest_addressessandbox.postal.so
Tamaño de los adjuntosattachments_mbEl total de los attachments: 0 no permite adjuntos; un número limita el total en MB; sin número vale el límite de 40 MB de la plataforma.
Enlaces para compartir correosshared_email_linksPOST /emails/{id}/share
Envío por SMTPsmtp_accessSMTP

Las cuotas de envío son otro límite: cuando se agotan, la respuesta es 429 daily_quota_exceeded o 429 monthly_quota_exceeded (consulta Cuotas). El límite diario es el menor entre el del plan y el tope de la reputación de la cuenta, que empieza en 500 correos por día en las cuentas nuevas. Antes de rechazar con monthly_quota_exceeded, Postal usa la reserva del Adicional de envíos, si el equipo tiene una; ella no levanta el límite diario. Los correos a direcciones de prueba no usan la cuota.

Envíos pausados

Cuando las tasas de rebote, de queja o de contenido rechazado del equipo superan el límite (mira Pausa automática), Postal pausa los envíos para proteger la reputación de tus dominios. Mientras tanto, POST /emails, POST /emails/batch y los envíos a direcciones de prueba responden 403 sending_paused. Los webhooks sending.paused y sending.resumed avisan cuando eso empieza y termina (consulta Webhooks).

Elementos pausados por el plan

Si el plan cambia y el equipo pasa a tener más dominios, plantillas, claves de API, buzones de entrada, webhooks u otros elementos de los que permite el nuevo plan, Postal no borra nada: los más antiguos, hasta el límite, siguen activos, y los más nuevos quedan pausados. Un elemento pausado sigue en la lista y se puede leer, y su campo paused_by_plan es true. Modificarlo o usarlo responde 403 paused_by_plan. En los envíos, esto vale para un from de dominio pausado, para una template pausada y para la propia clave de API, cuando es la pausada: toda solicitud hecha con ella se rechaza. Los elementos vuelven a estar activos en cuanto el plan lo permite de nuevo, o cuando eliminas otros elementos.

403Recurso fuera del plan
{
  "statusCode": 403,
  "name": "plan_limit_reached",
  "message": "Your plan does not include “Scheduled sending”. Upgrade your plan to use it."
}
403Elemento pausado por el plan
{
  "statusCode": 403,
  "name": "paused_by_plan",
  "message": "This item is paused because it is above the limit of your current plan. Upgrade your plan or delete other items to use it again."
}

Los mensajes siguen el Accept-Language (sin él, llegan en inglés); el name no cambia.

#Con los SDK

Los SDK oficiales tienen métodos para enviar (emails.send y emails.batch), consultar y listar (emails.get, emails.list y emails.iterate), reprogramar y cancelar (emails.update y emails.cancel) y leer el mensaje original (emails.raw). Los campos tienen los nombres de la API.

Programar, consultar y cancelar

Node.js

import { Postal } from 'postal-so'

const postal = new Postal() // lê POSTAL_API_KEY

const { id } = await postal.emails.send({
  from: 'Loja Exemplo <[email protected]>',
  to: '[email protected]',
  subject: 'Seu pedido #10482 saiu para entrega',
  html: '<p>O pedido <b>#10482</b> está a caminho.</p>',
  scheduled_at: new Date(Date.now() + 3600_000),
})

const email = await postal.emails.get(id) // email.last_event e email.events
await postal.emails.update(id, { scheduled_at: '2026-10-26T09:00:00-03:00' })
await postal.emails.cancel(id)

PHP

use Postal\Client;

$postal = new Client(); // lê POSTAL_API_KEY

$result = $postal->emails->send([
    'from' => 'Loja Exemplo <[email protected]>',
    'to' => '[email protected]',
    'subject' => 'Seu pedido #10482 saiu para entrega',
    'html' => '<p>O pedido <b>#10482</b> está a caminho.</p>',
    'scheduled_at' => new DateTimeImmutable('+1 hour'),
]);

$email = $postal->emails->get($result['id']); // last_event e events
$postal->emails->update($result['id'], '2026-10-26T09:00:00-03:00');
$postal->emails->cancel($result['id']);