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:
- Add your domain. This is the sending domain — you may only send from addresses on it.
- 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.
- 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
| Type | Name | Value |
|---|---|---|
| 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.
| Type | Name | Value |
|---|---|---|
| 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.
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
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
| Field | Type | Description |
|---|---|---|
recipients | string[] | Required. One or more recipient addresses. |
template_name | string | Required. The name of the template to render. |
sender | string | Sender address. The domain part must be the key's domain. Only required if the domain has no default sender. |
subject | string | Subject line. Only required if the template has no subject line. |
sender_name | string | Display name for the sender. Falls back to the domain's default. |
reply_to | string | Reply-to address, if it differs from the sender. Falls back to the domain's default. |
cc | string[] | Carbon copy. |
bcc | string[] | Blind carbon copy. |
data | object | The 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:
| Field | Taken from |
|---|---|
sender | The domain's default sender |
sender_name | The domain's default name |
reply_to | The domain's default reply-to address |
subject | The 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.
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.
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:
| Value | Counts as |
|---|---|
the field is missing from data | false |
null | false |
"" | false |
0 | false |
false | false |
[] and {} | false |
| everything else | true |
If the condition compares values, the comparison is written as a function call,
with the function first: eq .a .b, not .a == .b.
| Written | Means |
|---|---|
eq .a .b | equal. More arguments mean “equal to any of them”: eq .status "new" "pending" |
ne .a .b | not equal |
lt, le, gt, ge | less than, less than or equal, greater than, greater than or equal |
and .a .b, or .a .b, not .a | and, or, not |
len .list | number of elements in a list, or characters in a string |
index .list 0 | one element out of a list |
printf "%.2f" .amount | formats a value |
Parentheses group, so a compound condition becomes
{{ if and .subscriber (not .cancelled) }} and a counted one becomes
{{ if gt (len .lines) 3 }}.
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.
.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.
{{ 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:
- stream —
all,marketingortransactional. 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 becomesall. - reason —
manual(default),unsubscribe,complaintorhard_bounce. Other reasons are set by the system itself.
The list, newest first, as { page, page_size, total_elements, data }.
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" }
}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.
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.
Every entry ever written for an address, including lifted ones. This is where you go to answer “why did I stop getting your email”.
The whole list as CSV.
A single entry.
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": "…" }.
| Code | Means |
|---|---|
400 | Something 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. |
401 | X-Api-Key is missing or unknown. |
404 | The domain's DKIM key is not active yet — DNS is not ready. Or: the entry does not exist. |
500 | An error on our side — or an error in the template, see Templates. Nothing was sent. |
Most common at the start:
invalid sender domain—senderis 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 required—template_nameis missing. That field has no default to fall back on.sender field is required if no default sender is set on domain— setsenderin the call, or a default sender on the domain.subject field is required if no subject is set on template— setsubjectin 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.