PennyPost API

Send email with one POST. Every endpoint below is live at https://api.pennypost.io.

Quickstart

Sign up to get a test key, then replace pp_test_<secret> with yours:

# npm install pennypost
import { PennyPost } from "pennypost";

const pp = new PennyPost("pp_test_<secret>"); // ← your test keyconst { accepted } = await pp.emails.send({
  from: "Receipts <receipts@yourdomain.com>",
  to: ["customer@example.com"],
  subject: "Your order shipped",
  text: "Tracking inside.",
});

const email = await pp.emails.get(accepted[0].id);

Authentication

Authenticate every request with a Bearer token in the Authorization header. There are two kinds of API key, and each is shown only once, when it's created:

Paste it once and it's filled into every example on this page. Stored only for this browser session and never sent anywhere except api.pennypost.io. Test keys only: nothing is ever delivered. Forget it now
KeyWhat it does
pp_test_<secret>Sends in test mode: the API behaves like production, but no email is delivered. Issued at signup.
pp_live_<secret>Real sending from your verified domains. Issued automatically when your first domain verifies.

Test mode

Test mode lets you integrate before you've verified a domain. Sends made with a pp_test_ key behave exactly like live sends, but they go to a simulator and no email is delivered. They show up in your logs marked "mode": "test", and they don't count toward your caps or usage.

To test how your code handles failures, send to either of these addresses:

RecipientBehavior
fail@<any domain>The send fails permanently: the address appears in failed.
throttle@<any domain>The send fails as retryable, like a momentary provider limit.

MCP server

PennyPost runs a hosted MCP server so an agent can send email and manage your account through the Model Context Protocol. It authenticates with the same pp_live_ or pp_test_ key you use for the REST API, and each tool maps to one API endpoint, so every send goes through the same domain verification, caps, and abuse protections. A test key simulates delivery, so an agent can build and verify before a domain is live.

The endpoint is https://api.pennypost.io/mcp (Streamable HTTP). Everything an agent does appears in your dashboard's request log, the same as any API call.

Connect a client

Claude Code, from your terminal:

# uses a test key while you build; swap to pp_live_ to send for real
claude mcp add --transport http pennypost \
  https://api.pennypost.io/mcp \
  --header "Authorization: Bearer pp_test_..."

Cursor, Windsurf, or any client that reads an mcp.json:

{
  "mcpServers": {
    "pennypost": {
      "url": "https://api.pennypost.io/mcp",
      "headers": { "Authorization": "Bearer pp_test_..." }
    }
  }
}

For a client that only speaks local (stdio) MCP, use the published bridge. It forwards to the same hosted server and reads your key from the environment:

{
  "mcpServers": {
    "pennypost": {
      "command": "npx",
      "args": ["-y", "pennypost-mcp"],
      "env": { "PENNYPOST_API_KEY": "pp_test_..." }
    }
  }
}

Tools

ToolWhat it does
send_email, send_batchSend one transactional email, or up to 100 in a single batch.
list_emails, get_emailList sent emails or fetch one with its full delivery timeline.
get_accountRead the plan, caps, usage, and enforcement state.
add_domain, list_domainsAdd a sending domain (returns the DNS records to publish) or check verification status.
list_suppressions, add_suppression, remove_suppressionList, add, or remove account-wide suppressed addresses (bounces and complaints).
create_audience, list_audiences, add_contacts, list_contacts, remove_contactManage marketing lists and their opted-in contacts.
create_broadcast, list_broadcasts, update_broadcast, test_broadcast, send_broadcast, cancel_broadcastThe full broadcast lifecycle: draft, edit, test, send, and cancel a marketing email.
list_webhooks, create_webhook, delete_webhookSet up and manage webhook endpoints for delivery event notifications.

Example prompts

Once connected, ask your agent in plain language:

"Send a welcome email to sam@example.com from onboarding@myapp.com."
"List the emails I sent today and show which ones bounced."
"Add myapp.com as a sending domain and tell me the DNS records to set."
"Draft a broadcast to my Launch audience announcing the new release."

Send email

POST/v1/emails
curl -s https://api.pennypost.io/v1/emails \
  -H "Authorization: Bearer pp_test_<secret>" \
  -H "Idempotency-Key: order-8412-shipped" \
  -d '{
    "from": "Receipts <receipts@yourdomain.com>",
    "to": ["customer@example.com"],
    "subject": "Your order shipped",
    "text": "Tracking inside.",
    "tags": ["receipt"]
  }'
