Docs / Using API

Sending email over the API

Postwing exposes a small REST API over HTTPS. Send a single POST request to api.postwing.app and the message goes out — no SDK or SMTP client required. There are three endpoints: send a ready-made message (raw), render and send one of your saved templates, or queue up to a hundred of either in a single batch.

You can get them on the token management page. For security reasons, a token is shown only once — at the moment it is created.

Authentication uses the same domain token as the SMTP relay — pass it in the auth object on every request (username and password). Set an idempotency_key to make retries safe: repeating a request with the same key never sends a duplicate. The endpoints respond with ok: true on success, or error_code and error describing what went wrong.

The plain-text part

Every message goes out as multipart/alternative — an HTML part and a plain-text one. body is the HTML; text is the plain-text alternative, and it is optional. Leave it out and we derive one from your HTML: tags dropped, entities decoded, links spelled out after their anchor text.

Send it when the wording matters. A derived part cannot know which parts of a layout were decoration, and it never sees the sentence you would have written for a reader who gets no styling at all. Both parts are always sent, so this is the difference between a text reader seeing your words and seeing a flattening of your markup — and mailbox providers compare the two parts when scoring a message. An empty text falls back to the derived one rather than sending a blank part.

Sending before you verify a domain

You do not need DNS to see your first message arrive. Every account gets a sandbox credential on a shared domain we own — real DKIM, real delivery, the same API and the same SMTP relay — so you can wire the integration up and watch a message land while somebody else is still finding the login to your DNS panel.

It only sends to your own account address, and that is the point rather than a limitation: a shared sending domain anybody can sign up for would be an open relay otherwise. Your account email has to be confirmed, and there is a small daily limit. Everything else behaves normally — the same webhooks, the same event log, the same delivery history.

When you are ready to send to anybody else, add a domain of your own and verify it. Nothing about your integration changes except the credential and the From address.

Several recipients

recipient takes one address. to, cc and bcc take arrays and are the plural form of the same thing — send one spelling or the other, never both. They work on every endpoint on this page, including each item of a batch.

Every address is its own message. A send to two to, one cc and one bcc is four rows in your log, four Message-IDs, four delivery webhooks and four messages against your plan's allowance. That is the same thing SMTP submission has always done with a multi-recipient transaction, and it is what makes per-recipient tracking, suppression and retries work at all.

What the headers say is the same on every copy: the whole to list, the whole cc list, and never a Bcc. A blind recipient is an address that appears in no header of the message — the response's emails array is the only place you can read it back, which is exactly why that array carries recipient and kind. An address named twice, in to and again in cc, is one message; the first mention wins.

