Providers
Postmark
Uses the Ahena CLI (beta). Install it with npm install -g @ahena/cli, or prefix commands with npx @ahena/cli. See Installation. The dashboard covers projects, connections, Doctor, the Stack Graph, plans and approvals without it.
Overview
Ahena checks your Postmark account and server are ready to send, adds and re-verifies your
sending domain (DKIM and Return-Path), creates message streams and webhooks, and generates the
same ahena.email code it generates for Resend. Your app sends straight to Postmark with its
Server API token. Ahena is never in the sending path.
Package: @ahena/provider-postmark · Category: email
Capabilities: transactional-email, servers, domains, dns-requirements,
domain-verification, sender-signatures, message-streams, webhooks
Maturity: verified against a fake of the API; not yet verified against the real service.
Connection
Postmark has two kinds of token and Ahena needs both: domains and sender signatures belong to the account, message streams and webhooks belong to a server.
| Field | Secret | Used for |
|---|---|---|
POSTMARK_ACCOUNT_TOKEN |
yes | Domains, DKIM and Return-Path verification, sender signatures, listing servers (X-Postmark-Account-Token). |
POSTMARK_SERVER_TOKEN |
yes | The server's details, message streams and webhooks (X-Postmark-Server-Token). |
POSTMARK_SERVER_ID |
no, optional | The server the token belongs to. Ahena checks they match; ahena connect offers your servers. |
POSTMARK_ACCOUNT_TOKEN=… POSTMARK_SERVER_TOKEN=… ahena connect postmark -e production --set POSTMARK_SERVER_ID=…
Production refuses a Sandbox server's token (Sandbox servers never deliver, and Postmark can't change a server's delivery type). A Live server's token is reported as reaching live data.
Permissions
Postmark tokens have no scopes, so least privilege is limited to choosing which tokens Ahena
holds. Ahena reports permissionCheck: authentication-only: ahena connect proves both tokens
work, which is everything a Postmark token can tell.
| Token | What it can do (Postmark's rules, not Ahena's) | Why Ahena needs it |
|---|---|---|
| Account API token | Everything in the account: servers, domains, sender signatures, templates. Only account owners and admins can see it. | Domains are account-level; there's no narrower token for them. |
| Server API token | Everything on one server, including sending email. | Message streams and webhooks are server-level. |
Recommendations:
- A server and the account can each have up to 3 tokens. Generate separate tokens for Ahena so you can delete them without touching your app.
- Your app needs only its server's Server API token. Never give it the Account API token.
- Ahena never sends email, never reads message content, and strips
ApiTokensfrom server responses and passwords and header values from webhook responses before anything is returned.
Capabilities
| Capability | Access | Verification | Changes | Refuses |
|---|---|---|---|---|
transactional-email |
read-only | |||
servers |
read-only | Creating, editing or deleting servers; changing delivery type | ||
domains |
writable | read-back | CONFIRMATION_REQUIRED | Deleting domains; rotating DKIM |
dns-requirements |
manual | |||
domain-verification |
writable | read-back | SAFE | |
sender-signatures |
read-only | Creating or deleting signatures | ||
message-streams |
writable | read-back | CONFIRMATION_REQUIRED | Archiving, unarchiving, changing type |
webhooks |
writable | existence-only | CONFIRMATION_REQUIRED | Deleting webhooks; turning triggers off; replacing credentials or headers you set |
Webhooks are existence-only: Ahena reads back the URL, stream and triggers, and whether HTTP
auth is set, but never compares (or returns) the password.
Network: api.postmarkapp.com and cloudflare-dns.com (a credential-free DMARC lookup), enforced.
Your webhook route is probed through Ahena's credential-free probe. ahena lock tracks the
server's delivery type, the domain's DKIM and Return-Path state, the stream and the webhooks.
Configure
The email section uses Resend's keys, so they keep their meaning after ahena switch, plus stream:
email: {
provider: "postmark",
domain: { production: "leo.example.com" },
from: "Leo <hello@leo.example.com>",
webhook: { production: { endpoint: "https://leo.example.com/api/webhooks/postmark" } },
stream: "outbound", // or { id: "leo-alerts", name: "Leo alerts", type: "Transactional" }
},
webhook.events takes Postmark triggers (Delivery, Bounce, SpamComplaint, Open, Click,
SubscriptionChange) or the Resend event names that map exactly (email.delivered,
email.bounced, email.complained, email.opened, email.clicked). Anything else (for
example email.sent) is refused. Default: Bounce, Delivery, SpamComplaint.
| Change | Classification | Notes |
|---|---|---|
Add the sending domain (POST /domains) |
CONFIRMATION_REQUIRED | Postmark returns a DKIM TXT record and a Return-Path CNAME; publishing them is a manual DNS step (Doctor lists the exact records). |
Ask Postmark to re-check DKIM / Return-Path (verifyDkim, verifyReturnPath) |
SAFE | Only a DNS re-check; changes no configuration. Planned while either is unverified. |
| Create a message stream | CONFIRMATION_REQUIRED | Transactional unless type: "Broadcasts". IDs start with a letter, at most 30 characters, not pm-…. |
| Create a webhook on a stream | CONFIRMATION_REQUIRED | Matched by URL and stream, so never duplicated. Ahena generates HTTP basic auth credentials (user ahena, a random 256-bit password) and stores them as POSTMARK_WEBHOOK_USERNAME and POSTMARK_WEBHOOK_PASSWORD. |
| Turn on missing triggers | CONFIRMATION_REQUIRED | Sends only the missing triggers; others stay as they are. Never turns one off. |
| Add basic auth to an existing webhook with no credentials | CONFIRMATION_REQUIRED | Only when it has neither basic auth nor custom headers. Existing credentials are never replaced. |
Why basic auth: Postmark doesn't sign webhooks. The only way for your route to know a request
came from Postmark is a credential Postmark sends with it. Ahena creates webhooks with
Verify: false, because Postmark's test delivery would hit a route that doesn't have the
credentials yet; Doctor reports the webhook as unverified (INFO) until you send a test.
Refused outright: deleting anything; webhook URLs that aren't public https or that embed credentials; webhooks on archived or inbound streams; changing a stream's type.
Generate
ahena generate postmark [--framework nextjs]:
src/ahena/email/index.ts: the same interface as Resend's (SendEmail,email.send(message)resolving to{ id },email.rawfor the PostmarkServerClient). Sends to the configured stream (POSTMARK_MESSAGE_STREAM, default fromemail.stream).src/ahena/providers/postmark.ts: theServerClient, fromPOSTMARK_SERVER_TOKEN..env.example.postmark.src/app/api/webhooks/postmark/route.ts(Next.js): compares theAuthorization: Basicheader withPOSTMARK_WEBHOOK_USERNAME/POSTMARK_WEBHOOK_PASSWORDin constant time, answers 401 without them, 500 if they aren't configured, and 200 after handling (Postmark retries 5xx and timeouts, not 4xx).
Limitations
- Ahena doesn't delete or archive domains, streams or webhooks, rotate DKIM, or create servers.
- Postmark has no API to revoke tokens; delete them in Postmark yourself.
- Account approval (new Postmark accounts may only send to their own domain until approved) isn't exposed by the API, so Doctor can't see it.
- Suppression lists, templates, inbound processing and message history aren't managed.
ahena configure cloudflare --records-fromreads only Resend today; publish Postmark's DKIM and Return-Path records yourself (Doctor gives the full host names Postmark reports).- Doctor's sender check matches the
fromdomain exactly; it doesn't assume a verified domain covers its subdomains. - Not yet run against the real Postmark API: whether webhook test deliveries carry the HTTP auth credentials, and how strictly stream IDs must be lowercase, are taken from the docs and error codes, not observed.
Manual steps
- Publish the DKIM TXT and Return-Path CNAME records that Doctor lists at your DNS host, then
run
ahena configure postmarkso Postmark re-checks them. - Production on a Sandbox server: create a Live server in Postmark and reconnect with its token.
- Unarchive an archived stream in Postmark (Servers → the server → the stream).
- After deploying the webhook route with its credentials, send a test from the webhook's page in Postmark.
Doctor checks
| Id | Severity | Meaning |
|---|---|---|
postmark.account |
PASS / FAIL | The Account API token works. |
postmark.server |
PASS / FAIL | The Server API token works; the server's name and delivery type. |
postmark.server.id |
FAIL | The token belongs to a different server than POSTMARK_SERVER_ID. |
postmark.server.delivery |
PASS / INFO / FAIL | Sandbox server in production (FAIL, manual fix); Sandbox elsewhere (INFO). |
postmark.domain |
PASS / WARNING / FAIL | Domain added and fully verified; missing domain has a plannable fix. |
postmark.domain.dkim |
PASS / WARNING / FAIL | DKIM verified, rotation pending, or the exact TXT record to add (FAIL in production). |
postmark.domain.return_path |
PASS / WARNING / FAIL | Return-Path CNAME verified, or the exact record to add. |
postmark.domain.spf |
INFO | Postmark's deprecated domain-level SPF check, for reference. |
postmark.domain.dmarc |
PASS / INFO / WARNING | DMARC enforced, monitoring only, or missing (public DNS lookup). |
postmark.domain.undeclared |
INFO | No email.domain, so DNS can't be checked. |
postmark.sender |
PASS / WARNING / FAIL | email.from is on a DKIM-verified domain or a confirmed sender signature. |
postmark.stream |
PASS / FAIL | The stream exists, isn't archived and isn't inbound. |
postmark.webhook |
PASS / WARNING / FAIL | A webhook for the endpoint on the stream, with the triggers. |
postmark.webhook.auth |
PASS / INFO / WARNING / FAIL | Postmark sends basic auth; custom headers only (INFO); nothing (FAIL in production). |
postmark.webhook.status |
INFO | Postmark hasn't verified the URL yet. |
postmark.webhook.endpoint |
PASS / WARNING / FAIL | One POST without credentials: 401/403 is right; 2xx means the route doesn't check auth; 404/405 offers a SAFE generate fix. |
Core also checks that POSTMARK_WEBHOOK_USERNAME and POSTMARK_WEBHOOK_PASSWORD are stored
(by name) when a webhook is declared.
Disconnect behavior
Removes the connection and the tokens Ahena stored. Postmark can't revoke tokens by API: delete the Account API token at https://account.postmarkapp.com/api_tokens and the Server API token under Servers → your server → API Tokens. If your app uses the same server token it stops sending, which is why Ahena should have its own. Servers, domains, streams, webhooks and history are untouched.
Set up step by step
Get the tokens. Account API token: Postmark → Account → API Tokens (owners and admins only) → Generate another token. Server API token: Servers → your server → API Tokens → Generate another token. Use a Live server for production, Sandbox elsewhere if you like.
Minimum access. Postmark tokens have no scopes: one Account API token and the Server API token of the one server this environment sends through. Give your app a different server token.
Connect.
POSTMARK_ACCOUNT_TOKEN=… POSTMARK_SERVER_TOKEN=… ahena connect postmark -e development POSTMARK_ACCOUNT_TOKEN=… POSTMARK_SERVER_TOKEN=… ahena connect postmark -e production --set POSTMARK_SERVER_ID=…Pipe the tokens or type them at the hidden prompt; never pass them as arguments.
Verify the connection.
ahena inspect postmark -e productionlists servers, domains with their DNS records, sender signatures, streams and webhooks (no tokens or webhook passwords).Run Doctor.
ahena doctor -e production.Plan. Add the
emailsection above toahena.config.ts, thenahena plan -e production(orahena configure postmark -e productionto plan and apply one provider).Apply.
ahena apply -e production. Adding the domain, the stream and the webhook need confirmation; DNS re-checks are SAFE. Publish the DKIM and Return-Path records Doctor lists, then apply again to re-check.Verify.
ahena plan -e productionshows no Postmark changes (only the SAFE re-checks while DNS propagates),ahena doctor -e productionpasses, andahena env secrets productionlistsPOSTMARK_WEBHOOK_USERNAMEandPOSTMARK_WEBHOOK_PASSWORD(masked). Thenahena generate postmark -e production --framework nextjs, set the variables from.env.example.postmark(ahena env reveal production POSTMARK_WEBHOOK_PASSWORD) and deploy.Troubleshooting.
Symptom Cause Fix "Postmark rejected the Account API token" Deleted or mistyped token, or a server token in that field Copy the Account API token again and reconnect. "Postmark rejected the Server API token" Deleted token, or the account token in that field Copy the server's token and reconnect. "That's a Sandbox server's token" Production connected to a Sandbox server Create a Live server and connect with its token. "The Server API token belongs to another server" POSTMARK_SERVER_IDdoesn't match the tokenReconnect with matching values, or leave the id out. DKIM or Return-Path stays unverified Records not published or not propagated Check the full host names Doctor shows (Postmark gives fully qualified names), wait, apply again. "public email domains" at apply email.domainis gmail.com or similarSend from a domain you own. postmark.webhook.endpointaccepted a request without credentialsThe route doesn't check basic auth Use the generated route, or compare the Authorizationheader yourself.Webhook created but your route returns 500 POSTMARK_WEBHOOK_USERNAME/PASSWORDnot set in the appahena env reveal <environment> POSTMARK_WEBHOOK_PASSWORD, set both, redeploy."archived" when planning a webhook The stream is archived Unarchive it in Postmark, plan again. Disconnect and revoke.
ahena disconnect postmark -e production, then delete Ahena's Account API token (Account → API Tokens) and Server API token (the server → API Tokens).