Docs / White-label API

White-label API

If you resell sending — an agency running mail for its clients, or a SaaS with its own “connect your domain” screen — your backend can drive Postwing directly: create domains, read the DNS records your users have to publish, issue SMTP credentials and register webhooks, all from your own product. Your users never see this panel and never hear the name Postwing.

That is what a white-label token buys. It is an account-level API credential, issued from the dashboard, that authenticates against a separate machine API under /api/wl/v1/.

What you need

White-label is an account-level entitlement. It is not part of a plan and there is nothing to buy — we switch it on for your account when you ask us to. Until then the White-label page in your account settings tells you how to get in touch; afterwards it lists your tokens. The entitlement covers the whole account, so a token is not tied to any one of your domains.

Your hostnames in your customers' DNS, not ours

This is the part that makes the feature worth having, and it is a separate setting from the token. Out of the box, the records we generate for a domain name us in three places — the places your customer copies into their own DNS panel and reads every time they audit their zone:

RecordNames
SPFv=spf1 a mx include:spf.postwing.app ~all
Tracking CNAMEstats.<customer>stats.postwing.app
DMARCrua=mailto:dmarc@reports.postwing.app

One field replaces all three. In Account → White-label, set your own DNS domain — a domain you own, e.g. mail.reseller.com. From then on, domains you create through the API generate include:spf.mail.reseller.com, a CNAME to stats.mail.reseller.com and rua=mailto:dmarc@dmarc.mail.reseller.com. Nothing in your customer's zone says Postwing.

The setting belongs to the storefront you configure it on, not to your account as a whole. If you also sell through another of our storefronts, that one carries its own DNS domain and its own set of records — the hosts they have to point at are different, so a single zone cannot be correct for both. Configure each one where you sell.

The same field is writable over the API — as a dashboard credential, not a white-label token:

bash
curl -X PATCH https://api.postwing.app/api/whitelabel/settings/ \
  -H "Authorization: Bearer $YOUR_DASHBOARD_JWT" \
  -H "Content-Type: application/json" \
  -d '{"dns_domain": "mail.reseller.com"}'

Deliberately so. A leaked machine credential must not be able to repoint every future customer's DNS, which is why this one route stays on the panel with the token issuing and revoking.

What you publish, once

The labels spf., stats., dmarc. and mx. are fixed; you supply the domain part. In your zone you publish five records, one time — not per customer:

TypeNamePoints atWhat breaks without it
CNAMEspf.mail.reseller.comspf.postwing.appThe SPF policy your customers' domains include:. Every message they send fails SPF.
CNAME or Astats.mail.reseller.comstats.postwing.appOpen and click tracking. Tracking links and open pixels in your customers' mail stop resolving.
MXdmarc.mail.reseller.comreports.postwing.app (priority 10)Where your customers' DMARC aggregate reports are delivered. They go nowhere, and the alarm that catches a broken DKIM key or an SPF chain that stopped authorising you goes silent with them.
TXT*._report._dmarc.dmarc.mail.reseller.comv=DMARC1The RFC 7489 §7.1 permission for receivers to send those reports. Without it they are quietly withheld.
CNAME or Amx.mail.reseller.commx.postwing.appWhere mail addressed to your customers' inbound routes is delivered. Only customers who receive need it.

The SPF row is the one to read carefully: what belongs at spf.mail.reseller.com is a policy that includes spf.postwing.app, and a CNAME is the simplest way to publish it because it then keeps itself up to date as our sending IPs change. A TXT policy of your own works too, as long as it authorises us. Do not read the value the API reports for this record — v=spf1 — as a string to paste: it is the marker we compare against, and published on its own it authorises nobody. v=DMARC1 on the wildcard, by contrast, is the whole record.

The middle pair is for DMARC reports. Your customers' aggregate reports are addressed to a mailbox in your domain, so the MX has to reach us and — because the domain being reported on is your customer's and the mailbox is yours — RFC 7489 §7.1 makes a receiver look for permission before sending. That permission is one wildcard, not a record per customer. The White-label page resolves all five, shows each one's state and tells you what is missing; press Check after publishing.

Set it before you onboard anybody
The value is read once, when a domain is created, and frozen onto that domain. Setting or changing it later affects new domains only — every customer you have already onboarded keeps the records they were given and published, because rewriting a value somebody has already put in their zone would break their mail on our schedule rather than theirs.
Nothing else checks this for you
Record verification compares your customer's published SPF against the string we stored; it does not resolve spf.mail.reseller.com. So if you never publish it, every customer's setup screen goes green while every message fails SPF at the receiver. The Check button on the White-label page is the only thing that catches it.

