Skip to content

Email Integration -- Postmark Service Design

Design for Arda’s integration with Postmark as the ESP. Covers the API surface used for tenant provisioning, email sending, and delivery event handling. All provisioning operations are fully automatable via the Postmark API — only account creation and initial token retrieval require the Postmark console.

Postmark AccountPartitionsServer TypeDelivery Behavior
PostmarkProdprod, demoLive serversReal delivery to recipients
PostmarkNonProddev, stageSandbox serversAccepted but dropped (blackhole)

Both accounts use the Platform plan (unlimited servers, domains, message streams per account).

Postmark uses two token types. The token type determines which operations are available.

Token TypeHTTP HeaderScopeUsed For
Account TokenX-Postmark-Account-TokenAll servers and domains in the accountProvisioning: server CRUD, domain CRUD, verification
Server TokenX-Postmark-Server-TokenSingle serverRuntime: sending email, webhooks, bounces, stats, templates

The Account Token is stored in Secrets Manager (one per Postmark account) and delivered to the pod via the External Secrets Operator (ESO) at startup. The service reads it from HOCON config (extras.email.postmarkAccountToken), not from Secrets Manager at runtime.

SecretPath ConventionDeliveryUsed by
PostmarkProd account API tokenprod/email/postmark-account-tokenESO -> HOCON configemailConfiguration service (provisioning)
PostmarkNonProd account API tokendev/email/postmark-account-tokenESO -> HOCON configemailConfiguration service (provisioning)
Email encryption key (per partition)<partition>/email/encryption-keyESO -> HOCON configemailConfiguration service (encrypt/decrypt server tokens)

Per-tenant server tokens are not stored in Secrets Manager. They are encrypted with the partition-wide encryption key and persisted in the tenant_email_config database table. The emailConfiguration service decrypts them on read. See functional.md for details.


The full tenant provisioning flow, executed by L3 EmailConfigurationService via the L2 TenantProvisioner capability composer (see DQ-201).

Persist-first lifecycle (DQ-205): before the steps below run, L3 inserts a row in PROVISIONING status (inside a DB transaction that also runs the pre-flight checkAvailability checks of DQ-205.i). After the steps complete (or fail partway), L3 updates the row with the captured external IDs and the new status (PENDING_VERIFICATION on success, PROVISIONING_FAILED on partial failure). Steps 1-5 below are the L2 phase of the flow; the surrounding DB-state management lives in L3 and is documented in Scenario 1.

Mutation order (DQ-205.k): all Postmark mutations (Steps 1-3) execute before any Route53 mutation (Step 4). Route53 is more reliable than Postmark; doing Postmark first means a Postmark failure leaves zero Route53 orphans.

EndpointPOST /servers
AuthAccount Token
PurposeCreate an isolated sending environment for the tenant

Request:

{
"Name": "<tenant-slug>-<partition>",
"DeliveryType": "Live",
"SmtpApiActivated": true,
"RawEmailEnabled": false,
"TrackOpens": true,
"TrackLinks": "HtmlAndText"
}
  • For dev/stage partitions, use "DeliveryType": "Sandbox" (or create in the PostmarkNonProd account where sandbox is the default behavior).
  • The response includes the Server Token (ApiTokens array) — encrypted and stored in the database at Step 6.
  • Webhook configuration is done separately via POST /webhooks (see Step 3).
EndpointPOST /domains
AuthAccount Token
PurposeRegister the tenant’s sending domain and obtain DNS record values

Request:

{
"Name": "{config-slug}.{tenant-slug}.<partition>.{mail-root-domain}",
"ReturnPathDomain": "pm-bounces.{config-slug}.{tenant-slug}.<partition>.{mail-root-domain}"
}

Response includes the DNS records to publish:

Response FieldDNS Record TypePurpose
DKIMPendingHostTXT (name)Hostname for the DKIM TXT record
DKIMPendingTextValueTXT (value)DKIM public key value
ReturnPathDomainCNAME (name)The Return-Path subdomain (as requested)
ReturnPathDomainCNAMEValueCNAME (value)Target: pm.mtasv.net

Important: DKIM records are TXT records, not CNAMEs. The DKIMPendingHost is the record name and DKIMPendingTextValue is the record value. Return-Path records are CNAMEs with target pm.mtasv.net. Verified against the Postmark Domains API POST /domains response schema.