Up to 100 recipients per message, the same ceiling the SMTP relay applies. With an idempotency_key, the first recipient keeps your key exactly as you wrote it and every copy after it gets a derived one (your-key#…), so retrying the request replays all of them rather than only the first — the derived keys are ours and you never need to construct one.

Attachments

attachments takes an array of files, each with a filename and its bytes base64 encoded in content. content_type is optional — we guess it from the name, then fall back to application/octet-stream. Line breaks inside content are fine; that is how every base64 tool wraps its output. The message goes out as multipart/mixed wrapping the usual HTML and plain-text pair, so a reader gets both the message and the file.

Up to 20 files, 10 MB each and 10 MB in total per message; the whole request body, base64 included, is capped at 16 MB. Remember that base64 costs about a third on top of the file's real size.

An attachment is stored and sent once per recipient — one row, one message, one copy of the file — so a 5 MB PDF to twenty people is 100 MB, and there is a ceiling on that product too. If you hit it, link to the file instead: mail that size is refused or stripped by a good share of the mailboxes it is addressed to anyway.

Executable file types are refused, by extension and by declared content_type.exe, .msi, .jar, .js, .vbs, .bat, .scr, .lnk, .iso and the rest of the list Gmail publishes. That list is the one actually enforced against your mail, so refusing at submission is the difference between an error you can act on and a bounce that counts against your domain. Archives are allowed; we cannot see inside them, and the receiver will.

There is no way to attach a file by URL. Fetching one would mean this server opening a connection to a host named by whoever holds your sending token, which is a much larger thing to get right than it looks — if you have the file, send it.

Tags

tags is an object of your own labels on a message — {"stream": "password-reset", "lang": "en"}. They answer the one question nothing else on a message can: which of my streams is this. method says how it was submitted, mass_mail says whether it is a campaign; neither separates a password reset from a receipt from an invoice, because only you know your own taxonomy.

Names are 1–40 characters of letters, digits, underscores and dashes. Values are free text up to 256 characters and may contain colons. Up to 10 tags per message.

Tags are never written into the message. They are not a header: putting your internal taxonomy in front of your own recipients, inside the DKIM signature and in front of a receiver that scores unknown headers is not what you asked for. If you do want a label on the wire, the X- headers are still there.

Nothing on our side reads them. Delivery, throttling, reputation and abuse scoring are all blind to a tag — which is deliberate, because a label the sender chooses must not be able to change how their mail is treated.

What they are for is reading mail back:

  • GET /api/emails/emails/?tag=stream:password-reset and the same parameter on /api/emails/logs/. Repeat it to add a term — ?tag=stream:receipt&tag=lang:en is and, not or — and a bare ?tag=stream means "tagged with this at all".
  • tags in every delivery webhook, so a consumer can route an event without looking the message up.
  • A tags array on the analytics report, and a tags filter on the request that narrows every number on it — which is how you ask "how did the password-reset stream do this week".

Sending a batch

/external/send_email_batch/ takes up to 100 messages in one request. The auth object moves to the top level and is read once for the whole batch; everything else is per message, in an emails array. An item is either a raw message (subject and body) or a template one (tpl), and one batch may mix the two — a campaign whose recipients read different languages is a tpl and a lang per item.

A malformed batch fails whole, with HTTP 400 and the index of the item at fault. So does one your plan's remaining hourly or monthly allowance cannot cover: the whole batch is refused with the same Hourly limit exceeded / Monthly limit exceeded you would get from a single send, rather than half a campaign going out. Split the list or retry with a backoff.

The response is flat and in order: one entry per message, in the order the items and their recipients were named, each carrying the message_id every delivery webhook will report for it. An item with a cc or a bcc is more than one message, so it contributes more than one entry — read recipient and kind to tell them apart. The ceiling counts messages, not items, for the same reason your plan does.

Give every item an idempotency_key. It is the only way to retry a batch safely: keys are per message, so repeating the whole POST after a timeout replays the messages already accepted — replayed: true, no second send, no second charge against your limits — and queues only the ones that never arrived. Two items in one batch may not share a key; that would silently send one message where you meant two, so it is refused instead.

Limits and refusals

Two refusals are worth handling explicitly. Over your plan's hourly or monthly volume the request is rejected with HTTP 400 and Hourly limit exceeded / Monthly limit exceeded — retry with a backoff, they clear on their own. If sending from the domain has been stopped, the answer is HTTP 403 with a stable code of domain_banned and a reason, and retrying will not help.

Note that neither of these is what happens when you send faster than the domain's warm-up ramp: over-ramp mail is accepted normally and delivered a little later. See sending limits and anti-abuse.

Marking a campaign

Set mass_mail: true on marketing and other bulk mail. It attaches the one-click unsubscribe headers (List-Unsubscribe and List-Unsubscribe-Post) that Apple, Gmail and Yahoo require of bulk senders — without them a campaign can be rejected outright or filed as spam.

Omit it and we classify the message ourselves, but that is a heuristic and it can read a campaign as transactional. The flag is one-way: true marks a message bulk, while false simply leaves the decision to us — it is not a way to send a campaign without an unsubscribe header.

Reading mail back

The same credential that sends can read. Four GET endpoints, all scoped to the one domain the token belongs to — there is no domain parameter and there cannot be one.

EndpointWhat it answers
GET /external/emails/What have I sent, and how did each one end.
GET /external/emails/<uuid>/One message, including the HTML body that went out.
GET /external/emails/<uuid>/events/Everything that happened to that message, oldest first.
GET /external/events/The domain's whole event stream — what a consumer polls instead of subscribing to webhooks.
GET /external/stats/Totals, rates and a tag breakdown over a date range.

Authentication is HTTP Basic, with the same token username and password the SMTP relay takes:

bash
curl https://api.postwing.app/external/emails/ \
  -u "$POSTWING_TOKEN_USERNAME:$POSTWING_TOKEN_PASSWORD"

curl "https://api.postwing.app/external/emails/?tag=stream:password-reset&status=delivered" \
  -u "$POSTWING_TOKEN_USERNAME:$POSTWING_TOKEN_PASSWORD"

# repeat status= to ask for several at once
curl "https://api.postwing.app/external/emails/?status=deferred&status=delivery_failed" \
  -u "$POSTWING_TOKEN_USERNAME:$POSTWING_TOKEN_PASSWORD"

curl "https://api.postwing.app/external/stats/?start=2026-08-01&end=2026-08-28" \
  -u "$POSTWING_TOKEN_USERNAME:$POSTWING_TOKEN_PASSWORD"

Basic rather than a bearer token because that is what the credential already is — a username and a password. Note the consequence: this credential reaches /external/ and nothing else. It is not an account key, and it cannot read your domains, billing or team.

The lists are paginated and take the filters you would expect — status, method, message_id, recipient, subject, created_at_after / created_at_before, and the ?tag= terms above. /external/stats/ takes start, end and the same ?tag=.

Two limits worth knowing. The event endpoints are a plan feature — the same one that shows deferrals in the dashboard — and answer HTTP 403 on a plan without it; the message list is not gated. And history only goes back as far as your plan's retention: older rows are deleted, and /external/stats/ clamps the range it was asked for rather than refusing it, reporting what it used in window_days.

Unsubscribe preferences and topics

The unsubscribe link in every message now opens a preference centre on your own stats. hostname, wearing your logo, company name and brand colour. Nothing on that page or in its URL names us — which matters most on a white-label domain, where our hostname in the body used to be the one leak the whole product exists to prevent.

Define topics — "Product news", "Order updates" — under the domain's unsubscribe settings, then name one on a send with topic: "product-news". A recipient who refuses that topic stops receiving it and keeps receiving everything else, including their password resets. Define none and the page is what it always was: one button that stops everything.

Two behaviours worth designing around:

  • A message with no topic is only ever stopped by a recipient who unsubscribed from everything. That is what every message you have ever sent does today, and it does not change.
  • An unknown topic slug is rejected with a 400, never quietly ignored. Silently dropping it would deliver the campaign to exactly the people who asked not to receive it.

A topic's slug cannot be changed once created — every opt-out already recorded points at it. Retire a topic by disabling it instead; the opt-outs recorded against it stay in force. And the one-click unsubscribe header that Gmail and Apple show is always an unsubscribe from everything: it has no interface, so it cannot mean anything narrower.

Click tracking

Turn link tracking on for a domain and every <a href> in the HTML you send is rewritten through your own stats. hostname. Following one records a click, then redirects to the real destination.

It is off by default, unlike open tracking. A tracking pixel adds an invisible image; this changes what your recipients see when they hover a link and what a corporate link scanner is asked to fetch, so it is yours to switch on rather than ours to assume.

Your unsubscribe link is never rewritten. That is not a detail: a security gateway that prefetches every link in a message would otherwise unsubscribe the recipient before they had read it, and because suppression is per address, it would stop their password resets too. mailto:, tel: and in-page anchors are left alone as well.

A machine fetch — a scanner, a privacy proxy — is redirected but not counted, using the same classification the open pixel uses. A click also marks the message opened: it is stronger evidence than a pixel, and it is how open tracking survives a client that blocks images.

What you get back:

  • clicked_at on the message, the first time one of its links was followed.
  • A clicked event in the log for every click, with the URL — repeats included, since "which link, how many times" is the question.
  • A clicked webhook, carrying the URL in data.
  • A clicked-links report in the analytics breakdown.

One limit worth knowing: link rewriting applies to messages sent over this API, not over SMTP. SMTP submission hands us a finished MIME document whose parts may already be encoded, and editing URLs inside those is how a message body gets corrupted.

Sharing one message

When somebody asks what did you actually send me, open the message in the dashboard and mint a share link: a public URL that renders that one message, which your support team can paste into a reply.

The link is unguessable, expires (7 days by default, 30 at most) and can be revoked at any time — the record of who created it and how many times it was opened stays after you do. The page itself is deliberately plain: the message, who it went to and when, and no product branding of any kind.

The URL is on your own stats. hostname, the same one the tracking pixel and the unsubscribe link use, so nothing you forward carries our name. That is also the one prerequisite: a domain whose stats DNS record has never been published has no host to serve the page from, and minting is refused rather than producing a link that does not load.

Treat a live link as public. Anyone who has the URL can read the whole message until it expires — which matters most for mail that contains a one-time link or code, and is a good reason to revoke as soon as the conversation is over.

This credential is scoped to one domain and to /external/. The domain token authenticates the SMTP relay and every endpoint on this page — in the request body when you send, as HTTP Basic when you read. It reaches nothing else. The account API under /api/ — domains, templates, webhooks, team, billing, inbound messages and their attachments — is a different scheme: an Authorization header carrying your account token, which lives on Dashboard → Profile.

Postwing assigns each message its own Message-ID (<uuid@yourdomain.com>) and returns it — that is the id every delivery webhook reports. You cannot set your own: the accepted extra headers are In-Reply-To, References, Reply-To and any X- header, and anything else is refused.

Send Raw Email

Sending email without processing on the API side. Use it to send HTML emails to your customers

POST/external/send_email_simple/
Request Payload
json
{
    "to": [
        "first@test.com"
    ],
    "cc": [
        "watcher@test.com"
    ],
    "bcc": [
        "archive@test.com"
    ],
    "subject": "Subject",
    "body": "<p>Email content</p>",
    "text": "Email content",
    "sender": "from me <email@test.com>",
    "idempotency_key": "xxxxxx",
    "mass_mail": false,
    "tags": {
        "stream": "password-reset",
        "lang": "en"
    },
    "attachments": [
        {
            "filename": "invoice-4417.pdf",
            "content": "JVBERi0xLjQK…",
            "content_type": "application/pdf"
        }
    ],
    "auth": {
        "username": "Username",
        "password": "Password"
    }
}
Response Payload
json
{
    "ok": true,
    "emails": [
        {
            "ok": true,
            "message_id": "<uuid@yourdomain.com>",
            "uuid": "uuid",
            "recipient": "first@test.com",
            "kind": "to",
            "idempotency_key": "xxxxxx",
            "replayed": false
        }
    ]
}

Send Template Email

Sending generated email from specific template. You will need to provide used placeholders in template and specify the language.

POST/external/send_email_tpl/
Request Payload
json
{
    "recipient": "email@test.com",
    "tpl": "tpl slug",
    "lang": "en",
    "sender": "from me <email@test.com>",
    "idempotency_key": "xxxxxx",
    "mass_mail": false,
    "params": {
        "prop1": "value"
    },
    "auth": {
        "username": "Username",
        "password": "Password"
    }
}
Response Payload
json
{
    "ok": true,
    "emails": [
        {
            "ok": true,
            "message_id": "<uuid@yourdomain.com>",
            "uuid": "uuid",
            "recipient": "email@test.com",
            "kind": "to",
            "idempotency_key": "xxxxxx",
            "replayed": false
        }
    ]
}

Send a Batch

Up to 100 messages — raw, template, or a mix of both — queued on one authentication. Use it to fan a campaign out without a round trip per recipient.

POST/external/send_email_batch/
Request Payload
json
{
    "auth": {
        "username": "Username",
        "password": "Password"
    },
    "emails": [
        {
            "recipient": "first@test.com",
            "subject": "Subject",
            "body": "<p>Email content</p>",
            "sender": "from me <email@test.com>",
            "idempotency_key": "campaign-7-first"
        },
        {
            "to": [
                "second@test.com"
            ],
            "cc": [
                "watcher@test.com"
            ],
            "tpl": "tpl slug",
            "lang": "en",
            "params": {
                "prop1": "value"
            },
            "sender": "from me <email@test.com>",
            "idempotency_key": "campaign-7-second",
            "mass_mail": true
        }
    ]
}
Response Payload
json
{
    "ok": true,
    "accepted": 3,
    "rejected": 0,
    "emails": [
        {
            "ok": true,
            "message_id": "<uuid@yourdomain.com>",
            "uuid": "uuid",
            "recipient": "first@test.com",
            "kind": "to",
            "idempotency_key": "campaign-7-first",
            "replayed": false
        },
        {
            "ok": true,
            "message_id": "<uuid@yourdomain.com>",
            "uuid": "uuid",
            "recipient": "second@test.com",
            "kind": "to",
            "idempotency_key": "campaign-7-second",
            "replayed": false
        },
        {
            "ok": true,
            "message_id": "<uuid@yourdomain.com>",
            "uuid": "uuid",
            "recipient": "watcher@test.com",
            "kind": "cc",
            "idempotency_key": "campaign-7-second#a1b2c3d4e5f60718",
            "replayed": false
        }
    ]
}
Attention!
When using BCC, CC - a separate email will be created for each recipient in the system and your limits will be used.