Skip to content

Developers

ModernMail REST API

Upload a document, get a quote and post letters through Royal Mail from your own software. JSON over HTTPS, paid from your prepaid credit, described by an OpenAPI 3.1 document.

Using an AI assistant instead? See the MCP server.

Quote a letter
curl https://modernmail.co.uk/api/v1/quotes \  -H "Authorization: Bearer mm_live_…" \  -H "content-type: application/json" \  -d '{"letters":[{"recipientName":"Jane Doe","recipientAddressLine1":"1 High Street",       "recipientCity":"London","recipientPostcode":"SW1A 1AA",       "postageType":"first_class","uploadIds":["upl_…"]}]}'

Overview

One key, one JSON API. Everything a customer can do from the send page, your software can do here: upload, quote, order, track.
Base URL
https://modernmail.co.uk/api/v1
Version
2026-10-01
Format
JSON in and out. Money is integer pence (GBP); times are ISO 8601 UTC.
Authentication
Authorization: Bearer mm_live_…
Payment
Orders are paid from the account's prepaid credit. There is no card step.

The API is versioned in the path (/v1). Fields are added over time but never removed or retyped without a new version prefix, so a client written today keeps working. Every response carries cache-control: no-store except the public pricing and spec endpoints.

Orders placed here appear in the customer's account like any other, with an API badge, and go through the same print and posting run. Cancellation, refunds and address lookup are not available through the API yet; use your account for those.

Authentication

  1. Sign in (or create a free account), verify your email, then open Account → Developers. In a business, the primary user and admins can create keys that act for the business.
  2. Create a key: give it a name, choose its scopes, set a daily spend cap and an optional expiry. You retype your password to confirm.
  3. The key (mm_live_…) is shown once. We store only a keyed hash of it. The account owner is emailed whenever a key is created or revoked.

Send it on every request:

http
Authorization: Bearer mm_live_…

A missing, malformed, unknown, expired or revoked key always gets the same 401 unauthorized, so a probe learns nothing about which keys exist. A key stops working the moment it is revoked, its owner's account is suspended or closed, or its owner leaves the business it was created in. Keys never appear in our request logs.

Scopes

A key does only what its scopes allow. New keys can read and upload but not spend: order creation is opt-in.
API scopes
ScopeAllowsEndpointsOn new keys
No key neededRead the rate cardGET /pricingn/a
account:readRead the account and credit balanceGET /accountYes
files:writeUpload documentsPOST /filesGET /files/{id}POST /files/{id}/finalizeYes
orders:readRead orders and their statusPOST /quotesGET /ordersGET /orders/{id}Yes
orders:writePlace and cancel orders (spends credit)POST /ordersPOST /orders/{id}/cancelOpt in
addresses:validateCheck and correct UK addresses (a metered lookup)POST /addresses/validateOpt in
webhooks:manageManage webhook endpointsGET /webhooksPOST /webhooksGET /webhooks/{id}DELETE /webhooks/{id}Opt in
No key needed
AllowsRead the rate card
EndpointsGET /pricing
On new keysn/a
account:read
AllowsRead the account and credit balance
EndpointsGET /account
On new keysYes
files:write
AllowsUpload documents
EndpointsPOST /filesGET /files/{id}POST /files/{id}/finalize
On new keysYes
orders:read
AllowsRead orders and their status
EndpointsPOST /quotesGET /ordersGET /orders/{id}
On new keysYes
orders:write
AllowsPlace and cancel orders (spends credit)
EndpointsPOST /ordersPOST /orders/{id}/cancel
On new keysOpt in
addresses:validate
AllowsCheck and correct UK addresses (a metered lookup)
EndpointsPOST /addresses/validate
On new keysOpt in
webhooks:manage
AllowsManage webhook endpoints
EndpointsGET /webhooksPOST /webhooksGET /webhooks/{id}DELETE /webhooks/{id}
On new keysOpt in

A call without the right scope gets 403 insufficient_scope with details.required naming the missing one. The MCP server enforces the same scopes.

Ordering flow

Four calls take a PDF from your system to a stamped envelope.

Optionally first: POST /addresses/validate with a letter's recipient fields checks a UK address against Royal Mail's address file and hands it back as Royal Mail writes it, or lists what is at the postcode. It is a paid lookup on our side, so it needs the addresses:validate scope and is capped per key per day.

  1. POST /files for each document. You get a file id and its page count.
  2. POST /quotes with your letters. You get per-letter prices, discounts, the total and expectedTotalPence, plus whether your credit covers it.
  3. POST /orders with the same letters, the expectedTotalPence and an Idempotency-Key. The order is created and paid in one step.
  4. GET /orders/{id} to follow status, dispatch dates and Royal Mail tracking numbers, or register a webhook and let us tell you.

The letter object

A letter is one recipient plus the documents to print for them. Up to 500 letters per order, 100 copies per letter and 50 documents per letter. Allowed postageType and envelopeStyle values, with prices and delivery aims, come from GET /pricing. Non-UK recipients use postageType: "international_standard" with an ISO 3166-1 alpha-2 recipientCountry.