Note: Domains are account-wide resources, not server-specific. A domain registered in PostmarkProd can be used by any server in that account.

Step 3: Configure Webhooks (Postmark Server API)

Section titled “Step 3: Configure Webhooks (Postmark Server API)”
EndpointPOST /webhooks
AuthServer Token (from Step 1)
PurposeConfigure delivery event callbacks with Bearer token authentication

Request:

{
"Url": "https://<partition>.<infra>.io.arda.cards/v1/shop-access/email/postmark-events",
"MessageStream": "outbound",
"HttpHeaders": [
{
"Name": "Authorization",
"Value": "Bearer <webhook-token>"
}
],
"Triggers": {
"Delivery": { "Enabled": true },
"Bounce": { "Enabled": true, "IncludeContent": false },
"SpamComplaint": { "Enabled": true, "IncludeContent": false }
}
}
  • Uses the modern Webhooks API (/webhooks), not the legacy server-level URL fields.
  • The <webhook-token> is the same ARDA_API_KEY already used by the system for API authentication, or a dedicated webhook token stored in Secrets Manager. Using the existing API key avoids introducing a new credential type.
  • All three event types (Delivery, Bounce, SpamComplaint) are configured in a single webhook object pointing to the same endpoint.
  • The Arda endpoint validates the Authorization: Bearer header the same way it validates normal API requests.

Step 4: Publish DNS Records (Route53 UPSERT)

Section titled “Step 4: Publish DNS Records (Route53 UPSERT)”

Using the Route53 SDK (not Postmark API), UPSERT the following records in the tenant’s partition zone via the L1 route53ZoneProxy. UPSERT is idempotent: it creates the record if absent, replaces it if present (see DQ-205.m).

Record TypeNameValueSource
TXT{DKIMPendingHost}{DKIMPendingTextValue}From Step 2 response
CNAMEpm-bounces.{config-slug}.{tenant-slug}pm.mtasv.netFrom Step 2 response (ReturnPathDomainCNAMEValue)
TXT_dmarc.{config-slug}.{tenant-slug}v=DMARC1; p=noneStatic (ramped later)

MX records are not created in v1 (Reply-To external). In v2+, an MX record for <tenant> pointing to Postmark’s inbound MX will be added for the procurement inbox.

Step 5: Verify DNS (asynchronous, trigger-driven)

Section titled “Step 5: Verify DNS (asynchronous, trigger-driven)”
EndpointsPUT /domains/{id}/verifyDkim, PUT /domains/{id}/verifyReturnPath
AuthAccount Token
PurposeTrigger Postmark to check that DNS records are published and correct

Verification is trigger-driven and bounded — there is no continuous background loop. After Step 4 completes and L3 transitions the row to PENDING_VERIFICATION, L3 spawns a fire-and-forget bounded polling round (default 5 attempts × 60 seconds) that calls both verify endpoints per attempt. If both return verified, the row moves to UNLOCKED. If the bounded round exhausts without success, the row stays in PENDING_VERIFICATION until the next trigger fires (manual /retry-verification or a send attempt). See DQ-207 for the full design and Scenarios 1b.1, 1b.2, 1b.3 for the three trigger paths.

  • No webhook/callback for verification completion — Postmark requires an explicit verifyDkim / verifyReturnPath call to perform the lookup.
  • DNS propagation typically completes in seconds to minutes; Postmark documents up to 48h worst case for external resolvers. The bounded-polling design accepts that very slow propagations require operator-triggered retry rather than long-running pod state.

Deprecated: SPF verification (POST /domains/{id}/verifyspf) exists but is deprecated. Postmark no longer requires SPF verification — DKIM is sufficient.

After all L2 mutation steps succeed, L3 encrypts the server token (from Step 1) with the partition-wide encryption key (AES-256-GCM versioned envelope per DQ-202; key derived via HKDF per DQ-203) and UPDATEs the existing row (inserted in PROVISIONING state at the start of the flow per DQ-205.j) with the captured external IDs and the new status.

Fields written by Step 6 (full schema in functional.md):