FieldNotes
fromDisplay name optional. Domain must be verified for live keys.
toAlways an array. One request is one message: everyone on the to line sees each other. To send each person their own copy, use batch send.
cc / bccOptional arrays, like a normal mail client. to, cc, and bcc together allow 50 recipients, and each one gets its own log row.
subjectRequired.
html / textAt least one. Bodies are never stored after sending.
reply_to, tags, metadata, headersOptional. Up to 10 tags. metadata takes string key-value pairs.

Response, 201:

{ "accepted":    [{ "to": "customer@example.com", "id": "em_01K…" }],
  "suppressed":  [],
  "quarantined": [],
  "failed":      [] }

Every recipient lands in exactly one of the four arrays:

ArrayMeaning
acceptedQueued for sending, one email row each.
suppressedOn your suppression list. Reported, not sent, not charged.
quarantinedHeld because the recipient is new while your account is restricted.
failedThe provider rejected the message. A single send surfaces this as a 502 instead (the whole message fails together). In a batch, the item's recipients land here. Safe to retry.

To retry a request safely, set an Idempotency-Key header. If we've seen the key in the last 30 days, we return the original result instead of sending twice.

Attachments and inline images

Add an attachments array to any send. Each item is { filename, content, content_type, content_id }, where content is base64. A content_id embeds the file as an inline image (reference it in your HTML as cid:that-id), otherwise it is a downloadable attachment. Everything together stays under about 7 MB.

"attachments": [
  { "filename": "receipt.pdf", "content": "<base64>", "content_type": "application/pdf" },
  { "filename": "logo.png", "content": "<base64>", "content_id": "logo" }
]

Schedule for later

Add scheduled_at (an ISO 8601 time in the future, within a year) to send later. The response is a scheduled email with an id and "status": "scheduled". Reschedule it with PATCH /v1/emails/{id} (a new scheduled_at), or cancel it with DELETE /v1/emails/{id}, any time before it sends. A test key schedules the same way and simulates the send at fire time.

Batch send

POST/v1/emails/batch

Send up to 100 emails in one request. The body is a JSON array of the same send objects, one per email.

curl -s https://api.pennypost.io/v1/emails/batch \
  -H "Authorization: Bearer pp_test_<secret>" \
  -H "Idempotency-Key: invoices-2026-08-18" \
  -d '[
    { "from": "Billing <billing@yourdomain.com>", "to": ["a@example.com"],
      "subject": "Your August invoice", "text": "Attached below." },
    { "from": "Billing <billing@yourdomain.com>", "to": ["b@example.com"],
      "subject": "Your August invoice", "text": "Attached below." }
  ]'

Response, 201: data has one entry per item, in order. Each entry is exactly what a single send returns, with the same four arrays.

{ "data": [
  { "accepted": [{ "to": "a@example.com", "id": "em_01K…" }], "suppressed": [] },
  { "accepted": [{ "to": "b@example.com", "id": "em_01K…" }], "suppressed": [] }
] }

Validation is all or nothing. If any item is invalid, nothing sends and the error names the item, like 1.subject. One Idempotency-Key covers the whole batch. Daily and monthly limits count the total recipients across all items, so a batch either fits whole or returns 429 before anything sends.

Retrieve & list

GET/v1/emails/:id

Returns one email with its full event timeline.

curl -s https://api.pennypost.io/v1/emails/em_01K… \\
  -H "Authorization: Bearer pp_test_<secret>"

Response, 200:

{ "id": "em_01K…", "status": "delivered", "mode": "live", …,
  "events": [{ "type": "email.delivered", "at": "…" }] }

status is one of:

StatusMeaning
acceptedWe took the request and queued the send.
sentHanded to the mail provider.
deliveredThe receiving server accepted it.
bouncedRejected permanently. Terminal.
complainedThe recipient marked it as spam. Terminal.
failedThe provider rejected the send. Terminal.

Terminal statuses stick: a late delivery event never un-bounces an email. mode is live or test. Each entry in events has a type (one of email.sent, email.delivered, email.bounced, email.complained, email.failed), a timestamp, and for bounces the provider's code and reason.

GET/v1/emails

Lists your sends, most recent first. Logs stay searchable for 30 days on every plan.

curl -s "https://api.pennypost.io/v1/emails?to=customer@example.com" \\
  -H "Authorization: Bearer pp_test_<secret>"
Query paramNotes
toOptional. Only sends to this exact address.
domainOptional. Only sends from this sending domain (subdomains roll up).
limitOptional. 1 to 100, default 20.
cursorOptional. From next_cursor of the previous page.

Response, 200:

{ "data": [{ "id": "em_01K…", "to": "customer@example.com", "status": "delivered", … }],
  "has_more": true, "next_cursor": "…" }

