postmark-send-email
PublicRepository: activecampaign/postmark-skills
Low Risk
No security issues found
Skill manifest does not include a 'license' field. Specifying a license helps users understand usage terms.
Remediation Add 'license' field to SKILL.md frontmatter (e.g., MIT, Apache-2.0)
Description
Use when sending transactional or broadcast emails through Postmark — single sends, batch (up to 500), bulk, or template-based emails with support for attachments, tracking, and message streams.
Details
- author
- postmark
- version
- 1.0.0
Skill Files
# Send Email with Postmark
## Overview
Postmark provides multiple endpoints for sending emails:
| Approach | Endpoint | Use Case | Limits |
|----------|----------|----------|--------|
| **Single** | `POST /email` | Individual transactional emails | 1 email, 10 MB payload including attachments |
| **Batch** | `POST /email/batch` | Up to 500 emails in one request | 500 emails, 50 MB payload including attachments |
| **Template** | `POST /email/withTemplate` | Dynamic content with server-side templates | 1 email, 10 MB payload including attachments |
| **Batch Template** | `POST /email/batchWithTemplates` | Bulk templated emails | 500 emails, 50 MB payload including attachments |
| **Bulk** | `POST /email/bulk` | Broadcast stream campaigns | No fixed recipient cap, 50 MB payload including attachments |
**Choose batch when:** sending 2+ distinct emails at once, performance matters, or attachments are needed.
**Choose single when:** sending one email or real-time per-message error handling is needed.
## Message Streams (CRITICAL)
Postmark separates emails by intent. **Always specify MessageStream**:
| Stream | Value | Purpose | SMTP Endpoint |
|--------|-------|---------|---------------|
| **Transactional** | `outbound` | 1:1 triggered emails (default) | smtp.postmarkapp.com |
| **Broadcast** | `broadcast` | Marketing, newsletters | smtp-broadcasts.postmarkapp.com |
Never mix transactional and broadcast in the same stream — it damages deliverability. Servers can have up to 10 message streams.
## Quick Start
1. **Get API Token** from your [Postmark server settings](https://account.postmarkapp.com/servers)
2. **Verify sender** domain or email address
3. **Install SDK** — see [references/installation.md](references/installation.md)
4. **Choose endpoint** based on the decision matrix above
## Authentication
All API requests require the Server API Token:
```
X-Postmark-Server-Token: your-server-token-here
```
Store the token in `POSTMARK_SERVER_TOKEN`. For testing without sending, use `POSTMARK_API_TEST` as the token value.
## Single Email
**Endpoint:** `POST https://api.postmarkapp.com/email`
### Required Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `From` | string | Sender address (must be a verified domain or sender signature) |
| `To` | string | Recipients (comma-separated, max 50 total with Cc/Bcc) |
| `Subject` | string | Email subject line |
| `TextBody` or `HtmlBody` | string | Message content (at least one required) |
### Optional Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `Cc` | string | CC recipients |
| `Bcc` | string | BCC recipients |
| `ReplyTo` | string | Reply-to address |
| `MessageStream` | string | `outbound` (default) or `broadcast` |
| `Tag` | string | Category for statistics (one per message, max 1000 chars) |
| `Metadata` | object | Key-value pairs for custom tracking data (returned in webhook payloads) |
| `TrackOpens` | boolean | Enable open tracking |
| `TrackLinks` | string | `None`, `HtmlAndText`, `HtmlOnly`, `TextOnly` |
| `Headers` | array | Custom email headers `[{Name, Value}]` |
| `Attachments` | array | File attachments (max 10 MB total) |
```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);
const result = await client.sendEmail({
From: '[email protected]',
To: '[email protected]',
Subject: 'Your order has shipped',
TextBody: 'Your order #12345 is on its way!',
HtmlBody: '<p>Your order <strong>#12345</strong> is on its way!</p>',
MessageStream: 'outbound',
Tag: 'order-shipped'
});
console.log('MessageID:', result.MessageID);
```
Response includes `MessageID` — use it for tracking via webhooks or the Messages API.
See [references/single-email-examples.md](references/single-email-examples.md) for Python, Ruby, PHP, .NET, and cURL examples.
## Batch Email
**Endpoint:** `POST https://api.postmarkapp.com/email/batch`
Send up to **500 emails** in a single API call. Each message is independently validated and can have its own attachments, tags, and metadata.
```javascript
const results = await client.sendEmailBatch([
{
From: '[email protected]',
To: '[email protected]',
Subject: 'Order Shipped',
TextBody: 'Your order has shipped!',
MessageStream: 'outbound'
},
{
From: '[email protected]',
To: '[email protected]',
Subject: 'Order Confirmed',
TextBody: 'Your order is confirmed!',
MessageStream: 'outbound'
}
]);
// Check individual results — always handle partial failures
results.forEach((result, index) => {
if (result.ErrorCode === 0) {
console.log(`Email ${index + 1} sent: ${result.MessageID}`);
} else {
console.error(`Email ${index + 1} failed: ${result.Message}`);
}
});
```
For more than 500 emails, chunk the array into groups of 500 and send sequentially. See [references/batch-email-examples.md](references/batch-email-examples.md) for chunking patterns, attachments, and Python/Ruby/cURL examples.
## Send with Template
**Endpoint:** `POST https://api.postmarkapp.com/email/withTemplate`
Use server-side Handlebars templates — no client-side rendering needed. Always use `TemplateAlias` over `TemplateId` — aliases survive re-creation and work across environments.
```javascript
const result = await client.sendEmailWithTemplate({
From: '[email protected]',
To: '[email protected]',
TemplateAlias: 'order-confirmation',
TemplateModel: {
customer_name: 'Jane Doe',
order_number: 'ORD-67890',
items: [
{ name: 'Widget', price: '$19.99' },
{ name: 'Gadget', price: '$29.99' }
]
},
MessageStream: 'outbound'
});
```
For batch template sends (up to 500), use `POST /email/batchWithTemplates` via `client.sendEmailBatchWithTemplates([...])`. See [references/template-examples.md](references/template-examples.md) for full examples.
## Attachments
Include attachments as Base64-encoded content:
```json
{
"Attachments": [
{
"Name": "invoice.pdf",
"Content": "base64-encoded-content-here",
"ContentType": "application/pdf"
}
]
}
```
Embed inline images using `ContentID`:
```json
{
"HtmlBody": "<img src=\"cid:logo123\">",
"Attachments": [
{
"Name": "logo.png",
"Content": "base64-encoded-image",
"ContentType": "image/png",
"ContentID": "cid:logo123"
}
]
}
```
**Size limits:** TextBody/HtmlBody 5 MB each · Single message with attachments 10 MB · Batch payload 50 MB · Base64 encoding adds ~33% · Certain file types blocked (.exe, .bat)
## Tracking
Configure per-email or at server level:
```json
{ "TrackOpens": true, "TrackLinks": "HtmlAndText" }
```
**TrackLinks options:** `None` | `HtmlAndText` | `HtmlOnly` | `TextOnly`
Disable tracking for sensitive transactional emails (password resets, security alerts) to maximize deliverability.
## Testing
| Method | Address/Token | Result |
|--------|---------------|--------|
| **API Test Token** | `POSTMARK_API_TEST` | Validates request without sending |
| **Black Hole** | `[email protected]` | Dropped but appears in activity |
| **Sandbox Server** | Create sandbox server in dashboard | Full processing, no delivery |
| **Hard Bounce** | `[email protected]` | Simulates hard bounce |
| **Soft Bounce** | `[email protected]` | Simulates soft bounce |
**Never** test with fake addresses at real providers (e.g., [email protected]) — damages sender reputation.
## Error Handling
| Code | Meaning | Action |
|------|---------|--------|
| 200 | Success | Continue |
| 401 | Unauthorized | Check API token — do not retry |
| 406 | Inactive recipient | Check suppression list — do not retry |
| 409 | JSON required | Fix `Accept`/`Content-Type` headers |
| 410 | Too many batch messages | Reduce to 500 or fewer per batch |
| 413 | Payload too large | Reduce payload (10 MB single, 50 MB batch) |
| 422 | Validation error | Fix request parameters — do not retry |
| 429 | Rate limited | Retry with exponential backoff |
| 500 | Server error | Retry with exponential backoff |
```javascript
async function sendWithRetry(client, email, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await client.sendEmail(email);
} catch (error) {
const isRetryable = error.statusCode === 429 || error.statusCode === 500;
if (!isRetryable || attempt === maxRetries) throw error;
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
}
}
}
```
See [references/error-handling.md](references/error-handling.md) for complete error patterns including batch partial failures and typed error classes.
## SMTP
Postmark supports SMTP for legacy integrations — host `smtp.postmarkapp.com` (transactional) or `smtp-broadcasts.postmarkapp.com` (broadcast), ports 25/2525/587, Server API Token as username and password. See [references/smtp-migration.md](references/smtp-migration.md) for migration examples and custom header reference.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Missing MessageStream | Always specify `outbound` or `broadcast` |
| Using broadcast for transactional | Use separate streams for different email types |
| Testing with real addresses | Use `POSTMARK_API_TEST` or sandbox mode |
| Retrying 422 errors | These are validation errors — fix request, don't retry |
| Not handling partial batch failures | Check each result in batch response array |
| Tracking on sensitive transactional emails | Disable for password resets, security alerts, receipts |
| Exceeding 50 recipients per email | Split into multiple emails or use batch |
| Not verifying sender | Domain or address must be verified before sending |
## Notes
- `From` address must use a verified domain or sender signature
- Store API key in `POSTMARK_SERVER_TOKEN` environment variable
- Maximum 50 recipients total per email (To + Cc + Bcc)
- `MessageID` returned in response is used for bounce/webhook/API correlation
- For broadcast campaigns to large lists, use the Bulk API endpoint (`POST /email/bulk`)
- Use `POSTMARK_API_TEST` as the token value in development and CI — validates without sending
- New domains require gradual volume warm-up — see [`postmark-email-best-practices`](../postmark-email-best-practices/) for the schedule
# Batch Email Examples
Complete examples for sending batch emails via `POST /email/batch` (up to 500 per call).
## Node.js / TypeScript
### Basic Batch
```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);
const results = await client.sendEmailBatch([
{
From: '[email protected]',
To: '[email protected]',
Subject: 'Welcome!',
TextBody: 'Welcome to our service, User 1.',
MessageStream: 'outbound'
},
{
From: '[email protected]',
To: '[email protected]',
Subject: 'Welcome!',
TextBody: 'Welcome to our service, User 2.',
MessageStream: 'outbound'
}
]);
results.forEach((result, index) => {
if (result.ErrorCode === 0) {
console.log(`Email ${index + 1}: sent (${result.MessageID})`);
} else {
console.error(`Email ${index + 1}: failed (${result.Message})`);
}
});
```
### Batch with Attachments
```javascript
const fs = require('fs');
const invoiceContent = fs.readFileSync('./invoice-template.pdf').toString('base64');
const results = await client.sendEmailBatch([
{
From: '[email protected]',
To: '[email protected]',
Subject: 'Invoice #001',
TextBody: 'Please find your invoice attached.',
MessageStream: 'outbound',
Attachments: [
{
Name: 'invoice-001.pdf',
Content: invoiceContent,
ContentType: 'application/pdf'
}
]
},
{
From: '[email protected]',
To: '[email protected]',
Subject: 'Invoice #002',
TextBody: 'Please find your invoice attached.',
MessageStream: 'outbound',
Attachments: [
{
Name: 'invoice-002.pdf',
Content: invoiceContent,
ContentType: 'application/pdf'
}
]
}
]);
```
### Chunking Large Lists (500+ Emails)
```javascript
function chunkArray(array, size) {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
async function sendLargeBatch(client, emails) {
const chunks = chunkArray(emails, 500);
const allResults = [];
for (const chunk of chunks) {
const results = await client.sendEmailBatch(chunk);
allResults.push(...results);
// Log progress
const sent = results.filter(r => r.ErrorCode === 0).length;
const failed = results.filter(r => r.ErrorCode !== 0).length;
console.log(`Chunk: ${sent} sent, ${failed} failed`);
}
return allResults;
}
// Usage
const emails = users.map(user => ({
From: '[email protected]',
To: user.email,
Subject: `Hello ${user.name}`,
TextBody: `Hi ${user.name}, here is your weekly update.`,
MessageStream: 'outbound',
Tag: 'weekly-update',
Metadata: { user_id: user.id }
}));
const results = await sendLargeBatch(client, emails);
```
### Handling Partial Failures
```javascript
const results = await client.sendEmailBatch(emails);
const succeeded = [];
const failed = [];
results.forEach((result, index) => {
if (result.ErrorCode === 0) {
succeeded.push({ index, messageId: result.MessageID });
} else {
failed.push({
index,
errorCode: result.ErrorCode,
message: result.Message,
email: emails[index].To
});
}
});
console.log(`Sent: ${succeeded.length}, Failed: ${failed.length}`);
if (failed.length > 0) {
console.log('Failed emails:', failed);
// Retry only retryable failures
const retryable = failed.filter(f =>
f.errorCode === 429 || f.errorCode === 500
);
if (retryable.length > 0) {
const retryEmails = retryable.map(f => emails[f.index]);
// Retry after delay...
}
}
```
## Python
### Basic Batch
```python
from postmarker.core import PostmarkClient
import os
postmark = PostmarkClient(server_token=os.environ['POSTMARK_SERVER_TOKEN'])
results = postmark.emails.send_batch(
{
'From': '[email protected]',
'To': '[email protected]',
'Subject': 'Welcome!',
'TextBody': 'Welcome to our service, User 1.',
'MessageStream': 'outbound'
},
{
'From': '[email protected]',
'To': '[email protected]',
'Subject': 'Welcome!',
'TextBody': 'Welcome to our service, User 2.',
'MessageStream': 'outbound'
}
)
for i, result in enumerate(results):
if result.get('ErrorCode') == 0:
print(f"Email {i + 1}: sent ({result['MessageID']})")
else:
print(f"Email {i + 1}: failed ({result.get('Message', 'Unknown error')})")
```
### Chunking in Python
```python
def chunk_list(lst, size):
for i in range(0, len(lst), size):
yield lst[i:i + size]
emails = [
{
'From': '[email protected]',
'To': user['email'],
'Subject': f'Hello {user["name"]}',
'TextBody': f'Hi {user["name"]}, here is your update.',
'MessageStream': 'outbound'
}
for user in users
]
all_results = []
for chunk in chunk_list(emails, 500):
results = postmark.emails.send_batch(*chunk)
all_results.extend(results)
```
## Ruby
### Basic Batch
```ruby
require 'postmark'
client = Postmark::ApiClient.new(ENV['POSTMARK_SERVER_TOKEN'])
results = client.deliver_messages([
{
from: '[email protected]',
to: '[email protected]',
subject: 'Welcome!',
text_body: 'Welcome to our service, User 1.',
message_stream: 'outbound'
},
{
from: '[email protected]',
to: '[email protected]',
subject: 'Welcome!',
text_body: 'Welcome to our service, User 2.',
message_stream: 'outbound'
}
])
```
## cURL
### Batch Request
```bash
curl "https://api.postmarkapp.com/email/batch" \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" \
-d '[
{
"From": "[email protected]",
"To": "[email protected]",
"Subject": "Welcome!",
"TextBody": "Welcome to our service.",
"MessageStream": "outbound"
},
{
"From": "[email protected]",
"To": "[email protected]",
"Subject": "Welcome!",
"TextBody": "Welcome to our service.",
"MessageStream": "outbound"
}
]'
```
## Batch with Templates
**Endpoint:** `POST /email/batchWithTemplates`
```javascript
const results = await client.sendEmailBatchWithTemplates([
{
From: '[email protected]',
To: '[email protected]',
TemplateAlias: 'welcome-email',
TemplateModel: { name: 'User 1', action_url: 'https://app.yourdomain.com' },
MessageStream: 'outbound'
},
{
From: '[email protected]',
To: '[email protected]',
TemplateAlias: 'welcome-email',
TemplateModel: { name: 'User 2', action_url: 'https://app.yourdomain.com' },
MessageStream: 'outbound'
}
]);
```
## Key Notes
- Maximum **500 emails** per batch call
- Maximum **50 MB** total payload size
- Each email is independently validated — one failure does not affect others
- Attachments are supported in batch (unlike some alternatives)
- Always check each result's `ErrorCode` — partial failures are possible
- For more than 500 emails, chunk into groups of 500 and send sequentially
# Error Handling
Complete reference for handling Postmark API errors.
## HTTP Status Codes
| Code | Meaning | Retryable | Action |
|------|---------|-----------|--------|
| 200 | Success | — | Continue |
| 401 | Unauthorized | No | Check `X-Postmark-Server-Token` value |
| 406 | Inactive recipient | No | Recipient is suppressed — check suppression list |
| 409 | JSON required | No | Set `Content-Type: application/json` and `Accept: application/json` |
| 410 | Too many batch messages | No | Reduce batch to 500 or fewer messages |
| 413 | Payload too large | No | Reduce payload size (10 MB single, 50 MB batch) |
| 422 | Validation error | No | Fix request parameters (invalid From, missing required fields, etc.) |
| 429 | Rate limited | Yes | Retry with exponential backoff |
| 500 | Server error | Yes | Retry with exponential backoff |
## Postmark Error Codes
Beyond HTTP status, Postmark returns specific `ErrorCode` values in the response body:
| ErrorCode | Meaning |
|-----------|---------|
| 0 | No error (success) |
| 10 | Bad or missing API token |
| 300 | Invalid email request |
| 406 | Inactive recipient (suppressed due to bounce/complaint) |
| 409 | JSON body required |
| 410 | Too many batch messages (max 500) |
## Retry Strategy
Only retry **429** (rate limited) and **500** (server error). Never retry 401, 406, 422, or other client errors.
### Node.js
```javascript
async function sendWithRetry(client, email, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await client.sendEmail(email);
} catch (error) {
const statusCode = error.statusCode || error.code;
const isRetryable = statusCode === 429 || statusCode === 500;
if (!isRetryable || attempt === maxRetries) {
throw error;
}
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
console.log(`Attempt ${attempt + 1} failed (${statusCode}), retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Usage
try {
const result = await sendWithRetry(client, {
From: '[email protected]',
To: '[email protected]',
Subject: 'Hello',
TextBody: 'Hello!',
MessageStream: 'outbound'
});
console.log('Sent:', result.MessageID);
} catch (error) {
console.error('Failed after retries:', error.message);
}
```
### Python
```python
import time
def send_with_retry(postmark, email, max_retries=3):
for attempt in range(max_retries + 1):
try:
return postmark.emails.send(**email)
except Exception as e:
status_code = getattr(e, 'status_code', None)
is_retryable = status_code in (429, 500)
if not is_retryable or attempt == max_retries:
raise
delay = (2 ** attempt)
print(f"Attempt {attempt + 1} failed ({status_code}), retrying in {delay}s...")
time.sleep(delay)
# Usage
try:
result = send_with_retry(postmark, {
'From': '[email protected]',
'To': '[email protected]',
'Subject': 'Hello',
'TextBody': 'Hello!',
'MessageStream': 'outbound'
})
print('Sent:', result['MessageID'])
except Exception as e:
print('Failed after retries:', str(e))
```
## Handling Batch Partial Failures
Batch responses return individual results. Some emails may succeed while others fail:
```javascript
const results = await client.sendEmailBatch(emails);
const succeeded = [];
const permanentFailures = [];
const retryableFailures = [];
results.forEach((result, index) => {
if (result.ErrorCode === 0) {
succeeded.push({ index, messageId: result.MessageID });
} else if (result.ErrorCode === 429 || result.ErrorCode >= 500) {
retryableFailures.push({ index, error: result.Message });
} else {
permanentFailures.push({ index, error: result.Message, code: result.ErrorCode });
}
});
console.log(`Sent: ${succeeded.length}`);
console.log(`Permanent failures: ${permanentFailures.length}`);
console.log(`Retryable failures: ${retryableFailures.length}`);
// Retry only retryable failures
if (retryableFailures.length > 0) {
const retryEmails = retryableFailures.map(f => emails[f.index]);
await new Promise(r => setTimeout(r, 2000));
const retryResults = await client.sendEmailBatch(retryEmails);
// Process retry results...
}
```
## Handling Inactive Recipients (406)
When a recipient is suppressed (due to previous bounce or spam complaint):
```javascript
const postmark = require('postmark');
try {
await client.sendEmail(emailData);
} catch (error) {
if (error.statusCode === 406) {
// Recipient is inactive — check suppression list
console.log('Inactive recipient:', emailData.To);
// Optionally check the suppression list
const suppressions = await client.getSuppressions('outbound');
const suppressed = suppressions.Suppressions.find(
s => s.EmailAddress === emailData.To
);
if (suppressed) {
console.log('Suppression reason:', suppressed.SuppressionReason);
// SuppressionReason: "HardBounce" | "SpamComplaint" | "ManualSuppression"
}
}
}
```
## Error Object Structure (Node.js SDK)
The `postmark` SDK throws typed errors:
```javascript
const { Errors } = require('postmark');
try {
await client.sendEmail(emailData);
} catch (error) {
if (error instanceof Errors.InactiveRecipientsError) {
// 406 — Inactive recipient
console.log('Inactive recipients:', error.recipients);
} else if (error instanceof Errors.InvalidAPIKeyError) {
// 401 — Bad API token
console.log('Invalid API key');
} else if (error instanceof Errors.RateLimitExceededError) {
// 429 — Rate limited
console.log('Rate limited, retry after delay');
} else if (error instanceof Errors.ApiInputError) {
// 422 — Validation error
console.log('Invalid input:', error.message);
} else if (error instanceof Errors.InternalServerError) {
// 500 — Server error
console.log('Server error, retry');
}
}
```
## Key Guidelines
- **Never retry** 401, 406, 409, 410, 413, or 422 errors — fix the request
- **Always retry** 429 and 500 with exponential backoff (1s, 2s, 4s)
- **Max retries:** 3–5 for most use cases
- **Batch responses** can have mixed results — check each entry
- **406 (Inactive recipient)** means the address was suppressed — check suppression list for reason
- **Postmark handles rate limiting** automatically for optimal deliverability — 429 errors are rare
# SDK Installation
## Detect Project Language
| File | Language | SDK Package | Install Command |
|------|----------|-------------|-----------------|
| `package.json` | Node.js / TypeScript | `postmark` | `npm install postmark` |
| `requirements.txt` / `pyproject.toml` | Python | `postmarker` | `pip install postmarker` |
| `Gemfile` | Ruby | `postmark` | `gem install postmark` |
| `composer.json` | PHP | `wildbit/postmark-php` | `composer require wildbit/postmark-php` |
| `*.csproj` / `*.sln` | .NET | `Postmark` | `dotnet add package Postmark` |
## Node.js / TypeScript
```bash
npm install postmark
```
```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);
```
Or with ES modules:
```typescript
import { ServerClient } from 'postmark';
const client = new ServerClient(process.env.POSTMARK_SERVER_TOKEN);
```
**Source:** [github.com/ActiveCampaign/postmark.js](https://github.com/ActiveCampaign/postmark.js)
## Python
```bash
pip install postmarker
```
```python
import os
from postmarker.core import PostmarkClient
postmark = PostmarkClient(server_token=os.environ['POSTMARK_SERVER_TOKEN'])
```
**Source:** [github.com/Stranger6667/postmarker](https://github.com/Stranger6667/postmarker)
## Ruby
```bash
gem install postmark
```
```ruby
require 'postmark'
client = Postmark::ApiClient.new(ENV['POSTMARK_SERVER_TOKEN'])
```
**Source:** [github.com/ActiveCampaign/postmark-gem](https://github.com/ActiveCampaign/postmark-gem)
## PHP
```bash
composer require wildbit/postmark-php
```
```php
use Postmark\PostmarkClient;
$client = new PostmarkClient(getenv('POSTMARK_SERVER_TOKEN'));
```
**Source:** [github.com/ActiveCampaign/postmark-php](https://github.com/ActiveCampaign/postmark-php)
## .NET
```bash
dotnet add package Postmark
```
```csharp
using PostmarkDotNet;
var client = new PostmarkClient(Environment.GetEnvironmentVariable("POSTMARK_SERVER_TOKEN"));
```
**Source:** [github.com/ActiveCampaign/postmark-dotnet](https://github.com/ActiveCampaign/postmark-dotnet)
## Environment Variable
All SDKs should read the API token from the `POSTMARK_SERVER_TOKEN` environment variable:
```bash
export POSTMARK_SERVER_TOKEN=your-server-token-here
```
For testing, use the test token:
```bash
export POSTMARK_SERVER_TOKEN=POSTMARK_API_TEST
```
# Single Email Examples
Complete examples for sending a single email via `POST /email` in all supported SDKs.
## Node.js / TypeScript
### Basic Send
```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);
const result = await client.sendEmail({
From: '[email protected]',
To: '[email protected]',
Subject: 'Hello from Postmark',
TextBody: 'This is a plain text email.',
HtmlBody: '<p>This is an <strong>HTML</strong> email.</p>',
MessageStream: 'outbound'
});
console.log('Sent:', result.MessageID);
```
### With All Options
```javascript
const result = await client.sendEmail({
From: '[email protected]',
To: '[email protected]',
Cc: '[email protected]',
Bcc: '[email protected]',
ReplyTo: '[email protected]',
Subject: 'Order Confirmation',
TextBody: 'Your order #12345 is confirmed.',
HtmlBody: '<h1>Order Confirmed</h1><p>Your order #12345 is confirmed.</p>',
MessageStream: 'outbound',
Tag: 'order-confirmation',
Metadata: {
customer_id: '12345',
order_id: 'ORD-67890'
},
TrackOpens: true,
TrackLinks: 'HtmlAndText',
Headers: [
{ Name: 'X-Custom-Header', Value: 'custom-value' }
]
});
```
### With Attachments
```javascript
const fs = require('fs');
const result = await client.sendEmail({
From: '[email protected]',
To: '[email protected]',
Subject: 'Your Invoice',
TextBody: 'Please find your invoice attached.',
MessageStream: 'outbound',
Attachments: [
{
Name: 'invoice.pdf',
Content: fs.readFileSync('./invoice.pdf').toString('base64'),
ContentType: 'application/pdf'
}
]
});
```
### With Inline Image
```javascript
const result = await client.sendEmail({
From: '[email protected]',
To: '[email protected]',
Subject: 'Newsletter',
HtmlBody: '<h1>Our Newsletter</h1><img src="cid:logo">',
MessageStream: 'broadcast',
Attachments: [
{
Name: 'logo.png',
Content: fs.readFileSync('./logo.png').toString('base64'),
ContentType: 'image/png',
ContentID: 'cid:logo'
}
]
});
```
## Python
### Basic Send
```python
from postmarker.core import PostmarkClient
import os
postmark = PostmarkClient(server_token=os.environ['POSTMARK_SERVER_TOKEN'])
result = postmark.emails.send(
From='[email protected]',
To='[email protected]',
Subject='Hello from Postmark',
TextBody='This is a plain text email.',
HtmlBody='<p>This is an <strong>HTML</strong> email.</p>',
MessageStream='outbound'
)
print('Sent:', result['MessageID'])
```
### With All Options
```python
result = postmark.emails.send(
From='[email protected]',
To='[email protected]',
Cc='[email protected]',
Bcc='[email protected]',
ReplyTo='[email protected]',
Subject='Order Confirmation',
TextBody='Your order #12345 is confirmed.',
HtmlBody='<h1>Order Confirmed</h1><p>Your order #12345 is confirmed.</p>',
MessageStream='outbound',
Tag='order-confirmation',
Metadata={
'customer_id': '12345',
'order_id': 'ORD-67890'
},
TrackOpens=True,
TrackLinks='HtmlAndText',
Headers=[
{'Name': 'X-Custom-Header', 'Value': 'custom-value'}
]
)
```
### With Attachments
```python
import base64
with open('./invoice.pdf', 'rb') as f:
content = base64.b64encode(f.read()).decode('utf-8')
result = postmark.emails.send(
From='[email protected]',
To='[email protected]',
Subject='Your Invoice',
TextBody='Please find your invoice attached.',
MessageStream='outbound',
Attachments=[
{
'Name': 'invoice.pdf',
'Content': content,
'ContentType': 'application/pdf'
}
]
)
```
## Ruby
### Basic Send
```ruby
require 'postmark'
client = Postmark::ApiClient.new(ENV['POSTMARK_SERVER_TOKEN'])
result = client.deliver(
from: '[email protected]',
to: '[email protected]',
subject: 'Hello from Postmark',
text_body: 'This is a plain text email.',
html_body: '<p>This is an <strong>HTML</strong> email.</p>',
message_stream: 'outbound'
)
puts "Sent: #{result[:message_id]}"
```
## PHP
### Basic Send
```php
use Postmark\PostmarkClient;
$client = new PostmarkClient(getenv('POSTMARK_SERVER_TOKEN'));
$result = $client->sendEmail(
'[email protected]', // From
'[email protected]', // To
'Hello from Postmark', // Subject
'<p>This is an <strong>HTML</strong> email.</p>', // HtmlBody
'This is a plain text email.', // TextBody
null, // Tag
true, // TrackOpens
null, // ReplyTo
null, // Cc
null, // Bcc
null, // Headers
null, // Attachments
'outbound' // MessageStream
);
echo 'Sent: ' . $result->MessageID;
```
## .NET
### Basic Send
```csharp
using PostmarkDotNet;
var client = new PostmarkClient(Environment.GetEnvironmentVariable("POSTMARK_SERVER_TOKEN"));
var message = new PostmarkMessage
{
From = "[email protected]",
To = "[email protected]",
Subject = "Hello from Postmark",
TextBody = "This is a plain text email.",
HtmlBody = "<p>This is an <strong>HTML</strong> email.</p>",
MessageStream = "outbound"
};
var result = await client.SendMessageAsync(message);
Console.WriteLine($"Sent: {result.MessageID}");
```
## cURL
### Basic Send
```bash
curl "https://api.postmarkapp.com/email" \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" \
-d '{
"From": "[email protected]",
"To": "[email protected]",
"Subject": "Hello from Postmark",
"TextBody": "This is a plain text email.",
"HtmlBody": "<p>This is an <strong>HTML</strong> email.</p>",
"MessageStream": "outbound"
}'
```
### With Metadata and Tracking
```bash
curl "https://api.postmarkapp.com/email" \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" \
-d '{
"From": "[email protected]",
"To": "[email protected]",
"Subject": "Order Shipped",
"TextBody": "Your order has shipped.",
"HtmlBody": "<p>Your order has shipped.</p>",
"MessageStream": "outbound",
"Tag": "order-shipped",
"TrackOpens": true,
"TrackLinks": "HtmlAndText",
"Metadata": {
"customer_id": "12345",
"order_id": "ORD-67890"
}
}'
```
# SMTP Migration Guide
How to migrate from SMTP to the Postmark API, or use Postmark's SMTP interface.
## SMTP Settings
| Setting | Transactional | Broadcast |
|---------|--------------|-----------|
| **Host** | `smtp.postmarkapp.com` | `smtp-broadcasts.postmarkapp.com` |
| **Ports** | 25, 2525, or 587 | 25, 2525, or 587 |
| **TLS** | Required (STARTTLS) | Required (STARTTLS) |
| **Username** | Your Server API Token | Your Server API Token |
| **Password** | Your Server API Token | Your Server API Token |
## SMTP Authentication
Postmark supports two SMTP authentication methods:
### 1. API Token (Recommended)
Use your Server API Token as both username and password:
```
Username: your-server-api-token
Password: your-server-api-token
```
### 2. SMTP Tokens
Unique tokens per message stream, generated in the Postmark dashboard under Server → SMTP.
## Custom Headers via SMTP
Control Postmark features by adding custom headers to your SMTP messages:
| Header | Values | Description |
|--------|--------|-------------|
| `X-PM-Message-Stream` | `outbound`, `broadcast` | Message stream selection |
| `X-PM-Tag` | Any string (max 1000 chars) | Tag for filtering and stats |
| `X-PM-Track-Opens` | `true`, `false` | Enable/disable open tracking |
| `X-PM-Track-Links` | `None`, `HtmlAndText`, `HtmlOnly`, `TextOnly` | Link click tracking |
| `X-PM-Metadata-KEY` | Any string | Custom metadata (replace KEY with your key name) |
### Example Headers
```
X-PM-Message-Stream: outbound
X-PM-Tag: password-reset
X-PM-Track-Opens: true
X-PM-Track-Links: HtmlAndText
X-PM-Metadata-customer-id: 12345
X-PM-Metadata-order-id: ORD-67890
```
## Migration Examples
### Node.js (Nodemailer → Postmark API)
**Before (Nodemailer/SMTP):**
```javascript
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: 'smtp.old-provider.com',
port: 587,
auth: { user: 'username', pass: 'password' }
});
await transporter.sendMail({
from: '[email protected]',
to: '[email protected]',
subject: 'Hello',
text: 'Hello World',
html: '<p>Hello World</p>'
});
```
**After (Postmark API):**
```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);
await client.sendEmail({
From: '[email protected]',
To: '[email protected]',
Subject: 'Hello',
TextBody: 'Hello World',
HtmlBody: '<p>Hello World</p>',
MessageStream: 'outbound'
});
```
### Node.js (Nodemailer → Postmark SMTP)
If you prefer to keep using SMTP (e.g., existing infrastructure):
```javascript
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: 'smtp.postmarkapp.com',
port: 587,
secure: false,
auth: {
user: process.env.POSTMARK_SERVER_TOKEN,
pass: process.env.POSTMARK_SERVER_TOKEN
}
});
await transporter.sendMail({
from: '[email protected]',
to: '[email protected]',
subject: 'Hello',
text: 'Hello World',
html: '<p>Hello World</p>',
headers: {
'X-PM-Message-Stream': 'outbound',
'X-PM-Tag': 'migration-test'
}
});
```
### Python (smtplib → Postmark API)
**Before (smtplib/SMTP):**
```python
import smtplib
from email.mime.text import MIMEText
msg = MIMEText('Hello World')
msg['Subject'] = 'Hello'
msg['From'] = '[email protected]'
msg['To'] = '[email protected]'
with smtplib.SMTP('smtp.old-provider.com', 587) as server:
server.starttls()
server.login('username', 'password')
server.send_message(msg)
```
**After (Postmark API):**
```python
from postmarker.core import PostmarkClient
import os
postmark = PostmarkClient(server_token=os.environ['POSTMARK_SERVER_TOKEN'])
postmark.emails.send(
From='[email protected]',
To='[email protected]',
Subject='Hello',
TextBody='Hello World',
HtmlBody='<p>Hello World</p>',
MessageStream='outbound'
)
```
### Django (SMTP Backend → Postmark)
**Before (Django SMTP):**
```python
# settings.py
EMAIL_HOST = 'smtp.old-provider.com'
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = 'username'
EMAIL_HOST_PASSWORD = 'password'
```
**After (Postmark SMTP via Django):**
```python
# settings.py
EMAIL_HOST = 'smtp.postmarkapp.com'
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = os.environ['POSTMARK_SERVER_TOKEN']
EMAIL_HOST_PASSWORD = os.environ['POSTMARK_SERVER_TOKEN']
```
### Rails (ActionMailer → Postmark)
**Before (generic SMTP):**
```ruby
# config/environments/production.rb
config.action_mailer.smtp_settings = {
address: 'smtp.old-provider.com',
port: 587,
user_name: 'username',
password: 'password',
authentication: :plain,
enable_starttls_auto: true
}
```
**After (Postmark SMTP via ActionMailer):**
```ruby
# config/environments/production.rb
config.action_mailer.smtp_settings = {
address: 'smtp.postmarkapp.com',
port: 587,
user_name: ENV['POSTMARK_SERVER_TOKEN'],
password: ENV['POSTMARK_SERVER_TOKEN'],
authentication: :plain,
enable_starttls_auto: true
}
```
Or use the Postmark Rails gem for API-based sending:
```ruby
# Gemfile
gem 'postmark-rails'
# config/application.rb
config.action_mailer.delivery_method = :postmark
config.action_mailer.postmark_settings = {
api_token: ENV['POSTMARK_SERVER_TOKEN']
}
```
## API vs SMTP
| Feature | API | SMTP |
|---------|-----|------|
| **Performance** | Faster (direct HTTP) | Slower (SMTP handshake) |
| **Batch sending** | Up to 500 per call | One at a time |
| **Error handling** | Structured JSON errors | SMTP error codes |
| **Metadata** | Native support | Via custom headers |
| **Templates** | Native support | Not available |
| **Migration effort** | Requires code changes | Minimal (change host/credentials) |
**Recommendation:** Use the API for new integrations. Use SMTP when migrating existing infrastructure with minimal changes.
## Key Notes
- SMTP and API use the same Server API Token
- Always use TLS (STARTTLS on ports 25, 2525, or 587)
- Use `smtp.postmarkapp.com` for transactional, `smtp-broadcasts.postmarkapp.com` for broadcast
- Set `X-PM-Message-Stream` header via SMTP to control stream selection
- API provides better performance, structured errors, and batch support
- SMTP is ideal for legacy systems or frameworks with built-in SMTP support
# Template Email Examples
Examples for sending emails using Postmark server-side templates.
## Endpoints
| Endpoint | Description |
|----------|-------------|
| `POST /email/withTemplate` | Send single email with template |
| `POST /email/batchWithTemplates` | Send batch with templates (up to 500) |
## Node.js / TypeScript
### Send with Template ID
```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);
const result = await client.sendEmailWithTemplate({
From: '[email protected]',
To: '[email protected]',
TemplateId: 12345,
TemplateModel: {
name: 'Jane Doe',
order_id: 'ORD-67890',
items: [
{ name: 'Widget', quantity: 2, price: '$19.99' },
{ name: 'Gadget', quantity: 1, price: '$29.99' }
],
total: '$69.97'
},
MessageStream: 'outbound'
});
console.log('Sent:', result.MessageID);
```
### Send with Template Alias (Recommended)
```javascript
const result = await client.sendEmailWithTemplate({
From: '[email protected]',
To: '[email protected]',
TemplateAlias: 'order-confirmation',
TemplateModel: {
name: 'Jane Doe',
order_id: 'ORD-67890'
},
MessageStream: 'outbound',
Tag: 'order-confirmation',
Metadata: {
customer_id: '12345',
order_id: 'ORD-67890'
}
});
```
### Batch with Templates
```javascript
const results = await client.sendEmailBatchWithTemplates([
{
From: '[email protected]',
To: '[email protected]',
TemplateAlias: 'welcome-email',
TemplateModel: {
name: 'User 1',
action_url: 'https://app.yourdomain.com/start'
},
MessageStream: 'outbound'
},
{
From: '[email protected]',
To: '[email protected]',
TemplateAlias: 'welcome-email',
TemplateModel: {
name: 'User 2',
action_url: 'https://app.yourdomain.com/start'
},
MessageStream: 'outbound'
}
]);
results.forEach((result, index) => {
if (result.ErrorCode === 0) {
console.log(`Email ${index + 1}: sent (${result.MessageID})`);
} else {
console.error(`Email ${index + 1}: failed (${result.Message})`);
}
});
```
## Python
### Send with Template
```python
from postmarker.core import PostmarkClient
import os
postmark = PostmarkClient(server_token=os.environ['POSTMARK_SERVER_TOKEN'])
result = postmark.emails.send_with_template(
From='[email protected]',
To='[email protected]',
TemplateAlias='order-confirmation',
TemplateModel={
'name': 'Jane Doe',
'order_id': 'ORD-67890',
'items': [
{'name': 'Widget', 'quantity': 2, 'price': '$19.99'},
{'name': 'Gadget', 'quantity': 1, 'price': '$29.99'}
],
'total': '$69.97'
},
MessageStream='outbound'
)
print('Sent:', result['MessageID'])
```
## Ruby
### Send with Template
```ruby
require 'postmark'
client = Postmark::ApiClient.new(ENV['POSTMARK_SERVER_TOKEN'])
result = client.deliver_with_template(
from: '[email protected]',
to: '[email protected]',
template_alias: 'order-confirmation',
template_model: {
name: 'Jane Doe',
order_id: 'ORD-67890'
},
message_stream: 'outbound'
)
puts "Sent: #{result[:message_id]}"
```
## cURL
### Send with Template
```bash
curl "https://api.postmarkapp.com/email/withTemplate" \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" \
-d '{
"From": "[email protected]",
"To": "[email protected]",
"TemplateAlias": "order-confirmation",
"TemplateModel": {
"name": "Jane Doe",
"order_id": "ORD-67890",
"items": [
{ "name": "Widget", "quantity": 2, "price": "$19.99" }
],
"total": "$19.99"
},
"MessageStream": "outbound"
}'
```
### Batch with Templates
```bash
curl "https://api.postmarkapp.com/email/batchWithTemplates" \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" \
-d '{
"Messages": [
{
"From": "[email protected]",
"To": "[email protected]",
"TemplateAlias": "welcome-email",
"TemplateModel": { "name": "User 1" },
"MessageStream": "outbound"
},
{
"From": "[email protected]",
"To": "[email protected]",
"TemplateAlias": "welcome-email",
"TemplateModel": { "name": "User 2" },
"MessageStream": "outbound"
}
]
}'
```
## Handlebars Syntax Quick Reference
### Variables
```handlebars
{{name}} <!-- Escaped output -->
{{{html_content}}} <!-- Unescaped HTML -->
```
### Conditionals
```handlebars
{{#if premium_member}}
<p>Premium features included.</p>
{{else}}
<p>Upgrade for more features.</p>
{{/if}}
```
### Iteration
```handlebars
{{#each items}}
<tr>
<td>{{this.name}}</td>
<td>{{this.price}}</td>
</tr>
{{/each}}
```
### Nested Objects
```handlebars
{{customer.name}}
{{customer.address.city}}
```
## Key Notes
- Use `TemplateAlias` (string) over `TemplateId` (integer) for portability across environments
- Provide either `TemplateId` or `TemplateAlias`, not both
- `TemplateModel` keys must match the Handlebars variables in your template
- Missing variables render as empty strings — validate your model data
- Templates support the same optional parameters as regular sends (Tag, Metadata, Tracking, etc.)
- Batch templates support up to 500 messages per call