Documentation menu

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 ApiTokens from 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.raw for the Postmark ServerClient). Sends to the configured stream (POSTMARK_MESSAGE_STREAM, default from email.stream).
  • src/ahena/providers/postmark.ts: the ServerClient, from POSTMARK_SERVER_TOKEN.
  • .env.example.postmark.
  • src/app/api/webhooks/postmark/route.ts (Next.js): compares the Authorization: Basic header with POSTMARK_WEBHOOK_USERNAME / POSTMARK_WEBHOOK_PASSWORD in 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-from reads 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 from domain 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

  1. Publish the DKIM TXT and Return-Path CNAME records that Doctor lists at your DNS host, then run ahena configure postmark so Postmark re-checks them.
  2. Production on a Sandbox server: create a Live server in Postmark and reconnect with its token.
  3. Unarchive an archived stream in Postmark (Servers → the server → the stream).
  4. 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

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

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

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

  4. Verify the connection. ahena inspect postmark -e production lists servers, domains with their DNS records, sender signatures, streams and webhooks (no tokens or webhook passwords).

  5. Run Doctor. ahena doctor -e production.

  6. Plan. Add the email section above to ahena.config.ts, then ahena plan -e production (or ahena configure postmark -e production to plan and apply one provider).

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

  8. Verify. ahena plan -e production shows no Postmark changes (only the SAFE re-checks while DNS propagates), ahena doctor -e production passes, and ahena env secrets production lists POSTMARK_WEBHOOK_USERNAME and POSTMARK_WEBHOOK_PASSWORD (masked). Then ahena generate postmark -e production --framework nextjs, set the variables from .env.example.postmark (ahena env reveal production POSTMARK_WEBHOOK_PASSWORD) and deploy.

  9. 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_ID doesn't match the token Reconnect 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.domain is gmail.com or similar Send from a domain you own.
    postmark.webhook.endpoint accepted a request without credentials The route doesn't check basic auth Use the generated route, or compare the Authorization header yourself.
    Webhook created but your route returns 500 POSTMARK_WEBHOOK_USERNAME/PASSWORD not set in the app ahena env reveal <environment> POSTMARK_WEBHOOK_PASSWORD, set both, redeploy.
    "archived" when planning a webhook The stream is archived Unarchive it in Postmark, plan again.
  10. 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).