SDKs: Node.js, PHP y Laravel
Bibliotecas oficiales para enviar correos electrónicos y eventos, mantener contactos y comprobar webhooks, con errores tipados, idempotencia y reintentos.
#Visión general
Las bibliotecas oficiales se encargan de lo que escribirías a mano: autenticación, JSON, errores tipados, clave de idempotencia, reintentos con espera creciente y la comprobación de la firma de los webhooks. Los campos tienen los mismos nombres que la API, así que la referencia vale directamente para el código.
| Lenguaje | Paquete | Requisitos | Instalación |
|---|---|---|---|
| Node.js y TypeScript | postal-so | Node 18+, ESM, con tipos, sin dependencias | npm install postal-so |
| PHP y Laravel | postal-so/postal-php | PHP 8.2+, ext-curl, sin dependencias obligatorias | composer require postal-so/postal-php |
El código está en postal-node y postal-php. El paquete de Node incluye también la CLI postal y una skill para agentes de IA. ¿Quieres usar otro lenguaje? La API es HTTP con JSON: consulta los ejemplos en cURL, Python y PHP de cada endpoint.
¿Ya usas el SDK de Resend?
Los SDKs de Resend también funcionan: apunta la URL base a https://api.postal.so y usa tu clave de Postal. Consulta Migrar desde Resend. En Laravel, el transporte postal de este paquete (ver abajo) se encarga de Mail::to() sin otro paquete.
#Clave de API
Crea una clave en Claves de API (empieza por ps_) y guárdala en POSTAL_API_KEY. Los dos SDKs leen esa variable cuando no pasas la clave. Para sistemas que solo disparan correos electrónicos y eventos, usa una clave solo de envío. Consulta Autenticación.
POSTAL_API_KEY=ps_sua_chave
Solo en el servidor
Nunca pongas la clave en el navegador, en apps ni en el repositorio: cualquier persona podría copiarla.
#Enviar un correo electrónico
El dominio del remitente debe estar verificado. La respuesta trae el id del correo; sigue el estado con emails.get o con webhooks.
Node.js
import { Postal } from 'postal-so';
const postal = new Postal(); // lê POSTAL_API_KEY
const { id } = await postal.emails.send(
{
from: 'Minha empresa <[email protected]>',
to: '[email protected]',
subject: 'Bem-vindo!',
html: '<p>Que bom ter você por aqui.</p>',
text: 'Que bom ter você por aqui.',
},
{ idempotencyKey: 'welcome-42' },
);
PHP
use Postal\Client;
$postal = new Client(); // lê POSTAL_API_KEY
$result = $postal->emails->send([
'from' => 'Minha empresa <[email protected]>',
'to' => '[email protected]',
'subject' => 'Bem-vindo!',
'html' => '<p>Que bom ter você por aqui.</p>',
'text' => 'Que bom ter você por aqui.',
], 'welcome-42'); // chave de idempotência
echo $result['id'];
Con una plantilla publicada, envía template: { id, variables } en lugar de html y text. También funcionan emails.batch (hasta 100 correos en una solicitud), emails.update y emails.cancel (reprogramar y cancelar), emails.get y emails.list. Consulta Correos.
Para correos con código de verificación, otpTokenHeader(code, origin) (Node.js) y Postal\OtpToken::header($code, $origin) (PHP) montan el valor del encabezado OTP-Token con el escape correcto y comprueban el origen antes de llamar a la API. Consulta Encabezado OTP-Token.
#Contactos y eventos
external_id es el ID de la persona en tu sistema. contacts.upsert crea o actualiza el contacto buscando por id, luego external_id, luego email; repetir la llamada es seguro. Los eventos inician las automatizaciones con ese disparador y funcionan con clave solo de envío.
Node.js
// Cria ou atualiza o contato pelo ID dele no seu sistema
await postal.contacts.upsert({
external_id: 'user_42',
email: '[email protected]', // obrigatório para criar
first_name: 'Fulano',
properties: { plan: 'free' },
});
// Conta ao Postal o que aconteceu: inicia as automações do gatilho "signup"
await postal.events.send(
{
name: 'signup',
contact: 'external_id:user_42',
properties: { plan: 'free', source: 'site' },
},
{ idempotencyKey: 'signup-42' },
);
PHP
// Cria ou atualiza o contato pelo ID dele no seu sistema
$postal->contacts->upsert([
'external_id' => 'user_42',
'email' => '[email protected]', // obrigatório para criar
'first_name' => 'Fulano',
'properties' => ['plan' => 'free'],
]);
// Conta ao Postal o que aconteceu: inicia as automações do gatilho "signup"
$postal->events->send([
'name' => 'signup',
'contact' => 'external_id:user_42',
'properties' => ['plan' => 'free', 'source' => 'site'],
], 'signup-42');
Más métodos: contacts.get, getByExternalId, update, delete, list e iterate (recorre todas las páginas), segments, topics y domains (list, get, verify). Consulta Contactos, Eventos y Dominios.
#Verificar webhooks
verifyWebhook (Node.js) y Webhook::verify (PHP) comprueban la firma Standard Webhooks de la entrega, rechazan mensajes con más de 5 minutos de diferencia y devuelven el evento. Pasa el cuerpo crudo, exactamente como llegó, y el signing_secret (whsec_…). Si la firma no coincide, lanzan una excepción: responde 400. Los detalles del esquema están en Webhooks.
Node.js
import express from 'express';
import { verifyWebhook, PostalWebhookVerificationError } from 'postal-so';
const app = express();
// O corpo precisa chegar cru (Buffer) para a assinatura bater.
app.post('/webhooks/postal', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = verifyWebhook(req.body, req.headers, process.env.POSTAL_WEBHOOK_SECRET);
if (event.type === 'email.bounced') {
// descarte o endereço
}
res.sendStatus(200);
} catch (error) {
if (error instanceof PostalWebhookVerificationError) {
return res.sendStatus(400);
}
throw error;
}
});
PHP
use Postal\Exceptions\WebhookVerificationException;
use Postal\Webhook;
try {
$event = Webhook::verify(
file_get_contents('php://input'), // corpo cru
getallheaders(),
getenv('POSTAL_WEBHOOK_SECRET'),
);
} catch (WebhookVerificationException $e) {
http_response_code(400);
exit;
}
if ($event['type'] === 'email.bounced') {
// descarte o endereço
}
http_response_code(200);
#Errores y reintentos
Toda respuesta de error se convierte en una excepción con el statusCode, el name (el código estable de la lista de errores, como validation_error) y el message de Postal. Trata el error por el name, no por el mensaje. Los fallos de red y el tiempo agotado se convierten en errores con statusCode 0 y name network_error o timeout.
Node.js
import { PostalError } from 'postal-so';
try {
await postal.emails.send(params);
} catch (error) {
if (error instanceof PostalError) {
console.error(error.statusCode, error.name, error.message);
// 422 validation_error Missing `subject` field.
if (error.name === 'daily_quota_exceeded') {
// a cota diária acabou; volta às 00:00 UTC
}
}
throw error;
}
PHP
use Postal\Exceptions\PostalException;
try {
$postal->emails->send($params);
} catch (PostalException $e) {
$e->getStatusCode(); // 422
$e->getErrorName(); // 'validation_error'
$e->getMessage(); // 'Missing `subject` field.'
if ($e->getErrorName() === 'daily_quota_exceeded') {
// a cota diária acabou; volta às 00:00 UTC
}
}
Reintentos e idempotencia
- Por defecto, los SDKs reintentan hasta 2 veces, con una espera de 500 ms que se duplica en cada intento (máximo 30 s). Cámbialo con
maxRetries(Node.js) omax_retries(PHP);0lo desactiva. 429 rate_limit_exceededse reintenta siempre, esperando elRetry-After. La cuota agotada (daily_quota_exceeded,monthly_quota_exceeded) no se reintenta.- Los 5xx y los fallos de red se reintentan en lecturas, en eliminaciones, en
contacts.upserty en envíos con clave de idempotencia. UnPOSTsin clave, comocontacts.create, no se reintenta, para no duplicar el efecto. - En
emails.send,emails.batchyevents.send, el SDK genera una clave de idempotencia para la llamada y la reutiliza en los reintentos. Para proteger también tu repetición (un job que se ejecuta de nuevo), pasa una clave que identifique el motivo del envío, comoorder-10482-shipped.
#Laravel
El paquete de PHP se registra solo en Laravel (auto-discovery). Crea el Postal\Client en el contenedor, leyendo POSTAL_API_KEY, y un transporte de correo llamado postal: con él, Mail::to(…)->send(…), las notificaciones y los Mailables salen por la API de Postal, sin cambiar el código de la aplicación.
Define el mailer en el .env y declara el transporte en config/mail.php:
MAIL_MAILER=postal
[email protected]
MAIL_FROM_NAME="Minha empresa"
POSTAL_API_KEY=ps_sua_chave
// config/mail.php
'mailers' => [
'postal' => [
'transport' => 'postal',
// 'key' => env('POSTAL_API_KEY'), // opcional: uma chave só deste mailer
],
],
El dominio de MAIL_FROM_ADDRESS debe estar verificado. Se envían también los adjuntos, las imágenes incrustadas, Cc, Bcc, Reply-To, los encabezados adicionales y las etiquetas (tag() y metadata() del Mailable se convierten en etiquetas del envío). El ID del correo en Postal queda en $sentMessage->getMessageId().
Para eventos y contactos, inyecta el cliente:
use Postal\Client;
class SendSignupEvent
{
public function __construct(private Client $postal) {}
public function handle(Registered $event): void
{
$user = $event->user;
$this->postal->contacts->upsert([
'external_id' => (string) $user->id,
'email' => $user->email,
'first_name' => $user->name,
]);
$this->postal->events->send(
['name' => 'signup', 'contact' => 'external_id:'.$user->id],
'signup-'.$user->id,
);
}
}
Quien prefiera SMTP puro puede usar el SMTP de Postal en Laravel; el transporte postal usa la API y devuelve el ID del correo.
#Agentes de IA
El paquete de Node.js incluye una skill (skills/postal/SKILL.md) que enseña a Claude Code, Codex, Cursor y otros agentes a integrar Postal en tu app: instalar el SDK, guardar la clave, enviar correos electrónicos, mandar eventos de registro y pago, sincronizar contactos y verificar webhooks. Copia la carpeta skills/postal a la carpeta de skills de tu herramienta (por ejemplo, .claude/skills/) o pide al agente que lea el archivo.
Hay también un /llms.txt, con el resumen de la documentación y los enlaces de las páginas, para agentes que leen la documentación directamente. Y, para que el asistente de IA use tu cuenta por conversación (enviar, consultar, organizar contactos), existe el servidor MCP.