Sign in Create account
Guides

Postal SDKs for Node.js, PHP and Laravel

Official libraries to send emails and events, manage contacts and verify webhooks, with typed errors, idempotency and retries.

#Overview

The official libraries handle what you would otherwise write by hand: authentication, JSON, typed errors, idempotency keys, retries with exponential backoff and webhook signature verification. Fields have the same names as in the API, so the reference applies directly to your code.

LanguagePackageRequirementsInstall
Node.js and TypeScriptpostal-soNode 18+, ESM, typed, no dependenciesnpm install postal-so
PHP and Laravelpostal-so/postal-phpPHP 8.2+, ext-curl, no required dependenciescomposer require postal-so/postal-php

The source is at postal-node and postal-php. The Node package also includes the postal CLI and a skill for AI agents. Prefer another language? The API is plain HTTP with JSON: see the cURL, Python and PHP examples for each endpoint.

Already using the Resend SDK?

The Resend SDKs work too: point the base URL at https://api.postal.so and use your Postal key. See Migrate from Resend. In Laravel, this package's postal transport (see below) handles Mail::to() without any other package.

#API key

Create a key under API keys (it starts with ps_) and store it in POSTAL_API_KEY. Both SDKs read this variable when you do not pass a key. For systems that only send emails and events, use a sending-only key. See Authentication.

.env
POSTAL_API_KEY=ps_your_key

Server-side only

Never put the key in a browser, an app or a repository: anyone could copy it.

#Send an email

The sender's domain must be verified. The response includes the email id; follow the status with emails.get or with webhooks.

Send an email

Node.js

import { Postal } from 'postal-so';

const postal = new Postal(); // reads POSTAL_API_KEY

const { id } = await postal.emails.send(
  {
    from: 'Acme Store <[email protected]>',
    to: '[email protected]',
    subject: 'Welcome!',
    html: '<p>We are glad to have you here.</p>',
    text: 'We are glad to have you here.',
  },
  { idempotencyKey: 'welcome-42' },
);

PHP

use Postal\Client;

$postal = new Client(); // reads POSTAL_API_KEY

$result = $postal->emails->send([
    'from' => 'Acme Store <[email protected]>',
    'to' => '[email protected]',
    'subject' => 'Welcome!',
    'html' => '<p>We are glad to have you here.</p>',
    'text' => 'We are glad to have you here.',
], 'welcome-42'); // idempotency key

echo $result['id'];

With a published template, send template: { id, variables } instead of html and text. emails.batch (up to 100 emails per request), emails.update and emails.cancel (reschedule and cancel), emails.get and emails.list work too. See Emails.

For verification-code emails, otpTokenHeader(code, origin) (Node.js) and Postal\OtpToken::header($code, $origin) (PHP) build the value of the OTP-Token header with the right escaping and check the origin before calling the API. See the OTP-Token header guide.

#Contacts and events

external_id is the person's ID in your system. contacts.upsert creates or updates the contact, looking it up by id, then external_id, then email; repeating the call is safe. Events start the automations with that trigger and work with a sending-only key.

Contacts and events

Node.js

// Create or update the contact by its ID in your system
await postal.contacts.upsert({
  external_id: 'user_42',
  email: '[email protected]', // required to create
  first_name: 'Jane',
  properties: { plan: 'free' },
});

// Tell Postal what happened: starts the automations with the "signup" trigger
await postal.events.send(
  {
    name: 'signup',
    contact: 'external_id:user_42',
    properties: { plan: 'free', source: 'site' },
  },
  { idempotencyKey: 'signup-42' },
);

PHP

// Create or update the contact by its ID in your system
$postal->contacts->upsert([
    'external_id' => 'user_42',
    'email' => '[email protected]', // required to create
    'first_name' => 'Jane',
    'properties' => ['plan' => 'free'],
]);

// Tell Postal what happened: starts the automations with the "signup" trigger
$postal->events->send([
    'name' => 'signup',
    'contact' => 'external_id:user_42',
    'properties' => ['plan' => 'free', 'source' => 'site'],
], 'signup-42');