Letter fields
FieldTypeNotes
recipientName*stringmax length 200.
recipientAddressLine1*stringmax length 200.
recipientAddressLine2stringmax length 200.
recipientAddressLine3stringmax length 200.
recipientCity*stringmax length 100.
recipientPostcodestringmax length 20, default "".
recipientCountrystringmax length 2, default "GB".
returnNamestringmax length 200.
returnAddressLine1stringmax length 200.
returnAddressLine2stringmax length 200.
returnCitystringmax length 100.
returnPostcodestringmax length 10.
postageType*stringOne of second_class, first_class, second_class_signed, first_class_signed, tracked_48, tracked_24, special_delivery, international_standard
colourPrintstringdefault "bw".One of bw, colour
paperQualitystringdefault "standard".One of standard, premium
envelopeStylestringdefault "white_c5".One of white_c5, c5_boardback, a4_boardback, c5_window
quantityintegermin 1, max 100, default 1.
uploadIds*string[]min 1 item, max 50 items.
finishingobject
recipientName*
Typestring
Notesmax length 200.
recipientAddressLine1*
Typestring
Notesmax length 200.
recipientAddressLine2
Typestring
Notesmax length 200.
recipientAddressLine3
Typestring
Notesmax length 200.
recipientCity*
Typestring
Notesmax length 100.
recipientPostcode
Typestring
Notesmax length 20, default "".
recipientCountry
Typestring
Notesmax length 2, default "GB".
returnName
Typestring
Notesmax length 200.
returnAddressLine1
Typestring
Notesmax length 200.
returnAddressLine2
Typestring
Notesmax length 200.
returnCity
Typestring
Notesmax length 100.
returnPostcode
Typestring
Notesmax length 10.
postageType*
Typestring
NotesOne of second_class, first_class, second_class_signed, first_class_signed, tracked_48, tracked_24, special_delivery, international_standard
colourPrint
Typestring
Notesdefault "bw".One of bw, colour
paperQuality
Typestring
Notesdefault "standard".One of standard, premium
envelopeStyle
Typestring
Notesdefault "white_c5".One of white_c5, c5_boardback, a4_boardback, c5_window
quantity
Typeinteger
Notesmin 1, max 100, default 1.
uploadIds*
Typestring[]
Notesmin 1 item, max 50 items.
finishing
Typeobject
A letter
{  "recipientName": "Jane Doe",  "recipientAddressLine1": "1 High Street",  "recipientCity": "London",  "recipientPostcode": "SW1A 1AA",  "recipientCountry": "GB",  "postageType": "first_class",  "colourPrint": "bw",  "paperQuality": "standard",  "envelopeStyle": "white_c5",  "quantity": 1,  "uploadIds": [    "upl_…"  ]}

Paying

API orders are paid in full from prepaid credit. No card step, no partial payment.

Top up at Account → Credit before you order. When the balance is short the order is refused with 402 insufficient_credit, and details carries the total, the balance, the shortfall and a top-up link. Nothing is charged.

Pass the quote's expectedTotalPence on POST /orders. If prices or discounts have moved since the quote you get 409 price_changed rather than a surprise charge; quote again and resend.

Every key has a daily spend cap (default £50, counted per Europe/London calendar day). An order that would pass it fails with 403 spend_cap_exceeded. Personal accounts may set up to £5,000; a business's primary user up to £50,000. Business members' own monthly limits still apply.

Volume and account discounts apply exactly as they do on the website. There is no per-call fee: you pay the letter prices and nothing else.

Sandbox

Build against a sandbox key first. Same calls, same refusals, nothing charged, printed or posted.

When you create a key, choose Sandbox and it starts mm_test_… instead of mm_live_…. Every endpoint and MCP tool accepts it. Uploads, quotes and validation are real; orders are simulated and come back with livemode: false and a TEST- order number. They never reach our print room and never touch your credit.

  • Credit is a pretend balance of £1,000 per sandbox key per London day, drawn down by sandbox orders and refilled each morning. The key's daily spend cap applies to it too.
  • Status moves on its own. A sandbox order is paid for 2 minutes, processing until 10 minutes, then completed with each letter posted and, for tracked services, a TT…GB tracking number. A scheduled sandbox order waits as paid until 07:00 on its posting day.
  • Cancel works while the order is paid, so you have 2 minutes to exercise it; after that it is refused with 409 order_not_cancellable, as a live order would be once we had started.
  • Force a refusal by setting reference to sandbox:insufficient_credit, sandbox:spend_cap_exceeded, sandbox:price_changed or sandbox:internal. The order is refused with that error and nothing is stored.
  • Webhooks fire for order.paid on creation and order.status_changed on cancellation, to your own endpoints only, with livemode: false in the payload. The timed moves to processing and posted do not send events.
  • Sandbox orders are listed by GET /orders with a sandbox key and never by a live one. They are kept for 30 days.

Idempotency, rate limits, scopes and error shapes are identical in both modes, so an integration that passes against the sandbox needs only a live key to go live.

Idempotency

Retrying an order can never pay twice.

POST /orders requires an Idempotency-Key header: any unique string up to 200 printable characters (use a UUID). Keys are kept for 24 hours.

  • Same key, same body: the stored response is replayed with an idempotent-replayed: true header.
  • Same key, different body: 409 idempotency_key_reused.
  • Same key while the first request is still running: 409 idempotency_in_progress with retry-after: 2.

The link between the key and the paid order is committed in the same database transaction as the credit debit, so even a crash mid-response cannot double-charge. Outcomes you can fix are not stored: 402, 403 spend_cap_exceeded, 409 price_changed and server errors release the key, so you can top up or re-quote and retry with the same one.

Rate limits

Per key, held in the database so they apply across every server. Headers tell you where you stand.
Rate limits
BucketLimitCovers
Reads120 / min per keyEvery GET, and POST /v1/quotes is a write
Writes30 / min per keyPOST /v1/quotes, POST /v1/orders/{id}/cancel, POST and DELETE /v1/webhooks, and the MCP transport
File ingests10 / min per keyPOST /v1/files and POST /v1/files/{id}/finalize
Order creations5 / min per keyPOST /v1/orders
Address checks30 / min per keyPOST /v1/addresses/validate (also capped per key per day)
Before authentication60 / min per IPEvery request, before the key is looked up; also the keyless pricing and spec endpoints.
Reads
Limit120 / min per key
CoversEvery GET, and POST /v1/quotes is a write
Writes
Limit30 / min per key
CoversPOST /v1/quotes, POST /v1/orders/{id}/cancel, POST and DELETE /v1/webhooks, and the MCP transport
File ingests
Limit10 / min per key
CoversPOST /v1/files and POST /v1/files/{id}/finalize
Order creations
Limit5 / min per key
CoversPOST /v1/orders
Address checks
Limit30 / min per key
CoversPOST /v1/addresses/validate (also capped per key per day)
Before authentication
Limit60 / min per IP
CoversEvery request, before the key is looked up; also the keyless pricing and spec endpoints.

