LettrLabs API

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 new httpStatus field on the issue carries that status (for example 404 for a missing file, 403 for 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 than requestedRecipients, 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, normally Payment Needed (accepted, settling). This is distinct from the existing status, which reports the outcome of the call and still reads Success.
  • 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 to Draft if 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 same errors[] sentence, and additionally carry a stable machine-readable code at payload.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 500 with a generic body, instead of a 400 carrying the raw internal exception message. So the status code alone answers "is retrying worth it?": 400 means no (the request will never succeed), 500 means yes.
  • The one retryable refusal is explicit. ORDER_NOT_DELETABLE_STATUS on an order still in Payment Needed becomes 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.undeliverableRecipients carries a reason — 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), or INVALID_DATA (the submitted data could not be processed — e.g. an unsupported or unrecognized character). Same code style as the integration-orders reasonCode values.
  • 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.addressInvalidated counts recipients whose address could not be verified (UNVERIFIABLE_ADDRESS). undeliverable.invalidAddress counts hard failures (ADDRESS_NOT_VALID + INVALID_DATA); previously it also re-counted the no-match recipients. The list's length always equals addressInvalidated + 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 in undeliverable.invalidAddress. Previously such a recipient was grouped with verification no-matches and counted in deliverable.addressInvalidated. Migration note (value correction): for a batch containing a recipient with no address, addressInvalidated is now lower and invalidAddress higher 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) and acceptedRecipients (what this call added to the order). totalRecipients is unchanged — it always equals acceptedRecipients — and is now deprecated in favor of the explicit pair.
  • Count scopes are now documented per field. addressValidated, duplicated, and doNotMail are 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 in undeliverableRecipients.

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.

On this page