Postvow

Developer documentation

Three requests to a first send, the interactive API reference, and the index of every identifier the API links to. Everything here is generated from or checked against the running service — where a manual would be needed, this points at the generated contract instead of paraphrasing it.

Your first send

You need one thing that is not on this page: an API key. Keys are issued during onboarding, which is a conversation rather than a form.hello@postvow.com

  1. Send a message

    A test key — the pv_test_ prefix — captures the message instead of delivering it, so a first integration never reaches a real inbox and never spends your sending reputation. Swap it for the live key when you are ready.

    curl -X POST https://api.postvow.eu/v1/send \
      -H "Authorization: Bearer pv_test_..." \
      -H "Content-Type: application/json" \
      -d '{
        "to": "you@example.com",
        "from": "noreply@your-domain.com",
        "subject": "First send",
        "text": "It works."
      }'
  2. Read the acceptance

    Sending is asynchronous: 202 means accepted for delivery, not delivered. Keep the messageId — it is how every later question about this message is asked.

    {
      "messageId": "019fd464-18dc-7e6f-a755-2d492f4ba944",
      "stream": "transactional",
      "warnings": []
    }
  3. Ask what happened to it

    The first call returns the message and its current delivery status. The second is the one worth knowing about: it returns the decision tree — what was attempted, what the receiving server said, and why the outcome is what it is.

    curl https://api.postvow.eu/v1/messages/{messageId} \
      -H "Authorization: Bearer pv_test_..."
    
    curl https://api.postvow.eu/v1/messages/{messageId}/explain \
      -H "Authorization: Bearer pv_test_..."

The full API

Two ways to read one artefact. Pick by why you came.

Two things are authoritative, and neither of them is this page.

The response you are holding. Every Postvow error is RFC 9457 problem details and carries its own message and suggestedFix. That text is generated at the point the error occurs and is more specific than anything written here could be.

The OpenAPI specification. It is generated from the running code, so it cannot drift from the behaviour of the API you are calling.

If a code below is unfamiliar and the response text did not resolve it, write to us and quote the code.hello@postvow.com

Error codes

The codes the API emits from a fixed call site. The set is open — a code absent from this list is not an invalid code, and the response body remains authoritative for it.

API topics

Where a public operation exists, the label links to it in the interactive reference. A few name operator-only endpoints deliberately excluded from the public specification, or conventions (headers, rate limits) that span more than one operation — those carry no link, marked below.

Operator-only

  • admin dedicated ipsno spec link
  • admin invoicesno spec link
  • admin m2m clientsno spec link
  • admin partner webhook eventsno spec link
  • admin runtime configno spec link
  • admin tenant keysno spec link
  • admin tenant api keysno spec link
  • admin tenantsno spec link

Guides and compatibility

A compatibility reference INDEX, not a step-by-step guide library: each entry below is an anchor a response or the migration endpoints link to, with a short factual note on what the implementing code actually does — not a full walkthrough of every screen or field. The migration-diff tool returns machine-readable differences per ESP; several entries carry a one-line fact pulled from that tool or from a live credential-swap endpoint.