Responses carry x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset (Unix seconds). A refused request is 429 rate_limited with a retry-after header.

Errors

One shape for every error. The code is stable and safe to branch on; the message is for people and may change.
Error body
{  "error": {    "code": "insufficient_credit",    "message": "Your credit balance does not cover this order.",    "details": {      "totalPence": 2990,      "balancePence": 2570,      "shortfallPence": 420,      "topUpUrl": "https://modernmail.co.uk/account/credit"    }  }}
Error codes
HTTPcodeMeaning
400invalid_requestValidation failed; details lists the field errors.
400idempotency_key_requiredPOST /v1/orders was sent without an Idempotency-Key header.
401unauthorizedMissing, malformed, unknown, expired or revoked API key.
402insufficient_creditThe prepaid credit balance does not cover the total; details has the shortfall and a top-up link.
403insufficient_scopeThe key lacks the scope this endpoint needs (details.required).
403api_unavailableThe API is not open yet. Nothing about the key is wrong; it works once the API is released.
403forbiddenThe account may not place orders or pay with credit, or, in a business, manage webhooks without the api.manageKeys and orders.viewAll permissions.
403spend_cap_exceededThe order would pass the key's daily spend cap.
404not_foundUnknown id, or one that belongs to another account.
400post_on_invalidpostOn is not a UK working day in the allowed range; details has earliest, latest and, for a weekend or bank holiday, nextWorkingDay.
400deliver_by_invaliddeliverBy is not a day Royal Mail delivers on (Mon–Sat, not a bank holiday) in the allowed range; details has earliest, latest and, for a Sunday or bank holiday, nextDeliveryDay.
409price_changedexpectedTotalPence differs from the live total; quote again.
409scheduling_unavailableScheduled posting is switched off at the moment; omit postOn and deliverBy.
409deliver_by_unreachableNo posting day lets every letter's postage arrive by deliverBy; details.services gives each service's earliest achievable date and suggestedDeliverBy the first date they all meet.
409upload_not_readyA file is still being processed; poll GET /v1/files/{id}.
409idempotency_key_reusedThe same Idempotency-Key was sent with a different body.
409idempotency_in_progressA request with this Idempotency-Key is still being processed; retry after retry-after.
409order_not_cancellableThe order can no longer be cancelled through the API: we have started on it, or it is not in a paid state; details.status says which. Ask support instead.
409conflictThe request clashes with the current state, for example too many webhook endpoints.
413payload_too_largeAn inline upload is over the size limit; use a URL or a presigned upload.
415unsupported_media_typeNot a PDF or Word file, or a PDF with active content.
429rate_limitedToo many requests for this key or IP; wait for retry-after.
429lookup_cap_exceededThis key has used its daily allowance of address checks, or the site's shared lookup budget is spent; details has the cap and retry-after says when it resets.
503unavailableA dependency (storage or the document fetch) was unavailable; retry.
500internalOur fault; retry with the same Idempotency-Key.
400
codeinvalid_request
MeaningValidation failed; details lists the field errors.
400
codeidempotency_key_required
MeaningPOST /v1/orders was sent without an Idempotency-Key header.
401
codeunauthorized
MeaningMissing, malformed, unknown, expired or revoked API key.
402
codeinsufficient_credit
MeaningThe prepaid credit balance does not cover the total; details has the shortfall and a top-up link.
403
codeinsufficient_scope
MeaningThe key lacks the scope this endpoint needs (details.required).
403
codeapi_unavailable
MeaningThe API is not open yet. Nothing about the key is wrong; it works once the API is released.
403
codeforbidden
MeaningThe account may not place orders or pay with credit, or, in a business, manage webhooks without the api.manageKeys and orders.viewAll permissions.
403
codespend_cap_exceeded
MeaningThe order would pass the key's daily spend cap.
404
codenot_found
MeaningUnknown id, or one that belongs to another account.
400
codepost_on_invalid
MeaningpostOn is not a UK working day in the allowed range; details has earliest, latest and, for a weekend or bank holiday, nextWorkingDay.
400
codedeliver_by_invalid
MeaningdeliverBy is not a day Royal Mail delivers on (Mon–Sat, not a bank holiday) in the allowed range; details has earliest, latest and, for a Sunday or bank holiday, nextDeliveryDay.
409
codeprice_changed
MeaningexpectedTotalPence differs from the live total; quote again.
409
codescheduling_unavailable
MeaningScheduled posting is switched off at the moment; omit postOn and deliverBy.
409
codedeliver_by_unreachable
MeaningNo posting day lets every letter's postage arrive by deliverBy; details.services gives each service's earliest achievable date and suggestedDeliverBy the first date they all meet.
409
codeupload_not_ready
MeaningA file is still being processed; poll GET /v1/files/{id}.
409
codeidempotency_key_reused
MeaningThe same Idempotency-Key was sent with a different body.
409
codeidempotency_in_progress
MeaningA request with this Idempotency-Key is still being processed; retry after retry-after.
409
codeorder_not_cancellable
MeaningThe order can no longer be cancelled through the API: we have started on it, or it is not in a paid state; details.status says which. Ask support instead.
409
codeconflict
MeaningThe request clashes with the current state, for example too many webhook endpoints.
413
codepayload_too_large
MeaningAn inline upload is over the size limit; use a URL or a presigned upload.
415
codeunsupported_media_type
MeaningNot a PDF or Word file, or a PDF with active content.
429
coderate_limited
MeaningToo many requests for this key or IP; wait for retry-after.
429
codelookup_cap_exceeded
MeaningThis key has used its daily allowance of address checks, or the site's shared lookup budget is spent; details has the cap and retry-after says when it resets.
503
codeunavailable
MeaningA dependency (storage or the document fetch) was unavailable; retry.
500
codeinternal
MeaningOur fault; retry with the same Idempotency-Key.

