Creating orders
Updated 9/17/2026Creating orders
An order represents one print + fulfillment job: a PDF, a paper/finishing spec, and either a pickup shop or a postal address (with an optional return address).
Recommended flow
Placing an order from your code takes five calls. Steps 2 to 4 check everything first, so the final call succeeds.
| # | Call | Purpose |
|---|---|---|
| 1 | GET /api/v1/shops | Choose a print shop. |
| 2 | GET /api/v1/shops/{id}/pricing | See its print and post categories, options and prices. |
| 3 | POST /api/v1/files | Upload the PDF. You get its public filePath and page count. |
| 4 | POST /api/v1/orders/quote | Check credit, that the PDF is publicly accessible, the To and From addresses, and get the price. Nothing is charged. |
| 5 | POST /api/v1/orders | Create the order, with the same body you quoted. |
All calls use
Authorization: Bearer la_live_... (or a la_test_... key for test mode).List shops
GET /api/v1/shops
Returns every approved print shop (a provider) you can send orders to. No authentication is needed. Use a shop's
id as shopId when you place an order. You can also browse the same list, with a copy button for each ID, under Dashboard → Providers.json{"shops": [{"id": "3f7e2b10-4c9a-4e1a-9b7e-2a8b6c1d0f33","name": "Camden Print Co","addressLine1": "1 High Street","addressLine2": null,"city": "London","postcode": "NW1 1AA","acceptsPickup": true,"acceptsPost": true}]}
| Field | Notes |
|---|---|
id | The shopId to send when creating an order. |
name, addressLine1, addressLine2, city, postcode | The shop's public address. For pickup orders this is where the customer collects. |
acceptsPickup, acceptsPost | Which categories the shop offers: collect from the shop, or printed and posted for you. |
See what a shop offers
GET /api/v1/shops/{shopId}/pricing
Each shop prices every combination it offers. An order must match one of these
options.json{"shop": { "id": "3f7e2b10-...", "name": "Camden Print Co", "city": "London", "acceptsPickup": true, "acceptsPost": true },"options": [{"fulfillmentType": "pickup","postageClass": "none","paperSize": "A4","colorMode": "bw","pricePerPagePence": 10,"doubleSidedSupported": true,"finishingOptions": ["none", "stapled"],"finishingPricePence": { "stapled": 5 }},{"fulfillmentType": "post","postageClass": "second","paperSize": "A4","colorMode": "bw","pricePerPagePence": 150,"doubleSidedSupported": true,"finishingOptions": ["none"],"finishingPricePence": {}}]}
| Field | Notes |
|---|---|
fulfillmentType | pickup (collect from the shop) or post (printed and posted for you). |
postageClass | For post options: the Royal Mail service, first, first_signed, second or second_signed. none means the shop's price applies to any service. Always none for pickup. |
pricePerPagePence | Price per page, in pence, for one copy. The order total is pages × copies × this, plus finishing. |
finishingOptions, finishingPricePence | Finishing choices and their per-copy price in pence. |
Returns
404 if the shop does not exist or is not approved.Upload the PDF
POST /api/v1/filesContent-Type: multipart/form-data
Send the PDF in a form field named
file (up to 25 MB):bashcurl -X POST https://letterapi.uk/api/v1/files \-H "Authorization: Bearer la_live_xxxxxxxxxxxxxxxx" \-F "file=@letter.pdf;type=application/pdf"
json// 201 Created{ "filePath": "https://.../print-jobs/5e2f.../1736850300000-9c1b....pdf", "pageCount": 2, "sizeBytes": 48211 }
Use
filePath as filePath and pageCount as filePageCount when you quote and create the order. The returned filePath is publicly accessible, which the print shop needs in order to fulfil the order, so pass it exactly as returned. The page count is counted from the file itself, so pricing matches what the shop will print.| Status | Cause |
|---|---|
400 | No file field, or the file is empty. |
413 | Larger than 25 MB. |
415 | The content is not a PDF (the file is checked, not just its name). |
422 | The PDF could not be read, for example it is corrupted or password-protected. |
Check the order before creating it
POST /api/v1/orders/quote
Send the exact body you will send to
POST /api/v1/orders (see the fields). Nothing is created and nothing is charged. The checks run in this order, and every problem is reported at once so you can fix them in one go:- Price. The shop must be approved, offer the category, and have a price for the paper, colour, finishing and postage class you chose.
- Credits. For live API keys, your balance against the price, and any shortfall.
- The PDF.
filePathmust be publicly accessible, so the shop can open it. - To address, and the From (return) address if you supplied one.
json// 200 OK{"canCreate": false,"fundedBy": "credits","price": { "totalPence": 300, "pricePerPagePence": 150, "finishingPricePence": 0, "pages": 2, "copies": 1 },"credits": { "mode": "live", "balancePence": 120, "requiredPence": 300, "sufficient": false, "shortfallPence": 180 },"addresses": {"to": {"valid": false,"errors": [{ "field": "postcode", "message": "Enter a valid UK postcode, for example SW1A 1AA." }],"normalised": { "name": "Jane Doe", "line1": "221B Baker Street", "city": "London", "postcode": "NW1" }},"from": null},"errors": [{ "code": "insufficient_credits", "message": "Not enough credit: you need £3.00 but have £1.20." },{ "code": "address_invalid", "address": "to", "field": "postcode", "message": "Enter a valid UK postcode, for example SW1A 1AA." }]}
| Field | Notes |
|---|---|
canCreate | true only when errors is empty. |
fundedBy | credits for live API keys. test_mode for test keys, which need no credit. stripe for dashboard sessions, which pay by card. |
price | Total and breakdown in pence. null if the order could not be priced. |
credits | Balance, required amount and shortfall. null unless fundedBy is credits. |
addresses.to / addresses.from | Per-field errors, plus the normalised address that will be stored. to is null for pickup orders. from is null unless you sent a return address. |
errors[].code | shop_unavailable, post_not_offered, pricing_unavailable, insufficient_credits, file_unavailable, address_invalid. Address errors say which address (to or from) and which field. |
Address rules. Addresses must be UK addresses. The name,
line1, city and postcode are required and each line is at most 120 characters. Postcodes are checked for the right shape (not looked up) and stored tidied, for example ba11aa becomes BA1 1AA.Create an order
filePathis required, and it must be publicly accessible. The print shop opens the PDF straight from this URL, with no login. If the shop can't open it, the order cannot be fulfilled.
- Get the
filePathfromPOST /api/v1/files. It is already public and ready to use.- It must be a full
https://URL to a PDF stored with LetterAPI. A local path such asuploads/invoice.pdf, a link that needs a login, or a link on another site will be rejected with afile_unavailableerror before the order is created.- Run
POST /api/v1/orders/quotefirst. It checks that the file can be opened, so you find out before you place the order.
POST /api/v1/ordersAuthorization: Bearer la_live_...Content-Type: application/json
json{"shopId": "3f7e2b10-4c9a-4e1a-9b7e-2a8b6c1d0f33","fulfillmentType": "post","postageClass": "second","filePath": "https://.../print-jobs/5e2f.../1736850300000-9c1b....pdf","filePageCount": 2,"paperSize": "A4","colorMode": "bw","sides": "double","copies": 1,"finishing": "none","recipientName": "Jane Doe","recipientAddress": {"line1": "221B Baker Street","line2": "Flat 2","city": "London","postcode": "NW1 6XE"},"returnName": "Acme Ltd","returnAddress": {"line1": "10 Downing Court","city": "Leeds","postcode": "LS1 1AA"}}
| Field | Type | Required | Notes |
|---|---|---|---|
shopId | uuid | yes | An approved print shop's ID from GET /api/v1/shops. |
fulfillmentType | "pickup" | "post" | yes | post requires recipientName and recipientAddress. |
postageClass | "first" | "first_signed" | "second" | "second_signed" | no | post only. The Royal Mail service. Priced by the shop; if the shop has no price for that service, its "any class" price is used. |
filePath | string (public https URL) | yes | The publicly accessible URL of the PDF. Use the filePath returned by POST /api/v1/files. The shop must be able to open it without logging in, or the order will not be fulfilled. Local paths, private links and files stored elsewhere are rejected with file_unavailable. |
filePageCount | integer > 0 | yes | The pageCount returned by the upload. Used for pricing. |
paperSize | "A4" | "A3" | "Letter" | yes | |
colorMode | "color" | "bw" | yes | |
sides | "single" | "double" | yes | |
copies | integer > 0 | yes | Number of copies of the full document. |
finishing | "none" | "stapled" | "hole_punched" | yes | Must be offered by the shop for that option. |
recipientName | string | for post | The name to send to. |
recipientAddress | object | for post | The address to send to: { line1, line2?, city, postcode }. |
returnName | string | no | post only. Sender name printed as the return address. Send with returnAddress. |
returnAddress | object | no | post only. The return address: { line1, line2?, city, postcode }. |
How it's funded
- API key requests are paid from your account credit balance automatically at creation time for live orders. If your balance doesn't cover the price, the order is not created, and you get a
402. - Dashboard (session) requests create the order in
pending_paymentand are paid by card throughPOST /api/v1/orders/{id}/pay. - Test-mode keys always simulate payment instantly: no credits are deducted and no real charge occurs.
Response: 201 Created
json{"id": "8a1c...","orderNumber": "LA-2026-100482","customerId": "5e2f...","shopId": "3f7e2b10-4c9a-4e1a-9b7e-2a8b6c1d0f33","fulfillmentType": "post","postageClass": "second","status": "paid","filePath": "https://.../print-jobs/5e2f.../1736850300000-9c1b....pdf","filePageCount": 2,"paperSize": "A4","colorMode": "bw","sides": "double","copies": 1,"finishing": "none","pricePence": 300,"stripePaymentIntentId": null,"paidVia": "credits","recipientName": "Jane Doe","recipientAddress": { "line1": "221B Baker Street", "line2": "Flat 2", "city": "London", "postcode": "NW1 6XE" },"returnName": "Acme Ltd","returnAddress": { "line1": "10 Downing Court", "city": "Leeds", "postcode": "LS1 1AA" },"isTest": false,"trackingCarrier": null,"trackingNumber": null,"trackingUrl": null,"queuedAt": "2026-01-14T10:05:02.000Z","createdAt": "2026-01-14T10:05:00.000Z","updatedAt": "2026-01-14T10:05:02.000Z"}
postageClass, returnName and returnAddress are null when not applicable (pickup orders, or no return address given). The addresses in the response are the tidied versions that were stored.trackingCarrier, trackingNumber and trackingUrl are null until the print shop adds postage tracking, which it can do from the time the envelope is ready. They are only ever set on post orders.POST /api/v1/orders runs the same checks as the quote. If one fails it returns 400 with the same errors list, or 402 for insufficient credit.Order status lifecycle
Pickup: pending_payment → paid → sent_to_shop → printing → printed → ready_for_pickup → completedPost: pending_payment → paid → sent_to_shop → printing → printed → mailing → posted → completed(any → cancelled / refunded)
| Status | Meaning |
|---|---|
pending_payment | Created via dashboard, awaiting POST /orders/{id}/pay. API-key/credit orders skip this state entirely. |
paid | Payment confirmed; a webhook (order.paid) fires here. |
sent_to_shop | Dispatched to the assigned print shop's queue. |
printing | Shop has started printing (order.printing webhook). |
printed | Printing is done ("Printing done"). Used by both pickup and post orders. |
ready_for_pickup | Pickup orders only: ready for the customer to pick up. |
mailing | Post orders only: the letter is enveloped and ready to post ("Envelope ready" in the dashboard). |
posted | Post orders only: handed to Royal Mail (order.posted webhook). |
completed | Terminal state for a fulfilled order ("Picked up" for pickup, "Delivered" for post). |
cancelled / refunded | Terminal, non-fulfillment states. |
Status changes from
sent_to_shop onward are made by the fulfilling print shop, not the customer. A shop may skip ahead to a later stage but never go back. See the Print Shop API if you're building a shop-side integration.Paying for a dashboard-created order
POST /api/v1/orders/{id}/pay
Returns a Stripe
clientSecret for live orders (confirm it client-side with Stripe.js), or an immediate { status: "paid" } for test-mode orders.Get an order
GET /api/v1/orders/{id}
Returns the full order object shown above. Accessible to the order's customer, or to a print shop that owns
shopId.List your orders
GET /api/v1/orders
json{ "orders": [ /* PrintOrder[] */ ] }
Insufficient credit
If a live, credit-funded order can't be covered by your balance,
POST /api/v1/orders returns 402 Payment Required:json{"error": "Insufficient credit balance","balancePence": 120,"requiredPence": 300,"errors": [{ "code": "insufficient_credits", "message": "Not enough credit: you need £3.00 but have £1.20." }]}
Check beforehand with
POST /api/v1/orders/quote, which shows your shortfall, or GET /api/v1/account/credits for your balance. Top up via Dashboard → Settings → Billing.Errors
| Status | Meaning |
|---|---|
400 Bad Request | Validation failure. Either the request body is malformed (the response contains the field errors), or a check failed: the response has an errors list with a code for each problem, such as address_invalid or pricing_unavailable. |
401 Unauthorized | Missing/invalid API key. |
402 Payment Required | Insufficient credit balance (live, credit-funded orders only). |
403 Forbidden | You don't own this order or shop. |
404 Not Found | Order or shop doesn't exist. |
409 Conflict | Action doesn't apply to the order's current status (e.g. paying an already-paid order). |
413, 415, 422 | File upload problems: too large, not a PDF, or unreadable. See Upload the PDF. |
LetterAPIPrint and post letters in the UK, from one letter to thousands.
© 2026 LetterAPI. All rights reserved.