account

  • profile

    GET/PATCH /v1/account/profile reads and updates the legal name, address, contact email and DPO contact used to render your GDPR Art.28 Data Processing Agreement (GET /v1/compliance/dpa).

    Omit a field in the PATCH body to leave it unchanged, or send null to clear it. The legal name, address and contact email are required for a DPA: until all three are set, GET /v1/compliance/dpa returns 422 DPA_CONTROLLER_DETAILS_INCOMPLETE listing the missing fields, and never a document with placeholders. A missing DPO contact is not required, since it is a valid state for many tenants.

    These endpoints are gated on a tenant:manage-or-admin scope, the same owner-only pattern team management uses. The console exposes the same fields on its Account page, for tenant owners. Once the details are complete, an owner accepts the DPA electronically on the Compliance page (POST /v1/compliance/dpa/accept); the executed copy then carries an Execution Record with who accepted, when, the version and a SHA-256 fingerprint of the accepted terms.

  • suspended

    A tenant is suspended by Postvow operations staff, not through self-service. Once suspended, both native API-key and SMTP-relay authentication return 403 TENANT_SUSPENDED for every customer-facing operation except GET /v1/compliance/* and POST /v1/compliance/export, so a suspended tenant can still export its own compliance data. The M2M auth path enforces the same status check with no compliance-read carve-out.

authentication

  • dkim setup

    POST /v1/domains provisions DKIM automatically with dual-signing (RSA-2048 and Ed25519). Postvow hosts the DKIM public-key record itself — the only manual step for the tenant is publishing one CNAME per signing key, so a dual-signed domain needs two CNAMEs published.

  • dns setup

    GET /v1/domains/{domainId} returns the stored records to publish — one CNAME per active DKIM selector (required), an apex SPF TXT (recommended, not required — see below), and (only once a DMARC policy has been proposed) a recommended _dmarc TXT — with no live DNS lookup; GET /v1/domains/{domainId}/verify performs that live check, and only the DKIM CNAME can fail it.

    SPF is optional because every message Postvow sends uses a VERP envelope sender (bounce.postvow.eu), so receivers evaluate SPF against that Postvow-owned name, never against your domain, and DMARC alignment for Postvow-sent mail runs entirely through DKIM. Publish the SPF include only if your domain also sends mail directly (outside Postvow) and is not itself a CNAME — a CNAME apex cannot hold any other DNS record (RFC 1034 §3.6.2), which is the normal shape for a white-label custom domain. If your domain already publishes an SPF record, merge the Postvow include into it by hand rather than replacing it outright.

    The live verify check also reports three non-blocking advisories in preconditions[] (persisted, so a later GET still shows them):

    • from-domain-is-cname when the sending name itself resolves as a CNAME (which also means it cannot publish its own MX or TXT);
    • from-domain-no-mx when the sending name is not a CNAME but publishes no MX at all, so postmaster@/abuse@ mail to it is undeliverable; and
    • org-domain-dmarc-absent when the organisational domain above the sending name publishes no _dmarc of its own.

    None of the three blocks verification or sending.

  • removing a domain

    DELETE /v1/domains/{domainId} immediately stops sending from that domain and revokes its DKIM keys; the domain string can be registered again afterwards with a fresh POST /v1/domains.

  • m2m scopes

    An M2M identity-provider bearer token normalizes to one of three native scopes, never to native admin:

    • provider mail:send maps to native send, the only scope POST /v1/send accepts;
    • provider api:read maps to native read, which GET /v1/messages/{messageId} and GET /v1/messages/{messageId}/explain both accept for ANY message in the tenant;
    • provider mail:read-own maps to native messages:read-own, which the same two endpoints also accept, but ONLY for a message the same credential sent (anything else is a 404, never a 403 — it never discloses whether the message exists).

    The same send/read split applies to native pv_* API keys (send vs messages:read/messages:explain/messages:read-own).

    A send-only credential — provider mail:send alone, or a native key carrying only send — is fire-and-forget: POST /v1/send still returns a messageId and stream, but the response omits _links.explain, and that same credential is refused on both message-read endpoints (sending never implies reading, even your own message — request a read scope explicitly).

    To poll the status or decision tree of the messages YOU sent, and nothing else, request mail:read-own / messages:read-own — the least-privilege choice for most integrations. To read every message in the tenant (a dashboard, a support tool), request api:read / messages:read+messages:explain instead. Otherwise consume the postvow.email.* webhooks for delivery outcomes. Request only the scopes an integration actually needs; a send-only integration should stay send-only rather than default to broader read access.

billing

  • limits

    Where billing enforcement is active, exceeding your budget returns 402 on further sends. A priority:critical send is never hard-blocked by a billing limit — it delivers as billed overage, capped per tenant per day.

delivery

  • bounces after acceptance

    A 202/sent response means the receiving mail server accepted the message over SMTP — not that it reached an inbox, and not that it will stay accepted. (This is the API status the landing page stage named accepted-by-MTA maps to — same fact, same transcript, different name for a reader outside this API reference.)

    A bounce generated AFTER that acceptance (an RFC 3464 Delivery Status Notification, sent by the receiving system to the return-path address, sometimes minutes to days later) is sourced via a VERP return-path and an inbound listener under a dedicated bounce subdomain. NOT LIVE ON EVERY TENANT TODAY — it activates per-deployment once that subdomain DNS and inbound listener are provisioned; until then, a message can still bounce after acceptance with nothing further reported on this API beyond the initial sent status.

    When live, a message that bounces this way transitions from sent to bounced and fires the same postvow.email.bounced webhook a synchronous bounce fires, with one added field: data.source is set to dsn for a bounce sourced this way and absent for a synchronous one. GET /v1/messages/{messageId} and /explain do not yet surface that distinction — only the webhook payload does.

    The SAME inbound path also sources a genuine sent → delivered transition when the DSN reports Action: delivered instead of a failure — see delivery → confirmation (delivered) below.

  • confirmation (delivered)

    Message status delivered is reached two ways (deliveredEvidence on GET /v1/messages/{messageId} and the webhook payload names which):

    • dsn_verified — a positive RFC 3464 Delivery Status Notification the receiving mail system sends back, cryptographically matched to the original send via the VERP return-path before it is trusted — this IS the confirmation from the receiver itself.
    • no_bounce_window — NO receiver signal at all: the Postvow retry-window inference found no bounce, deferral or complaint recorded for the message within N hours of acceptance (N defaults to 72 hours; the recipient MTA ecosystem typical retry horizon, RFC 5321 §4.5.4.1 / the ~5-day convention most mail servers converge on, is why a longer N is always safe — Postvow itself delivers direct-to-MX with no outbound retry queue of its own).

    This is NOT a receiver verdict — it is the absence of a negative signal, surfaced as confidence: inferred (vs. confirmed for dsn_verified) precisely so it is never mistaken for one.

    Most large consumer mailbox providers (Gmail, Outlook.com, Yahoo) never issue a success DSN at all, so almost every delivered status you see for them is no_bounce_window.

    Either way the message transitions from sent to delivered, GET /v1/messages returns a non-null deliveredAt for it, and a postvow.email.delivered webhook fires — data.source is dsn for dsn_verified, inferred for no_bounce_window (the same field the bounce-after-acceptance webhook carries).

    A message delivered via no_bounce_window can still regress to bounced if a genuinely late bounce/DSN arrives after the window closed; a dsn_verified delivered never regresses — the receiver already confirmed it.

  • proof (signed daily checkpoint)

    Every UTC day Postvow builds a Merkle tree (RFC 6962, SHA-256) over that UTC day terminal delivery events — one erasure-safe leaf per message (identifiers and hashes only, never an address, subject or body) — signs the root as a detached JWS (RFC 7515, Ed25519, JCS RFC 8785 payload) and chains it to the previous root.

    The signing public key is published at /.well-known/postvow-delivery-proof/keys (JWKS) and any signed checkpoint at GET /v1/delivery-proof/checkpoints/{date}, both unauthenticated; the per-message inclusion proof is GET /v1/messages/{id}/proof (scope messages:read) and requires the audit_proof add-on, which is not yet available.

    The format is the published postvow-delivery-proof v1 spec with a dependency-free offline verifier, so a record can be verified without contacting Postvow.

    • What a proof attests: the Postvow record of MX acceptance or of a receiver-issued DSN, at a signed time.
    • What it does NOT attest: mailbox placement, that a human read the message, or anything court-admissible — a qualified (eIDAS) timestamp over the daily root is a reserved field, not yet issued.

compat

  • sendgrid

    Live credential-swap endpoint (POST /v3/mail/send). Handles personalizations, from, subject, content, custom_args, categories, template_id and asm; other fields (mail_settings, tracking_settings, attachments, …) are accepted but ignored — reported via X-Postvow-Compat-Ignored on the response.

compliance

  • data processing agreement

    GET /v1/compliance/dpa renders your GDPR Art. 28 Data Processing Agreement as Markdown or PDF. Until the current version is accepted it is a DRAFT, marked as such; after an owner accepts it, the same route returns the executed copy, rendered from the recorded acceptance and reproducible on every read. The X-DPA-Execution-Status response header says which one you received.

    POST /v1/compliance/dpa/accept, with the current version as dpaVersion, records the acceptance: who (the signed-in owner), when, the version, and a SHA-256 fingerprint of the accepted terms, all printed in the Execution Record. Only a tenant owner signed in to the dashboard can accept; API keys are refused. GET /v1/compliance/dpa/status reports the current state.

    Refusals: 422 DPA_CONTROLLER_DETAILS_INCOMPLETE when the legal name, address or contact email is missing (set them with PATCH /v1/account/profile); 409 DPA_VERSION_MISMATCH or DPA_ALREADY_ACCEPTED on accept; 409 DPA_NOT_APPLICABLE_SAME_LEGAL_ENTITY when the account is operated by the Processor itself, where an internal processing record applies instead of a DPA.

    A 500 DPA_TEMPLATE_CHANGED_SINCE_ACCEPTANCE means the executed copy cannot be shown because the wording of the agreement changed after it was accepted; the copy is never rendered in any text other than the one you accepted. This is a condition on our side, not something your request caused or can fix: contact privacy@postvow.com.

dashboard

  • login

    GET /v1/auth/login starts an SSO sign-in; the callback verifies the identity and maps it to your tenant, and a session is issued for 24 hours. A tenant can require a minimum authentication strength for its members; a login that falls short is refused rather than silently downgraded. Back-Channel Logout (OIDC): when you sign out of your identity provider, your Postvow console session ends within seconds — Postvow does not wait for the 24-hour session to expire naturally. RP-Initiated Logout (OIDC): signing out of the console (POST /v1/auth/logout) also ends the session at your identity provider — the console navigates you to your identity provider’s own sign-out step as part of signing out. Session retention: an ended session record is kept for a short grace period after it expires, then deleted; nothing usable to continue the session is retained past expiry or sign-out.

deliverability

  • bounce management

    A hard bounce or a spam complaint suppresses the address, refusing it on every future send for a numeric retention window (24 months by default, tracked from when the entry was created — see the retention schedule in your DPA), not forever. Removing a hard-bounce or complaint entry early requires the suppression:manage scope plus a justification and who requested it (tenant or data subject), which is itself recorded in your compliance log; a self-added (manual) entry can be removed by any caller with read access to compliance data, with no justification needed. An entry created by a GDPR Art. 17 erasure request can never be removed this way — the only path off it is the numeric retention purge. EmailBounced, EmailComplained, EmailDeferred and EmailDropped webhook events report which of these happened for a given send. A 4xx reply from the next hop is never final: Postvow defers and retries; only exhausted retries produce a soft bounce.

  • email storm prevention

    An automated early-warning layer periodically reviews each domain and can raise a warning or a critical alert recommending specific interventions (cut volume, review templates, purge the suppression list, pause sending). It is advisory, not an automatic throttle — see operations → circuit breaker for what actually pauses sending.

  • spam prevention

    POST /v1/check/disposable lets you check a recipient address for disposable-domain use before sending. Separately, each tenant/domain accrues a deliverability reputation score from live sending outcomes; a newly active domain starts provisional and only becomes reliable once enough real sending history has accumulated.

migration

Landing anchor for manual migration guidance when /v1/tools/migration-diff cannot auto-detect the ESP.

  • mailgun

    The migration-diff tool flags Mailgun recipient-variables, attachments and batch sending as blockers (no live Mailgun credential-swap endpoint exists — analysis only).

  • nodemailer

    The migration-diff tool flags attachments as a blocker, and multiple recipients or a raw SMTP transport config as warnings (no live Nodemailer credential-swap endpoint exists — analysis only).

  • postmark

    Live credential-swap endpoint (POST /email). Handles To, From, Subject, HtmlBody, TextBody, Tag, Metadata, Headers, TemplateId/TemplateAlias/TemplateModel, Bcc and Cc; TrackOpens, TrackLinks, Attachments, ReplyTo and MessageStream are accepted but ignored. The migration-diff tool additionally flags templates, Attachments and batch sends as blockers.

  • resend

    The migration-diff tool flags React email templates and batch sending as blockers (no live Resend credential-swap endpoint exists — analysis only).

  • sendgrid

    The migration-diff tool flags template_id, multi-recipient personalizations and attachments as blockers, and asm as a warning — see compat → sendgrid above for the live endpoint.

  • ses

    The migration-diff tool flags raw MIME messages (SendRawEmail) and bulk sending as blockers, and a ConfigurationSet reference as a warning (no live SES credential-swap endpoint exists — analysis only).

  • open tracking (opt-in)

    Off by default, per DOMAIN and per STREAM (PUT/DELETE/GET /v1/domains/{id}/open-tracking, domain-scope opt-in plus an explicit stream allow-list, instant revocation), AND per message (tracking.opens: true on /v1/send, default false) — a pixel is injected only when all three hold. Open tracking is off by default. When a stream enables it, postvow places a 1x1 image hosted on Postvow-controlled servers in the HTML part of the message and records the first time it is fetched. Because the image is fetched by whichever mail client the recipient uses, EU law (Art. 5(3) ePrivacy Directive; in Poland art. 399 PKE) requires prior consent from the RECIPIENT before enabling — postvow does not and cannot obtain that consent on the tenant behalf. The signal is low-confidence (image blocking, privacy proxies, preview panes) and is never a proof of delivery. It is never applied to authentication, one-time-code, password-reset or security messages, and never to a recipient on the suppression or erasure list. Records kept: a message id, the first-fetch timestamp, and a coarse user-agent class — never the raw User-Agent, never an IP address, never a recipient identifier — retained with the delivery record and purged on the same cycle. The read message status is reachable only from sent/delivered and is not proof of delivery or of reading (GET /v1/messages/{id} returns a readSignalNote field saying so; the postvow.email.read webhook carries the same statement). A tenant that enables open tracking at large scale, for vulnerable recipients, or where the content is sensitive should carry out its own DPIA — postvow provides a DPIA screening and RoPA entry (see the GRC privacy gate — open tracking record) as inputs to that, not a substitute for it.

operations

  • circuit breaker

    A per-tenant circuit breaker can pause a tenant sending abnormally high complaint or hard-bounce rates, also pausing active IP warmup for that tenant; resetting it is not self-service today — contact support@postvow.com once your list and templates are fixed.

    A separate backend breaker protects against a failing delivery provider and recovers on its own once that provider is healthy again — no tenant action is needed for that one.

  • dkim rotation

    DKIM keys rotate automatically on a periodic cadence. A new key is only promoted into active signing once Postvow has confirmed its DNS record actually resolves, so rotation cannot itself break signing while a change is still propagating — but this only holds if the domain has pre-published its next rotation slot.

    DKIM key rotation — what you publish once: Postvow rotates each signing algorithm between two fixed selector slots per domain. Publishing the CNAME for BOTH slots once, ahead of time, means every future rotation for that domain is pre-covered and needs no further action.

    If a slot is not yet covered when its rotation comes due, Postvow REFUSES to start it rather than risk unsigned mail — the current key keeps signing, and a daily notice emails the registered contact for that domain the exact records to publish (a second, more urgent reminder follows if the rotation date is within a few days).

    GET /v1/domains/{domainId} returns the domain-scoped CNAME targets to publish for its DKIM algorithm(s) under recommended DNS records.

  • sdk

    There is no published Postvow SDK.

    Integration is the raw HTTP API shown in the quickstart above, or — for AI-agent use specifically — the @postvow/mcp Model Context Protocol server, a local package not published to npm, exposing exactly three tools:

    • send a message,
    • check delivery status, and
    • get a deliverability score.

security

  • mta sts

    Postvow checks the RECIPIENT MTA-STS policy before sending, not the reverse — per RFC 8461, a lookup or parse error fails open (TLS not mandated) rather than blocking the send.

  • transport encryption

    Our delivery relay requires an encrypted session (TLS via STARTTLS) on the final hop to the recipient mail server; a recipient mail server that does not support STARTTLS is not delivered to in plaintext.

    Today that refusal reaches you as a postvow.email.bounced webhook (the same event a recipient-side rejection would produce), so treat it the way you would any other bounce: retrying the same send will not help until the recipient side changes.

    When the recipient domain publishes DANE TLSA records or an MTA-STS policy, the relay honours it and the session additionally verifies the recipient server certificate; absent either, a successful encrypted session does not by itself confirm the recipient server identity.

    This is a delivery-time requirement enforced on every send, not a fact reported back to you per message today.

  • smtp relay

    SMTP is a customer-facing send path, not internal-only: a customer can authenticate an SMTP client directly with a pv_live_/pv_test_ API key carrying send scope, on port 587 in production, the same process as the REST API. A suspended tenant loses SMTP access the same way it loses API-key access. Both paths deliver through the same processing pipeline and go direct-to-MX in production.

team

  • members

    Invitation is self-service: POST /v1/team/members (team:manage or admin scope) creates an invited row with a single-use token; the first login of the invitee binds their identity and activates membership, with no manual database write required. DELETE refuses to remove the last remaining owner. Listing and role changes are GET/PATCH /v1/team/members.

  • roles

    Team members carry one of four self-service roles: owner, developer, dpo, viewer. The data-protection role is the API value dpo; the console labels it Data protection. It grants compliance and data-export access and is NOT a designation under GDPR Art.37. Team-management endpoints are gated on a team:manage-or-admin scope rather than the role name directly — owner carries that scope, which is why an owner can manage the team. A full permission matrix for what developer, dpo and viewer can and cannot do beyond team management is not encoded in the API today.

warmup

  • schedule

    This is a shared-IP sending ramp, not a dedicated IP — Postvow does not offer a dedicated sending IP today. The default schedule ramps from 50 messages/day on day 1 to 10,000+/day by day 60 (hard ceiling 50,000). The ramp is automatically adjusted based on delivery performance and can be held, slowed or paused, so the default schedule is a starting trajectory, not a guarantee.

webhooks

  • url requirements

    A registered webhook endpoint must be HTTPS, with no credentials or fragment in the URL; Postvow resolves the hostname and rejects it if any resolved address is private, loopback or otherwise non-public, and rejects raw IP-literal targets outright.

    Deliveries are signed — an X-Postvow-Signature header carrying an HMAC-SHA256 digest, dual-signed during secret rotation — and timestamped, and retry up to 8 times over roughly 1 minute to 24 hours with jitter; a 410 response disables the endpoint immediately, and an endpoint with zero successes for 72 hours (and at least one failure) is auto-disabled.