Endpoints

Base URL https://modernmail.co.uk/api/v1. Full request and response schemas are in the OpenAPI document, which is public and CORS-enabled so tooling can load it directly.
REST endpoints
MethodPathScopeWhat it does
GET/accountaccount:readWho the key acts as, credit balance and today's spend
GET/pricingNo key neededCurrent rate card
POST/filesfiles:writeIngest a PDF or Word document
GET/files/{id}files:writeA file's status and page count
POST/files/{id}/finalizefiles:writeCount pages and seal a presigned upload
POST/addresses/validateaddresses:validateCheck a UK address against Royal Mail's address file
POST/quotesorders:readPrice an order without creating it
GET/ordersorders:readList orders, newest first
POST/ordersorders:writeCreate and pay for an order from credit
GET/orders/{id}orders:readOne order with its letters
POST/orders/{id}/cancelorders:writeCancel an order for a full refund
GET/webhookswebhooks:manageList webhook endpoints
POST/webhookswebhooks:manageRegister an https endpoint
GET/webhooks/{id}webhooks:manageOne endpoint
DELETE/webhooks/{id}webhooks:manageRemove an endpoint
GET
Path/account
Scopeaccount:read
What it doesWho the key acts as, credit balance and today's spend
GET
Path/pricing
ScopeNo key needed
What it doesCurrent rate card
POST
Path/files
Scopefiles:write
What it doesIngest a PDF or Word document
GET
Path/files/{id}
Scopefiles:write
What it doesA file's status and page count
POST
Path/files/{id}/finalize
Scopefiles:write
What it doesCount pages and seal a presigned upload
POST
Path/addresses/validate
Scopeaddresses:validate
What it doesCheck a UK address against Royal Mail's address file
POST
Path/quotes
Scopeorders:read
What it doesPrice an order without creating it
GET
Path/orders
Scopeorders:read
What it doesList orders, newest first
POST
Path/orders
Scopeorders:write
What it doesCreate and pay for an order from credit
GET
Path/orders/{id}
Scopeorders:read
What it doesOne order with its letters
POST
Path/orders/{id}/cancel
Scopeorders:write
What it doesCancel an order for a full refund
GET
Path/webhooks
Scopewebhooks:manage
What it doesList webhook endpoints
POST
Path/webhooks
Scopewebhooks:manage
What it doesRegister an https endpoint
GET
Path/webhooks/{id}
Scopewebhooks:manage
What it doesOne endpoint
DELETE
Path/webhooks/{id}
Scopewebhooks:manage
What it doesRemove an endpoint

Account

GET

/account

account:read

Who the key acts as, credit balance and today's spend

GET https://modernmail.co.uk/api/v1/account

Responses

Pricing

GET

/pricing

No key needed

Current rate card

GET https://modernmail.co.uk/api/v1/pricing

No API key required. Postage services with prices and delivery aims, envelope, paper and print options, extra-page cost and volume discount tiers. Cacheable for five minutes.

Responses

  • 200Postage, envelope, print and paper prices and volume tiers
  • 429Rate limitedError

Files

POST

/files

files:write

Ingest a PDF or Word document

POST https://modernmail.co.uk/api/v1/files

Send multipart/form-data with a file part (≤ 4 MB), or JSON with url (https only, fetched server-side, ≤ 50 MB), base64, or presign: true for a PUT URL you upload to yourself, followed by POST /files/{id}/finalize. The file type is detected from its bytes; PDFs containing scripts, launch actions or embedded files are refused.

Body multipart/form-data

POST /files multipart/form-data body
FieldTypeNotes
file*string (binary)
file*
Typestring (binary)

Body application/json

With url

POST /files application/json body
FieldTypeNotes
url*string (uri)Public https URL of a PDF or Word document; fetched server-side. http is refused. max length 2048.
filenamestringmax length 200.
url*
Typestring (uri)
NotesPublic https URL of a PDF or Word document; fetched server-side. http is refused. max length 2048.
filename
Typestring
Notesmax length 200.

With filename

POST /files application/json body
FieldTypeNotes
filename*stringmax length 200.
base64*stringBase64 (standard or URL-safe) of the document bytes; inline limit applies. max length 5592410.
filename*
Typestring
Notesmax length 200.
base64*
Typestring
NotesBase64 (standard or URL-safe) of the document bytes; inline limit applies. max length 5592410.

With presign

POST /files application/json body
FieldTypeNotes
presign*"true"
filename*stringmax length 200.
contentType*stringOne of application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document
sizeBytes*integermin 1, max 52428800.
presign*
Type"true"
filename*
Typestring
Notesmax length 200.
contentType*
Typestring
NotesOne of application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document
sizeBytes*
Typeinteger
Notesmin 1, max 52428800.

Responses

  • 201The fileFile
  • 400Invalid requestError
  • 401Invalid or missing API keyError
  • 403Forbidden, missing scope or daily spend cap exceededError
  • 413Payload too largeError
  • 415Unsupported media typeError
  • 422The document could not be processedError
  • 429Rate limitedError
  • 503Temporarily unavailableError
GET

/files/{id}

files:write

A file's status and page count

GET https://modernmail.co.uk/api/v1/files/{id}

Parameters

NameInTypeNotes
id*pathstring
id*
Inpath
Typestring
Notes

Responses

POST

/files/{id}/finalize

files:write

Count pages and seal a presigned upload

POST https://modernmail.co.uk/api/v1/files/{id}/finalize

Parameters

