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);
# pip install pennypost from pennypost import PennyPost pp = PennyPost("pp_test_<secret>") # ← your test key r = pp.send_email({ "from": "Receipts <receipts@yourdomain.com>", "to": ["customer@example.com"], "subject": "Your order shipped", "text": "Tracking inside.", })
# composer require pennypost/pennypost $pp = new PennyPost\PennyPost("pp_test_<secret>"); // ← your test key $r = $pp->sendEmail([ "from" => "Receipts <receipts@yourdomain.com>", "to" => ["customer@example.com"], "subject" => "Your order shipped", "text" => "Tracking inside.", ]);
// go get github.com/pennypost-io/pennypost-go pp := pennypost.New("pp_test_<secret>") // ← your test key r, err := pp.SendEmail(&pennypost.SendEmailRequest{ From: "Receipts <receipts@yourdomain.com>", To: []string{"customer@example.com"}, Subject: "Your order shipped", Text: "Tracking inside.", }, "")
// cargo add pennypost let pp = pennypost::PennyPost::new("pp_test_<secret>"); // ← your test key pp.send_email(&SendEmailRequest { from: "Receipts <receipts@yourdomain.com>".into(), to: vec!["customer@example.com".into()], subject: "Your order shipped".into(), text: Some("Tracking inside.".into()), ..Default::default() }, None)?;
# your test key goes after Bearer curl -s https://api.pennypost.io/v1/emails \ -H "Authorization: Bearer pp_test_<secret>" \ -d '{ "from": "Receipts <receipts@yourdomain.com>", "to": ["customer@example.com"], "subject": "Your order shipped", "text": "Tracking inside." }'
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:
| Key | What 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:
| Recipient | Behavior |
|---|---|
| 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
| Tool | What it does |
|---|---|
send_email, send_batch | Send one transactional email, or up to 100 in a single batch. |
list_emails, get_email | List sent emails or fetch one with its full delivery timeline. |
get_account | Read the plan, caps, usage, and enforcement state. |
add_domain, list_domains | Add a sending domain (returns the DNS records to publish) or check verification status. |
list_suppressions, add_suppression, remove_suppression | List, add, or remove account-wide suppressed addresses (bounces and complaints). |
create_audience, list_audiences, add_contacts, list_contacts, remove_contact | Manage marketing lists and their opted-in contacts. |
create_broadcast, list_broadcasts, update_broadcast, test_broadcast, send_broadcast, cancel_broadcast | The full broadcast lifecycle: draft, edit, test, send, and cancel a marketing email. |
list_webhooks, create_webhook, delete_webhook | Set 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
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"] }'
| Field | Notes |
|---|---|
| from | Display name optional. Domain must be verified for live keys. |
| to | Always 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 / bcc | Optional arrays, like a normal mail client. to, cc, and bcc together allow 50 recipients, and each one gets its own log row. |
| subject | Required. |
| html / text | At least one. Bodies are never stored after sending. |
| reply_to, tags, metadata, headers | Optional. 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:
| Array | Meaning |
|---|---|
| accepted | Queued for sending, one email row each. |
| suppressed | On your suppression list. Reported, not sent, not charged. |
| quarantined | Held because the recipient is new while your account is restricted. |
| failed | The 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
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
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:
| Status | Meaning |
|---|---|
| accepted | We took the request and queued the send. |
| sent | Handed to the mail provider. |
| delivered | The receiving server accepted it. |
| bounced | Rejected permanently. Terminal. |
| complained | The recipient marked it as spam. Terminal. |
| failed | The 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.
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 param | Notes |
|---|---|
| to | Optional. Only sends to this exact address. |
| domain | Optional. Only sends from this sending domain (subdomains roll up). |
| limit | Optional. 1 to 100, default 20. |
| cursor | Optional. 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:
| Reason | How it got there |
|---|---|
| bounce | A send to it hard-bounced. Removable. |
| complaint | The recipient marked mail as spam. Not removable. |
| manual | You added it via the API. Removable. |
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 }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" }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
curl -s https://api.pennypost.io/v1/audiences \ -H "Authorization: Bearer pp_live_<secret>" \ -d '{"name": "Product updates"}'
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"} ] }'
| Field | Notes |
|---|---|
| contacts | 1 to 1,000 entries per request. For a single contact you can send top-level email and name instead. |
| Required per contact. Re-importing an unsubscribed address never resubscribes it. | |
| name | Optional. 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
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>" }'
| Field | Notes |
|---|---|
| audience_id | Required. Must belong to this account. |
| from | Display name optional. The domain must be verified. |
| subject | Required, up to 998 characters. Supports {{name}}, {{email}}, and any contact property as {{key}}. |
| html / text | At least one, 200 KB combined. Same personalization tokens. |
| reply_to, scheduled_at | Optional. scheduled_at is ISO-8601. Omit it to create a draft you send explicitly. |
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
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
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": "…" }Lists your API keys: id, name, prefix, mode, and when each was last used. The full key is never shown again after creation.
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": "…" }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.
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:
| Event | Fires when |
|---|---|
| email.sent | We handed the email to the delivery infrastructure. |
| email.delivered | The receiving server accepted the email. |
| email.delivery_delayed | Delivery is retrying, for example a full mailbox. Includes the delay type. |
| email.opened | First open of a tracked email. Privacy proxies inflate opens, so treat clicks as the reliable signal. |
| email.clicked | First click on a tracked link. Includes the link URL. |
| contact.unsubscribed | A recipient opted out of a marketing audience. Sync this to your own database. |
| suppression.added | An address joined your suppression list, whether by bounce, complaint, or the API. |
| suppression.removed | An address was removed from your suppression list. |
| email.bounced | The email was rejected. Includes the provider's code and reason. |
| email.complained | The recipient marked it as spam. |
| email.failed | The 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.
Lists your endpoints, including each one's secret, status, and failure counters.
Fires a signed webhook.test event at one endpoint and reports whether it was delivered.
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:
| Type | Name | Value |
|---|---|---|
| TXT | pp1._domainkey.yourdomain.com | v=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. |
| MX | send.yourdomain.com | 10 feedback-smtp.us-east-1.amazonses.com. The return path. |
| TXT | send.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 } }| Code | Meaning |
|---|---|
| missing_api_key / invalid_api_key | 401. Check the Authorization header. |
| validation_failed | 422 with param naming the field. |
| domain_not_verified | 422. Live keys send only from verified domains. |
| tld_not_supported | 422. This TLD isn't supported yet. Email support@pennypost.io to request it. We open TLDs based on demand. |
| daily_cap_reached | 429, retryable. Free plans send up to 100 emails a day across all domains. Paid plans have no daily limit. |
| account_paused | 401. The email we sent you has the reason and the fix window. |
| rate_limited | 429, retryable. Test sends allow 120 per minute per account. |
| suppression_locked | 403. Complaint suppressions can't be removed. |
| filter_not_supported | 422. The log filters by to=, domain=, and key= today. Rich filters are coming. |
| monthly_cap_reached | 429, not retryable. The free plan's monthly allowance is used. Upgrade or wait for the month to reset. |
| key_not_found | 404. No API key with that id. |
| webhook_limit_reached | 403. Five endpoints per account. |
| webhook_not_found | 404. No endpoint with that id. |
| send_failed | 502, 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:
- A warning.
- A restriction: sends to brand-new recipients are held in the
quarantinedarray, while mail to people you've already reached keeps flowing. - A pause, with the reason.
To appeal, email support@pennypost.io.