FieldValue
statusPENDING_VERIFICATION (was PROVISIONING)
postmarkServerIdFrom Step 1 response
postmarkDomainIdFrom Step 2 response
postmarkWebhookIdFrom Step 3 response
serverTokenEncryptedServer token from Step 1, AES-256-GCM-encrypted with partition-wide key (DQ-202 versioned envelope)
dkimVerifiedfalse (set to true by Scenario 1b on verification success)
returnPathVerifiedfalse (set to true by Scenario 1b on verification success)
dmarcPolicynone (initial; ramped over time)
provisionedAtnow()
verificationStartedAtnow() (used by the operator-alert query in DQ-207.j)

Fields populated at the start of the flow (initial INSERT, not Step 6):

FieldValue
configIdUUID generated by L3
tenantSlugDNS-safe tenant identifier (per DQ-206)
configSlugConfiguration slug
sendingDomain{configSlug}.{tenantSlug}.<partition>.{mail-root-domain}
provisioningStartedAtnow() (used by the stuck-row alert in DQ-205.f)

On partial failure, instead of UPDATE-to-PENDING_VERIFICATION, L3 UPDATEs the row with whatever external IDs were captured plus status='PROVISIONING_FAILED' and a diagnosticMessage. See Scenario 1 and DQ-205.c.

The encryption key is available from HOCON config (extras.email.encryptionKey), loaded at startup via ESO and converted to an AES SecretKey via HKDF in TokenCipher’s constructor. See functional.md for the encryption approach.

PlantUML diagram


Runtime email delivery using the per-tenant Server Token.

EndpointPOST /email
AuthServer Token (per-tenant)
PurposeSend a single transactional email

Request:

{
"From": "procurement@{config-slug}.{tenant-slug}.<partition>.{mail-root-domain}",
"To": "supplier@example.com",
"Cc": "",
"ReplyTo": "user@tenant-company.com",
"Subject": "Order ORD-00123 from TenantName",
"HtmlBody": "<html>...</html>",
"TextBody": "Plain text fallback (optional)",
"Attachments": [
{
"Name": "PO-ORD-00123.pdf",
"Content": "<base64-encoded-content>",
"ContentType": "application/pdf"
}
],
"MessageStream": "outbound"
}

Response:

{
"To": "supplier@example.com",
"SubmittedAt": "2026-04-21T10:30:00.000Z",
"MessageID": "b7bc2f4a-e38e-4336-af7d-e6c392c2f817",
"ErrorCode": 0,
"Message": "OK"
}
  • MessageID is stored in the email_send_log for correlation with delivery events.
  • ErrorCode: 0 means accepted by Postmark. Non-zero indicates a send-time rejection (invalid address, inactive recipient, etc.).
  • Attachment Content is base64-encoded. Maximum total message size: 10 MB.

Postmark sends HTTP POST requests to the configured webhook URL when delivery status changes. Webhooks are configured per server via the modern Webhooks API (POST /webhooks) during tenant provisioning (see Step 3).

Event types and their mapping to Arda statuses:

Postmark EventArda StatusKey Fields
DeliveryDELIVEREDMessageID, Recipient, DeliveredAt
BounceBOUNCEDMessageID, Type (e.g., HardBounce, SoftBounce), Description, BouncedAt
SpamComplaintCOMPLAINEDMessageID, Recipient, Type
Open(tracked, not status-changing)MessageID, Recipient, ReceivedAt

All event types are handled by the same Arda endpoint; dispatched based on the RecordType field in the payload.

Postmark does not sign webhook payloads (no HMAC, no cryptographic signature). Instead, Postmark’s modern Webhooks API supports custom HTTP headers via the HttpHeaders field, which allows configuring a Bearer token.

Bearer Token (primary mechanism)

The webhook is configured with an Authorization: Bearer <token> header via the HttpHeaders field on POST /webhooks. Postmark sends this header on every webhook request. The Arda endpoint validates the Bearer token using the same authentication mechanism already in place for normal API requests (ARDA_API_KEY validation).

This approach:

  • Reuses the existing Arda API authentication infrastructure — no new auth mechanism needed
  • Avoids credentials in URL strings (which can appear in access logs and proxy logs)
  • Is configured entirely via API (no console access required)

IP allowlisting (defense-in-depth, optional)

Postmark publishes webhook source IPs (as of March 2025):

  • 3.134.147.250
  • 50.31.156.6
  • 50.31.156.77
  • 18.217.206.57