More methods: contacts.get, getByExternalId, update, delete, list and iterate (walks through every page), segments, topics and domains (list, get, verify). See Contacts, Events and Domains.

#Verify webhooks

verifyWebhook (Node.js) and Webhook::verify (PHP) check the Standard Webhooks signature of the delivery, reject messages whose timestamp is more than 5 minutes off, and return the event. Pass the raw body, exactly as it arrived, and the signing_secret (whsec_…). If the signature does not match, they throw an exception: respond with 400. The details of the scheme are in Webhooks.

Verify a delivery

Node.js

import express from 'express';
import { verifyWebhook, PostalWebhookVerificationError } from 'postal-so';

const app = express();

// The body must arrive raw (a Buffer) for the signature to match.
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') {
      // drop the address
    }

    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'), // raw body
        getallheaders(),
        getenv('POSTAL_WEBHOOK_SECRET'),
    );
} catch (WebhookVerificationException $e) {
    http_response_code(400);
    exit;
}

if ($event['type'] === 'email.bounced') {
    // drop the address
}

http_response_code(200);

#Errors and retries

Every error response becomes an exception with the statusCode, the name (the stable code from the error list, such as validation_error) and the Postal message. Handle errors by name, not by message. Network failures and timeouts become errors with statusCode 0 and name network_error or timeout.

Handle errors

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') {
      // the daily quota is used up; it resets at 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') {
        // the daily quota is used up; it resets at 00:00 UTC
    }
}

Retries and idempotency

  • By default, the SDKs retry up to 2 times, waiting 500 ms and doubling the wait on each attempt (30 s at most). Change it with maxRetries (Node.js) or max_retries (PHP); 0 turns retries off.
  • 429 rate_limit_exceeded is always retried, after waiting for Retry-After. An exhausted quota (daily_quota_exceeded, monthly_quota_exceeded) is not retried.
  • 5xx errors and network failures are retried for reads, deletions, contacts.upsert and sends that have an idempotency key. A POST without a key, such as contacts.create, is not retried, so its effect is never duplicated.
  • In emails.send, emails.batch and events.send, the SDK generates an idempotency key for the call and reuses it on retries. To also protect your own repetition (a job that runs again), pass a key that identifies the reason for the send, such as order-10482-shipped.

#Laravel

The PHP package registers itself in Laravel (auto-discovery). It creates the Postal\Client in the container, reading POSTAL_API_KEY, and a mail transport called postal: with it, Mail::to(…)->send(…), notifications and Mailables go out through the Postal API, with no change to your application code.

Set the mailer in .env and declare the transport in config/mail.php:

.env
MAIL_MAILER=postal
[email protected]
MAIL_FROM_NAME="Acme Store"
POSTAL_API_KEY=ps_your_key
config/mail.php
// config/mail.php
'mailers' => [
    'postal' => [
        'transport' => 'postal',
        // 'key' => env('POSTAL_API_KEY'), // optional: a key just for this mailer
    ],
],

The domain of MAIL_FROM_ADDRESS must be verified. Attachments, inline images, Cc, Bcc, Reply-To, extra headers and tags all go along (the tag() and metadata() of a Mailable become tags on the email). The Postal email ID is in $sentMessage->getMessageId().

For events and contacts, inject the client:

app/Listeners/SendSignupEvent.php
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,
        );
    }
}

If you prefer plain SMTP, you can use Postal SMTP in Laravel; the postal transport uses the API and returns the email ID.

#AI agents

The Node.js package ships a skill (skills/postal/SKILL.md) that teaches Claude Code, Codex, Cursor and other agents how to integrate Postal into your app: install the SDK, store the key, send emails, send sign-up and payment events, sync contacts and verify webhooks. Copy the skills/postal folder into your tool's skills folder (for example, .claude/skills/) or ask the agent to read the file.

There is also an /llms.txt, with a summary of the documentation and the links to its pages, for agents that read the documentation directly. And for an AI assistant to use your account in conversation (send, check, organize contacts), there is the MCP server.