NameInTypeNotes
id*pathstring
id*
Inpath
Typestring
Notes

Responses

  • 200The file, now readyFile
  • 401Invalid or missing API keyError
  • 404Not foundError
  • 409Conflict (price changed, idempotency key reused, file not ready)Error
  • 413Payload too largeError
  • 422The document could not be processedError
  • 429Rate limitedError
  • 503Temporarily unavailableError

Addresses

POST

/addresses/validate

addresses:validate

Check a UK address against Royal Mail's address file

POST https://modernmail.co.uk/api/v1/addresses/validate

Send the recipient fields of a letter. With address lines, the result says whether the address is on the file as typed (verified), closely matches one address (corrected, with the address as Royal Mail writes it in address), or fits nothing at that postcode (unmatched, with candidates). With a postcode alone it lists every address at it (lookup). UK only. Each call is a paid lookup on our side, so it is capped per key per day (lookups in the response; 429 lookup_cap_exceeded when spent) as well as per minute.

Body application/json

POST /addresses/validate application/json body
FieldTypeNotes
recipientNamestringHelps match a business name on the address file. max length 200.
recipientAddressLine1stringmax length 200.
recipientAddressLine2stringmax length 200.
recipientAddressLine3stringmax length 200.
recipientCitystringmax length 100.
recipientPostcode*stringA UK postcode; the lookup key. min length 5, max length 8.
recipientCountrystringMust be GB (the default): only UK addresses can be checked. max length 2.
recipientName
Typestring
NotesHelps match a business name on the address file. max length 200.
recipientAddressLine1
Typestring
Notesmax length 200.
recipientAddressLine2
Typestring
Notesmax length 200.
recipientAddressLine3
Typestring
Notesmax length 200.
recipientCity
Typestring
Notesmax length 100.
recipientPostcode*
Typestring
NotesA UK postcode; the lookup key. min length 5, max length 8.
recipientCountry
Typestring
NotesMust be GB (the default): only UK addresses can be checked. max length 2.

Responses

Quotes

POST

/quotes

orders:read

Price an order without creating it

POST https://modernmail.co.uk/api/v1/quotes

Body application/json

POST /quotes application/json body
FieldTypeNotes
letters*object[]min 1 item, max 500 items.
letters*
Typeobject[]
Notesmin 1 item, max 500 items.

Responses

  • 200The quoteQuote
  • 400Invalid requestError
  • 401Invalid or missing API keyError
  • 403Forbidden, missing scope or daily spend cap exceededError
  • 409Conflict (price changed, idempotency key reused, file not ready)Error
  • 429Rate limitedError

Orders

GET

/orders

orders:read

List orders, newest first

GET https://modernmail.co.uk/api/v1/orders

Parameters

NameInTypeNotes
statusquerystringOne of created, paid, processing, completed, cancelled, refunded.
referencequerystring
minequerystringOnly orders placed with this key One of 1.
limitqueryintegermin 1, max 100, default 25.
cursorquerystring
status
Inquery
Typestring
NotesOne of created, paid, processing, completed, cancelled, refunded.
reference
Inquery
Typestring
Notes
mine
Inquery
Typestring
NotesOnly orders placed with this key One of 1.
limit
Inquery
Typeinteger
Notesmin 1, max 100, default 25.
cursor
Inquery
Typestring
Notes

Responses

  • 200A page of orders
  • 400Invalid requestError
  • 401Invalid or missing API keyError
  • 403Forbidden, missing scope or daily spend cap exceededError
  • 429Rate limitedError
POST

/orders

orders:write

Create and pay for an order from credit

POST https://modernmail.co.uk/api/v1/orders

All-or-nothing: the order is created and paid in one step, or refused with 402 and the shortfall. Pass the quote's expectedTotalPence to be refused with 409 price_changed if prices moved. Pass postOn (a UK working day, tomorrow to 12 months ahead) to pay now and have the order held until that day; it is refused with 400 post_on_invalid or 409 scheduling_unavailable. Or pass deliverBy (a day Royal Mail delivers on) to have the posting day worked out from each letter's postage so it arrives by then; a service that can't make it is refused with 409 deliver_by_unreachable and each service's earliest date. Requires Idempotency-Key; the same key and body replays the first response for 24 hours.

Parameters

NameInTypeNotes
Idempotency-Key*headerstringmax length 200.
Idempotency-Key*
Inheader
Typestring
Notesmax length 200.

Body application/json

POST /orders application/json body
FieldTypeNotes
letters*object[]min 1 item, max 500 items.
expectedTotalPenceintegermin 0.
referencestringmax length 120.
postOnstringPost on this UK working day (YYYY-MM-DD), from tomorrow up to 12 months ahead. Paid now, held until then; cancel free until it is released that morning. Omit to post on the next posting day.
deliverBystringArrive by this UK date (YYYY-MM-DD; Mon–Sat, not a bank holiday). We post on the last working day that meets Royal Mail's aim for each letter's postage and hold the order until then; an aim, not a guarantee. Not together with postOn.
letters*
Typeobject[]
Notesmin 1 item, max 500 items.
expectedTotalPence
Typeinteger
Notesmin 0.
reference
Typestring
Notesmax length 120.
postOn
Typestring
NotesPost on this UK working day (YYYY-MM-DD), from tomorrow up to 12 months ahead. Paid now, held until then; cancel free until it is released that morning. Omit to post on the next posting day.
deliverBy
Typestring
NotesArrive by this UK date (YYYY-MM-DD; Mon–Sat, not a bank holiday). We post on the last working day that meets Royal Mail's aim for each letter's postage and hold the order until then; an aim, not a guarantee. Not together with postOn.

Responses

  • 201The paid orderOrder
  • 400Invalid requestError
  • 401Invalid or missing API keyError
  • 402Insufficient creditError
  • 403Forbidden, missing scope or daily spend cap exceededError
  • 409Conflict (price changed, idempotency key reused, file not ready)Error
  • 429Rate limitedError
