Documentation

Postministeriet's api is a single call to send email, plus a suppression list that keeps track of the addresses you must not mail. It is all JSON over HTTPS.

Getting started

Three steps in the portal before your first call:

  1. Add your domain. This is the sending domain — you may only send from addresses on it.
  2. Set up DNS. The portal shows the four records the domain needs: an SPF record, a DKIM record and two records for the return path. All four must resolve before the domain is activated — we check them every minute and activate the domain automatically once they answer.
  3. Create an api key. A key belongs to one domain. It is shown once, when it is created, so save it straight away.

SPF and DKIM

TypeNameValue
TXT @ v=spf1 include:_spf.postministeriet.se -all
TXT <selector>._domainkey v=DKIM1; k=rsa; p=… (unique per domain, found in the portal)

If you already send email from the domain through someone else: merge them into one SPF record — several SPF records on the same name make the lookup fail entirely.

A return path on your own domain

Bounces, rejections and complaints come back to a return path, and yours sits on pm-bounce.yourdomain.com. It needs two records of its own, and they are required just like SPF and DKIM for the domain to be activated.

TypeNameValue
MX pm-bounce 10 mx.postministeriet.se
TXT pm-bounce v=spf1 include:_spf.postministeriet.se -all

The MX record is the way back for the reports. The TXT record is what the receiver checks spf against, because spf is checked against the return path's domain and not against the sender shown in the mail. If your dns provider has a field of its own for priority, put 10 there and only mx.postministeriet.se as the value.

There are two gains: spf lands on your own domain instead of ours, which gives you a second passing check for dmarc alongside dkim, and the reputation the return path builds up with receivers becomes your own rather than shared with everyone else sending through us.

Both records are needed On their own they do no good: an MX without spf accepts mail the receiver would have been entitled to reject, and an spf record without an MX points at a name nothing can be delivered to. That is why they count as a single item in the portal — either both answer, or the return path is not in place.

We move the return path to your domain as soon as both records answer. Until then it stays on ours, so nothing stops working while you wait for dns to propagate — but the domain is not activated until the records are in place.

Authentication

The base url is https://api.postministeriet.se. Every call is authenticated with the api key in the X-Api-Key header:

X-Api-Key: YOUR_API_KEY

The key is the domain. That is why no domain is ever named in the calls — a key reaches exactly the domain it was created for, and no other.

A missing or unknown key gives 401 Unauthorized.

Sending email

POST /api/message

A send is always made against a template that lives on the domain. You create the template in the portal; the call names it and passes the values it needs.

Fields

FieldTypeDescription
recipientsstring[]Required. One or more recipient addresses.
template_namestringRequired. The name of the template to render.
senderstringSender address. The domain part must be the key's domain. Only required if the domain has no default sender.
subjectstringSubject line. Only required if the template has no subject line.
sender_namestringDisplay name for the sender. Falls back to the domain's default.
reply_tostringReply-to address, if it differs from the sender. Falls back to the domain's default.
ccstring[]Carbon copy.
bccstring[]Blind carbon copy.
dataobjectThe values the template fills in.

Defaults

Four of the fields do not need to be sent every time. Leave them out and they are taken from where they belong:

FieldTaken from
senderThe domain's default sender
sender_nameThe domain's default name
reply_toThe domain's default reply-to address
subjectThe template's subject line

A value in the call always wins over the default. The first three you set per domain in the portal — the default sender must be on the domain, exactly like a sender in the call. The subject line you set on the template.

If both the field and the default are missing, the call is rejected with 400: sender field is required if no default sender is set on domain and subject field is required if no subject is set on template respectively.

A layout's subject line is not used If the template sits in a layout, it is the template's own subject line that applies. A subject line set on the layout is never read.

Example

curl -X POST https://api.postministeriet.se/api/message \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recipients": ["recipient@example.com"],
    "sender": "no-reply@yourdomain.com",
    "sender_name": "Your Domain",
    "subject": "Welcome to Postministeriet",
    "template_name": "welcome",
    "data": { "name": "Anna" }
  }'

If the domain has a default sender and the template a subject line, recipients, template and data are enough:

curl -X POST https://api.postministeriet.se/api/message \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recipients": ["recipient@example.com"],
    "template_name": "welcome",
    "data": { "name": "Anna" }
  }'

Response

{
  "id": "0f6c2f1e-4a3b-4c7d-9e11-2b8a5d6f7c40",
  "pending_count": 1,
  "suppressed_count": 0
}

200 OK means the message has been created and queued, not that it has already been delivered. The send happens shortly afterwards in a separate process, and messages that for some reason did not go out are picked up and retried.

pending_count is how many envelopes actually go out. suppressed_count is how many recipients were skipped because they are on the suppression list — they are then also listed in the suppressed field. A send to twenty recipients where one has unsubscribed goes out to nineteen and says why, rather than being rejected.