Issuing a token

  1. Go to Account → White-label in the dashboard.
  2. Click Issue token, give it a name, optionally an expiry, and pick its scopes.
  3. Copy the secret. It looks like wl_ followed by 48 characters and it is displayed exactly once — it is stored hashed and cannot be recovered. If you lose it, revoke the token and issue a new one.

The list afterwards shows a masked form (wl_ab12cd34…) so you can tell two tokens apart, plus when each was last used. Tokens cannot be edited: to change a token's scopes or expiry, issue a new one and revoke the old. Revoking takes effect immediately — anything still using that credential starts failing at once.

Authentication

Send the secret as a bearer token on every request:

bash
curl https://api.postwing.app/api/wl/v1/domains/ \
  -H "Authorization: Bearer wl_your-token-here"

A white-label token authenticates only against /api/wl/v1/. It cannot read your profile, your billing, or mint another token — the rest of the API does not know how to authenticate it and answers 401.

Rate limit: 120 requests per minute, per token. The bucket is per token, not per account, so separate integrations don't compete for the same budget. Over the limit you get 429.

Scopes

Read and write are split, so an integration that only mirrors state into your own UI can hold a credential that cannot create or delete anything.

ScopeGrants
domains:readList and retrieve domains, read their DNS records.
domains:writeCreate, delete and re-check domains.
smtp_tokens:readList and retrieve SMTP credentials.
smtp_tokens:writeCreate and delete SMTP credentials.
webhooks:readList and retrieve webhook endpoints.
webhooks:writeCreate, update and delete webhook endpoints.
inbound:readRead the inbound hostname, routes and received messages.
inbound:writeSet the inbound hostname, manage routes, delete messages.

A token issued without narrowing gets all eight — the common case is one backend running the whole onboarding flow. A request outside its token's scopes is refused with a 403 naming the scope it needed:

json
{
    "detail": "This token is missing the \"domains:write\" scope"
}

Endpoints

Base URL https://api.postwing.app/api/wl/v1/. Everything is addressed by uuid, including the ?domain= filter. The version is in the path from the first release, so a future /v2/ will not move your integration out from under you.

MethodPathScopeWhat it does
GET/domains/domains:readList the account's domains.
POST/domains/domains:writeCreate a domain. Body {"domain": "example.com"}. DKIM keys are generated; read the records to publish from /records/.
GET/domains/<uuid>/domains:readRetrieve one domain.
GET/domains/<uuid>/records/domains:readThe DNS records to publish, as a plain array (not paginated). This is the only place they come from.
POST/domains/<uuid>/verify/domains:writeRe-check DNS now, instead of waiting for the periodic sweep.
DELETE/domains/<uuid>/domains:writeDelete a domain. Only domains created through this API — 400 otherwise, see below.
GET/smtp-tokens/?domain=<uuid>smtp_tokens:readList a domain's SMTP sending credentials.
POST/smtp-tokens/smtp_tokens:write Create one: domain, username (a local part — we append @<domain>), optional note. The domain must be verified already, or 400. The password is returned once, in this response.
GET / DELETE/smtp-tokens/<uuid>/read / writeRetrieve or delete a credential.
GET/webhooks/?domain=<uuid>webhooks:readList a domain's webhook endpoints.
POST/webhooks/webhooks:writeRegister an endpoint: url, enabled, events, description.
GET / PATCH / PUT / DELETE/webhooks/<uuid>/read / writeRetrieve, update or remove an endpoint.
GET / PUT/domains/<uuid>/inbound/inbound:read / inbound:write The hostname this domain receives on, and the MX target to publish for it. PUT {"hostname": ""} switches receiving off.
GET/inbound-routes/?domain=<uuid>inbound:readList a domain's inbound routes.
POST/inbound-routes/inbound:writeCreate one: domain, pattern, endpoint, description.
GET / PATCH / PUT / DELETE/inbound-routes/<uuid>/read / writeRetrieve, update or remove a route.
The receiving hostname is a domain path with inbound scopes
/domains/<uuid>/inbound/ hangs off a domain but takes inbound:*, not domains:* — what it changes is which addresses our SMTP listener will accept mail for, and a token narrowed to domain management should not acquire that. It lives on this API because your customer has no dashboard: without it a white-label domain could hold routes and never accept a message. Set it first; POST /inbound-routes/ is a 400 until it is. See Receiving email for routes, patterns and the inbound.received payload.
Delete only reaches domains the API created
DELETE /api/wl/v1/domains/<uuid>/ removes a domain only if that domain was created through this API. Pointing it at one of your account's own ordinary domains — the ones you added in the dashboard — refuses with 400. A bug in your provisioning code cannot take down your own production sending.