GET

/orders/{id}

orders:read

One order with its letters

GET https://modernmail.co.uk/api/v1/orders/{id}

Parameters

NameInTypeNotes
id*pathstring
id*
Inpath
Typestring
Notes

Responses

POST

/orders/{id}/cancel

orders:write

Cancel an order for a full refund

POST https://modernmail.co.uk/api/v1/orders/{id}/cancel

The same rule as the Cancel button in the account: only while the order is paid and nothing on it has been started (no letter printed, labelled or picked up by Dispatch), which in practice means the same day, before processing begins. Credit-paid orders get their credit back at once; a card-paid order is refunded to the card. The order comes back with every letter cancelled and its status refunded (everything paid was returned) or cancelled (nothing was owed). Otherwise 409 order_not_cancellable with details.status; ask support to cancel instead. No request body.

Parameters

NameInTypeNotes
id*pathstring
id*
Inpath
Typestring
Notes

Responses

  • 200The cancelled order, plus what was refunded
  • 401Invalid or missing API keyError
  • 403Forbidden, missing scope or daily spend cap exceededError
  • 404Not foundError
  • 409Conflict (price changed, idempotency key reused, file not ready)Error
  • 429Rate limitedError

Webhooks

GET

/webhooks

webhooks:manage

List webhook endpoints

GET https://modernmail.co.uk/api/v1/webhooks

Responses

  • 200Endpoints
  • 401Invalid or missing API keyError
  • 403Forbidden, missing scope or daily spend cap exceededError
  • 429Rate limitedError
POST

/webhooks

webhooks:manage

Register an https endpoint

POST https://modernmail.co.uk/api/v1/webhooks

Deliveries are POSTed with x-modernmail-signature: t=<unix>,v1=<hex> where v1 is HMAC-SHA256 over ${t}.${body} using the secret returned here, and x-modernmail-delivery carries a unique id for de-duplication. The body-only x-modernmail-signature-legacy header is deprecated and stops being sent after 31 December 2026.

Body application/json

POST /webhooks application/json body
FieldTypeNotes
url*string (uri)Must be https and publicly reachable. max length 2048.
events*string[]min 1 item.
labelstringmax length 60.
url*
Typestring (uri)
NotesMust be https and publicly reachable. max length 2048.
events*
Typestring[]
Notesmin 1 item.
label
Typestring
Notesmax length 60.

Responses

  • 201The endpoint, with its secretWebhookEndpoint
  • 400Invalid requestError
  • 401Invalid or missing API keyError
  • 403Forbidden, missing scope or daily spend cap exceededError
  • 409Conflict (price changed, idempotency key reused, file not ready)Error
  • 429Rate limitedError
GET

/webhooks/{id}

webhooks:manage

One endpoint

GET https://modernmail.co.uk/api/v1/webhooks/{id}

Parameters

NameInTypeNotes
id*pathstring
id*
Inpath
Typestring
Notes

Responses

DELETE

/webhooks/{id}

webhooks:manage

Remove an endpoint

DELETE https://modernmail.co.uk/api/v1/webhooks/{id}

Parameters

NameInTypeNotes
id*pathstring
id*
Inpath
Typestring
Notes

Responses

  • 200Deleted
  • 401Invalid or missing API keyError
  • 404Not foundError
  • 429Rate limitedError

Specification

GET

/openapi.json

No key needed

The OpenAPI 3.1 document for this API

Public, no key, access-control-allow-origin: *, cached for five minutes. Point Postman, Bruno, Stoplight or a code generator at it.

Objects

The shapes the API returns. Fields marked * are always present.

Error

Error fields
FieldTypeNotes
error*object
error*
Typeobject

File

File fields
FieldTypeNotes
object"file"
idstring
filenamestring
contentTypestring
sizeBytesinteger
statusstringOne of ready, pending
pageCountinteger | null
pageCountEstimatedboolean
createdAtstring (date-time)
uploadobjectPresent only for presigned uploads: PUT the bytes here, then POST /files/{id}/finalize.
object
Type"file"
id
Typestring
filename
Typestring
contentType
Typestring
sizeBytes
Typeinteger
status
Typestring
NotesOne of ready, pending
pageCount
Typeinteger | null
pageCountEstimated
Typeboolean
createdAt
Typestring (date-time)
upload
Typeobject
NotesPresent only for presigned uploads: PUT the bytes here, then POST /files/{id}/finalize.

Address

Address fields
FieldTypeNotes
idstring | nullRoyal Mail delivery point id (UDPRN).
organisationstring | nullBusiness at this delivery point, or null for a home.
recipientAddressLine1string
recipientAddressLine2string | null
recipientAddressLine3string | null
recipientCitystring
recipientPostcodestring
recipientCountry"GB"
id
Typestring | null
NotesRoyal Mail delivery point id (UDPRN).
organisation
Typestring | null
NotesBusiness at this delivery point, or null for a home.
recipientAddressLine1
Typestring
recipientAddressLine2
Typestring | null
recipientAddressLine3
Typestring | null
recipientCity
Typestring
recipientPostcode
Typestring
recipientCountry
Type"GB"

AddressValidation

AddressValidation fields
FieldTypeNotes
object"address_validation"
postcodestringThe postcode as Royal Mail writes it.
statusstringverified: the typed address is on the file as typed. corrected: it closely matches one address, returned in address. unmatched: the postcode exists but nothing fits; candidates lists what is there. lookup: no lines were sent; candidates is every address at the postcode. postcode_unknown: Royal Mail has nothing at that postcode.One of verified, corrected, unmatched, lookup, postcode_unknown
confidencenumber0 to 1; 1 for verified, the match score for corrected, 0 otherwise.
addressAddress | null
candidatesAddress[]
lookupsobject
object
Type"address_validation"
postcode
Typestring
NotesThe postcode as Royal Mail writes it.
status
Typestring
Notesverified: the typed address is on the file as typed. corrected: it closely matches one address, returned in address. unmatched: the postcode exists but nothing fits; candidates lists what is there. lookup: no lines were sent; candidates is every address at the postcode. postcode_unknown: Royal Mail has nothing at that postcode.One of verified, corrected, unmatched, lookup, postcode_unknown
confidence
Typenumber
Notes0 to 1; 1 for verified, the match score for corrected, 0 otherwise.
address
TypeAddress | null
candidates
TypeAddress[]
lookups
Typeobject

