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.

Base URLhttps://sendlettersonline.uk/api/v1
AuthAuthorization: Bearer
FormatJSON · multipart upload

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.

1. Get a key

We issue it once and show it once. Store it as a secret in your application, not in your repository.

2. Put funds on the account

By card, or by bank transfer. The balance is what pays for letters.

3. POST a PDF

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

StatusCodeMeaning
401missing_api_keyNo Authorization header, or it is not a bearer token.
401invalid_api_keyThe key is unknown or revoked. We return the same answer for both on purpose.
403account_inactiveThe 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.

Charged

At submission, once. The exact figure and its breakdown come back in the response.

Refunded

In full, if you cancel before printing, or if we cannot send it. Back on the balance immediately.

Recorded

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.

FieldTypeNotes
filefilerequiredThe PDF. Up to 30MB and 200 pages. A4, portrait.
servicestringrequiredA slug from /services, e.g. second-class.
colourbooleanoptionalDefaults to false. Colour print costs more per page.
double_sidedbooleanoptionalDefaults to false. Halves the sheets, which can drop the envelope tier.
recipient[name]stringrequiredUp to 120 characters.
recipient[line1]stringrequiredStreet address.
recipient[line2]stringoptionalSecond address line.
recipient[city]stringrequiredTown or city.
recipient[postcode]stringrequiredPostcode, or the local equivalent abroad.
recipient[country]stringoptionalTwo-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

StatusWhat it meansCharge
queuedAccepted and paid for, waiting for the next print run. Cancellable.Taken
printedPrinted and enveloped. Past the point of cancelling.Taken
postedHanded to Royal Mail. despatched_at is set, and tracking_number too on a tracked service.Taken
deliveredConfirmed delivered. Only ever set for tracked services.Taken
returnedCame back to us undelivered.Taken
cancelledYou cancelled it before printing.Refunded
failedWe 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."
  }
}
StatusCodeWhat to do
401missing_api_keySend the Authorization header.
401invalid_api_keyThe key is wrong or revoked. Do not retry.
403account_inactiveTalk to us. Retrying will not help.
402insufficient_balanceTop up by at least shortfall_pence, then retry with the same idempotency key.
404not_foundNo letter with that id on your account.
409not_cancellableAlready printed or beyond. Nothing to do.
422unreadable_documentThe PDF is damaged or password protected. Fix the file.
422too_many_pagesSplit it, or email us to run it as a batch.
422not_priceableThat 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

LimitValue
Requests120 a minute per key. Unauthenticated attempts: 20 a minute per IP.
File size30MB
Pages per letter200
File typePDF only
Same-day cutoff3: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-Key derived 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.id against your own record, so the two can be reconciled.
  • Someone reads failure_reason on 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]