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 key const { 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. |
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, 1 to 50 recipients, one email row each. |
| subject | Required. |
| html / text | At least one. Bodies are never stored after sending. |
| reply_to, tags, metadata, headers | Optional. Up to 10 tags; metadata is string key-value. |
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 it. 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.
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. |
| 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 dev@pockadot.com.
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.delivered | The receiving server accepted the email. |
| 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.
The dashboard gives you six DNS records to add wherever your DNS lives:
| Records | What they do |
|---|---|
| 3 CNAMEs | Sign your email so mailbox providers trust it. |
| 1 MX + 1 TXT | Set up the return path on send.yourdomain.com. |
| 1 TXT | A starter DMARC policy. |
The dashboard checks verification for you. When your domain verifies, your live key is issued automatically and shown once.
You can verify one domain without a card, or up to five with a card on file. A domain can belong to only one PennyPost account.
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. |
| daily_cap_reached | 429, retryable. Your cap grows automatically: see sending policy. |
| 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= today; rich filters are coming. |
| 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
New senders start with a cap of 500 emails a day. As long as your bounce and complaint rates stay low, it grows about 5× a week: 500, then 2,500, then 12,500, then 50,000. Test sends don't count.
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 dev@pockadot.com.