Letter

Letter fields
FieldTypeNotes
object"letter"
idstring
statusstringOne of pending, processing, posted, cancelled
recipientobject
returnAddressobject | null
postageTypestring
colourPrintboolean
paperQualitystring
envelopeStylestring
pagesinteger
quantityinteger
pricePenceinteger | null
trackingNumberstring | null
dispatchedAtstring | null
cancelledAtstring | null
object
Type"letter"
id
Typestring
status
Typestring
NotesOne of pending, processing, posted, cancelled
recipient
Typeobject
returnAddress
Typeobject | null
postageType
Typestring
colourPrint
Typeboolean
paperQuality
Typestring
envelopeStyle
Typestring
pages
Typeinteger
quantity
Typeinteger
pricePence
Typeinteger | null
trackingNumber
Typestring | null
dispatchedAt
Typestring | null
cancelledAt
Typestring | null

Order

Order fields
FieldTypeNotes
object"order"
idstring
orderNumberstring
statusstringOne of created, paid, processing, completed, cancelled, refunded
statusLabelstring
sourcestringOne of web, api, mcp
livemodebooleanFalse for an order placed with a sandbox (mm_test_) key: simulated, never charged, printed or posted.
referencestring | null
currencystring
amountsobject
lettersLetter[]
urlstring
paidAtstring | null
postOnstring | nullScheduled letters: the UK posting day asked for (YYYY-MM-DD); null when posting on the normal schedule.
postOnReleasedAtstring | nullWhen a scheduled order was released to the dispatch queue; null while it waits for its day.
deliverBystring | nullDeliver by a date: the arrival date the posting day was worked out from (YYYY-MM-DD); null otherwise.
createdAtstring (date-time)
updatedAtstring (date-time)
object
Type"order"
id
Typestring
orderNumber
Typestring
status
Typestring
NotesOne of created, paid, processing, completed, cancelled, refunded
statusLabel
Typestring
source
Typestring
NotesOne of web, api, mcp
livemode
Typeboolean
NotesFalse for an order placed with a sandbox (mm_test_) key: simulated, never charged, printed or posted.
reference
Typestring | null
currency
Typestring
amounts
Typeobject
letters
TypeLetter[]
url
Typestring
paidAt
Typestring | null
postOn
Typestring | null
NotesScheduled letters: the UK posting day asked for (YYYY-MM-DD); null when posting on the normal schedule.
postOnReleasedAt
Typestring | null
NotesWhen a scheduled order was released to the dispatch queue; null while it waits for its day.
deliverBy
Typestring | null
NotesDeliver by a date: the arrival date the posting day was worked out from (YYYY-MM-DD); null otherwise.
createdAt
Typestring (date-time)
updatedAt
Typestring (date-time)

Quote

Quote fields
FieldTypeNotes
object"quote"
currency"gbp"
lettersobject[]
envelopeCountinteger
subtotalPenceinteger
accountDiscountPenceinteger
volumeDiscountPercentinteger
volumeDiscountPenceinteger
totalPenceinteger
expectedTotalPenceintegerPass back as expectedTotalPence on POST /orders.
creditobject
dailyCapobject
object
Type"quote"
currency
Type"gbp"
letters
Typeobject[]
envelopeCount
Typeinteger
subtotalPence
Typeinteger
accountDiscountPence
Typeinteger
volumeDiscountPercent
Typeinteger
volumeDiscountPence
Typeinteger
totalPence
Typeinteger
expectedTotalPence
Typeinteger
NotesPass back as expectedTotalPence on POST /orders.
credit
Typeobject
dailyCap
Typeobject

WebhookEndpoint

WebhookEndpoint fields
FieldTypeNotes
idstring
urlstring
labelstring | null
secretHintstring
eventsstring[]
enabledboolean
disabledReasonstring | null
consecutiveFailuresinteger
createdAtstring (date-time)
lastDeliveryobject | null
secretstringOnly on creation. HMAC-SHA256 signing secret; store it.
id
Typestring
url
Typestring
label
Typestring | null
secretHint
Typestring
events
Typestring[]
enabled
Typeboolean
disabledReason
Typestring | null
consecutiveFailures
Typeinteger
createdAt
Typestring (date-time)
lastDelivery
Typeobject | null
secret
Typestring
NotesOnly on creation. HMAC-SHA256 signing secret; store it.

Account

Account fields
FieldTypeNotes
object"account"
livemodebooleanFalse for a sandbox (mm_test_) key.
typestringOne of personal, business
namestring | null
emailstring
businessobject | null
creditobject
keyobject
permissionsobject
object
Type"account"
livemode
Typeboolean
NotesFalse for a sandbox (mm_test_) key.
type
Typestring
NotesOne of personal, business
name
Typestring | null
email
Typestring
business
Typeobject | null
credit
Typeobject
key
Typeobject
permissions
Typeobject

AddressValidateRequest

