/account
account:readWho the key acts as, credit balance and today's spend
GET https://modernmail.co.uk/api/v1/account
Developers
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.
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_…"]}]}'https://modernmail.co.uk/api/v12026-10-01Authorization: Bearer mm_live_…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.
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:
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.
| Scope | Allows | Endpoints | On new keys |
|---|---|---|---|
| No key needed | Read the rate card | GET /pricing | n/a |
| account:read | Read the account and credit balance | GET /account | Yes |
| files:write | Upload documents | POST /filesGET /files/{id}POST /files/{id}/finalize | Yes |
| orders:read | Read orders and their status | POST /quotesGET /ordersGET /orders/{id} | Yes |
| orders:write | Place and cancel orders (spends credit) | POST /ordersPOST /orders/{id}/cancel | Opt in |
| addresses:validate | Check and correct UK addresses (a metered lookup) | POST /addresses/validate | Opt in |
| webhooks:manage | Manage webhook endpoints | GET /webhooksPOST /webhooksGET /webhooks/{id}DELETE /webhooks/{id} | Opt 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.
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.
POST /files for each document. You get a file id and its page count.POST /quotes with your letters. You get per-letter prices, discounts, the total and expectedTotalPence, plus whether your credit covers it.POST /orders with the same letters, the expectedTotalPence and an Idempotency-Key. The order is created and paid in one step.GET /orders/{id} to follow status, dispatch dates and Royal Mail tracking numbers, or register a webhook and let us tell you.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.
| Field | Type | Notes |
|---|---|---|
| recipientName* | string | max length 200. |
| recipientAddressLine1* | string | max length 200. |
| recipientAddressLine2 | string | max length 200. |
| recipientAddressLine3 | string | max length 200. |
| recipientCity* | string | max length 100. |
| recipientPostcode | string | max length 20, default "". |
| recipientCountry | string | max length 2, default "GB". |
| returnName | string | max length 200. |
| returnAddressLine1 | string | max length 200. |
| returnAddressLine2 | string | max length 200. |
| returnCity | string | max length 100. |
| returnPostcode | string | max length 10. |
| postageType* | string | One of second_class, first_class, second_class_signed, first_class_signed, tracked_48, tracked_24, special_delivery, international_standard |
| colourPrint | string | default "bw".One of bw, colour |
| paperQuality | string | default "standard".One of standard, premium |
| envelopeStyle | string | default "white_c5".One of white_c5, c5_boardback, a4_boardback, c5_window |
| quantity | integer | min 1, max 100, default 1. |
| uploadIds* | string[] | min 1 item, max 50 items. |
| finishing | object |
second_class, first_class, second_class_signed, first_class_signed, tracked_48, tracked_24, special_delivery, international_standardbw, colourstandard, premiumwhite_c5, c5_boardback, a4_boardback, c5_window{ "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_…" ]}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.
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.
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.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.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.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.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.
POST /orders requires an Idempotency-Key header: any unique string up to 200 printable characters (use a UUID). Keys are kept for 24 hours.
idempotent-replayed: true header.409 idempotency_key_reused.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.
| Bucket | Limit | Covers |
|---|---|---|
| Reads | 120 / min per key | Every GET, and POST /v1/quotes is a write |
| Writes | 30 / min per key | POST /v1/quotes, POST /v1/orders/{id}/cancel, POST and DELETE /v1/webhooks, and the MCP transport |
| File ingests | 10 / min per key | POST /v1/files and POST /v1/files/{id}/finalize |
| Order creations | 5 / min per key | POST /v1/orders |
| Address checks | 30 / min per key | POST /v1/addresses/validate (also capped per key per day) |
| Before authentication | 60 / min per IP | Every 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.
{ "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" } }}| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_request | Validation failed; details lists the field errors. |
| 400 | idempotency_key_required | POST /v1/orders was sent without an Idempotency-Key header. |
| 401 | unauthorized | Missing, malformed, unknown, expired or revoked API key. |
| 402 | insufficient_credit | The prepaid credit balance does not cover the total; details has the shortfall and a top-up link. |
| 403 | insufficient_scope | The key lacks the scope this endpoint needs (details.required). |
| 403 | api_unavailable | The API is not open yet. Nothing about the key is wrong; it works once the API is released. |
| 403 | forbidden | The account may not place orders or pay with credit, or, in a business, manage webhooks without the api.manageKeys and orders.viewAll permissions. |
| 403 | spend_cap_exceeded | The order would pass the key's daily spend cap. |
| 404 | not_found | Unknown id, or one that belongs to another account. |
| 400 | post_on_invalid | postOn is not a UK working day in the allowed range; details has earliest, latest and, for a weekend or bank holiday, nextWorkingDay. |
| 400 | deliver_by_invalid | deliverBy 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 | price_changed | expectedTotalPence differs from the live total; quote again. |
| 409 | scheduling_unavailable | Scheduled posting is switched off at the moment; omit postOn and deliverBy. |
| 409 | deliver_by_unreachable | No 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 | upload_not_ready | A file is still being processed; poll GET /v1/files/{id}. |
| 409 | idempotency_key_reused | The same Idempotency-Key was sent with a different body. |
| 409 | idempotency_in_progress | A request with this Idempotency-Key is still being processed; retry after retry-after. |
| 409 | order_not_cancellable | The 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 | conflict | The request clashes with the current state, for example too many webhook endpoints. |
| 413 | payload_too_large | An inline upload is over the size limit; use a URL or a presigned upload. |
| 415 | unsupported_media_type | Not a PDF or Word file, or a PDF with active content. |
| 429 | rate_limited | Too many requests for this key or IP; wait for retry-after. |
| 429 | lookup_cap_exceeded | This 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 | unavailable | A dependency (storage or the document fetch) was unavailable; retry. |
| 500 | internal | Our fault; retry with the same Idempotency-Key. |
details lists the field errors.Idempotency-Key header.details has the shortfall and a top-up link.details.required).api.manageKeys and orders.viewAll permissions.postOn is not a UK working day in the allowed range; details has earliest, latest and, for a weekend or bank holiday, nextWorkingDay.deliverBy 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.expectedTotalPence differs from the live total; quote again.postOn and deliverBy.deliverBy; details.services gives each service's earliest achievable date and suggestedDeliverBy the first date they all meet.Idempotency-Key was sent with a different body.Idempotency-Key is still being processed; retry after retry-after.details.status says which. Ask support instead.retry-after.details has the cap and retry-after says when it resets.Idempotency-Key.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.| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /account | account:read | Who the key acts as, credit balance and today's spend |
| GET | /pricing | No key needed | Current rate card |
| POST | /files | files:write | Ingest a PDF or Word document |
| GET | /files/{id} | files:write | A file's status and page count |
| POST | /files/{id}/finalize | files:write | Count pages and seal a presigned upload |
| POST | /addresses/validate | addresses:validate | Check a UK address against Royal Mail's address file |
| POST | /quotes | orders:read | Price an order without creating it |
| GET | /orders | orders:read | List orders, newest first |
| POST | /orders | orders:write | Create and pay for an order from credit |
| GET | /orders/{id} | orders:read | One order with its letters |
| POST | /orders/{id}/cancel | orders:write | Cancel an order for a full refund |
| GET | /webhooks | webhooks:manage | List webhook endpoints |
| POST | /webhooks | webhooks:manage | Register an https endpoint |
| GET | /webhooks/{id} | webhooks:manage | One endpoint |
| DELETE | /webhooks/{id} | webhooks:manage | Remove an endpoint |
Who the key acts as, credit balance and today's spend
GET https://modernmail.co.uk/api/v1/account
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.
200Postage, envelope, print and paper prices and volume tiers429Rate limitedErrorIngest 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.
multipart/form-data| Field | Type | Notes |
|---|---|---|
| file* | string (binary) |
application/jsonWith url
| Field | Type | Notes |
|---|---|---|
| url* | string (uri) | Public https URL of a PDF or Word document; fetched server-side. http is refused. max length 2048. |
| filename | string | max length 200. |
With filename
| Field | Type | Notes |
|---|---|---|
| filename* | string | max length 200. |
| base64* | string | Base64 (standard or URL-safe) of the document bytes; inline limit applies. max length 5592410. |
With presign
| Field | Type | Notes |
|---|---|---|
| presign* | "true" | |
| filename* | string | max length 200. |
| contentType* | string | One of application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document |
| sizeBytes* | integer | min 1, max 52428800. |
application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.documentA file's status and page count
GET https://modernmail.co.uk/api/v1/files/{id}
| Name | In | Type | Notes |
|---|---|---|---|
| id* | path | string |
Count pages and seal a presigned upload
POST https://modernmail.co.uk/api/v1/files/{id}/finalize
| Name | In | Type | Notes |
|---|---|---|---|
| id* | path | string |
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.
application/json| Field | Type | Notes |
|---|---|---|
| recipientName | string | Helps match a business name on the address file. max length 200. |
| recipientAddressLine1 | string | max length 200. |
| recipientAddressLine2 | string | max length 200. |
| recipientAddressLine3 | string | max length 200. |
| recipientCity | string | max length 100. |
| recipientPostcode* | string | A UK postcode; the lookup key. min length 5, max length 8. |
| recipientCountry | string | Must be GB (the default): only UK addresses can be checked. max length 2. |
Price an order without creating it
POST https://modernmail.co.uk/api/v1/quotes
application/json| Field | Type | Notes |
|---|---|---|
| letters* | object[] | min 1 item, max 500 items. |
List orders, newest first
GET https://modernmail.co.uk/api/v1/orders
| Name | In | Type | Notes |
|---|---|---|---|
| status | query | string | One of created, paid, processing, completed, cancelled, refunded. |
| reference | query | string | |
| mine | query | string | Only orders placed with this key One of 1. |
| limit | query | integer | min 1, max 100, default 25. |
| cursor | query | string |
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.
| Name | In | Type | Notes |
|---|---|---|---|
| Idempotency-Key* | header | string | max length 200. |
application/json| Field | Type | Notes |
|---|---|---|
| letters* | object[] | min 1 item, max 500 items. |
| expectedTotalPence | integer | min 0. |
| reference | string | max length 120. |
| postOn | string | Post 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 | string | Arrive 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. |
One order with its letters
GET https://modernmail.co.uk/api/v1/orders/{id}
| Name | In | Type | Notes |
|---|---|---|---|
| id* | path | string |
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.
| Name | In | Type | Notes |
|---|---|---|---|
| id* | path | string |
List webhook endpoints
GET https://modernmail.co.uk/api/v1/webhooks
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.
application/json| Field | Type | Notes |
|---|---|---|
| url* | string (uri) | Must be https and publicly reachable. max length 2048. |
| events* | string[] | min 1 item. |
| label | string | max length 60. |
One endpoint
GET https://modernmail.co.uk/api/v1/webhooks/{id}
| Name | In | Type | Notes |
|---|---|---|---|
| id* | path | string |
200The endpointWebhookEndpoint401Invalid or missing API keyError404Not foundError429Rate limitedErrorRemove an endpoint
DELETE https://modernmail.co.uk/api/v1/webhooks/{id}
| Name | In | Type | Notes |
|---|---|---|---|
| id* | path | string |
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.
| Field | Type | Notes |
|---|---|---|
| error* | object |
| Field | Type | Notes |
|---|---|---|
| object | "file" | |
| id | string | |
| filename | string | |
| contentType | string | |
| sizeBytes | integer | |
| status | string | One of ready, pending |
| pageCount | integer | null | |
| pageCountEstimated | boolean | |
| createdAt | string (date-time) | |
| upload | object | Present only for presigned uploads: PUT the bytes here, then POST /files/{id}/finalize. |
ready, pending| Field | Type | Notes |
|---|---|---|
| id | string | null | Royal Mail delivery point id (UDPRN). |
| organisation | string | null | Business at this delivery point, or null for a home. |
| recipientAddressLine1 | string | |
| recipientAddressLine2 | string | null | |
| recipientAddressLine3 | string | null | |
| recipientCity | string | |
| recipientPostcode | string | |
| recipientCountry | "GB" |
| Field | Type | Notes |
|---|---|---|
| object | "address_validation" | |
| postcode | string | The postcode as Royal Mail writes it. |
| status | string | verified: 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 | number | 0 to 1; 1 for verified, the match score for corrected, 0 otherwise. |
| address | Address | null | |
| candidates | Address[] | |
| lookups | object |
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| Field | Type | Notes |
|---|---|---|
| object | "letter" | |
| id | string | |
| status | string | One of pending, processing, posted, cancelled |
| recipient | object | |
| returnAddress | object | null | |
| postageType | string | |
| colourPrint | boolean | |
| paperQuality | string | |
| envelopeStyle | string | |
| pages | integer | |
| quantity | integer | |
| pricePence | integer | null | |
| trackingNumber | string | null | |
| dispatchedAt | string | null | |
| cancelledAt | string | null |
pending, processing, posted, cancelled| Field | Type | Notes |
|---|---|---|
| object | "order" | |
| id | string | |
| orderNumber | string | |
| status | string | One of created, paid, processing, completed, cancelled, refunded |
| statusLabel | string | |
| source | string | One of web, api, mcp |
| livemode | boolean | False for an order placed with a sandbox (mm_test_) key: simulated, never charged, printed or posted. |
| reference | string | null | |
| currency | string | |
| amounts | object | |
| letters | Letter[] | |
| url | string | |
| paidAt | string | null | |
| postOn | string | null | Scheduled letters: the UK posting day asked for (YYYY-MM-DD); null when posting on the normal schedule. |
| postOnReleasedAt | string | null | When a scheduled order was released to the dispatch queue; null while it waits for its day. |
| deliverBy | string | null | Deliver by a date: the arrival date the posting day was worked out from (YYYY-MM-DD); null otherwise. |
| createdAt | string (date-time) | |
| updatedAt | string (date-time) |
created, paid, processing, completed, cancelled, refundedweb, api, mcp| Field | Type | Notes |
|---|---|---|
| object | "quote" | |
| currency | "gbp" | |
| letters | object[] | |
| envelopeCount | integer | |
| subtotalPence | integer | |
| accountDiscountPence | integer | |
| volumeDiscountPercent | integer | |
| volumeDiscountPence | integer | |
| totalPence | integer | |
| expectedTotalPence | integer | Pass back as expectedTotalPence on POST /orders. |
| credit | object | |
| dailyCap | object |
| Field | Type | Notes |
|---|---|---|
| id | string | |
| url | string | |
| label | string | null | |
| secretHint | string | |
| events | string[] | |
| enabled | boolean | |
| disabledReason | string | null | |
| consecutiveFailures | integer | |
| createdAt | string (date-time) | |
| lastDelivery | object | null | |
| secret | string | Only on creation. HMAC-SHA256 signing secret; store it. |
| Field | Type | Notes |
|---|---|---|
| object | "account" | |
| livemode | boolean | False for a sandbox (mm_test_) key. |
| type | string | One of personal, business |
| name | string | null | |
| string | ||
| business | object | null | |
| credit | object | |
| key | object | |
| permissions | object |
personal, business| Field | Type | Notes |
|---|---|---|
| recipientName | string | Helps match a business name on the address file. max length 200. |
| recipientAddressLine1 | string | max length 200. |
| recipientAddressLine2 | string | max length 200. |
| recipientAddressLine3 | string | max length 200. |
| recipientCity | string | max length 100. |
| recipientPostcode* | string | A UK postcode; the lookup key. min length 5, max length 8. |
| recipientCountry | string | Must be GB (the default): only UK addresses can be checked. max length 2. |
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.
| Event | Sent when |
|---|---|
| order.paid | An order has been paid for and accepted into production. |
| order.refunded | An order was refunded, in full or in part. |
| order.status_changed | An order moved to a new status (processing, printed, posted, cancelled…). |
| order.released | A scheduled order reached its posting day (or was released early) and joined the dispatch queue. |
| letter.dispatched | A letter in an order was handed to Royal Mail. |
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.
{ "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_…" } }}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.
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));}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}'.doc, .docx). The type is detected from the bytes, not trusted from the header or filename.url (fetched by us) or presign (you PUT the bytes to storage, then call finalize).415.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 →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.