Developers
The Letters API
Post a PDF to one endpoint. We print it, fold it, frank it and hand it to Royal Mail from our UK facility. Each letter is charged to your prepaid balance the moment it is accepted, so there is no invoice to reconcile at month end.
Getting started
Three things have to be true before a letter can be sent: you have an account, that account has a key, and that account has money on it. Email [email protected] and we will set up the first two; the third is yours to manage.
We issue it once and show it once. Store it as a secret in your application, not in your repository.
By card, or by bank transfer. The balance is what pays for letters.
One request per letter, with the recipient address. You get a letter id back.
The whole thing, in one request
curl -X POST https://sendlettersonline.uk/api/v1/letters \
-H "Authorization: Bearer slo_live_YOUR_KEY" \
-H "Idempotency-Key: invoice-8841-reminder-1" \
-F "[email protected]" \
-F "service=second-class" \
-F "recipient[name]=Ms J Whitfield" \
-F "recipient[line1]=18 Colston Street" \
-F "recipient[city]=Bristol" \
-F "recipient[postcode]=BS1 5AP"
Anything accepted before 3:00pm on a working day goes out the same day. After that it joins the next working day's run.
Authentication
Every request carries your key as a bearer token. There is no session, no cookie and no OAuth dance.
Authorization: Bearer slo_live_2f8Ub1x…
Keys start slo_live_ or slo_test_ so you can tell at a
glance which one an environment is holding. We store only a SHA-256 hash of the
key, which means we genuinely cannot tell you what a lost key was — revoke it
and take a new one. Revoking is immediate; letters already submitted are unaffected.
Issue a separate key per system rather than sharing one. When something has to be revoked at three in the morning, you want to revoke the thing that leaked and nothing else.
If authentication fails
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_api_key | No Authorization header, or it is not a bearer token. |
| 401 | invalid_api_key | The key is unknown or revoked. We return the same answer for both on purpose. |
| 403 | account_inactive | The account has been suspended. Keys still exist; nothing will send until it is reactivated. |
How billing works
The account holds a balance in pence. When a letter is accepted we count its pages from the PDF itself, price it against the live rate card, and debit the balance in the same database transaction that creates the letter. Either both happen or neither does — there is no window in which you are charged for a letter that does not exist, or hold a letter that was never paid for.
The page count is ours, not yours. We read it from the file you send. It is what sets the price, so it is not a number a client gets to assert.
At submission, once. The exact figure and its breakdown come back in the response.
In full, if you cancel before printing, or if we cannot send it. Back on the balance immediately.
Every movement is a ledger row you can read over the API. Nothing is ever edited or deleted.
If the balance will not cover a letter, the request is rejected with 402 Payment Required and nothing is stored. The response tells you the shortfall, so a queue can top up and retry rather than guess:
{
"error": {
"code": "insufficient_balance",
"message": "Not enough balance to send this letter.",
"balance_pence": 50,
"balance": "£0.50",
"required_pence": 299,
"required": "£2.99",
"shortfall_pence": 249,
"shortfall": "£2.49"
}
}
Poll GET /balance on a schedule and alert on your own threshold. The
response also tells you when the balance has fallen below the warning level we hold
on the account.
Topping up
Two ways to put money on the account, and both land on the same ledger.
- By card. From your dashboard, through Stripe Checkout. The balance moves when Stripe confirms the payment — not when the browser returns — so a closed tab never loses a top-up. Minimum £10, maximum £5,000 per payment.
- By bank transfer. Email [email protected] for details. We credit the account against your reference when the money lands.
Card payments are card payments: they never touch this API, and no card details go anywhere near it.
Send a letter
POST/api/v1/letters
A multipart/form-data request. PDF only — a Word file has
no fixed page count until something lays it out, and we will not price a letter on
a guess.
| Field | Type | Notes | |
|---|---|---|---|
file | file | required | The PDF. Up to 30MB and 200 pages. A4, portrait. |
service | string | required | A slug from /services, e.g. second-class. |
colour | boolean | optional | Defaults to false. Colour print costs more per page. |
double_sided | boolean | optional | Defaults to false. Halves the sheets, which can drop the envelope tier. |
recipient[name] | string | required | Up to 120 characters. |
recipient[line1] | string | required | Street address. |
recipient[line2] | string | optional | Second address line. |
recipient[city] | string | required | Town or city. |
recipient[postcode] | string | required | Postcode, or the local equivalent abroad. |
recipient[country] | string | optional | Two-letter ISO code. Defaults to GB. |
Response — 201 Created
{
"letter": {
"id": "ltr_01m1hvn4b8j9v1z51hm6rt6ddg",
"status": "queued",
"created_at": "2026-09-02T20:06:06+00:00",
"pages": 3,
"colour": false,
"double_sided": false,
"service": "second-class",
"price": {
"amount_pence": 339,
"formatted": "£3.39",
"currency": "GBP"
},
"recipient": {
"name": "Ms J Whitfield",
"line1": "18 Colston Street",
"line2": null,
"city": "Bristol",
"postcode": "BS1 5AP",
"country": "GB"
},
"tracking_number": null,
"despatched_at": null,
"cancelled_at": null,
"failure_reason": null
},
"balance": {
"amount_pence": 24661,
"formatted": "£246.61"
}
}
Keep letter.id. It is the handle for everything afterwards, and it is
worth storing next to whatever caused the letter to be sent.
Idempotency
Networks fail after the server has done the work. If you retry a submission blindly,
somebody gets two letters and pays twice. Send an Idempotency-Key header
and that cannot happen.
Idempotency-Key: invoice-8841-reminder-1
A repeat of a key we have already seen on your account returns the original letter
with "replayed": true and a 200 rather than a 201. Nothing
is printed again and nothing is charged again.
Derive the key from what the letter is — the invoice, the case, the reminder number — not from a random value generated at the point of sending. A fresh random key on a retry is not idempotent; it is a second letter.
Keys are scoped to your account and kept indefinitely. Sending the same key with different content still returns the first letter: the key identifies the intent, and the first one won.
Reading letters
GET/api/v1/letters/{id}
One letter, in the same shape as the submission response.
curl https://sendlettersonline.uk/api/v1/letters/ltr_01m1hvn4b8j9v1z51hm6rt6ddg \
-H "Authorization: Bearer slo_live_YOUR_KEY"
GET/api/v1/letters
Your letters, newest first. Optional status and limit
(1–100, default 25), paginated with page.
{
"letters": [ { "id": "ltr_01m1hv…", "status": "queued", "pages": 3, … } ],
"pagination": {
"total": 1,
"per_page": 2,
"current_page": 1,
"last_page": 1
}
}
There are no outbound webhooks yet, so status is read by polling. Once a day is plenty for despatch; there is nothing to learn by asking every minute.
Cancelling
POST/api/v1/letters/{id}/cancel
Works while the letter is still queued. Once it has been printed it is
a physical object on its way to a postbox, and we will tell you so with a
409 and not_cancellable.
A successful cancellation refunds the full charge:
{
"letter": { "id": "ltr_01m1hv…", "status": "cancelled", … },
"refunded": { "amount_pence": 339, "formatted": "£3.39" },
"balance": { "amount_pence": 25000, "formatted": "£250.00" }
}
Balance and ledger
GET/api/v1/balance
{
"account": { "name": "Halewood Legal" },
"balance": {
"amount_pence": 25000,
"formatted": "£250.00",
"currency": "GBP"
},
"low_balance": {
"threshold_pence": 2000,
"triggered": false
}
}
GET/api/v1/transactions
The account's own ledger, newest first. Filter with type
(topup, charge, refund,
adjustment) and page with limit and page.
balance_after_pence is the running balance, which makes the ledger
reconcilable on your side without replaying arithmetic.
{
"transactions": [
{
"id": 1,
"type": "topup",
"amount_pence": 25000,
"amount": "£250.00",
"balance_after_pence": 25000,
"description": "Card top-up £250.00",
"reference": "stripe:cs_test_docs",
"created_at": "2026-09-02T20:04:54+00:00"
}
],
"pagination": { "total": 1, "per_page": 3, "current_page": 1, "last_page": 1 }
}
Charges carry the letter id as their reference, so a line on the ledger
can always be traced back to the thing that caused it.
Services and prices
GET/api/v1/services
What you can send and what it will cost, priced live from the same engine that does the charging. Ask it before you commit rather than holding a copy of our rate card that will quietly go out of date.
Optional: pages, colour, double_sided,
country. Defaults to a single mono page to Great Britain.
curl "https://sendlettersonline.uk/api/v1/services?pages=3&colour=1" \
-H "Authorization: Bearer slo_live_YOUR_KEY"
{
"quoted_for": { "pages": 3, "colour": true, "double_sided": false, "country": "GB" },
"services": [
{
"slug": "second-class",
"name": "Second Class",
"carrier": "Royal Mail",
"delivery_estimate": "2-3 working days",
"tracked": false,
"signed": false,
"envelope": "C5",
"zone": null,
"price_pence": 369,
"price": "£3.69"
}
]
}
Price the exact job. Print is banded, so ten pages is not ten times one page, and the envelope tier changes with the number of sheets.
Letter statuses
| Status | What it means | Charge |
|---|---|---|
queued | Accepted and paid for, waiting for the next print run. Cancellable. | Taken |
printed | Printed and enveloped. Past the point of cancelling. | Taken |
posted | Handed to Royal Mail. despatched_at is set, and tracking_number too on a tracked service. | Taken |
delivered | Confirmed delivered. Only ever set for tracked services. | Taken |
returned | Came back to us undelivered. | Taken |
cancelled | You cancelled it before printing. | Refunded |
failed | We could not send it. failure_reason says why. | Refunded |
Errors
Failures are always JSON, never an HTML page, and always carry a stable
error.code. Branch on the code; the message is for your logs and may be
reworded.
{
"error": {
"code": "too_many_pages",
"message": "That document is 240 pages; the limit for one letter is 200."
}
}
| Status | Code | What to do |
|---|---|---|
| 401 | missing_api_key | Send the Authorization header. |
| 401 | invalid_api_key | The key is wrong or revoked. Do not retry. |
| 403 | account_inactive | Talk to us. Retrying will not help. |
| 402 | insufficient_balance | Top up by at least shortfall_pence, then retry with the same idempotency key. |
| 404 | not_found | No letter with that id on your account. |
| 409 | not_cancellable | Already printed or beyond. Nothing to do. |
| 422 | unreadable_document | The PDF is damaged or password protected. Fix the file. |
| 422 | too_many_pages | Split it, or email us to run it as a batch. |
| 422 | not_priceable | That combination has no rate — often an unsupported destination. Check /services. |
| 422 | (validation) | Laravel's {"message": …, "errors": {…}} shape, keyed by field. Fix the request. |
| 429 | (throttled) | Back off and respect Retry-After. |
What to retry
Retry 429 and 5xx, with backoff and the same idempotency key. Never retry a 4xx other than 429 without changing the request — the answer will be the same, and 402 needs money before it needs another attempt.
Limits
| Limit | Value |
|---|---|
| Requests | 120 a minute per key. Unauthenticated attempts: 20 a minute per IP. |
| File size | 30MB |
| Pages per letter | 200 |
| File type | PDF only |
| Same-day cutoff | 3:00pm on working days |
| Card top-up | £10 minimum, £5,000 maximum per payment |
Sending several thousand letters in one run? Tell us first. We would rather plan the print capacity than have you discover it at the rate limit.
Before you go live
- The key is in a secret store, not in your repository, and not in a log line.
- Every submission carries an
Idempotency-Keyderived from the thing being sent. - Retries are limited to 429 and 5xx, with backoff.
- A 402 alerts a human. Letters stop going out when the balance runs dry.
- The balance is checked on a schedule, against your own threshold.
- You store
letter.idagainst your own record, so the two can be reconciled. - Someone reads
failure_reasonon failed letters. A refund is not a delivery. - Your PDFs are A4 portrait with a 15mm margin, so nothing important lands under the fold.
Questions, or a volume you would like to talk through first? Email [email protected]