Creating orders

Updated 9/17/2026

Creating 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).
Placing an order from your code takes five calls. Steps 2 to 4 check everything first, so the final call succeeds.
#CallPurpose
1GET /api/v1/shopsChoose a print shop.
2GET /api/v1/shops/{id}/pricingSee its print and post categories, options and prices.
3POST /api/v1/filesUpload the PDF. You get its public filePath and page count.
4POST /api/v1/orders/quoteCheck credit, that the PDF is publicly accessible, the To and From addresses, and get the price. Nothing is charged.
5POST /api/v1/ordersCreate 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
}
]
}
FieldNotes
idThe shopId to send when creating an order.
name, addressLine1, addressLine2, city, postcodeThe shop's public address. For pickup orders this is where the customer collects.
acceptsPickup, acceptsPostWhich 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": {}
}
]
}
FieldNotes
fulfillmentTypepickup (collect from the shop) or post (printed and posted for you).
postageClassFor 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.
pricePerPagePencePrice per page, in pence, for one copy. The order total is pages × copies × this, plus finishing.
finishingOptions, finishingPricePenceFinishing 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/files
Content-Type: multipart/form-data
Send the PDF in a form field named file (up to 25 MB):
bash
curl -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.
StatusCause
400No file field, or the file is empty.
413Larger than 25 MB.
415The content is not a PDF (the file is checked, not just its name).
422The 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:
  1. Price. The shop must be approved, offer the category, and have a price for the paper, colour, finishing and postage class you chose.
  2. Credits. For live API keys, your balance against the price, and any shortfall.
  3. The PDF. filePath must be publicly accessible, so the shop can open it.
  4. 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." }
]
}
FieldNotes
canCreatetrue only when errors is empty.
fundedBycredits for live API keys. test_mode for test keys, which need no credit. stripe for dashboard sessions, which pay by card.
priceTotal and breakdown in pence. null if the order could not be priced.
creditsBalance, required amount and shortfall. null unless fundedBy is credits.
addresses.to / addresses.fromPer-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[].codeshop_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

filePath is 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 filePath from POST /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 as uploads/invoice.pdf, a link that needs a login, or a link on another site will be rejected with a file_unavailable error before the order is created.
  • Run POST /api/v1/orders/quote first. It checks that the file can be opened, so you find out before you place the order.
POST /api/v1/orders
Authorization: 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"
}
}
FieldTypeRequiredNotes
shopIduuidyesAn approved print shop's ID from GET /api/v1/shops.
fulfillmentType"pickup" | "post"yespost requires recipientName and recipientAddress.
postageClass"first" | "first_signed" | "second" | "second_signed"nopost only. The Royal Mail service. Priced by the shop; if the shop has no price for that service, its "any class" price is used.
filePathstring (public https URL)yesThe 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.
filePageCountinteger > 0yesThe pageCount returned by the upload. Used for pricing.
paperSize"A4" | "A3" | "Letter"yes
colorMode"color" | "bw"yes
sides"single" | "double"yes
copiesinteger > 0yesNumber of copies of the full document.
finishing"none" | "stapled" | "hole_punched"yesMust be offered by the shop for that option.
recipientNamestringfor postThe name to send to.
recipientAddressobjectfor postThe address to send to: { line1, line2?, city, postcode }.
returnNamestringnopost only. Sender name printed as the return address. Send with returnAddress.
returnAddressobjectnopost 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_payment and are paid by card through POST /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 → completed
Post: pending_payment → paid → sent_to_shop → printing → printed → mailing → posted → completed
(any → cancelled / refunded)
StatusMeaning
pending_paymentCreated via dashboard, awaiting POST /orders/{id}/pay. API-key/credit orders skip this state entirely.
paidPayment confirmed; a webhook (order.paid) fires here.
sent_to_shopDispatched to the assigned print shop's queue.
printingShop has started printing (order.printing webhook).
printedPrinting is done ("Printing done"). Used by both pickup and post orders.
ready_for_pickupPickup orders only: ready for the customer to pick up.
mailingPost orders only: the letter is enveloped and ready to post ("Envelope ready" in the dashboard).
postedPost orders only: handed to Royal Mail (order.posted webhook).
completedTerminal state for a fulfilled order ("Picked up" for pickup, "Delivered" for post).
cancelled / refundedTerminal, 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

StatusMeaning
400 Bad RequestValidation 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 UnauthorizedMissing/invalid API key.
402 Payment RequiredInsufficient credit balance (live, credit-funded orders only).
403 ForbiddenYou don't own this order or shop.
404 Not FoundOrder or shop doesn't exist.
409 ConflictAction doesn't apply to the order's current status (e.g. paying an already-paid order).
413, 415, 422File upload problems: too large, not a PDF, or unreadable. See Upload the PDF.