Suppressions

When an address hard-bounces or complains, we suppress it automatically. Future sends to it come back in the suppressed array and are never charged. You can also manage the list yourself. reason is one of:

ReasonHow it got there
bounceA send to it hard-bounced. Removable.
complaintThe recipient marked mail as spam. Not removable.
manualYou added it via the API. Removable.
GET/v1/suppressions

Lists suppressed addresses. Takes the same limit and cursor params as the email log.

curl -s https://api.pennypost.io/v1/suppressions \\
  -H "Authorization: Bearer pp_test_<secret>"

Response, 200:

{ "data": [{ "email": "angry@example.com", "reason": "complaint", "at": "…" }],
  "has_more": false, "next_cursor": null }
POST/v1/suppressions

Suppresses an address by hand. It's recorded with reason manual.

curl -s -X POST https://api.pennypost.io/v1/suppressions \
  -H "Authorization: Bearer pp_test_<secret>" \
  -H "Content-Type: application/json" \
  -d '{ "email": "someone@example.com" }'

Response, 201:

{ "email": "someone@example.com", "reason": "manual" }
DELETE/v1/suppressions/:email

Removes one entry. The next send to that address tries again, and if it hard-bounces again, it goes right back on the list.

curl -s -X DELETE https://api.pennypost.io/v1/suppressions/someone@example.com \\
  -H "Authorization: Bearer pp_test_<secret>"

Response, 200:

{ "removed": true }

Complaint suppressions can't be removed: the recipient marked the mail as spam, so sending again would hurt your deliverability. If they re-opted in, email support@pennypost.io.

To manage many at once, POST or DELETE /v1/suppressions/batch with { "emails": [ … ] } (up to 100). The add response reports how many were added and any invalid addresses; the remove response reports how many were removed and any complaint suppressions that stayed locked.

GET /v1/emails/metrics returns account totals (sent, delivered, bounced, complained) and rates over a date range, defaulting to the last 30 days. Pass ?start=YYYY-MM-DD&end=YYYY-MM-DD for a specific window (at most 92 days).

Marketing Emails

Marketing Emails send one personalized message to an opted-in audience. The API resource is a broadcast. It is a separate product from Transactional Emails: unlimited sends, priced by the number of subscribed contacts, and marketing volume never uses Transactional daily or monthly allowances or creates Transactional overage. Pick a contact tier on the dashboard's Marketing Emails page.

Audiences & contacts

POST/v1/audiences
curl -s https://api.pennypost.io/v1/audiences \
  -H "Authorization: Bearer pp_live_<secret>" \
  -d '{"name": "Product updates"}'
POST/v1/audiences/:id/contacts
curl -s https://api.pennypost.io/v1/audiences/aud_123/contacts \
  -H "Authorization: Bearer pp_live_<secret>" \
  -d '{
    "contacts": [
      {"email": "dana@example.com", "name": "Dana"},
      {"email": "sam@example.com"}
    ]
  }'
FieldNotes
contacts1 to 1,000 entries per request. For a single contact you can send top-level email and name instead.
emailRequired per contact. Re-importing an unsubscribed address never resubscribes it.
nameOptional. Available as {{name}} in broadcasts.

The response reports added and rejected (reasons invalid or plan_contact_limit). List with GET /v1/audiences/:id/contacts?status=&limit=&cursor=, update or resubscribe with POST /v1/audiences/:id/contacts/:email, and remove with DELETE on the contact or the audience. Deleting an audience while a marketing email is scheduled or sending returns 409 audience_in_use.

Broadcasts

POST/v1/broadcasts
curl -s https://api.pennypost.io/v1/broadcasts \
  -H "Authorization: Bearer pp_live_<secret>" \
  -d '{
    "audience_id": "aud_123",
    "from": "Updates <updates@yourdomain.com>",
    "subject": "What is new for {{name}}",
    "html": "<p>Hi {{name}}, ...</p>"
  }'
FieldNotes
audience_idRequired. Must belong to this account.
fromDisplay name optional. The domain must be verified.
subjectRequired, up to 998 characters. Supports {{name}}, {{email}}, and any contact property as {{key}}.
html / textAt least one, 200 KB combined. Same personalization tokens.
reply_to, scheduled_atOptional. scheduled_at is ISO-8601. Omit it to create a draft you send explicitly.
POST/v1/broadcasts/:id/send
curl -s https://api.pennypost.io/v1/broadcasts/bc_123/send \
  -H "Authorization: Bearer pp_live_<secret>" \
  -d '{"confirm_opt_in": true}'

