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 Account Structure
Section titled “Postmark Account Structure”| Postmark Account | Partitions | Server Type | Delivery Behavior |
|---|---|---|---|
| PostmarkProd | prod, demo | Live servers | Real delivery to recipients |
| PostmarkNonProd | dev, stage | Sandbox servers | Accepted but dropped (blackhole) |
Both accounts use the Platform plan (unlimited servers, domains, message streams per account).
Authentication
Section titled “Authentication”Postmark uses two token types. The token type determines which operations are available.
| Token Type | HTTP Header | Scope | Used For |
|---|---|---|---|
| Account Token | X-Postmark-Account-Token | All servers and domains in the account | Provisioning: server CRUD, domain CRUD, verification |
| Server Token | X-Postmark-Server-Token | Single server | Runtime: 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.
| Secret | Path Convention | Delivery | Used by |
|---|---|---|---|
| PostmarkProd account API token | prod/email/postmark-account-token | ESO -> HOCON config | emailConfiguration service (provisioning) |
| PostmarkNonProd account API token | dev/email/postmark-account-token | ESO -> HOCON config | emailConfiguration service (provisioning) |
| Email encryption key (per partition) | <partition>/email/encryption-key | ESO -> HOCON config | emailConfiguration 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.
Required Actions
Section titled “Required Actions”Tenant Provisioning
Section titled “Tenant Provisioning”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.
Step 1: Create Server
Section titled “Step 1: Create Server”| Endpoint | POST /servers |
| Auth | Account Token |
| Purpose | Create 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 (
ApiTokensarray) — encrypted and stored in the database at Step 6. - Webhook configuration is done separately via
POST /webhooks(see Step 3).
Step 2: Create Sending Domain
Section titled “Step 2: Create Sending Domain”| Endpoint | POST /domains |
| Auth | Account Token |
| Purpose | Register 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 Field | DNS Record Type | Purpose |
|---|---|---|
DKIMPendingHost | TXT (name) | Hostname for the DKIM TXT record |
DKIMPendingTextValue | TXT (value) | DKIM public key value |
ReturnPathDomain | CNAME (name) | The Return-Path subdomain (as requested) |
ReturnPathDomainCNAMEValue | CNAME (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)”| Endpoint | POST /webhooks |
| Auth | Server Token (from Step 1) |
| Purpose | Configure 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 sameARDA_API_KEYalready 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: Bearerheader 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 Type | Name | Value | Source |
|---|---|---|---|
| TXT | {DKIMPendingHost} | {DKIMPendingTextValue} | From Step 2 response |
| CNAME | pm-bounces.{config-slug}.{tenant-slug} | pm.mtasv.net | From Step 2 response (ReturnPathDomainCNAMEValue) |
| TXT | _dmarc.{config-slug}.{tenant-slug} | v=DMARC1; p=none | Static (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)”| Endpoints | PUT /domains/{id}/verifyDkim, PUT /domains/{id}/verifyReturnPath |
| Auth | Account Token |
| Purpose | Trigger 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/verifyReturnPathcall 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.
Step 6: Persist Tenant Config
Section titled “Step 6: Persist Tenant Config”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):
| Field | Value |
|---|---|
status | PENDING_VERIFICATION (was PROVISIONING) |
postmarkServerId | From Step 1 response |
postmarkDomainId | From Step 2 response |
postmarkWebhookId | From Step 3 response |
serverTokenEncrypted | Server token from Step 1, AES-256-GCM-encrypted with partition-wide key (DQ-202 versioned envelope) |
dkimVerified | false (set to true by Scenario 1b on verification success) |
returnPathVerified | false (set to true by Scenario 1b on verification success) |
dmarcPolicy | none (initial; ramped over time) |
provisionedAt | now() |
verificationStartedAt | now() (used by the operator-alert query in DQ-207.j) |
Fields populated at the start of the flow (initial INSERT, not Step 6):
| Field | Value |
|---|---|
configId | UUID generated by L3 |
tenantSlug | DNS-safe tenant identifier (per DQ-206) |
configSlug | Configuration slug |
sendingDomain | {configSlug}.{tenantSlug}.<partition>.{mail-root-domain} |
provisioningStartedAt | now() (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.
Provisioning Sequence
Section titled “Provisioning Sequence”Email Sending
Section titled “Email Sending”Runtime email delivery using the per-tenant Server Token.
| Endpoint | POST /email |
| Auth | Server Token (per-tenant) |
| Purpose | Send 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"}MessageIDis stored in theemail_send_logfor correlation with delivery events.ErrorCode: 0means accepted by Postmark. Non-zero indicates a send-time rejection (invalid address, inactive recipient, etc.).- Attachment
Contentis base64-encoded. Maximum total message size: 10 MB.
Delivery Events (Inbound from Postmark)
Section titled “Delivery Events (Inbound from Postmark)”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 Event | Arda Status | Key Fields |
|---|---|---|
Delivery | DELIVERED | MessageID, Recipient, DeliveredAt |
Bounce | BOUNCED | MessageID, Type (e.g., HardBounce, SoftBounce), Description, BouncedAt |
SpamComplaint | COMPLAINED | MessageID, 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.
Webhook Authentication
Section titled “Webhook Authentication”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.25050.31.156.650.31.156.7718.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.
Webhook Retry Behavior
Section titled “Webhook Retry Behavior”| Event Type | Retries | Schedule |
|---|---|---|
| Delivery | 3 | 1 min, 5 min, 15 min |
| Bounce, SpamComplaint | 8 | Up 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).
Legacy vs Modern Webhook API
Section titled “Legacy vs Modern Webhook API”Postmark has two webhook configuration approaches:
| Approach | Fields | Auth Support | Used by Arda |
|---|---|---|---|
| Legacy (server-level) | DeliveryWebhook, BounceWebhook, SpamComplaintWebhook on Server object | Basic Auth embedded in URL only | No |
| Modern (Webhook objects) | POST /webhooks, PUT /webhooks/{id} with HttpHeaders, HttpAuth, Triggers | Bearer token via HttpHeaders, Basic Auth via HttpAuth, per-trigger config | Yes |
Arda uses the modern Webhooks API exclusively. The legacy server-level URL fields are not used.
Query and Inspection
Section titled “Query and Inspection”Operations available for CS diagnostics and the future admin UI.
Bounce Management
Section titled “Bounce Management”| Operation | Method | Endpoint | Auth |
|---|---|---|---|
| Get delivery stats | GET | /deliverystats | Server Token |
| List bounces | GET | /bounces?count=N&offset=N&type=... | Server Token |
| Get single bounce | GET | /bounces/{bounceid} | Server Token |
| Activate bounce (re-enable sending) | PUT | /bounces/{bounceid}/activate | Server Token |
| Get bounce tags | GET | /bounces/tags | Server Token |
Message Search
Section titled “Message Search”| Operation | Method | Endpoint | Auth |
|---|---|---|---|
| Search outbound messages | GET | /messages/outbound?count=N&offset=N&recipient=...&tag=... | Server Token |
| Get message details | GET | /messages/outbound/{messageid}/details | Server Token |
| Get message dump (raw) | GET | /messages/outbound/{messageid}/dump | Server Token |
Server Stats
Section titled “Server Stats”| Operation | Method | Endpoint | Auth |
|---|---|---|---|
| Outbound overview | GET | /stats/outbound?fromdate=...&todate=... | Server Token |
| Sent counts | GET | /stats/outbound/sends?fromdate=...&todate=... | Server Token |
| Bounce counts | GET | /stats/outbound/bounces?fromdate=...&todate=... | Server Token |
| Spam complaint counts | GET | /stats/outbound/spam?fromdate=...&todate=... | Server Token |
Domain Management (ongoing)
Section titled “Domain Management (ongoing)”Operations for maintaining sending domains after initial provisioning.
| Operation | Method | Endpoint | Auth | Purpose |
|---|---|---|---|---|
| List domains | GET | /domains?count=N&offset=N | Account Token | Inventory of all registered domains |
| Get domain | GET | /domains/{id} | Account Token | Check verification status |
| Rotate DKIM | POST | /domains/{id}/rotatedkim | Account Token | Generate new DKIM keys (returns new pending TXT values; old keys remain valid until new ones are verified) |
| Delete domain | DELETE | /domains/{id} | Account Token | Tenant deprovisioning |
| Delete server | DELETE | /servers/{id} | Account Token | Tenant deprovisioning (may require Postmark support to enable) |
Sender Signatures vs Domains
Section titled “Sender Signatures vs Domains”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:
| Operation | When | Notes |
|---|---|---|
| Create Postmark account | Infrastructure setup (once) | Two accounts: PostmarkProd, PostmarkNonProd |
| Retrieve Account API Token | Infrastructure setup (once) | From account.postmarkapp.com/api_tokens |
| Billing / plan management | Infrastructure setup (once) | Upgrade to Platform plan |
| User/team member management | As needed | Adding account admins |
| Enable server deletion | Once (if needed) | Contact Postmark support; DELETE /servers/{id} is not enabled by default on all accounts |
Copyright: © Arda Systems 2025-2026, All rights reserved