Sending Email
Send transactional emails via the Postkit REST API
The Postkit API lets you send transactional emails with full control over content, recipients, scheduling, and tracking. This guide covers every sending feature available through the REST API.
You need an API key (pk_live_ for production, pk_test_ for sandbox). See the quickstart to get your key.
Send a single email
Send a transactional email with a POST request to /v1/emails. At minimum, you need a sender address (from), at least one recipient (to), a subject, and either HTML content or a template.
curl -X POST https://api.postkit.eu/v1/emails \
-H "Authorization: Bearer pk_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <noreply@acme.eu>",
"to": ["user@example.com"],
"subject": "Welcome to Acme",
"html": "<h1>Welcome!</h1><p>Thanks for signing up.</p>"
}'const response = await fetch('https://api.postkit.eu/v1/emails', {
method: 'POST',
headers: {
'Authorization': 'Bearer pk_live_abc123...',
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Acme <noreply@acme.eu>',
to: ['user@example.com'],
subject: 'Welcome to Acme',
html: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
}),
});
const email = await response.json();
console.log(email.id); // em_a1b2c3d4e5import requests
response = requests.post(
'https://api.postkit.eu/v1/emails',
headers={'Authorization': 'Bearer pk_live_abc123...'},
json={
'from': 'Acme <noreply@acme.eu>',
'to': ['user@example.com'],
'subject': 'Welcome to Acme',
'html': '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
},
)
email = response.json()
print(email['id']) # em_a1b2c3d4e5A successful response returns the email ID and status:
{
"id": "em_a1b2c3d4e5",
"status": "queued"
}id-- unique identifier for this email, prefixed withem_status: "queued"-- the email was accepted and will be delivered shortly
HTML and plain text
Use the html field for HTML content and the text field for a plain text fallback. If you omit text, Postkit auto-generates a plain text version from your HTML.
For maximum compatibility across email clients, include both:
curl -X POST https://api.postkit.eu/v1/emails \
-H "Authorization: Bearer pk_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <noreply@acme.eu>",
"to": ["user@example.com"],
"subject": "Your weekly digest",
"html": "<h1>Weekly Digest</h1><p>Here is what happened this week...</p>",
"text": "Weekly Digest\n\nHere is what happened this week..."
}'const response = await fetch('https://api.postkit.eu/v1/emails', {
method: 'POST',
headers: {
'Authorization': 'Bearer pk_live_abc123...',
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Acme <noreply@acme.eu>',
to: ['user@example.com'],
subject: 'Your weekly digest',
html: '<h1>Weekly Digest</h1><p>Here is what happened this week...</p>',
text: 'Weekly Digest\n\nHere is what happened this week...',
}),
});
const email = await response.json();response = requests.post(
'https://api.postkit.eu/v1/emails',
headers={'Authorization': 'Bearer pk_live_abc123...'},
json={
'from': 'Acme <noreply@acme.eu>',
'to': ['user@example.com'],
'subject': 'Your weekly digest',
'html': '<h1>Weekly Digest</h1><p>Here is what happened this week...</p>',
'text': 'Weekly Digest\n\nHere is what happened this week...',
},
)Reply-to, CC, and BCC
Use reply_to to set one or more reply-to addresses, and cc / bcc to add carbon copy and blind carbon copy recipients.
curl -X POST https://api.postkit.eu/v1/emails \
-H "Authorization: Bearer pk_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"from": "Acme Support <support@acme.eu>",
"to": ["user@example.com"],
"cc": ["manager@example.com"],
"bcc": ["archive@acme.eu"],
"reply_to": ["help@acme.eu"],
"subject": "Your support ticket #1234",
"html": "<p>We have received your request and will respond within 24 hours.</p>"
}'const response = await fetch('https://api.postkit.eu/v1/emails', {
method: 'POST',
headers: {
'Authorization': 'Bearer pk_live_abc123...',
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Acme Support <support@acme.eu>',
to: ['user@example.com'],
cc: ['manager@example.com'],
bcc: ['archive@acme.eu'],
reply_to: ['help@acme.eu'],
subject: 'Your support ticket #1234',
html: '<p>We have received your request and will respond within 24 hours.</p>',
}),
});
const email = await response.json();response = requests.post(
'https://api.postkit.eu/v1/emails',
headers={'Authorization': 'Bearer pk_live_abc123...'},
json={
'from': 'Acme Support <support@acme.eu>',
'to': ['user@example.com'],
'cc': ['manager@example.com'],
'bcc': ['archive@acme.eu'],
'reply_to': ['help@acme.eu'],
'subject': 'Your support ticket #1234',
'html': '<p>We have received your request and will respond within 24 hours.</p>',
},
)The combined total of to, cc, and bcc recipients must not exceed 50 per email.
Attachments
Include files with the attachments array. Each attachment requires a filename and base64-encoded content. You can send up to 10 attachments per email.
curl -X POST https://api.postkit.eu/v1/emails \
-H "Authorization: Bearer pk_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"from": "Acme Billing <billing@acme.eu>",
"to": ["user@example.com"],
"subject": "Your invoice for March 2026",
"html": "<p>Please find your invoice attached.</p>",
"attachments": [
{
"filename": "invoice-2026-03.pdf",
"content": "JVBERi0xLjQK...",
"content_type": "application/pdf"
}
]
}'import { readFileSync } from 'node:fs';
const pdfContent = readFileSync('invoice-2026-03.pdf').toString('base64');
const response = await fetch('https://api.postkit.eu/v1/emails', {
method: 'POST',
headers: {
'Authorization': 'Bearer pk_live_abc123...',
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Acme Billing <billing@acme.eu>',
to: ['user@example.com'],
subject: 'Your invoice for March 2026',
html: '<p>Please find your invoice attached.</p>',
attachments: [
{
filename: 'invoice-2026-03.pdf',
content: pdfContent,
content_type: 'application/pdf',
},
],
}),
});
const email = await response.json();import base64
with open('invoice-2026-03.pdf', 'rb') as f:
pdf_content = base64.b64encode(f.read()).decode()
response = requests.post(
'https://api.postkit.eu/v1/emails',
headers={'Authorization': 'Bearer pk_live_abc123...'},
json={
'from': 'Acme Billing <billing@acme.eu>',
'to': ['user@example.com'],
'subject': 'Your invoice for March 2026',
'html': '<p>Please find your invoice attached.</p>',
'attachments': [
{
'filename': 'invoice-2026-03.pdf',
'content': pdf_content,
'content_type': 'application/pdf',
},
],
},
)Each attachment has these fields:
| Field | Type | Required | Description |
|---|---|---|---|
filename | string | Yes | The file name shown to the recipient |
content | string | Yes | Base64-encoded file content |
content_type | string | No | MIME type (defaults to application/octet-stream) |
Send with a template
Instead of providing inline HTML, you can send using a published template. Pass the template_id and a template_data object with your Handlebars variables. The template's subject becomes the default, but you can override it.
curl -X POST https://api.postkit.eu/v1/emails \
-H "Authorization: Bearer pk_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <noreply@acme.eu>",
"to": ["user@example.com"],
"template_id": "tmpl_abc123",
"template_data": {
"customer_name": "Max Mustermann",
"order_id": "ORD-4821",
"total": "49.99 EUR"
}
}'const response = await fetch('https://api.postkit.eu/v1/emails', {
method: 'POST',
headers: {
'Authorization': 'Bearer pk_live_abc123...',
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Acme <noreply@acme.eu>',
to: ['user@example.com'],
template_id: 'tmpl_abc123',
template_data: {
customer_name: 'Max Mustermann',
order_id: 'ORD-4821',
total: '49.99 EUR',
},
}),
});
const email = await response.json();response = requests.post(
'https://api.postkit.eu/v1/emails',
headers={'Authorization': 'Bearer pk_live_abc123...'},
json={
'from': 'Acme <noreply@acme.eu>',
'to': ['user@example.com'],
'template_id': 'tmpl_abc123',
'template_data': {
'customer_name': 'Max Mustermann',
'order_id': 'ORD-4821',
'total': '49.99 EUR',
},
},
)template_id is mutually exclusive with html and text. You can provide either inline content or a template, but not both. See the Templates guide for creating and publishing templates.
Batch sending
Send up to 100 emails in a single API call with POST /v1/emails/batch. The request body is an array where each item is a full email request. Each email is processed independently, and the response contains per-item results in the same order.
curl -X POST https://api.postkit.eu/v1/emails/batch \
-H "Authorization: Bearer pk_live_abc123..." \
-H "Content-Type: application/json" \
-d '[
{
"from": "Acme <noreply@acme.eu>",
"to": ["alice@example.com"],
"subject": "Welcome Alice",
"html": "<h1>Hello Alice!</h1><p>Your account is ready.</p>"
},
{
"from": "Acme <noreply@acme.eu>",
"to": ["bob@example.com"],
"subject": "Welcome Bob",
"html": "<h1>Hello Bob!</h1><p>Your account is ready.</p>"
}
]'const response = await fetch('https://api.postkit.eu/v1/emails/batch', {
method: 'POST',
headers: {
'Authorization': 'Bearer pk_live_abc123...',
'Content-Type': 'application/json',
},
body: JSON.stringify([
{
from: 'Acme <noreply@acme.eu>',
to: ['alice@example.com'],
subject: 'Welcome Alice',
html: '<h1>Hello Alice!</h1><p>Your account is ready.</p>',
},
{
from: 'Acme <noreply@acme.eu>',
to: ['bob@example.com'],
subject: 'Welcome Bob',
html: '<h1>Hello Bob!</h1><p>Your account is ready.</p>',
},
]),
});
const result = await response.json();
// result.data = [{ id: "em_...", status: "queued" }, { id: "em_...", status: "queued" }]response = requests.post(
'https://api.postkit.eu/v1/emails/batch',
headers={'Authorization': 'Bearer pk_live_abc123...'},
json=[
{
'from': 'Acme <noreply@acme.eu>',
'to': ['alice@example.com'],
'subject': 'Welcome Alice',
'html': '<h1>Hello Alice!</h1><p>Your account is ready.</p>',
},
{
'from': 'Acme <noreply@acme.eu>',
'to': ['bob@example.com'],
'subject': 'Welcome Bob',
'html': '<h1>Hello Bob!</h1><p>Your account is ready.</p>',
},
],
)
result = response.json()The response contains an array of results, one per email:
{
"data": [
{ "id": "em_x1y2z3a4b5", "status": "queued" },
{ "id": "em_c6d7e8f9g0", "status": "queued" }
]
}Schedule for later
Use the send_at field with an ISO 8601 timestamp to schedule delivery for a future time. Scheduled emails can be sent up to 72 hours in the future.
curl -X POST https://api.postkit.eu/v1/emails \
-H "Authorization: Bearer pk_live_abc123..." \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <noreply@acme.eu>",
"to": ["user@example.com"],
"subject": "Your weekly digest",
"html": "<h1>Weekly Digest</h1><p>Here is your summary...</p>",
"send_at": "2026-04-02T09:00:00Z"
}'const response = await fetch('https://api.postkit.eu/v1/emails', {
method: 'POST',
headers: {
'Authorization': 'Bearer pk_live_abc123...',
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Acme <noreply@acme.eu>',
to: ['user@example.com'],
subject: 'Your weekly digest',
html: '<h1>Weekly Digest</h1><p>Here is your summary...</p>',
send_at: '2026-04-02T09:00:00Z',
}),
});
const email = await response.json();
// email.status === "scheduled"response = requests.post(
'https://api.postkit.eu/v1/emails',
headers={'Authorization': 'Bearer pk_live_abc123...'},
json={
'from': 'Acme <noreply@acme.eu>',
'to': ['user@example.com'],
'subject': 'Your weekly digest',
'html': '<h1>Weekly Digest</h1><p>Here is your summary...</p>',
'send_at': '2026-04-02T09:00:00Z',
},
)
email = response.json()
# email['status'] == 'scheduled'When send_at is provided, the response status is "scheduled" instead of "queued":
{
"id": "em_a1b2c3d4e5",
"status": "scheduled"
}Cancel a scheduled email
Cancel a scheduled email before it is sent with POST /v1/emails/{id}/cancel. This only works for emails with status scheduled.
curl -X POST https://api.postkit.eu/v1/emails/em_a1b2c3d4e5/cancel \
-H "Authorization: Bearer pk_live_abc123..."const response = await fetch(
'https://api.postkit.eu/v1/emails/em_a1b2c3d4e5/cancel',
{
method: 'POST',
headers: {
'Authorization': 'Bearer pk_live_abc123...',
},
}
);
const email = await response.json();
// email.status === "canceled"response = requests.post(
'https://api.postkit.eu/v1/emails/em_a1b2c3d4e5/cancel',
headers={'Authorization': 'Bearer pk_live_abc123...'},
)
email = response.json()
# email['status'] == 'canceled'The response confirms the email has been canceled:
{
"id": "em_a1b2c3d4e5",
"status": "canceled"
}You can only cancel emails with status scheduled. Once an email starts sending, it cannot be canceled.
Tags
Use the tags array to categorize emails for filtering and analytics. You can add up to 5 tags per email, each containing alphanumeric characters, underscores, and hyphens.
{
"from": "Acme <noreply@acme.eu>",
"to": ["user@example.com"],
"subject": "Password reset",
"html": "<p>Click the link below to reset your password.</p>",
"tags": ["password-reset", "auth"]
}Tags are included in webhook payloads and can be used to filter emails when listing them via the API (GET /v1/emails?tag=password-reset).
Idempotent requests
Include an Idempotency-Key header to safely retry requests without sending duplicate emails. If Postkit receives a second request with the same key within 48 hours, it returns the original response without processing the request again.
curl -X POST https://api.postkit.eu/v1/emails \
-H "Authorization: Bearer pk_live_abc123..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-confirm-ORD-4821" \
-d '{
"from": "Acme <noreply@acme.eu>",
"to": ["user@example.com"],
"subject": "Order confirmed",
"html": "<p>Your order ORD-4821 has been confirmed.</p>"
}'The idempotency key must be a unique string up to 255 characters. A common pattern is to use the event that triggered the email (e.g., order-confirm-{order_id} or a UUID).
For a deeper explanation of how idempotency works, see the Idempotency concept page.
What's next?
- Email Delivery Pipeline -- understand what happens after you send
- Idempotency -- safe retries and duplicate prevention
- Templates -- create reusable email templates
- API Reference -- full endpoint documentation