Developers
API reference
A small REST API over HTTPS. All endpoints are under /api/v1.
Using an AI coding assistant?
Give it your API key and point it at https://sovmail.co.uk/llms-full.txt — the complete contract as plain Markdown, including the rules integrations most often get wrong. For example: “Integrate Sovmail using https://sovmail.co.uk/llms-full.txt. My API key is in SOVMAIL_API_KEY.” With the key, it can call GET /api/v1/account to find a from-address it's allowed to use. Tools that take an OpenAPI spec (ChatGPT Actions, Gemini function calling) can import /openapi.json.
Authentication
Send your API key as a Bearer token. Live keys (smk_live_…) deliver; test keys (smk_test_…) validate and record but never deliver.
Authorization: Bearer smk_live_…
Send an email
POST /api/v1/email — requires the From domain to be registered & verified. Returns 202 with { id, status } where status is queued (accepted for delivery) | test | suppressed — all three are success.
curl -X POST https://sovmail.co.uk/api/v1/email \
-H "Authorization: Bearer smk_live_…" -H "content-type: application/json" \
-d '{
"from": "noreply@mail.your-company.co.uk",
"to": "user@example.org",
"subject": "Welcome",
"text": "Thanks for signing up.",
"html": "<p>Thanks for signing up.</p>"
}'
Send with a stored template instead of subject/body:
{ "from": "...", "to": "...", "templateId": "…", "variables": { "name": "Ada" } }
Add "track": true to record delivery status (a delivered status and delivered/deferred events) for a message — handy for confirming sends while testing. It's off by default and overrides your account default; it's delivery status only, never open/click tracking or link rewriting. Bounces and complaints are always recorded regardless.
Code examples
The API is a plain HTTPS POST with a Bearer token, so it drops into any language or framework. Set SOVMAIL_API_KEY in your environment and use the snippet for your stack.
Node.js (fetch)
const res = await fetch("https://sovmail.co.uk/api/v1/email", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SOVMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "noreply@mail.your-company.co.uk",
to: "user@example.org",
subject: "Welcome",
text: "Thanks for signing up.",
}),
});
if (!res.ok) throw new Error(`Sovereign Mail: ${res.status}`);
const { id } = await res.json();
NestJS (@nestjs/axios)
import { Injectable } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { firstValueFrom } from "rxjs";
@Injectable()
export class MailService {
constructor(private readonly http: HttpService) {}
async send(to: string, subject: string, text: string) {
const { data } = await firstValueFrom(
this.http.post(
"https://sovmail.co.uk/api/v1/email",
{ from: "noreply@mail.your-company.co.uk", to, subject, text },
{ headers: { Authorization: `Bearer ${process.env.SOVMAIL_API_KEY}` } },
),
);
return data; // { id, status }
}
}
PHP (Guzzle)
$client = new GuzzleHttp\Client();
$res = $client->post('https://sovmail.co.uk/api/v1/email', [
'headers' => ['Authorization' => 'Bearer ' . getenv('SOVMAIL_API_KEY')],
'json' => [
'from' => 'noreply@mail.your-company.co.uk',
'to' => 'user@example.org',
'subject' => 'Welcome',
'text' => 'Thanks for signing up.',
],
]);
$id = json_decode((string) $res->getBody(), true)['id'];
Symfony (HttpClient)
use Symfony\Contracts\HttpClient\HttpClientInterface;
public function send(HttpClientInterface $client): string
{
$response = $client->request('POST', 'https://sovmail.co.uk/api/v1/email', [
'auth_bearer' => $_ENV['SOVMAIL_API_KEY'],
'json' => [
'from' => 'noreply@mail.your-company.co.uk',
'to' => 'user@example.org',
'subject' => 'Welcome',
'text' => 'Thanks for signing up.',
],
]);
return $response->toArray()['id'];
}
Laravel (HTTP client)
use Illuminate\Support\Facades\Http;
$response = Http::withToken(env('SOVMAIL_API_KEY'))
->post('https://sovmail.co.uk/api/v1/email', [
'from' => 'noreply@mail.your-company.co.uk',
'to' => 'user@example.org',
'subject' => 'Welcome',
'text' => 'Thanks for signing up.',
]);
$id = $response->json('id');
Python (requests)
import os, requests
res = requests.post(
"https://sovmail.co.uk/api/v1/email",
headers={"Authorization": f"Bearer {os.environ['SOVMAIL_API_KEY']}"},
json={
"from": "noreply@mail.your-company.co.uk",
"to": "user@example.org",
"subject": "Welcome",
"text": "Thanks for signing up.",
},
timeout=15,
)
res.raise_for_status()
message_id = res.json()["id"]
Ruby (Net::HTTP)
require "net/http"
require "json"
uri = URI("https://sovmail.co.uk/api/v1/email")
req = Net::HTTP::Post.new(uri,
"Authorization" => "Bearer #{ENV['SOVMAIL_API_KEY']}",
"Content-Type" => "application/json")
req.body = {
from: "noreply@mail.your-company.co.uk",
to: "user@example.org", subject: "Welcome", text: "Thanks for signing up.",
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
id = JSON.parse(res.body)["id"]
Go (net/http)
payload, _ := json.Marshal(map[string]string{
"from": "noreply@mail.your-company.co.uk",
"to": "user@example.org",
"subject": "Welcome",
"text": "Thanks for signing up.",
})
req, _ := http.NewRequest("POST", "https://sovmail.co.uk/api/v1/email", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SOVMAIL_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
Batch send
POST /api/v1/email/batch — up to 500 messages; each returns its own result, in request order.
{ "messages": [ { "from": "...", "to": "a@x.org", "subject": "Hi", "text": "1" }, … ] }
202 { "results": [
{ "to": "a@x.org", "status": "queued", "id": "…" },
{ "to": "b@x.org", "status": "error", "error": "from-domain x.net is not verified" }
] }
A 202 does not mean every message was accepted — check each result's status, and retry only the entries that say error. Retrying the whole batch sends the others twice.
Templates
GET /api/v1/templates · POST /api/v1/templates — bodies support {{variables}}, {{#if}}/{{#unless}} conditionals, and layouts.
Messages & usage
GET /api/v1/messages — recent sends. Filter with ?status= (e.g. delivered, bounced), ?track=true|false (delivery-tracked messages only), ?q= (recipient/subject) and ?limit= (max 500). Each row includes track. GET /api/v1/usage — plan, monthly limit, used, remaining.
Webhooks
PUT /api/v1/webhook sets a receiver URL and returns a signing secret; we POST events signed with HMAC-SHA256 in X-Sovmail-Signature. We always send message.sent (accepted by the MTA); for messages with delivery tracking on we also send message.delivered when the recipient MTA confirms delivery. Delivery is best-effort — one attempt, no retries — so reconcile with GET /api/v1/messages rather than relying on every event arriving. Bounces and complaints aren't sent as events; the address is suppressed automatically.
Errors
Standard HTTP codes: 401 bad key · 402 over quota · 403 unverified/suspended/unverified domain · 404 template not found · 422 failed content checks · 429 rate limited. Bodies are { "error": "…" }.
Set it up with an AI assistant
If you build with Claude, Copilot or Cursor, paste the prompt below into your project and let it do the integration. It carries the whole contract, so the assistant has nothing to guess at — which is where these integrations usually go wrong.
Signed-in users get two better, personalised versions: the API keys page fills in your verified sending domain, and each domain page produces a prompt containing your actual DNS records.
Integrate Sovereign Mail (a UK transactional email API) into this codebase.
Use this contract exactly as written — do not guess endpoints, fields or responses:
POST https://sovmail.co.uk/api/v1/email
Authorization: Bearer <key> # read from the SOVMAIL_API_KEY env var
Content-Type: application/json
Body: {
"from": "noreply@your-verified-domain.co.uk", # a bare address — no "Name <addr>" display name
"to": "someone@example.com", # exactly ONE address, not a list
"subject": "...",
"text": "...", # text and/or html; at least one is required
"html": "..."
}
Success: HTTP 202 with { "id", "status" }
status is "queued" | "test" | "suppressed" — all three are success.
"queued" means accepted for delivery; it is what a live send returns.
Templates (optional): instead of subject/text/html, send
"templateId": "<uuid>", "variables": { "name": "Ada" } # values are strings
using a template saved in the Sovereign Mail dashboard.
Batch: POST https://sovmail.co.uk/api/v1/email/batch
{ "messages": [ { ...same fields as above... } ] } # 1 to 500
Returns HTTP 202 with { "results": [ { "to", "status", "id"?, "error"? } ] },
one entry per message in the same order. The 202 does NOT mean every message was
accepted: any entry can have status "error" with a reason. Check each one.
Account check: GET https://sovmail.co.uk/api/v1/account (same Authorization header)
Returns { "key": { "mode" }, "sendingDomains": { "verified", "pending" },
"suggestedFrom", "canSendLive", "blockers": [ { "code", "message", "fix" } ],
"usage" }
Errors are JSON { "error": "<reason>", "docs": "<url>" }:
400 invalid body (with an "issues" array) · 401 missing or invalid key
402 monthly quota exceeded · 403 from-domain not verified, account email not
verified, account pending review or suspended · 404 templateId not found
422 content rejected · 429 daily limit or per-minute rate limit reached
Keys: live keys start smk_live_ and deliver. Test keys start smk_test_ and are a
sandbox — fully validated and recorded, never delivered, not counted against quota
(status "test"). Use a test key in development, CI and automated tests; only
production should hold a live key.
Rules that matter:
- If you can run commands and have the key, call GET /account first (e.g. curl) and
use it: suggestedFrom is a valid from-address, and blockers lists anything that
stops live mail. Blockers need a human in the dashboard — report them to me with
their "fix" text rather than trying to work around them.
- "from" MUST be on a verified sending domain. For this account use noreply@your-verified-domain.co.uk.
A from-address on any other domain returns 403. Make it configurable (for
example a SOVMAIL_FROM env var) rather than hardcoding it.
- Treat 202 as success and store the returned id — it is how sends are traced later.
- "suppressed" is a SUCCESSFUL response, not an error: the recipient previously
hard-bounced or complained and is deliberately not mailed again. Do not retry it
and do not treat it as a failure.
- Retry only on 429 and 5xx, with exponential backoff. Never retry other 4xx.
- For batch, retry only the entries that failed, never the whole batch — the
others were already accepted and would be sent twice.
- Never hardcode the API key. Never log it.
- Do NOT add open or click tracking, and do not rewrite links. This service
deliberately does not do it, and rewriting magic links breaks them.
Please:
1. Add a small mail client module written in the idiomatic style of this codebase.
2. Route existing transactional email (sign-up verification, password reset,
receipts, alerts) through it.
3. Add a unit test that mocks the HTTP call and asserts the request shape.
4. Show me a diff before changing anything.