Raw html/text without a template The endpoint POST /api/message/raw exists in the routing but is not finished yet — it validates the call and answers 200 without sending anything. Use POST /api/message for now.

Templates

A template has an html part and a text part, and can sit in a layout shared by several templates. Placeholders are written {{ .fieldname }} and filled from the data object in the call:

<h1>Hello {{ .name }}!</h1>
<p>Your order {{ .ordernumber }} is on its way.</p>
"data": { "name": "Anna", "ordernumber": "10424" }

Case does not matter: {{ .name }}, {{ .Name }} and {{ .NAME }} read the same value. Values are html-escaped automatically in the html part.

Conditionals: if, else if, else

A template can choose its text based on what is in data. The condition starts with {{ if … }}, may be followed by any number of {{ else if … }} and one {{ else }}, and always ends with {{ end }}:

{{ if eq .status "paid" }}
  <p>Thank you! We are packing your order now.</p>
{{ else if eq .status "pending" }}
  <p>We are still waiting for your payment.</p>
{{ else }}
  <p>Your order has been cancelled.</p>
{{ end }}

If the condition is just a field name — {{ if .discountcode }} — the question is whether the field has any content. These count as false:

ValueCounts as
the field is missing from datafalse
nullfalse
""false
0false
falsefalse
[] and {}false
everything elsetrue

If the condition compares values, the comparison is written as a function call, with the function first: eq .a .b, not .a == .b.

WrittenMeans
eq .a .bequal. More arguments mean “equal to any of them”: eq .status "new" "pending"
ne .a .bnot equal
lt, le, gt, geless than, less than or equal, greater than, greater than or equal
and .a .b, or .a .b, not .aand, or, not
len .listnumber of elements in a list, or characters in a string
index .list 0one element out of a list
printf "%.2f" .amountformats a value

Parentheses group, so a compound condition becomes {{ if and .subscriber (not .cancelled) }} and a counted one becomes {{ if gt (len .lines) 3 }}.

Compare numbers with a decimal point Numbers out of data are always decimals, because they come from json. So compare against a decimal: {{ if gt .amount 100.0 }}. Writing 100 makes the rendering fail with incompatible types for comparison: float64 and int and nothing is sent.

Values that may be missing

A field that is not in data does not stop the send. In the html part it comes out empty — but in the text part it is printed as <no value>, in the middle of the mail. That is the most common cause of junk text in the text version, and the reason to always guard fields that are not there every time.

Three ways, depending on what should happen when the value is missing:

Hello {{ or .name "customer" }}!

{{ if .discountcode }}Use the code {{ .discountcode }} at checkout.{{ end }}

{{ with .address }}
  Delivered to {{ .street }}, {{ .city }}.
{{ else }}
  We will get back to you about the delivery address.
{{ end }}
  • or .name "customer" gives a fallback value: the first value that is not empty wins.
  • {{ if .discountcode }} removes the whole passage when the field is missing.
  • {{ with .address }} does two things: it skips the passage when the field is missing, and makes . the value inside, so the fields under it are read directly as {{ .street }}. An {{ else }} runs when it is missing.
Empty is harmless, the wrong shape is not Reading a field that is missing is fine. Reading a field out of something that is not an object is not: if .customer is a string, then {{ .customer.name }} fails. Same with {{ len .lines }} when lines is missing entirely — guard it with {{ if .lines }} first.

Loops

{{ range … }} repeats a passage once per element in a list. Inside the loop . is the current element, so the fields on the element are read as {{ .item }}:

<table>
{{ range .lines }}
  <tr><td>{{ .item }}</td><td>{{ .quantity }} pcs</td><td>{{ .price }} kr</td></tr>
{{ end }}
</table>
"data": {
  "lines": [
    { "item": "Coffee", "quantity": 2, "price": 89 },
    { "item": "Filter", "quantity": 1, "price": 29 }
  ]
}

If the list is a plain list of strings or numbers, you print the element itself with {{ . }}:

{{ range .tags }}<span>{{ . }}</span> {{ end }}

An {{ else }} in a range runs when the list is empty or the field is missing — so you do not need a separate {{ if }} around it:

{{ range .lines }}
  <p>{{ .item }}</p>
{{ else }}
  <p>Your order is empty.</p>
{{ end }}

If you need the number of the row, or the element under a name of its own, you declare them in the loop. The number starts at 0:

{{ range $no, $line := .lines }}
  <p>{{ $no }}. {{ $line.item }} — {{ $line.price }} kr</p>
{{ end }}

Because . points at the element inside the loop, you reach the top-level fields with $. instead. Conditionals and loops can nest inside each other to any depth:

{{ range .orders }}
  <h2>Order {{ .number }} for {{ $.customername }}</h2>
  {{ range .lines }}
    <p>{{ .item }}{{ if gt .quantity 1.0 }} ({{ .quantity }} pcs){{ end }}</p>
  {{ end }}
{{ end }}

If range points at an object rather than a list, it runs once per value in the object, in alphabetical order of the keys.

Only loop over lists {{ range … }} requires a list or an object. If it points at a string or a number, the rendering fails with range can't iterate over …. A missing field, on the other hand, is harmless — it is treated as an empty list and runs {{ else }}.

Line breaks around conditionals and loops

{{ if }}, {{ range }} and {{ end }} print nothing themselves, but the line they sit on remains in the result. In html it does not show; in the text part it becomes blank lines. A hyphen inside the braces eats whitespace and the line break on that side:

Your order:
{{- range .lines }}
- {{ .item }}
{{- end }}

When rendering fails

A template that cannot be parsed or run stops the whole call: it answers 500 with the error from the template in message, and nothing is sent to anyone. The test send in the portal is the place to catch that, because it runs exactly the same rendering with data you choose yourself.

A template can also have a subject line. It is used when the call does not give one — see Defaults.

A layout contains {{ .Content }} where the template's content goes. A layout cannot be sent as a template in itself — the call is then rejected with 400. A layout's own subject line is never used.

Suppression list

The suppression list is the addresses the domain must not mail: bounces, complaints and unsubscribes. It is checked both when the message is created and just before it goes out, so an unsubscribe arriving in between still counts.

Every entry has two properties worth understanding:

  • streamall, marketing or transactional. An unsubscribe from a newsletter stops marketing, not the password reset the person asks for themselves ten minutes later. Leave the field out and it becomes all.
  • reasonmanual (default), unsubscribe, complaint or hard_bounce. Other reasons are set by the system itself.
GET /api/suppressions?page=1&page_size=25&include_removed=false

The list, newest first, as { page, page_size, total_elements, data }.

GET /api/suppressions/check?address=…&stream=all

Answers the question “may I mail this address”, without sending anything:

{
  "address": "suppressed@example.com",
  "stream": "all",
  "suppressed": true,
  "entry": { "reason": "unsubscribe", "created": "2026-08-14T09:12:03Z" }
}
POST /api/suppression
curl -X POST https://api.postministeriet.se/api/suppression \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "address": "customer@example.com",
    "stream": "marketing",
    "reason": "unsubscribe",
    "note": "Unsubscribed through our own page"
  }'

Adding an address that is already on the list returns the existing entry instead of failing — the same call twice leaves you in the same place.

POST /api/suppressions/bulk

Import, up to 10,000 entries per call. stream, reason and note at the top level apply to the whole import and can be overridden per entry.

{
  "reason": "unsubscribe",
  "stream": "marketing",
  "entries": [
    { "address": "one@example.com" },
    { "address": "two@example.com", "reason": "hard_bounce", "stream": "all" }
  ]
}

The response says what the import did:

{ "submitted": 2, "added": 2, "skipped": 0 }

skipped is addresses that were already on the list — they are not overwritten, the entry that already exists is the one that says why. Addresses that cannot be parsed do not stop the import, they come back in invalid.

GET /api/suppressions/history?address=…

Every entry ever written for an address, including lifted ones. This is where you go to answer “why did I stop getting your email”.

GET /api/suppressions/export?include_removed=false

The whole list as CSV.

GET /api/suppression/{id}

A single entry.

DELETE /api/suppression/{id}

Lifts the suppression, which re-enables email to the address. Answers 204 No Content. The entry is not deleted but stamped with when it was lifted — the trail is the whole point of the list.

Error codes

Errors answer with JSON: { "message": "…" }.

CodeMeans
400Something in the call is wrong: an invalid address, a missing field that neither the call nor the defaults fill in, a sender domain that does not belong to the key, an unknown template, or the quota is used up.
401X-Api-Key is missing or unknown.
404The domain's DKIM key is not active yet — DNS is not ready. Or: the entry does not exist.
500An error on our side — or an error in the template, see Templates. Nothing was sent.

Most common at the start:

  • invalid sender domainsender is not on the domain the key was created for.
  • domain key not active — the DNS records do not answer yet. Check the domain's status in the portal.
  • template name is requiredtemplate_name is missing. That field has no default to fall back on.
  • sender field is required if no default sender is set on domain — set sender in the call, or a default sender on the domain.
  • subject field is required if no subject is set on template — set subject in the call, or a subject line on the template.

Quota

Every account has a monthly quota. When it is used up, the send call answers 400 with monthly quota exceeded, and the counter resets on the first of the month. Recipients skipped because they are on the suppression list do not count.