The first marketing send on an account must include confirm_opt_in: true, a one-time attestation that every recipient asked to receive this email. Without it the send returns 422 opt_in_attestation_required. Sending a draft with scheduled_at set queues it for that time. Otherwise delivery starts immediately. POST /v1/broadcasts/:id/cancel stops remaining work at the next chunk boundary.

GET /v1/broadcasts/:id reports status (draft, scheduled, sending, paused_cap, paused_enforcement, canceled, sent) and live counters (queued, sent, delivered, bounced, complained, failed, skipped, unsubscribed). Each recipient is delivered exactly once, even across retries.

Every delivered marketing message automatically carries RFC 8058 one-click unsubscribe headers and a tokenized mailto fallback. Both feed the same per-audience opt-out. Unsubscribing from an audience never blocks that person's Transactional email. Tenant-wide bounce and complaint suppressions apply to both products.

Ghost compatibility

POST/v3/:domain/messages

Ghost's newsletter integration speaks a small Mailgun subset, and PennyPost implements exactly that subset. In Ghost's Mailgun settings use username api, a pp_live_ key as the password, and https://api.pennypost.io/v3/YOUR_VERIFIED_DOMAIN as the base URL. Recipients land in a per-domain audience named Ghost · <domain>, so unsubscribes and counters work like any other broadcast. Confirm the one-time opt-in attestation on the dashboard first. Other Mailgun routes return 501.

Account & keys

GET/v1/account

Returns your plan, caps, usage so far this billing period (calendar month on the free plan), and the account's enforcement state with its reason. If sending is ever warned, restricted, or paused, this is where your code can see exactly why.

Response, 200:

{ "id": "tn_01K…", "plan": "free", "status": "active", "daily_cap": 100,
  "month_to_date_sent": 128, "card_on_file": false,
  "enforcement": { "state": "healthy" }, "created_at": "…" }
GET/v1/keys

Lists your API keys: id, name, prefix, mode, and when each was last used. The full key is never shown again after creation.

POST/v1/keys

Creates a key. mode is live or test. Live keys need a verified domain. The raw key appears once, in this response.

{ "name": "ci", "mode": "live" }

Response, 201:

{ "id": "key_01K…", "name": "ci", "mode": "live", "key": "pp_live_…", "created_at": "…" }
DELETE/v1/keys/:id

Revokes a key immediately. Response: { "revoked": true }.

The free plan includes 3,000 emails per calendar month for each verified domain, and sends up to 100 emails a day. Past the monthly allowance, sends return monthly_cap_reached until you upgrade or the month resets. Past the daily 100, they return daily_cap_reached until midnight UTC. Paid plans have no daily limit. See the sending policy.

Webhooks

Webhooks push delivery events to your server as they happen, so you don't have to poll the log. Register an https endpoint and we POST each event to it, signed.

POST/v1/webhooks

Registers an endpoint. The signing secret comes back in the response. You can have up to five endpoints.

curl -s -X POST https://api.pennypost.io/v1/webhooks \
  -H "Authorization: Bearer pp_test_<secret>" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://yourapp.com/hooks/pennypost", "events": ["email.bounced", "email.complained"] }'

Response, 201:

{ "id": "wh_01K…", "url": "https://yourapp.com/hooks/pennypost",
  "events": ["email.bounced", "email.complained"],
  "secret": "whsec_…", "status": "active", "created_at": "…" }

events defaults to all four types:

EventFires when
email.sentWe handed the email to the delivery infrastructure.
email.deliveredThe receiving server accepted the email.
email.delivery_delayedDelivery is retrying, for example a full mailbox. Includes the delay type.
email.openedFirst open of a tracked email. Privacy proxies inflate opens, so treat clicks as the reliable signal.
email.clickedFirst click on a tracked link. Includes the link URL.
contact.unsubscribedA recipient opted out of a marketing audience. Sync this to your own database.
suppression.addedAn address joined your suppression list, whether by bounce, complaint, or the API.
suppression.removedAn address was removed from your suppression list.
email.bouncedThe email was rejected. Includes the provider's code and reason.
email.complainedThe recipient marked it as spam.
email.failedThe provider rejected the send.

Each POST body looks like this, and the data object carries the email's id, recipient, subject, mode, and any provider code or reason:

{ "id": "evt_01K…", "type": "email.bounced", "created_at": "…",
  "data": { "email_id": "em_01K…", "to": "customer@example.com", "reason": "550 no mailbox", … } }

Verifying signatures

