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);

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.

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, 1 to 50 recipients, one email row each.
subjectRequired.
html / textAt least one. Bodies are never stored after sending.
reply_to, tags, metadata, headersOptional. 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:

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 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

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.
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 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.

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.deliveredThe receiving server accepted the email.
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.

The dashboard gives you six DNS records to add wherever your DNS lives:

RecordsWhat they do
3 CNAMEsSign your email so mailbox providers trust it.
1 MX + 1 TXTSet up the return path on send.yourdomain.com.
1 TXTA 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 } }
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.
daily_cap_reached429, retryable. Your cap grows automatically: see sending policy.
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= today; rich filters are coming.
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

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:

  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 dev@pockadot.com.

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