Billing for domains you create

A domain created through POST /api/wl/v1/domains/ lands on a dedicated white-label plan that is never billed: no invoices, no payment reminders, no plan checkout, nothing for your customer or for you to pay per domain. Their sending limits are set by us per customer as part of your white-label arrangement — talk to us if you need them raised.

A domain's own plan still applies

White-label changes who is calling, never what a domain's plan includes. Every other feature gate is untouched: registering a webhook endpoint on a domain whose own plan does not include webhooks is still a 403, exactly as it would be in the dashboard. The same goes for templates, analytics and exports.

Domain webhook events

Two event types exist for the domain lifecycle, on top of the message events. They are opt-in: put them in an endpoint's events array (via the API above, or the Webhooks screen in the dashboard). Existing endpoints are not changed.

EventWhen it fires
domain.verifiedA domain's records passed the check and it became verified.
domain.unverifiedA domain's verified status was cleared.

A re-check that changes nothing emits nothing, so a domain that is already verified does not produce a webhook every time the sweep runs.

The payload is a different shape
These events are about a domain, not a message, so there is no message_id and no email key — absent, not null. Instead you get domain and domain_id. A consumer handling both families has to branch on event.
json
{
    "event_id": "5d1f7ce0-6f0f-4d09-9f27-3fd06d6fa1a1",
    "event": "domain.verified",
    "domain": "mail.customer.example",
    "domain_id": "0e0f3b6f-3d9e-4a8a-9c65-2b9a5b3f1f22",
    "timestamp": "2026-08-03T09:41:07.512340+00:00",
    "data": { "verified_at": "2026-08-03T09:41:07.480112+00:00" }
}
domain.unverified is not a DNS alarm
Broken DNS does not un-verify a domain — a domain whose records stop resolving stays verified and keeps sending, on purpose. domain.unverified only fires when the verified status is actually cleared, which is a staff action. If you want to react to DNS breaking, watch dns_error_at on the domain instead.

Worked example

The normal onboarding flow, end to end. The order is not just a suggestion: SMTP credentials cannot be issued for a domain that has not verified yet, so a provisioning job that tries to hand back a username and password in the same call that creates the domain will always fail.

1. Create the domain

bash
curl -X POST https://api.postwing.app/api/wl/v1/domains/ \
  -H "Authorization: Bearer $WL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"domain": "mail.customer.example"}'

The 201 is the domain itself — its uuid, its plan, its verification state. It does not contain the DNS records; keep the uuid and ask for them next.

json
{
    "uuid": "0e0f3b6f-3d9e-4a8a-9c65-2b9a5b3f1f22",
    "domain": "mail.customer.example",
    "verified_at": null,
    "last_check_at": null,
    "dns_error_at": null,
    "plan": {
        "uuid": "c4a1e0d6-9b52-4f8e-83a1-7c5d0e2f9b41",
        "name": "White label",
        "price": "0.00",
        "on_request": true,
        "webhooks_enabled": true,
        "inbound_enabled": true,
        "max_inbound_routes": 25
    },
    "created_at": "2026-08-03T09:40:12.884210+00:00"
}

2. Read the records to publish

A plain array, not a paginated envelope. Show them in your own UI, and poll this to render each record's current state — verified_at is set once that record checks out, checked_at is when it was last looked at, and last_result says why in the cases where it did not.

bash
curl https://api.postwing.app/api/wl/v1/domains/0e0f3b6f-3d9e-4a8a-9c65-2b9a5b3f1f22/records/ \
  -H "Authorization: Bearer $WL_TOKEN"
json
[
    {
        "uuid": "b81c6f4a-1e77-4d2e-9a55-6f0d1c3b7e90",
        "domain": "0e0f3b6f-3d9e-4a8a-9c65-2b9a5b3f1f22",
        "type": "TXT",
        "name": "@",
        "full_name": "mail.customer.example",
        "value": "v=spf1 a mx include:spf.mail.reseller.com ~all",
        "match": "spf",
        "required": true,
        "verified_at": null,
        "checked_at": null,
        "last_result": null
    },
    {
        "uuid": "3a0e9d21-55b8-4c07-8f13-9d2b6a4e1c58",
        "domain": "0e0f3b6f-3d9e-4a8a-9c65-2b9a5b3f1f22",
        "type": "TXT",
        "name": "default._domainkey",
        "full_name": "default._domainkey.mail.customer.example",
        "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqhki...",
        "match": "exact",
        "required": true,
        "verified_at": null,
        "checked_at": null,
        "last_result": null
    }
]

