API Changelog
What changed in the LettrLabs public API — newest first, each entry flagged breaking or non-breaking, with migration notes where action is needed.
Release notes for the LettrLabs public API (/api/v1/*). Entries are listed
newest first. Each change is flagged breaking or non-breaking; a
breaking entry always carries a migration note describing what you must change.
For the current shape of every endpoint, see the
API reference — this page tells you what changed, the
reference tells you what the contract is now.
2026-08-05 — Variable-image failures now tell you the specific cause
Non-breaking (existing fields keep their name, type, and location; one added
field; a value-format correction on an undocumented field). When you append
recipients with per-recipient variable images by URL to
PUT /api/v1/order/{id}/recipients:append (and its alias
PUT /api/v1/order/{id}/recipients), a failed image used to be reported with a
single vague signal: a broken link, a timeout, a network error, and an
unsupported or unreadable file all counted together as unsupportedOrBroken in
the summary and shared one generic message, so you could not tell a dead link
from a bad file.
Each variable-image issue in recipientsWithIssues[].issues[] now carries a
stable, documented code from a published set, and a broken link reports the
HTTP status it received:
IMAGE_BROKEN_LINK— the link returned an HTTP error status. The newhttpStatusfield on the issue carries that status (for example404for a missing file,403for one you cannot access).IMAGE_TIMEOUT— the link did not respond in time.IMAGE_UNREACHABLE— the link could not be reached (bad address, or a network / DNS / TLS error).IMAGE_UNSUPPORTED_FORMAT— the link responded, but the content is not a readable image (for example an SVG or a non-image file).IMAGE_ASPECT_MISMATCH,IMAGE_LOW_DPI,IMAGE_MISSING,IMAGE_UNKNOWN_FIELD— the aspect-ratio, resolution, empty-slot, and unknown-field cases (previously distinct, now documented and published as part of the same set).IMAGE_UNKNOWN— a documented catch-all for an image that could not be validated for an unclassified reason. It should not occur in normal use; handle any unrecognized code the same way you handle this one.
Each issue's human-readable message now names its one specific cause. The set
is published as an enum on the API reference and is additive —
new causes may be added, existing codes are never renamed or removed. In every
case the slot still falls back to your template's default image, and the failure
never blocks the append.
What to check in your client: the per-batch summary is unchanged — the four
link/fetch/format causes still roll into unsupportedOrBroken, so a client that
reads only the summary keeps working. Migration note (value correction): the
per-issue code values were previously undocumented, lower-case strings
(aspect-mismatch, low-dpi, non-2xx, missing, does-not-exist, and a
download-error that lumped unreachable and unreadable together); if you
reverse-engineered any of those, switch to the published IMAGE_* codes above,
and read the new httpStatus on IMAGE_BROKEN_LINK to distinguish a missing file
from an inaccessible one.
2026-08-03 — Unparseable query parameters now return an actionable 400 (instead of being silently ignored)
Non-breaking (a silently-misread 200 on garbage input becomes a
self-explaining 400; absent parameters and valid values are unchanged). When
you passed a typed query parameter that could not be parsed — for example
showRecipients=yes, pageSize=abc, or holdUntilDate=notadate — to
GET /api/v1/order, GET /api/v1/order/{id}/recipients,
GET /api/v1/order/{id}/checkout (the preview), or GET /api/v1/templates, the
value was silently replaced with its default and the request answered 200:
recipients you asked for were omitted, your page size was ignored, and a
checkout preview was calculated as if no hold date had been requested.
These endpoints now reject a present-but-unparseable showRecipients,
pageNumber, pageSize, or holdUntilDate with a 400 Bad Request
carrying errorCode: "INVALID_PARAMETER" and an actionable message naming the
parameter and the expected shape (for example, "Query string showRecipients
must be true or false."). Omitting a parameter keeps its documented default
exactly as before (pageNumber=0, pageSize=100, recipients not included, no
hold date), and every value that parsed before still parses.
What to check in your client: if any of your requests were relying on a typo'd
parameter being ignored, they will now receive an immediate 400 telling you
which value to fix — the same treatment the paid filter already had.
2026-08-03 — A malformed order id now returns an actionable 400 (not a 500)
Non-breaking (a server-fault 500 on malformed input becomes a client-error
400; valid ids and success responses are unchanged). When you passed a
non-numeric or malformed order id in the id list — a value like id=abc, or
a stray trailing comma like id=48213, — to GET /api/v1/order,
GET /api/v1/order/analytics, or GET /api/v1/order/transaction, the request
answered a generic 500 Internal Server Error. That was misleading: a
malformed id is your input to fix, not a temporary server-side failure to retry.
These endpoints now reject a malformed id with a 400 Bad Request carrying
errorCode: "INVALID_PARAMETER" and an actionable message (for example, "Query
string id must be a comma-separated list of whole order id numbers …"). A valid
id list behaves exactly as before, and a wholly-absent id on GET /api/v1/order
still lists all your orders.
What to check in your client: if you retry on 5xx, you will no longer retry a
malformed-id request that can never succeed — you now get an immediate,
self-explaining 400 telling you which value to fix.
2026-08-03 — Server-side failures now return 5xx (and stop echoing internal errors)
Non-breaking (error-response hardening — success responses and
genuine-client-error responses are unchanged). When something failed on our side
while serving a request — a transient database or dependency issue — most v1
endpoints used to answer 400 Bad Request and include our internal error
text in the response. That was misleading: 400 tells your integration the
request was wrong (so it should not retry), when in fact the failure was
temporary and on our side.
Those endpoints now return a proper 500 Internal Server Error for
unexpected server-side failures, with a short generic message and
errorCode: "INTERNAL_ERROR" — the internal detail stays in our logs and is no
longer returned to you.
What to check in your client: if you retry on 5xx but not on 4xx (the
recommended pattern), a transient server-side failure on these endpoints will now
be retried automatically instead of being treated as a permanent bad request. If
you previously special-cased a 400 on these calls to detect a server outage,
switch that check to 5xx. Your handling of real client errors (validation
failures, missing parameters) is unaffected — those still return 4xx.
2026-07-31 — Deleting recipients tells you what was removed
Non-breaking (three added response fields; nothing existing changed). DELETE /api/v1/order/{id}/recipients used to answer 200 "Success" whether it removed
the recipients you named or none of them — so an id that had drifted out of sync
(already deleted, or never on the order) looked identical to a real removal. The
200 response now reports what actually happened:
requestedRecipients— how many distinct recipient ids you submitted.deletedRecipients— how many were actually removed. When this is less thanrequestedRecipients, one or more of your ids matched nothing.notFoundRecipientIds— the ids you submitted that matched no recipient on the order (stale, already-removed, or not part of this order). It lists only the ids you sent back to you.
A request where nothing matched still returns 200, now with deletedRecipients: 0 and every id in notFoundRecipientIds — so a no-op is detectable instead of
looking like a success.
What to check in your client: if you reconcile recipient removals, read
deletedRecipients (and notFoundRecipientIds) instead of trusting the Success
status alone. Existing fields (orderId, status) are unchanged, so a client
that ignores the new fields keeps working exactly as before.
2026-07-29 — Checkout tells you how long settlement takes
Non-breaking (three added response fields; nothing existing changed). POST /api/v1/order/{id}/checkout settles payment asynchronously, and its 200 has
always meant accepted, not charged — but the response said nothing about what
happened next, so sizing a polling window was guesswork. It now carries three
fields:
orderStatus— the order's own state as the call returns, normallyPayment Needed(accepted, settling). This is distinct from the existingstatus, which reports the outcome of the call and still readsSuccess.pollAfterSeconds— how long to wait before your first status read. Reading sooner cannot tell you anything new.settlementExpectedWithinSeconds— when settlement is expected to have reached a terminal state (Paid, or back toDraftif the payment failed).
The operation's reference description also no longer says the call charges payment inline, which was never true of this endpoint.
What to check in your client: if you hard-coded a polling timeout for
checkout, replace it with settlementExpectedWithinSeconds from the response.
Treat that value as an expectation, not a guarantee — if it elapses while the
order is still Payment Needed, the order is not lost and no second charge is
pending, so ask us to confirm settlement rather than treating it as a failure.
Never retry a checkout that returned 200: the charge is already in flight and
a second checkout charges again. Existing fields (orderId, status, and the
"payment is being processed" warning) are unchanged, so a client that ignores the
new fields keeps working exactly as before.
2026-07-29 — Order delete tells you why it refused, and stops reporting faults as refusals
Non-breaking for refusals; one deliberate status change on the failure path.
DELETE /api/v1/order/{id} used to answer every non-success outcome with the
same 400 and a single English sentence in errors[]. A permanent refusal
("this order is paid — it will never be deletable") and a transient server fault
were indistinguishable, so the only safe client behaviour was to parse the
sentence — and the lost-race case actively told you to retry a request that could
never succeed.
Now:
- Refusals still return
400, still carry the sameerrors[]sentence, and additionally carry a stable machine-readable code atpayload.reasonCode:ORDER_NOT_AVAILABLE(no such order, already deleted, or not on your account — one code for all three, deliberately, so order ids can't be probed across accounts),ORDER_NOT_DELETABLE_STATUS,PAID_RECIPIENT_SEARCH,PAID_RADIUS_MAIL_ORDER,TEMPLATE_IN_USE. The set is published as an enum on the API reference, so you can switch on it safely. - Unexpected server failures now return
500with a generic body, instead of a400carrying the raw internal exception message. So the status code alone answers "is retrying worth it?":400means no (the request will never succeed),500means yes. - The one retryable refusal is explicit.
ORDER_NOT_DELETABLE_STATUSon an order still inPayment Neededbecomes permanent only once checkout settles; the sentence names the status, so you can wait and re-read rather than guess. Deleting mid-settlement no longer produces "An error occurred… please try again later… contact support".
What to check in your client: nothing breaks if you handle refusals today —
payload.reasonCode is a new field and every existing field is unchanged. Two
things are worth doing: branch on reasonCode instead of matching the sentence,
and make sure a 500 from this endpoint is treated as retryable rather than as a
rejection. If you currently treat any non-200 as "the order is undeletable",
you will now retry-loop less and give up correctly more often.
2026-07-28 — Checkout locks the order immediately: no more edits while payment settles
Non-breaking (it closes an edit window that was never part of the contract).
POST /api/v1/order/{id}/checkout settles payment asynchronously — the 200
means the checkout was accepted, not that the order is paid. Previously the
order kept reporting status Draft (with paidDate: null) until settlement
completed, roughly a minute or two later, and during that window recipient
deletes and re-checkouts still succeeded — so a client could strip recipients
from an order that had already been charged, or trigger a second charge.
Now, the moment checkout is accepted the order's status becomes
Payment Needed, and it stops being editable immediately:
DELETE …/recipients, DELETE /order/{id}, GET …/checkout (preview), and a
second POST …/checkout all return the standard 400 invalid-status error
until the order settles. On success the order moves to Paid as before;
if payment fails it returns to Draft, so you can edit and check out again.
What to check in your client: if you poll GET /v1/order/{id} after
checkout, you will now see orderStatus: "Payment Needed" while payment
settles (previously "Draft"). Treat it as "in flight — don't edit"; keep
polling for Paid (or a return to Draft, which means the payment failed and
you can retry). If your integration hard-codes the set of possible status
strings, add "Payment Needed".
2026-07-28 — Appending recipients now requires an editable order
Non-breaking (it corrects behavior that was never part of the contract).
PUT /api/v1/order/{id}/recipients previously accepted appends at any stage of
an order's life: appending to an order that was already paid or in production
returned 200 and silently increased the order's piece count after payment
— pieces that were never charged. The operation now enforces the same rule the
recipient-delete, order-delete, and checkout operations already enforce: the
order must be editable (status Draft or Edits Needed). Appending to an
order in any other status returns the standard 400 invalid-status error and
changes nothing.
Migration note (only if you appended after checkout): add all recipients before executing checkout. To send to additional recipients after an order is paid, create a new order — a post-payment append was never a supported way to grow a paid order, and it now fails loudly instead of shipping unpaid pieces. Error responses for nonexistent or inaccessible order ids are unchanged.
2026-07-28 — Appending recipients: failed recipients are reason-tagged, listed once, and counted once
Non-breaking. The response of PUT /api/v1/order/{id}/recipients:append
(and its alias PUT /api/v1/order/{id}/recipients) now reports failed
recipients coherently. Every field your client reads today keeps its name,
type, and location.
- Every entry in
undeliverable.undeliverableRecipientscarries areason— a stable machine-readable category:ADDRESS_NOT_VALID(the address is hard-invalid: undeliverable, missing address fields, or non-US),UNVERIFIABLE_ADDRESS(address verification found no match for the address), orINVALID_DATA(the submitted data could not be processed — e.g. an unsupported or unrecognized character). Same code style as the integration-ordersreasonCodevalues. - Each failed recipient is listed exactly once. Previously a recipient whose address verification found no match appeared twice in the list with nothing to tell the entries apart. Migration note (value correction): if your client deduplicated the list as a workaround, the workaround is now a no-op and can be removed.
- The two failure counts no longer overlap.
deliverable.addressInvalidatedcounts recipients whose address could not be verified (UNVERIFIABLE_ADDRESS).undeliverable.invalidAddresscounts hard failures (ADDRESS_NOT_VALID+INVALID_DATA); previously it also re-counted the no-match recipients. The list's length always equalsaddressInvalidated + invalidAddress. Migration note (value correction): if your client summed the two counts to size the failure set, the sum now equals the list length instead of over-counting no-matches. - A recipient submitted with no address at all is now reported as a hard
failure, not as an unverifiable address: it carries
reason: "ADDRESS_NOT_VALID"and is counted inundeliverable.invalidAddress. Previously such a recipient was grouped with verification no-matches and counted indeliverable.addressInvalidated. Migration note (value correction): for a batch containing a recipient with no address,addressInvalidatedis now lower andinvalidAddresshigher than before. The distinction matters because an unverifiable address may become sendable in future (we are considering an opt-in to mail unverified addresses), whereas a missing address always requires the caller to supply address data. - New explicit totals:
submittedRecipients(what this call sent) andacceptedRecipients(what this call added to the order).totalRecipientsis unchanged — it always equalsacceptedRecipients— and is now deprecated in favor of the explicit pair. - Count scopes are now documented per field.
addressValidated,duplicated, anddoNotMailare whole-order cumulative counts;submittedRecipients,acceptedRecipients,addressInvalidated,invalidAddress, and the recipient list are scoped to this call's batch. Duplicated and do-not-mail recipients ARE added to the order (suppressed from mailing) and are not listed inundeliverableRecipients.
Recipients that fail are still not added to the order — that behavior is unchanged.
2026-07-27 — Order deletion is now immediate and reliable
Non-breaking. DELETE /api/v1/order/{id} no longer physically removes the
order's data during the request — it marks the order deleted instantly. What
you'll notice:
- Deletes are fast regardless of order size. Previously, deleting an order with many recipients could take minutes and sometimes time out or fail; the request now completes quickly even for very large orders.
- Deleted orders disappear immediately, everywhere. After a successful delete, the order behaves exactly like an order id that never existed on every other operation (listing, recipients, proof, checkout, append). Repeating the delete returns the same response as deleting a nonexistent id.
- Eligibility rules are unchanged — and one is now explicit: only Draft / Needs Edits orders can be deleted, orders with paid recipient searches are still rejected, and an order that is in use as an automation's template is now rejected with a clear message (previously this failed with a generic error).
Deleted orders' underlying records are retained internally for a period after deletion (they are never served to any API consumer) and are then permanently purged on a scheduled cleanup. No client change is required.
2026-07-26 — Four response-contract corrections: the API now does what its contract says
Non-breaking. Each of these corrects live behavior to the contract this documentation already declares. No request shape changes and no new API version; if your client compensated for any of the old behaviors below, remove the workaround.
The paid filter on GET /api/v1/order now means what it says. Previously
paid=true returned exactly the unpaid orders, and paid=false applied no
filter at all. Now the parameter is a true tri-state: omit it to get orders
regardless of payment state, paid=true for paid orders only, paid=false for
unpaid orders only. Migration note: if you passed paid=true to get unpaid
orders, switch to paid=false.
Auth failure on the address-book operations returns 401.
POST /api/v1/address-book and POST /api/v1/zapierActions/address-book
returned 404 when the X-API-KEY header was missing or invalid, while their
contract declared 401 ("Authorization key not valid"). They now return the
declared 401, and 404 is no longer a declared response on these operations.
Migration note: if your integration matched on 404 to detect an auth
failure on these paths, match on 401 instead.
Recipient rejections on POST /api/v1/integration-orders/{id}/recipients
carry a machine-readable reason code. The 422 response previously carried
only a human-readable string. It now also returns a payload with a stable
reasonCode — one of RETURN_ADDRESS_PARTIAL, RETURN_ADDRESS_MISSING_FIELDS,
RETURN_NAME_PARTIAL, ORDER_NOT_AVAILABLE, ADDRESS_NOT_VALID,
DUPLICATE_RECIPIENT, DO_NOT_MAIL, MISSING_MAIL_MERGE_FIELDS,
ORDER_TEMPLATE_MISSING — plus the processed recipient details (name, the
standardized address, and whether it validated). The errors array and its
existing messages are unchanged, so current parsers keep working. Two edge
behaviors are also corrected: an order with no configured template now rejects
with ORDER_TEMPLATE_MISSING instead of a generic message, and a genuinely
unexpected server failure now returns 500 instead of masquerading as a 422.
Migration note: treat a 500 from this operation as retryable server error,
not as input rejection.
Entitlement failures return the declared bodiless 403 — everywhere. When
a valid API key's subscription lacks the feature an operation requires, every
/api/v1/* operation previously surfaced an empty 500 (documented here as a
known issue). All of them now return the declared 403 with no response body,
and the known-issue notes are gone from the reference. Migration note: a
bodiless 500 no longer signals an entitlement failure — check for 403.
2026-07-26 — Appending recipients is faster and no longer re-processes earlier batches
Non-breaking. PUT /api/v1/order/{id}/recipients:append (and its published
alias PUT /api/v1/order/{id}/recipients) now scopes its duplicate and
do-not-mail processing to the recipients submitted in each call, so append time
tracks the size of your batch instead of the size of the whole order — large
orders built batch-by-batch no longer slow down with every call.
One observable behaviour change rides along: recipients appended by earlier
calls keep the duplicate/do-not-mail flags computed when they were appended — a
later append no longer refreshes them against your current suppression lists
(each new batch is always evaluated against the lists as they stand at that
call). Request shape, response shape, and response-field semantics are unchanged
— including the cumulative whole-order counts (deliverable.addressValidated,
undeliverable.doNotMail, undeliverable.duplicated). No action is required.
2026-07-26 — Documentation and declared contract now match the running API
Non-breaking. A full audit of the published contract found places where the specification disagreed with the service. Nothing about the API's behaviour changed — what changed is that the documentation now tells the truth. No action is required, but if you generate a client from our OpenAPI document, regenerate it to pick up the corrections below.
Two published paths are now callable. PUT /api/v1/order/{id}/recipients and
POST /api/v1/integration-orders/{id}-by-proximity appeared in the reference but
matched no route on the server, so calling them exactly as documented failed. Both
now work. The older paths they were published alongside continue to work
unchanged, so nothing you have built needs to move.
A vendor-neutral address-book path. POST /api/v1/address-book is the
canonical path for adding an address-book entry. The previous
POST /api/v1/zapierActions/address-book behaves identically and is retained —
use either.
Four response bodies were declared incorrectly. Order analytics
(GET /api/v1/order/analytics), order transactions
(GET /api/v1/order/transaction) and both checkout operations declared a
response envelope different from the one they actually return. A client generated
from the old document deserialized the wrong shape on all four. The declarations
now match what has always been sent — regenerate your client if you call any
of these.
The proof endpoints return a PDF, not JSON. GET /api/v1/order/{id}/proof
and GET /api/v1/order/{id}/proof/{recipient} declared application/json while
returning raw PDF bytes. They now declare application/pdf on success. Their
error responses are still JSON, so branch on the status code before choosing how
to read the body.
Optional fields are no longer marked required. Every request field used to be
published as required, including ones whose own description says to omit them —
useQr, qrUrl and handwritingFont on order creation, and holdUntilDate and
autoBill on checkout. Sending null for these to satisfy the old schema is no
longer necessary; omit them to get the template's defaults.
Fields with a fixed set of values now publish that set. Order status, delivery
status, postage class, production speed, transaction type and sub-type, automation
status, conversion type, conversion status and source, address DPV code, and font
source all list their accepted or returned values in the reference, in the exact
spelling the API uses. Two things worth noting: order status uses spaced display
text, so send "Ready For Production", not "ReadyForProduction"; and product
is deliberately not a fixed list — it changes as products are added and
retired, so treat it as an opaque string.
Some response fields are now declared nullable. Order status and postage class
on the order-list and statistics responses, and status and postage on the
automations response, are declared nullable because the service has always been
able to return null there — the schema previously denied it. Nothing changes on
the wire. A regenerated client will widen those fields (in TypeScript, to
string | null), which may require a null check you did not previously have.
Entitlement failures return no body. A 403 on any endpoint gated by your
subscription tier has no response body — do not attempt to parse one. Note a known
issue: this condition currently surfaces as a 500 with an empty body rather than
a 403. Treat a bodiless 500 on these endpoints as a permissions failure and
do not retry it; a retry will fail identically. A fix is in progress.
Field-level documentation and examples. Every published field now carries a description, and the core integration flow carries worked examples. Several previously undocumented rules are now stated, including the 5,000-record limit on conversion uploads (a larger batch is rejected in full and nothing is stored), the 7-day expiry on uploaded-font URLs, and that handwriting accepts LettrLabs global fonts only. Four new pages cover what per-endpoint reference cannot: response shapes and errors, pagination, rate limits and batch sizes, versioning and addresses and deliverability.
2026-07-24 — Checkout preview is now side-effect-free, and checkout is faster
Non-breaking. GET /api/v1/order/{id}/checkout (checkout preview) no
longer has side effects: previewing an order does not modify the order and
does not create a payment intent. Previously, each preview call persisted
the requested postage type, production speed, and hold-until date onto the
order and created or updated a payment-intent record. The request parameters,
response shape, and pricing values are unchanged — no change is required in
your integration. Preview as often as you like; only
POST /api/v1/order/{id}/checkout commits anything.
Non-breaking. Checkout latency is reduced on both the preview
(GET /api/v1/order/{id}/checkout) and the execute
(POST /api/v1/order/{id}/checkout) endpoints — the same requests now return
faster. No request or response changes.