Every delivery carries a pennypost-signature header: t=<unix seconds>,v1=<hex>. Recompute the HMAC over t and the raw body with your endpoint's secret, and reject anything older than five minutes:

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret, header, rawBody) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

Delivery and retries

We deliver at least once, so treat the event id as your dedupe key. A delivery counts as received on any 2xx within five seconds. Anything else is retried after one minute, then after fifteen. If an endpoint fails twenty deliveries in a row we disable it. Delete and re-create it to start again.

GET/v1/webhooks

Lists your endpoints, including each one's secret, status, and failure counters.

POST/v1/webhooks/:id/test

Fires a signed webhook.test event at one endpoint and reports whether it was delivered.

DELETE/v1/webhooks/:id

Removes an endpoint. Response: { "removed": true }.

Domains & going live

Add your domain in the dashboard, using the registrable domain, like example.com. Subdomains roll up to it, and the free tier counts per registrable domain. Mainstream TLDs (including the major country TLDs) are supported. If yours isn't yet, the error says so, and we open new TLDs based on demand, so email support@pennypost.io to request one.

The dashboard gives you four DNS records to add wherever your DNS lives. Three of them follow a fixed template, so you can stage them before you even add the domain. Only the DKIM value is minted per domain and shown when you add it:

TypeNameValue
TXTpp1._domainkey.yourdomain.comv=DKIM1; k=rsa; p=<shown when you add the domain>. Signs your email so mailbox providers trust it. The value is long: paste it whole.
MXsend.yourdomain.com10 feedback-smtp.us-east-1.amazonses.com. The return path.
TXTsend.yourdomain.com"v=spf1 include:amazonses.com ~all". SPF for the return path.
TXT_dmarc.yourdomain.com"v=DMARC1; p=none;". A starter DMARC policy: skip it if the domain already has one.

The dashboard checks verification for you, and we keep checking in the background every few minutes and email you the moment it verifies, so you can close the tab. When your domain verifies, your live key is issued automatically and shown once.

The free plan includes one verified domain, and paid plans include 10 or more. A domain can belong to only one PennyPost account.

Brand avatar (BIMI)

Show your logo in the inbox instead of a blank avatar. Open a verified domain in the dashboard and follow the brand-avatar steps. There are three requirements, on your sending domain:

1. DMARC at enforcement. BIMI needs your _dmarc record at p=quarantine or p=reject (we default new domains to p=none, and the dashboard shows you the record to move to). 2. An SVG Tiny PS logo (square, under 32 KB, no scripts or external references), which we validate for you. 3. For Gmail specifically, a certificate: bring your own CMC or VMC, or a managed option is coming soon.

With steps 1 and 2, your logo shows in Apple Mail and other clients that do not require a certificate. Gmail additionally needs the certificate from step 3.

Errors

Every error has the same shape. type is one of invalid_request, authentication, rate_limit, provider, or account:

{ "error": { "type": "invalid_request", "code": "validation_failed",
             "message": "Expected array, received string", "param": "to",
             "retryable": false } }
CodeMeaning
missing_api_key / invalid_api_key401. Check the Authorization header.
validation_failed422 with param naming the field.
domain_not_verified422. Live keys send only from verified domains.
tld_not_supported422. This TLD isn't supported yet. Email support@pennypost.io to request it. We open TLDs based on demand.
daily_cap_reached429, retryable. Free plans send up to 100 emails a day across all domains. Paid plans have no daily limit.
account_paused401. The email we sent you has the reason and the fix window.
rate_limited429, retryable. Test sends allow 120 per minute per account.
suppression_locked403. Complaint suppressions can't be removed.
filter_not_supported422. The log filters by to=, domain=, and key= today. Rich filters are coming.
monthly_cap_reached429, not retryable. The free plan's monthly allowance is used. Upgrade or wait for the month to reset.
key_not_found404. No API key with that id.
webhook_limit_reached403. Five endpoints per account.
webhook_not_found404. No endpoint with that id.
send_failed502, retryable says whether to retry.

The SDK throws these as a typed PennyPostError with status, code, param, and retryable.

Sending policy

The free plan sends up to 100 emails a day across all domains. Paid plans have no daily limit: the monthly included volume and extra-email billing take over. Test sends never count against any limit.

If your rates climb, enforcement happens in steps, and every notice includes your exact numbers:

  1. A warning.
  2. A restriction: sends to brand-new recipients are held in the quarantined array, while mail to people you've already reached keeps flowing.
  3. A pause, with the reason.

To appeal, email support@pennypost.io.

Cold outreach and purchased lists are banned by the acceptable use policy. The ban protects deliverability for everyone who sends through the shared pool.