match is the field to build your setup screen around. It says how DNS's answer is compared with value, and the two cases need different instructions to your customer:

matchWhat to tell your customer
exactPublish this value verbatim. It sits at a name nobody else writes to — the DKIM key, the tracking CNAME.
spfMerge, don't add. RFC 7208 allows one SPF record per name, so a customer who already sends from anywhere else has to fold our include: into the record they have. We compare by meaning, so their term order and other senders don't matter.
dmarcSame, for _dmarc — we check that our rua mailbox is among the ones listed, not that the string matches.
mxThe inbound MX, compared by where mail would actually land. Their own host A-recorded at our address is correct, and so is the preference number of their choosing.

A screen that treats every row as "publish exactly this" will tell a correctly configured customer their zone is broken, which is the support ticket this field exists to prevent.

3. Publish them, then verify

Once your user has added the records at their DNS provider, ask for a check immediately rather than waiting for the periodic sweep:

bash
curl -X POST https://api.postwing.app/api/wl/v1/domains/0e0f3b6f-3d9e-4a8a-9c65-2b9a5b3f1f22/verify/ \
  -H "Authorization: Bearer $WL_TOKEN"

4. Issue SMTP credentials

Verify the domain first. A credential can only be issued for a domain that has already passed its DNS check — call it before verified_at is set and it refuses with 400. That is what dictates the order above: create the domain, hand your customer the records, verify, then issue.

bash
curl -X POST https://api.postwing.app/api/wl/v1/smtp-tokens/ \
  -H "Authorization: Bearer $WL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "0e0f3b6f-3d9e-4a8a-9c65-2b9a5b3f1f22",
    "username": "app",
    "note": "customer app"
  }'
json
{
    "id": 4711,
    "uuid": "7b1a4c2e-8f30-4f5b-9d61-0c4e2a7b8d33",
    "domain": "0e0f3b6f-3d9e-4a8a-9c65-2b9a5b3f1f22",
    "username": "app@mail.customer.example",
    "note": "customer app",
    "_password": "generated-password-shown-once"
}

username is required, and it is a local part, not an address — we append @<domain> ourselves. Send app and the credential logs in as app@mail.customer.example; send app@mail.customer.example and you get the domain twice over. Keep it to letters, digits, underscores and hyphens, as the panel does. note is optional and is for you — it is what tells your support team which of a customer's integrations a credential belongs to. There is no password to choose: one is generated for you.

_password is returned in this response and nowhere else — store it as you would any other secret. Listing or retrieving a credential afterwards carries no password field at all, in any form: if you lose it, delete the credential and issue another. Use the pair against smtp.postwing.app exactly as described in Send over SMTP, and your customer's mail is flowing.

Don't generate a client from OPTIONS on this endpoint
The metadata OPTIONS /api/wl/v1/smtp-tokens/ returns describes the model, not this route's create contract, and it is wrong in three ways: domain is listed read-only when it is required input, password is listed required when you never send one, and the fields it advertises are those of the read shape. The create response is the slimmer object above — no enabled, no created_at. Those two appear when you GET the credential back.

5. Subscribe to events

bash
curl -X POST https://api.postwing.app/api/wl/v1/webhooks/ \
  -H "Authorization: Bearer $WL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "0e0f3b6f-3d9e-4a8a-9c65-2b9a5b3f1f22",
    "url": "https://your-app.example.com/hooks/postwing",
    "events": ["delivered", "bounced", "domain.verified"],
    "description": "customer 4711"
  }'

Signing, retries and delivery semantics are identical to the ordinary webhooks — see Webhooks for the signature scheme and the retry schedule.

6. Optional: let them receive

bash
curl -X PUT https://api.postwing.app/api/wl/v1/domains/0e0f3b6f-3d9e-4a8a-9c65-2b9a5b3f1f22/inbound/ \
  -H "Authorization: Bearer $WL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"hostname": "inbound.mail.customer.example"}'

mx_target in the response is the value your customer publishes an MX at — and for a white-label domain it is a name under your DNS domain, so never build that string yourself. The MX record then appears in that domain's /records/ alongside the rest, and routes can be created:

bash
curl -X POST https://api.postwing.app/api/wl/v1/inbound-routes/ \
  -H "Authorization: Bearer $WL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "0e0f3b6f-3d9e-4a8a-9c65-2b9a5b3f1f22",
    "pattern": "support",
    "endpoint": "9f2c1b7e-4d38-4a10-8b6c-5e0a3d7f2c19"
  }'

Matched mail arrives at your endpoint as an inbound.received webhook. Patterns, plus-tag matching and the payload are covered in Receiving email.