These can be allowlisted at the API Gateway or network level as an additional layer. However:

  • The IP may vary between retries of the same webhook delivery.
  • Postmark may change these IPs over time — they require periodic monitoring.
  • IP allowlisting should not be the sole authentication mechanism.

HTTPS

Mandatory. Encrypts token and payload in transit. All Arda API endpoints are HTTPS-only.

Event TypeRetriesSchedule
Delivery31 min, 5 min, 15 min
Bounce, SpamComplaint8Up to 6 hours
  • Non-200 responses trigger retries.
  • HTTP 403 immediately stops retries — useful for rejecting unauthorized requests cleanly.
  • The Arda endpoint should return 200 after successfully processing an event, 403 if authentication fails, and 500 for transient errors (to trigger retry).

Postmark has two webhook configuration approaches:

ApproachFieldsAuth SupportUsed by Arda
Legacy (server-level)DeliveryWebhook, BounceWebhook, SpamComplaintWebhook on Server objectBasic Auth embedded in URL onlyNo
Modern (Webhook objects)POST /webhooks, PUT /webhooks/{id} with HttpHeaders, HttpAuth, TriggersBearer token via HttpHeaders, Basic Auth via HttpAuth, per-trigger configYes

Arda uses the modern Webhooks API exclusively. The legacy server-level URL fields are not used.


Operations available for CS diagnostics and the future admin UI.

OperationMethodEndpointAuth
Get delivery statsGET/deliverystatsServer Token
List bouncesGET/bounces?count=N&offset=N&type=...Server Token
Get single bounceGET/bounces/{bounceid}Server Token
Activate bounce (re-enable sending)PUT/bounces/{bounceid}/activateServer Token
Get bounce tagsGET/bounces/tagsServer Token
OperationMethodEndpointAuth
Search outbound messagesGET/messages/outbound?count=N&offset=N&recipient=...&tag=...Server Token
Get message detailsGET/messages/outbound/{messageid}/detailsServer Token
Get message dump (raw)GET/messages/outbound/{messageid}/dumpServer Token
OperationMethodEndpointAuth
Outbound overviewGET/stats/outbound?fromdate=...&todate=...Server Token
Sent countsGET/stats/outbound/sends?fromdate=...&todate=...Server Token
Bounce countsGET/stats/outbound/bounces?fromdate=...&todate=...Server Token
Spam complaint countsGET/stats/outbound/spam?fromdate=...&todate=...Server Token

Operations for maintaining sending domains after initial provisioning.

OperationMethodEndpointAuthPurpose
List domainsGET/domains?count=N&offset=NAccount TokenInventory of all registered domains
Get domainGET/domains/{id}Account TokenCheck verification status
Rotate DKIMPOST/domains/{id}/rotatedkimAccount TokenGenerate new DKIM keys (returns new pending TXT values; old keys remain valid until new ones are verified)
Delete domainDELETE/domains/{id}Account TokenTenant deprovisioning
Delete serverDELETE/servers/{id}Account TokenTenant deprovisioning (may require Postmark support to enable)

Postmark has two identity verification mechanisms:

  • Sender Signatures (/senders): Individual email address verification via confirmation email. Useful for getting started with a single address but not scalable for multi-tenant provisioning.
  • Domains (/domains): Domain-level verification via DKIM DNS records. Once verified, any address on the domain can send without individual signature setup.

Arda uses the Domains API exclusively. Once DKIM is verified for {config-slug}.{tenant-slug}.<partition>.{mail-root-domain}, any From address (e.g., procurement@..., shipping@...) works without further setup.


Operations Requiring Console (One-Time Only)

Section titled “Operations Requiring Console (One-Time Only)”

The following cannot be done via API and must be performed manually in the Postmark console:

OperationWhenNotes
Create Postmark accountInfrastructure setup (once)Two accounts: PostmarkProd, PostmarkNonProd
Retrieve Account API TokenInfrastructure setup (once)From account.postmarkapp.com/api_tokens
Billing / plan managementInfrastructure setup (once)Upgrade to Platform plan
User/team member managementAs neededAdding account admins
Enable server deletionOnce (if needed)Contact Postmark support; DELETE /servers/{id} is not enabled by default on all accounts