AddressValidateRequest fields
FieldTypeNotes
recipientNamestringHelps match a business name on the address file. max length 200.
recipientAddressLine1stringmax length 200.
recipientAddressLine2stringmax length 200.
recipientAddressLine3stringmax length 200.
recipientCitystringmax length 100.
recipientPostcode*stringA UK postcode; the lookup key. min length 5, max length 8.
recipientCountrystringMust be GB (the default): only UK addresses can be checked. max length 2.
recipientName
Typestring
NotesHelps match a business name on the address file. max length 200.
recipientAddressLine1
Typestring
Notesmax length 200.
recipientAddressLine2
Typestring
Notesmax length 200.
recipientAddressLine3
Typestring
Notesmax length 200.
recipientCity
Typestring
Notesmax length 100.
recipientPostcode*
Typestring
NotesA UK postcode; the lookup key. min length 5, max length 8.
recipientCountry
Typestring
NotesMust be GB (the default): only UK addresses can be checked. max length 2.

Webhooks

Rather than polling, register an https endpoint and we POST you a signed event when something happens.

Register endpoints at Account → Developers or with POST /webhooks (webhooks:manage). Up to 50 per account, https only. The signing secret is returned once, on creation.

Webhook events
EventSent when
order.paidAn order has been paid for and accepted into production.
order.refundedAn order was refunded, in full or in part.
order.status_changedAn order moved to a new status (processing, printed, posted, cancelled…).
order.releasedA scheduled order reached its posting day (or was released early) and joined the dispatch queue.
letter.dispatchedA letter in an order was handed to Royal Mail.
order.paid
Sent whenAn order has been paid for and accepted into production.
order.refunded
Sent whenAn order was refunded, in full or in part.
order.status_changed
Sent whenAn order moved to a new status (processing, printed, posted, cancelled…).
order.released
Sent whenA scheduled order reached its posting day (or was released early) and joined the dispatch queue.
letter.dispatched
Sent whenA letter in an order was handed to Royal Mail.

Delivery

Each delivery is a POST with a JSON body { id, type, createdAt, data }. data carries the event's own fields (for example the letter, its postage and tracking numbers) plus an order summary with the order number, status and your reference. Headers: x-modernmail-event (the type), x-modernmail-delivery (a unique id to de-duplicate on), x-modernmail-signature and User-Agent: ModernMail-Webhooks/1.0.

Any 2xx within 10 seconds counts as delivered. Failures retry with exponential backoff for up to 6 attempts. After 25 consecutive failures the endpoint is switched off and the owner emailed; re-enable it from Developers, where you can also send a test event.

A delivery
{  "id": "whd_…",  "type": "letter.dispatched",  "createdAt": "2026-10-07T16:02:11.000Z",  "data": {    "orderId": "ord_…",    "letterId": "ltr_…",    "recipientName": "Jane Doe",    "postageType": "tracked_24",    "quantity": 1,    "trackingNumbers": [      "JX123456789GB"    ],    "dispatchedAt": "2026-10-07T16:02:10.000Z",    "order": {      "object": "order",      "id": "ord_…",      "orderNumber": "MM-10231",      "status": "processing",      "reference": "INV-123",      "totalPence": 299,      "letterCount": 1,      "url": "https://modernmail.co.uk/account/orders/ord_…"    }  }}

Verifying the signature

x-modernmail-signature: t=<unix seconds>,v1=<hex> where v1 is HMAC-SHA256 of "<t>.<raw body>" under your endpoint's secret. Reject deliveries whose t is more than five minutes off, compare in constant time, and de-duplicate on id.

Deprecated: the older x-modernmail-signature-legacy header (HMAC of the body alone, with no timestamp) is still sent until 31 December 2026 and then dropped. It cannot tell a replay from a fresh delivery, so verify x-modernmail-signature instead.

verify.mjs
import { createHmac, timingSafeEqual } from "node:crypto";export function verify(secret, signatureHeader, rawBody) {  const { t, v1 } = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay window  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");  return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));}

Worked example

The whole flow from a shell, with curl and jq.
order.sh
BASE=https://modernmail.co.uk/api/v1KEY=mm_live_…# 1. Upload the document (multipart, ≤ 4 MB inline)FILE=$(curl -s -H "Authorization: Bearer $KEY" -F file=@letter.pdf $BASE/files | jq -r .id)# 2. Build the letters and get a quoteBODY=$(jq -n --arg f "$FILE" '{letters:[{recipientName:"Jane Doe",recipientAddressLine1:"1 High Street",  recipientCity:"London",recipientPostcode:"SW1A 1AA",postageType:"first_class",uploadIds:[$f]}]}')TOTAL=$(curl -s -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \  -d "$BODY" $BASE/quotes | jq .expectedTotalPence)# 3. Create and pay for the order (needs orders:write and an Idempotency-Key)curl -s -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \  -H "Idempotency-Key: $(uuidgen)" \  -d "$(echo "$BODY" | jq --argjson t "$TOTAL" '. + {expectedTotalPence:$t, reference:"INV-123"}')" \  $BASE/orders# 4. Follow itcurl -s -H "Authorization: Bearer $KEY" $BASE/orders/ord_… | jq '.letters[] | {status, trackingNumber}'

Document rules

  • PDF or Word (.doc, .docx). The type is detected from the bytes, not trusted from the header or filename.
  • Inline uploads (multipart or base64) up to 4 MB. Larger files, up to 50 MB, go by url (fetched by us) or presign (you PUT the bytes to storage, then call finalize).
  • PDFs containing JavaScript, launch actions, open actions, embedded files or rich media are refused with 415.
  • URL fetches must be https, are blocked for private and local addresses and time out after 20 seconds; up to five redirects are followed, each hop checked against the same rules.
  • Page counts come from the file itself; the first pages included free and the per-page price follow the rate card.

Using an AI assistant instead?

The same keys work with our MCP server, so Claude Code, Cursor, VS Code and other MCP clients can quote and post letters for you with the same scopes, caps and limits.

Set up the MCP server →

Support

Stuck, or need something the API does not do yet? Email info@modernmail.co.uk with the time of the request and the x-modernmail-delivery or order id, and we will look at the log. We never store request bodies or keys, so include what you sent.