# Addresses and deliverability (/addresses) Every recipient address is validated and standardized before the mailpiece is produced. Understanding what validation does to your data — and which fields it writes back — avoids the two most common surprises: recipients silently marked undeliverable, and fields you set being overwritten. ## Two ways to supply an address [#two-ways-to-supply-an-address] Either works. The address requirement is satisfied by **either** the component fields or a single line. **Component fields** (preferred), under `address`: ```json { "address": { "address1": "1600 Pennsylvania Ave NW", "address2": "Suite 300", "city": "Washington", "state": "DC", "zipCode": "20500" } } ``` **A single unparsed line**, as `metadata.fullAddress`: ```json { "metadata": { "fullAddress": "1600 Pennsylvania Ave NW, Washington, DC 20500" } } ``` `fullAddress` is a genuine fallback, not a convenience alias: when the component fields are incomplete, supplying `fullAddress` is what stops the recipient being rejected for a missing address. If you have structured data, send the component fields — parsing is more reliable when you have already done it. `metadata.fullAddress` and `metadata.text`, `metadata.text2` are declared non-nullable. Send an empty string rather than `null` for any you are not using. ## What you send versus what validation writes [#what-you-send-versus-what-validation-writes] | Field | Who sets it | | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `address1`, `address2`, `city`, `state`, `zipCode` | You. Validation may standardize the values. | | `zip4` | **Validation.** Sending it has no effect on delivery. | | `zip2` | **Validation.** The delivery-point code; sending it has no effect. | | `country` | You. US spellings and US territory names are canonicalized; anything else passes through unchanged. Omit for US addresses. | | `metadata.dpvConfirmationCode` | **Validation.** Read-only; ignored on input. | | `metadata.deliveryStatus` | **Carrier tracking.** Read-only; ignored on input. | `state` accepts either the two-letter abbreviation or the full state name — a full name is converted for you. ## Reading the deliverability result [#reading-the-deliverability-result] `dpvConfirmationCode` is the USPS Delivery Point Validation result, passed through as the postal standard defines it: | Code | Meaning | | ----- | ------------------------------------------------------------ | | `Y` | Confirmed — a valid delivery point. | | `N` | Not confirmed — could not be validated. | | `S` | Confirmed after dropping the unit or suite information. | | `D` | Confirmed to the building only; unit information is missing. | | empty | Not yet validated. | `S` and `D` are deliverable but imprecise: mail reaches the building, and a unit number you supplied may have been dropped or was never matched. If unit-level accuracy matters to your campaign, treat them as warnings. Do not confuse this with DPV **footnotes**, a longer code set in which the same letters carry different meanings. This field is the confirmation code only. ## Undeliverable recipients [#undeliverable-recipients] Recipients that fail validation are **not** silently dropped: the add-recipients response separates them out, so read its payload rather than assuming everything you sent was accepted. Compare the count you sent against the count accepted on every call, and log the undeliverable set — that is your data-quality signal, and it is cheaper to fix at the source than to discover in a delivery report. Recipients are also checked against your suppression lists and de-duplicated within the batch during the same step, so the accepted count can legitimately be lower than what you sent for reasons other than a bad address. ## Delivery status after mailing [#delivery-status-after-mailing] Once a piece mails, `deliveryStatus` tracks it through: `Waiting on USPS Info`, `Mailed`, `In Transit`, `At Local Facility`, `Delivered`, or `Undeliverable`. Before the first tracking update arrives you will see `No Tracking Available`. That is the normal pending state, **not** an error and not a delivery failure — do not alert on it. # Response shapes and errors (/api-contract) Every JSON response wraps its result in an envelope. Reading the envelope correctly is the difference between a robust integration and one that reports success on a failed order — because a few failures on this API arrive inside a `200`. **Check `status` before you trust the payload.** An HTTP `200` is not on its own proof that the operation succeeded. See [Failures that arrive as 200](#failures-that-arrive-as-200). ## The two envelopes [#the-two-envelopes] Order operations use one shape and the remaining operations use another. They overlap, so code against the fields you need rather than assuming one shape everywhere. | Field | Orders | Conversions, automations, fonts, templates, address book, profile | Meaning | | -------------- | :----: | :---------------------------------------------------------------: | ---------------------------------------------------------------------------------------------------------------------- | | `payload` | ✓ | ✓ | The result. Always present; check `errors` before reading it. | | `errors` | ✓ | ✓ | Array of human-readable messages. Empty on success. | | `hasErrors` | ✓ | ✓ | Read-only, true when `errors` is non-empty. | | `warnings` | ✓ | ✓ | Non-fatal messages. The operation still succeeded. | | `hasWarnings` | ✓ | ✓ | Read-only, true when `warnings` is non-empty. | | `errorDetails` | ✓ | — | Structured error objects. **The shape varies by operation**, so treat it as opaque unless that operation documents it. | | `profile` | — | ✓ | The account the API key belongs to. | | `metadata` | — | ✓ | Free-form. Populated only where the operation says so — see below. | | `orderId` | ✓ | — | The order the call was about, or `0` when the operation is not about one order. | | `status` | ✓ | — | The **call's** outcome. Not the order's status. | ### `metadata` is usually empty [#metadata-is-usually-empty] Only **create conversions** populates `metadata`, where it carries validation counts (`TotalRecords`, `ValidRecords`, `InvalidRecords`, and per-identifier counts). Everywhere else it is an empty object — including the conversions list and conversion status operations. Do not look for totals or paging in it. ### `orderId` of `0` means "not applicable" [#orderid-of-0-means-not-applicable] Operations scoped to one order set it. The order listing and the multi-order analytics and transaction operations leave it at `0`. That is not order 0 — read each item's own identifier from the payload instead. ## Failures that arrive as 200 [#failures-that-arrive-as-200] Three cases return a success status code for a request that did not fully succeed. **1. `status` is not `"Success"`.** On order operations this field carries `"Success"` or `"Error"`. On the per-order analytics and transaction operations it can also carry `"Error: "` followed by the underlying exception message, for an individual order that failed while others succeeded. Always branch on it: ```js const body = await response.json(); if (body.status && body.status !== "Success") { throw new Error(`Order ${body.orderId} failed: ${body.status}`); } ``` **2. `errors` is non-empty.** Partial-success operations report per-record problems here while still returning `200`. Creating conversions is the clearest example: it processes the valid records and tells you about the invalid ones. **3. A filter matched nothing.** Filter values on the order listing are not validated — they are compared for equality. A misspelled status returns `200` with an empty list, not a `400`. If you get an empty list unexpectedly, check the spelling against [the published values](/api-reference/orders/v1-order-get) before assuming the account has no such orders. ## Status codes [#status-codes] | Code | What it means | Body | | ----- | ----------------------------------------------------------------------- | ---------------------------------------------------------------- | | `200` | Request was accepted. **Still check `status` and `errors`.** | Envelope, except the proof operations — see below | | `400` | Malformed request, an unparseable value, or a batch over its size limit | Envelope, with the reason in `errors` | | `401` | API key missing or invalid | Varies: an envelope on some operations, a plain string on others | | `403` | Your subscription tier does not include this feature | **None** — see the warning below | | `404` | The referenced object does not exist, or is not yours | Envelope | | `409` | Conflicts with the object's current state | Envelope | | `422` | Nothing in the request was usable | Envelope, with detail in `metadata` and `warnings` | | `500` | Unhandled server error | Usually none | **A bodiless `500` on an entitlement-gated operation is a permissions failure, not a transient one.** When your subscription tier lacks API access the intended response is a `403`, but it currently surfaces as a `500` with an empty body. Do not retry it — retrying will fail identically. Check your plan, or contact support. This is a known defect and is being fixed; a `403` with no body means the same thing. ## Not every response is JSON [#not-every-response-is-json] The two proof operations return the PDF itself — `application/pdf`, raw bytes. Write the body to a file; do not parse it. Their **error** responses are still JSON, so branch on the status code before deciding how to read the body: ```js const response = await fetch(url, { headers: { "X-API-KEY": key } }); if (response.ok) { await writeFile("proof.pdf", Buffer.from(await response.arrayBuffer())); } else { const { errors } = await response.json(); throw new Error(errors.join("; ")); } ``` ## Enumerated values [#enumerated-values] Fields with a fixed set of accepted or returned values publish that set in the reference, in the exact spelling the API uses. Two things to know: * **Order status uses spaced display text**, not identifiers: send `"Ready For Production"`, not `ReadyForProduction`. One value breaks the pattern and has no space — `InShipping` — which is why you should copy values from the reference rather than deriving them. * **`product` is deliberately not enumerated.** It is a server-defined list that changes as products are added and retired, so treat it as an opaque string and do not switch exhaustively on it. # API Reference (/api-reference) The reference below is generated from the LettrLabs **public** OpenAPI schema, so it always matches the live API. Every operation lists its parameters, request and response schemas, status codes, and the `X-API-KEY` authentication requirement — and each page has an interactive **“try it”** console you can call with your own key. ## Groups [#groups] * **Profile** — your account profile (`GET /v1/me`). * **Orders** — create, inspect, and manage direct‑mail orders, recipients, proofs, and checkout. * **Templates** — list the account's active templates (the `templateId` values you pass when creating an order). * **Fonts** — list the handwriting fonts available to the account (the `handwritingFont` values accepted by `POST /v1/order`). * **Conversions** — record and read back conversions for attribution. * **Automations** — integration‑driven order endpoints. * **Order Recipient Analytics** — recipient‑level statistics for an order. * **Address Book** — add address‑book entries. ## Before you call [#before-you-call] Generate a key (see [Authentication & API keys](/authentication-api-keys)) and send it in the `X-API-KEY` header. All requests use the base URL `https://app.lettrlabs.com/api`. ## Building with an AI agent? [#building-with-an-ai-agent] Point your agent at [`/llms.txt`](/llms.txt) for a curated index of these pages, or [`/llms-full.txt`](/llms-full.txt) for the entire reference and guides as a single file — so it can build against the LettrLabs API effectively. # Attributions and Conversions (/attributions-and-conversions) **Conversions** let you tell LettrLabs when a recipient took action after receiving your mail — a purchase, a signup, a booked appointment — so you can attribute outcomes to a campaign and measure return on your direct mail. ## Record conversions [#record-conversions] Send one or more conversions in a single call to `POST /v1/conversions`. The exact body — what identifies a recipient, the conversion value, and any metadata — is documented on the [`POST /v1/conversions`](/api-reference/conversions/v1-conversions-post) reference page, with a live console. ```bash curl -X POST https://app.lettrlabs.com/api/v1/conversions \ -H "X-API-KEY: your_api_key_here" \ -H "Content-Type: application/json" \ -d '[ { /* see the reference for the conversion shape */ } ]' ``` ## Read conversions back [#read-conversions-back] | Action | Endpoint | | ----------------------------- | -------------------------------------------------------------------------------------------- | | List all conversions | [`GET /v1/conversions`](/api-reference/conversions/v1-conversions-get) | | Check one conversion's status | [`GET /v1/conversions/{id}/status`](/api-reference/conversions/v1-conversions-id-status-get) | | Delete conversions | [`DELETE /v1/conversions/{id}`](/api-reference/conversions/v1-conversions-id-delete) | Each is documented in full, with parameters and response schemas, under [API Reference](/api-reference). ## How attribution fits together [#how-attribution-fits-together] 1. You **send mail** with [an order](/creating-an-order). 2. A recipient **acts** — and your system calls `POST /v1/conversions` to record it. 3. You **read conversions back** for reporting, or check an individual conversion's processing status. For richer recipient‑level reporting, see the **Order Recipient Analytics** group in the [API Reference](/api-reference). # Authentication & API keys (/authentication-api-keys) The LettrLabs public API authenticates every request with a **public API key** sent in the **`X-API-KEY`** header. You generate the key once, inside the LettrLabs app, and use it for all subsequent calls. ## Generate an API key [#generate-an-api-key] In the LettrLabs app: 1. Open **[Automations](https://app.lettrlabs.com/automations)**. 2. Go to **Setup**. 3. Select **OpenAPI**. 4. Click **Manage**. 5. Click **Generate Key**. Your new key is shown **once**, at creation time — copy it immediately and store it somewhere secure (a secret manager or your server's environment). If you lose it, generate a new key and update your integration. Treat the key like a password. It grants full access to your account's public API surface. Never embed it in client‑side code, a mobile app, or a public repository — keep it server‑side. ## Authenticate a request [#authenticate-a-request] Send the key in the `X-API-KEY` header on every request: ```bash curl https://app.lettrlabs.com/api/v1/me \ -H "X-API-KEY: your_api_key_here" ``` A quick way to confirm a key works is `GET /v1/me`, which returns your profile. A missing or invalid `X-API-KEY` header returns `401 Unauthorized`. ```http GET /v1/me HTTP/1.1 Host: app.lettrlabs.com X-API-KEY: your_api_key_here ``` Every endpoint in the [API Reference](/api-reference) shows the same `X-API-KEY` requirement, and the interactive console lets you paste your key and call the endpoint live. ## Rotating keys [#rotating-keys] To rotate, generate a new key (same steps above), deploy it to your integration, then stop using the old one. Generating a key does not automatically revoke previous keys. # Automations vs. creating an order (/automations-vs-creating-an-order) LettrLabs gives you two ways to put mail in motion. They are not competitors — they sit at different points on the "how much do I want to run myself" scale. ## The two paths [#the-two-paths] * **Create an order (API)** — you call [`POST /v1/order`](/api-reference/orders/v1-order-post) yourself, attach recipients, and check out. You own the trigger, the timing, and the recipient list. Fully programmatic, one order at a time. See [Creating an Order](/creating-an-order). * **Automations (app)** — you configure a rule in the LettrLabs app ([Automations](https://app.lettrlabs.com/automations)) that places orders for you when something happens (a CRM event, a list import, a proximity trigger). The matching API surface is the **Automations** group (`/v1/integration-orders`, see the [API Reference](/api-reference)), used to feed and inspect those integration‑driven orders. ## Which should I use? [#which-should-i-use] | Use **Create an Order** when… | Use **Automations** when… | | ------------------------------------------- | ------------------------------------------------------ | | Your own system decides when to mail | A recurring or event‑driven rule should mail for you | | You want full control of each order in code | You want the app to manage the trigger and cadence | | One‑off or batch sends on your schedule | Ongoing campaigns tied to CRM / list / location events | ## Rule of thumb [#rule-of-thumb] If you can answer *"my code decides exactly when and to whom"*, create orders directly. If you'd rather *"set it up once and let an event drive it"*, use Automations. Many integrations use both — Automations for the steady‑state flow, direct order creation for the exceptions. # API Changelog (/changelog) 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](/api-reference) — this page tells you *what changed*, the reference tells you *what the contract is now*. ## 2026-09-25 — Mail by proximity accepts one full address per pin [#2026-09-25--mail-by-proximity-accepts-one-full-address-per-pin] **Non-breaking** (one optional field added; no existing field, endpoint, or response shape changed). `POST /api/v1/integration-orders/{id}-by-proximity` takes an optional `fullAddress` on each item of `Addresses`. Put the whole address in it, for example `"2610 East Powers Road, Custer, MI 49405"`, and leave `address1`, `address2`, `city`, `state` and `zip` empty. When `fullAddress` is not empty, it wins: we find the pin from it and ignore the separate fields. A pin with no address field at all is still rejected with a `400`. The error text now names `FullAddress` among the fields you can send. You do not need to change anything. Requests that send `address1`, or the separate fields, work as before. ## 2026-09-21 — Street-and-ZIP-only recipients verify again [#2026-09-21--street-and-zip-only-recipients-verify-again] **Non-breaking** (no field, endpoint, or response shape changed; a regression fix that restores prior behavior). Between the 2026-08-31 address-engine change and this fix, a recipient sent to `PUT /api/v1/order/{id}/recipients` (and its alias `PUT /api/v1/order/{id}/recipients:append`) with a street (`address1`) and a ZIP (`zipCode`) but no city and no state was rejected as undeliverable (`UNVERIFIABLE_ADDRESS`) and dropped. The same shape reached integration orders and the Zapier address book. A recipient with only a street and a ZIP now verifies again. Address verification completes the city and the state from the ZIP, as it did before 2026-08-31. No request or response field changed. If you added a city and a state as a workaround, you can keep sending them or stop. Both verify. ## 2026-08-05 — Variable-image failures now tell you the specific cause [#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](/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) [#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) [#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) [#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 [#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 [#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 [#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](/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 [#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 [#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 [#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 [#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 [#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 [#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 [#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](/api-contract), [pagination](/pagination), [rate limits and batch sizes](/rate-limits), [versioning](/versioning) and [addresses and deliverability](/addresses). ## 2026-07-24 — Checkout preview is now side-effect-free, and checkout is faster [#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. # Creating an Order (/creating-an-order) An **order** is a batch of direct mail generated from one of your templates and addressed to a set of recipients. You create orders with a single call to `POST /v1/order`, referencing a template you have already designed in the LettrLabs app. ## Before you start [#before-you-start] * A **public API key** — see [Authentication & API keys](/authentication-api-keys). * A **template** built in the LettrLabs app. Its numeric **template ID** is what you pass as `templateId`. ## Create an order from a template [#create-an-order-from-a-template] The only required field is `templateId`. The full request body — recipients, mailing options, and template merge fields — is documented, field by field, on the [`POST /v1/order`](/api-reference/orders/v1-order-post) reference page, where you can also fire the request live with your own key. ```bash curl -X POST https://app.lettrlabs.com/api/v1/order \ -H "X-API-KEY: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "templateId": 12345 }' ``` A successful call returns the created order. Keep the returned **order ID** — you use it to manage recipients, pull proofs, and check out the order. ## Work with an order after it's created [#work-with-an-order-after-its-created] Each of these is documented in full under [API Reference](/api-reference): | Action | Endpoint | | ---------------------------- | ----------------------------------------------------------------------------------------- | | List orders | [`GET /v1/order`](/api-reference/orders/v1-order-get) | | Review an order's recipients | [`GET /v1/order/{id}/recipients`](/api-reference/orders/v1-order-id-recipients-get) | | Add or update recipients | [`PUT /v1/order/{id}/recipients`](/api-reference/orders/v1-order-id-recipients-put) | | Remove recipients | [`DELETE /v1/order/{id}/recipients`](/api-reference/orders/v1-order-id-recipients-delete) | | Preview a proof | [`GET /v1/order/{id}/proof`](/api-reference/orders/v1-order-id-proof-get) | | Check out (confirm + pay) | [`POST /v1/order/{id}/checkout`](/api-reference/orders/v1-order-id-checkout-post) | | Cancel an order | [`DELETE /v1/order/{id}`](/api-reference/orders/v1-order-id-delete) | ## Idempotency [#idempotency] Re‑sending the same `POST /v1/order` request creates a **new** order each time — there is no automatic de‑duplication. If a request times out, fetch `GET /v1/order` to check whether the order was created before retrying, so you don't mail the same batch twice. # Inbound webhooks (/inbound-webhooks) Inbound webhooks let **your** systems (or a third party) push data **to** LettrLabs. You configure a webhook in the LettrLabs app, which gives you a unique endpoint, and your sender posts events to it. These are **inbound** webhooks — data flowing *into* LettrLabs. LettrLabs does not currently emit outbound event webhooks; that is planned for a future release. ## Endpoint [#endpoint] Post your payload to the webhook's unique URL: ``` POST /v1/webhooks/{guid} ``` `{guid}` is the identifier of the webhook you created in the app. The full request goes to: ``` https://app.lettrlabs.com/api/v1/webhooks/{guid} ``` ## Security: signature verification and API key [#security-signature-verification-and-api-key] Each webhook can independently require **either, both, or neither** of two protections, configured per webhook in the app: ### Optional HMAC‑SHA256 signature [#optional-hmacsha256-signature] When signature verification is enabled, the sender computes an **HMAC‑SHA256** of the raw request body using the webhook's shared secret and sends it in a standard signature header. The endpoint accepts any of these four headers: * `X-Signature-256` * `X-Hub-Signature-256` * `X-Signature` * `X-Hub-Signature` The signature value is the lowercase hex digest, prefixed with **`sha256=`**: ``` X-Signature-256: sha256=3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b ``` Compute it over the **exact raw bytes** of the request body (before any parsing). In Node.js: ```js import { createHmac } from 'node:crypto'; const signature = 'sha256=' + createHmac('sha256', webhookSecret).update(rawBody).digest('hex'); ``` Send the resulting `signature` in one of the four accepted headers (for example `X-Signature-256`). LettrLabs recomputes the digest over the received body and rejects the request if it does not match. ### Optional per‑webhook API key [#optional-perwebhook-api-key] A webhook can also require an API key. When enabled, include your **`X-API-KEY`** header on the request (see [Authentication & API keys](/authentication-api-keys)). A webhook may require the API key, the signature, both, or neither — whatever you configured. ## Example [#example] ```bash curl -X POST https://app.lettrlabs.com/api/v1/webhooks/your-webhook-guid \ -H "Content-Type: application/json" \ -H "X-Signature-256: sha256=" \ -H "X-API-KEY: your_api_key_here" \ --data-binary @payload.json ``` If verification is enabled and the signature header is missing or wrong, or a required API key is absent, the request is rejected. # Introduction (/) The LettrLabs API lets you drive real, physical direct mail from your own systems: create orders from a template, target recipients, track delivery and conversions, and receive events as they happen. Everything the LettrLabs app does for direct‑mail automation is available over a simple HTTPS + JSON API. **Building with an AI agent?** Point it at [`/llms.txt`](/llms.txt) for a curated index of these docs, or [`/llms-full.txt`](/llms-full.txt) for the full content as a single file — so it can build against the LettrLabs API effectively. ## What you can do [#what-you-can-do] * **Create orders** from a template and a recipient list — handwritten cards and printed mail, sent on your schedule (see [Creating an Order](/creating-an-order)). * **Attribute and convert** — record conversions against the mail you send and read them back for reporting (see [Attributions & Conversions](/attributions-and-conversions)). * **Manage your address book and recipients** — add entries and adjust an order's recipients before it mails. * **React to events** — receive inbound webhooks when something happens, with optional HMAC signature verification (see [Inbound webhooks](/inbound-webhooks)). ## Base URL [#base-url] All requests go to: ``` https://app.lettrlabs.com/api ``` Every endpoint is documented under [API Reference](/api-reference), grouped by area, with parameters, request and response schemas, status codes, and an interactive **“try it”** console you can call with your own key. ## Authentication in one line [#authentication-in-one-line] Every request carries your public API key in the `X-API-KEY` header: ```bash curl https://app.lettrlabs.com/api/v1/me \ -H "X-API-KEY: your_api_key_here" ``` You generate that key inside the LettrLabs app — the full walk‑through is in [Authentication & API keys](/authentication-api-keys). ## Next steps [#next-steps] 1. [Generate an API key](/authentication-api-keys) and make your first authenticated call to `GET /v1/me`. 2. Follow the [order of operations](/order-of-operations) to send your first order end to end. 3. Not sure whether to drive orders yourself or let the app? See [Automations vs. creating an order](/automations-vs-creating-an-order). 4. Explore the full [API Reference](/api-reference) and try requests live. # MCP server (/mcp) The LettrLabs **MCP server** lets MCP-compatible AI agents — Claude, ChatGPT, Cursor, and others — work with your LettrLabs account directly from chat. Your agent can draft campaigns, preview pricing, and submit mailings without you copying anything into a separate dashboard. **Public beta.** The MCP server is live at `https://mcp.lettrlabs.com/mcp` for all LettrLabs customers. We're still refining it, so connection flows and behavior may change — tell your LettrLabs contact if anything looks off. ## What you need [#what-you-need] * **A LettrLabs API key.** Generate one in the app and keep it somewhere secure — see [Authentication & API keys](/authentication-api-keys). The same key authorizes your agent. * **A plan with API access.** The MCP server calls the LettrLabs public API, which is available on plans with OpenAPI integrations enabled. If your agent reports that your subscription doesn't include OpenAPI integrations, ask your LettrLabs rep to enable it. ## Connect your agent [#connect-your-agent] The MCP server is available at: ``` https://mcp.lettrlabs.com/mcp ``` The exact menu names vary by agent, but the flow is the same: 1. In your agent, add a **custom MCP connector** (for example, Claude: **Settings → Connectors → Add custom connector**; ChatGPT: **Settings → Connectors → Add MCP connector**). 2. Set the server **URL** to `https://mcp.lettrlabs.com/mcp`. 3. **Authenticate with your API key.** Agents that use a consent page (such as Claude) open a LettrLabs authorization page where you paste your key. Agents that use header authentication send it as the **`X-API-KEY`** header. 4. Enable the connector for your conversation, then try asking it to *"list my recent orders."* **Leave any optional OAuth fields empty.** Your API key is not an OAuth client secret. If your agent's connector form offers advanced fields for an **OAuth Client ID** or **Client Secret** (Claude shows these under *Advanced settings*), leave them blank — filling them in causes errors such as *"A client id must be provided with a client secret."* Your agent registers itself with the server automatically; you enter your API key on the LettrLabs authorization page after adding the connector. For IDE and CLI agents that take a JSON config, point the server at the same URL with your key in the `X-API-KEY` header: ```json { "url": "https://mcp.lettrlabs.com/mcp", "headers": { "X-API-KEY": "your_api_key_here" } } ``` ## What you can do [#what-you-can-do] Once connected, your agent can act on your LettrLabs account on your behalf: * **Read your account** — list your direct mail orders and their recipients, pull printed-piece proofs, and review spend and delivery analytics. * **Preview pricing** — cost out an order *without* charging anything, so you can see the price before you commit. * **Draft and send campaigns** — start a draft from one of your templates, add a mailing list, and submit it for production. Sending is never silent: submitting a charge requires an explicit confirmation, so a well-behaved agent shows you the price and asks before anything goes out. ## Staying safe [#staying-safe] * **You authorize each connection.** Your agent connects once, with your consent, using the API key you provide. * **Charges require confirmation.** Nothing is submitted for production until you confirm — your agent surfaces the cost first. * **Treat your key like a password.** It grants access to your account's API surface; rotate or revoke it from the same screen if it's ever exposed. # Order of operations (/order-of-operations) This walkthrough puts the API calls in the order you actually make them: start from a template, create an order, attach recipients, then confirm and pay. Each step links to the full reference, where you can fire the request live with your own key. New here? Skim [Authentication & API keys](/authentication-api-keys) first — every call below sends your key in the `X-API-KEY` header. ## 1. Get a template ID [#1-get-a-template-id] Orders are generated from a **template** you design in the LettrLabs app (handwritten cards, printed mail, and so on). Open the template in the app; its numeric **template ID** is the `templateId` you pass when creating an order. You can also list your account's templates — and read their IDs — with [`GET /v1/templates`](/api-reference/templates/v1-templates-get). ## 2. Create the order [#2-create-the-order] Create the order with [`POST /v1/order`](/api-reference/orders/v1-order-post). The only required field is `templateId`; recipients and mailing options can be set now or added in the next step. ```bash curl -X POST https://app.lettrlabs.com/api/v1/order \ -H "X-API-KEY: your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "templateId": 12345 }' ``` Keep the returned **order ID** — every later step uses it. ## 3. Add recipients [#3-add-recipients] Attach (or replace) the order's recipients with [`PUT /v1/order/{id}/recipients`](/api-reference/orders/v1-order-id-recipients-put). Review what's currently attached with [`GET /v1/order/{id}/recipients`](/api-reference/orders/v1-order-id-recipients-get). ```bash curl -X PUT https://app.lettrlabs.com/api/v1/order/ORDER_ID/recipients \ -H "X-API-KEY: your_api_key_here" \ -H "Content-Type: application/json" \ -d '[ { /* see the reference for the recipient shape */ } ]' ``` ## 4. Preview a proof (optional) [#4-preview-a-proof-optional] Before you pay, pull a rendered proof with [`GET /v1/order/{id}/proof`](/api-reference/orders/v1-order-id-proof-get) to confirm the mail looks right. ## 5. Check out [#5-check-out] Confirm and pay for the order with [`POST /v1/order/{id}/checkout`](/api-reference/orders/v1-order-id-checkout-post). Until you check out, the order is a draft and nothing mails. ```bash curl -X POST https://app.lettrlabs.com/api/v1/order/ORDER_ID/checkout \ -H "X-API-KEY: your_api_key_here" ``` ### The 200 means accepted, not charged [#the-200-means-accepted-not-charged] Payment settles asynchronously, so a `200` tells you the checkout was **accepted** — not that the order has been paid. The response tells you how to wait for the rest: | Field | What it tells you | | --------------------------------- | ------------------------------------------------------------------------------------------------ | | `orderStatus` | the order's state as the call returns — normally `Payment Needed`, meaning accepted and settling | | `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 | Poll [`GET /v1/order`](/api-reference/orders/v1-order-get) filtered to your order id and watch for `paidDate` to be set, or for `status` to become `Paid`. A return to `Draft` means the payment failed and you can edit and check out again. While the order is `Payment Needed` it is **not editable** — recipient changes, deletion, and a second checkout all return `400` until it settles. Size your polling from `settlementExpectedWithinSeconds` rather than a bound of your own, and treat it as an expectation rather than a guarantee: if it elapses while the order is still `Payment Needed`, the order is not lost and no second charge is pending. **Never retry a checkout that returned 200** — the charge is already in flight, and a second checkout charges again. Ask us to confirm settlement instead. ## After it mails [#after-it-mails] * Track recipient‑level delivery and engagement with **Order Recipient Analytics** (see the [API Reference](/api-reference)). * Record outcomes with conversions — see [Attributions & Conversions](/attributions-and-conversions). Re‑sending `POST /v1/order` creates a **new** order every time — there is no automatic de‑duplication. See the idempotency note in [Creating an Order](/creating-an-order) before adding retries. # Pagination (/pagination) Three list operations page, one does not, and the paging is zero-based with no total count. Getting the first two facts wrong silently skips or repeats records. ## Zero-based, and there is no total [#zero-based-and-there-is-no-total] `pageNumber` is an **offset in pages starting at 0**. The first page is `pageNumber=0`; passing `1` gives you the second page. A loop written as `for (let p = 1; ...)` skips the first page entirely and reports nothing wrong. No list operation returns a total count, a page count, or a has-more flag. **Detect the end by a short page**: fewer items than `pageSize` means you have reached the last one. ```js async function* allOrders(key, pageSize = 100) { for (let pageNumber = 0; ; pageNumber++) { const response = await fetch( `https://app.lettrlabs.com/api/v1/order?pageNumber=${pageNumber}&pageSize=${pageSize}`, { headers: { "X-API-KEY": key } }, ); const { payload } = await response.json(); const orders = payload.orders ?? []; yield* orders; if (orders.length < pageSize) return; } } ``` The `orders.length < pageSize` check is the end condition: a page shorter than you asked for is the last one. Records are ordered newest-first by creation date, and paging is by offset rather than by cursor. So an order created while you are part-way through a walk shifts everything down one place, and you can see the same record twice. For a reconciliation job, prefer filtering to a fixed set of order IDs over walking the whole list. ## Which operations page [#which-operations-page] | Operation | Parameters | Default page size | | -------------------------- | ------------------------------------------------ | ----------------- | | List orders | `pageNumber`, `pageSize` | 100 | | List an order's recipients | `pageNumber`, `pageSize`, plus `query` to filter | see the reference | | List conversions | **none** — returns everything | — | There is no maximum enforced on `pageSize`. A very large value will work until the response is big enough to time out, which surfaces as a `500` rather than as a clear error, so stay near the default and page rather than pulling everything at once. ## Filtering instead of paging [#filtering-instead-of-paging] For the order listing, filtering is usually better than walking. `id` accepts a comma-separated list, so if you track order IDs on your side you can fetch exactly the ones you care about: ``` GET /api/v1/order?id=48213,48214,48215 ``` Filter values are matched by equality and are **not validated**: a misspelled `status` or `product` returns `200` with an empty list rather than an error. Copy values from [the reference](/api-reference/orders/v1-order-get), where the accepted set for each filter is published. # Rate limits and batch sizes (/rate-limits) ## There is no request rate limit today [#there-is-no-request-rate-limit-today] The public API does not enforce a request-rate limit and never returns `429`. You do not need to implement backoff for rate limiting. No enforced limit is not a promise of unlimited throughput. Everything below shares one database, so a fast enough loop degrades your own calls before anything rejects them. Treat the guidance in this page as the supported shape of a bulk load. If a limit is introduced it will arrive as `429` with a `Retry-After` header, and it will be announced on the [changelog](/changelog) first. ## The limits that do exist [#the-limits-that-do-exist] These are per-request size limits, not rate limits. Each rejects the **whole** request — no partial application — so split client-side before sending. | Operation | Limit | On exceeding | | -------------------------- | ---------------------------- | -------------------------- | | Create conversions | 5,000 records per request | `400`, nothing stored | | Add recipients to an order | 5,000 recipients per request | `400`, no recipients added | Both are enforced before any processing, so a rejected batch leaves no partial state — you can safely resubmit smaller batches. ## Pacing a bulk load [#pacing-a-bulk-load] Adding recipients is the expensive step: every recipient is address-validated and deduplicated against your suppression lists before it is stored. So: * **Send recipients in sequential batches, not in parallel.** Concurrent appends to the same order contend on the same rows and are slower in aggregate than one batch after another. * **Batch in the low thousands** rather than at the 5,000 ceiling. Smaller batches fail smaller: a rejected batch of 1,000 is cheaper to diagnose and resend. * **Expect the call to be slow** — it does real work per recipient. Set a generous client timeout (minutes, not seconds) rather than retrying, because a retry after a client-side timeout can re-send recipients the first call already stored. ## Retrying safely [#retrying-safely] The API does not implement idempotency keys, so a blind retry can duplicate work. Before retrying anything that writes: * **Adding recipients** — re-read the order's recipients and compare against what you sent, rather than resending the batch. * **Checkout** — re-read the order's status. If it moved past payment, the checkout succeeded and must not be repeated. * **Creating conversions** — a resend is comparatively safe: matchback deduplicates on the identifiers you supply, so the same record submitted twice does not double-count. And do not retry a `400`, `403`, `404`, `409`, `422`, or a **bodiless `500` on an entitlement-gated operation** — see [response shapes](/api-contract#not-every-response-is-json). Those are all deterministic; the same request will fail the same way. # Versioning and change policy (/versioning) Every path is prefixed `/v1`. There is one live version, and there is no `/v2`. ## What we change in place [#what-we-change-in-place] `/v1` receives **additive and corrective** changes without a new version: * **New operations, and new optional request fields.** An operation you do not call and a field you do not send cannot affect you. * **New response fields.** Parse responses tolerantly — deserialize into a type that ignores unknown fields rather than one that rejects them. This is the single most common way an integration breaks on an otherwise compatible change. * **New values in an enumerated set**, when the product gains a state. Handle an unrecognized value as "something new" rather than throwing. * **Corrections to the specification** where it disagreed with the service. These change the document, not the behaviour: a field that was always nullable being declared nullable, a response body that was declared but never sent being removed, a media type corrected to what is actually returned. If a generated client breaks on one of these, it was relying on something the API never actually did. Corrections are the category to watch after a documentation update. They can change generated client types — for instance, a response field correctly declared nullable becomes `string | null` in TypeScript. The wire bytes are unchanged; what changed is that the document stopped over-promising. ## What would require a new version [#what-would-require-a-new-version] We would not do any of these to `/v1`: * Removing or renaming an operation, a path, or a field * Changing a field's type, or the spelling of a value it returns * Making an optional request field required, or narrowing what a field accepts * Changing the meaning of an existing status code Work of that kind is tracked separately and would ship as a new version alongside `/v1`, not as a change to it. ## Staying informed [#staying-informed] The [changelog](/changelog) records changes to this API. Read it before upgrading a generated client, since that is where corrections are called out. The machine-readable specification is always current at `https://app.lettrlabs.com/api/openapi/v3.json`. It is generated from the running service rather than hand-maintained, so it reflects what is deployed. If you generate a client, pin a copy of the specification in your own repository and diff it when you update — that way a corrected declaration shows up as a reviewable change rather than as a surprise compile error. ## Writing an integration that survives changes [#writing-an-integration-that-survives-changes] * Ignore unknown response fields. * Treat an unrecognized enumerated value as unknown, not as an error. * Send only the fields you mean to set; omit the rest rather than sending `null`. Where a field is genuinely optional, its documentation says so and it is absent from the schema's `required` list. * Do not depend on field ordering, on `metadata` containing anything not documented for that operation, or on the absence of fields you do not use. # Retrieve the authenticated profile (/api-reference/profile/v1-me-get) Returns profile data for the user authenticated by the supplied API key. ## `GET /v1/me` ### Responses - `200` — Contains the authenticated profile data. - `401` — Authorization key not valid # List conversions (/api-reference/conversions/v1-conversions-get) Returns all conversions recorded for the authenticated account, with their processing status. ## `GET /v1/conversions` ### Responses - `200` — Every conversion batch recorded for the account, with its processing status. This operation has no pagination parameters and returns the full set. Metadata is always an empty object here despite the envelope carrying the field — do not look for totals or paging in it. - `400` — Bad Request - `401` — Authorization key not valid - `500` — Internal Server Error # Create conversions (/api-reference/conversions/v1-conversions-post) Submits conversion events for matchback and attribution. Returns validation information and processes the valid records even when some records are invalid. At most 5,000 records per request — a larger batch is rejected in full with a 400 and none of it is processed, so split larger uploads client-side. ## `POST /v1/conversions` ### Request body Content type: `application/json` (required) _Array of objects with the following fields:_ - `firstName` (string | null, required) - `lastName` (string | null, required) - `fullName` (string | null, required) - `businessName` (string | null, required) - `primaryAddress` (`ConversionAddressViewModel`, required) - `secondaryAddress` (`ConversionAddressViewModel`, required) - `couponCode` (string | null, required) - `primaryPhoneNumber` (string | null, required) - `secondaryPhoneNumber` (string | null, required) - `primaryEmail` (string | null (email), required) - `secondaryEmail` (string | null (email), required) - `revenue` (string | null, required) — Converted revenue as a string (optional). Supports currency symbols like $, €, £, ¥, ₹, commas, and spaces. If omitted, null, or empty, revenue defaults to 0. - `type` (string | null (enum: Conversion, Lead, null), required) — Whether the record is a completed conversion or a lead, matched case-insensitively. Revenue decides this in practice: after parsing, any record typed `Conversion` — whether you sent it explicitly, omitted the field, or sent something unrecognized — is re-typed to `Lead` unless revenue is greater than zero. Sending `Conversion` with no revenue therefore stores a `Lead`. An explicit `Lead` is always kept as sent. - `orderId` (string | null, required) - `storeId` (string | null, required) - `uniqueId` (string | null, required) - `crmId` (string | null, required) - `customerId` (string | null, required) - `eventDate` (string | null (date-time), required) — Date and time of the conversion event in UTC. If omitted or not parseable, defaults to the current UTC timestamp at processing time. Common accepted formats include ISO 8601 (e.g. 2025-12-25T15:30:00Z), date-only YYYY-MM-DD, US dates MM/DD/YYYY, and long-form month names (e.g. December 25, 2025). ### Responses - `200` — Returns processed conversions with validation statistics in Metadata. The Metadata includes TotalRecords, ValidRecords, InvalidRecords, PrimaryEmails, SecondaryEmails, PrimaryAddresses, SecondaryAddresses, PrimaryPhones, SecondaryPhones, CouponCodes, and may include DetailedErrors and DetailedWarnings with specific validation data. - `400` — Invalid format or processing errors occurred. Also returned when the batch exceeds 5,000 records, in which case no records at all were stored. - `401` — Authorization key not valid - `422` — All records are invalid. Check Metadata for validation details and Warnings for specific validation failures. - `500` — Internal Server Error # Delete a conversion (/api-reference/conversions/v1-conversions-id-delete) Marks a conversion for deletion after validating its status. Deletion is asynchronous — check its progress via the conversion status endpoint. ## `DELETE /v1/conversions/{id}` ### Parameters - `id` (path, required) ### Responses - `200` — Specified conversion has been validated and queued for deletion process - `400` — Bad Request - `401` — Unauthorized - `500` — Internal Server Error # Retrieve a conversion's status (/api-reference/conversions/v1-conversions-id-status-get) Returns the processing status of one conversion by its ID, including whether it has been matched or is pending deletion. ## `GET /v1/conversions/{id}/status` ### Parameters - `id` (path, required) — The ID of the conversion to check ### Responses - `200` — The batch's processing status. Metadata is always an empty object here despite the envelope carrying the field — timestamps and status detail are in the payload, not the metadata. - `400` — Bad Request - `401` — Authorization key not valid - `404` — Specified conversion ID does not exist - `500` — Internal Server Error # List handwriting fonts (/api-reference/fonts/v1-fonts-get) Lists the LettrLabs handwriting fonts available to the authenticated account. Every returned name is accepted by POST /v1/order's handwritingFont field. ## `GET /v1/fonts` ### Responses - `200` — Successful operation - `400` — Something went wrong - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `500` — Internal Server Error # List orders (/api-reference/orders/v1-order-get) Returns the account's orders with their status, product, postage, and delivery progress. Supports the id, status, paid, product, postage, and showRecipients filters, plus pageNumber/pageSize pagination. ## `GET /v1/order` ### Parameters - `id` (query) — Filter orders by a list of order IDs separated by comma. - `status` (query) — Filter orders by status (e.g., 'Draft', 'Mailed'). - `paid` (query) — Tri-state filter on payment state: omit the parameter to return orders regardless of payment state, true for paid orders only, false for unpaid orders only. Any other value is rejected with a 400. - `product` (query) — Filter orders by product type. - `postage` (query) — Filter orders by postage type. - `showRecipients` (query) — Filter whether to return recipient information. - `pageNumber` (query) — Used for pagination, the page number. Defaults to 0. - `pageSize` (query) — Used for pagination, the number of records to retrieve. Defaults to 100. ### Responses - `200` — Successful operation - `400` — Something went wrong - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `500` — Internal Server Error # Create an order (/api-reference/orders/v1-order-post) Creates a new order from an existing template, copying its design and product settings, and returns the new order's ID. Add recipients and execute checkout separately to send the order. ## `POST /v1/order` ### Request body Content type: `application/json` (required) - `templateId` (integer (int32), required) — The template to create the order from. Obtain it from the templates listing; note that a template is itself an order that is never fulfilled, so this is an order identifier. - `useQr` (boolean | null) — Whether the generated mail piece should include the template's QR code. true = include the QR code at its existing template placement; false = remove it. The selected template must already have a QR code element placed on its design, otherwise the request is rejected. Omit to keep the template's current setting. - `qrUrl` (string | null) — Optional target URL the QR code should direct to. When supplied, overrides the template's existing QR target; when omitted (with UseQr true) the template target is kept. - `handwritingFont` (string | null) — Optional handwriting style identifier. Must be a style supported by LettrLabs and available to the authenticated account. Handwriting styles are global only — fonts uploaded to the account cannot be used for handwriting and are rejected. Use the fonts listing operation to discover valid values. Omit to use the template's default style. ### Responses - `200` — Successful operation - `400` — Something went wrong - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `500` — Internal Server Error # Retrieve a recipient's delivery status (/api-reference/orders/v1-order-id-recipient-delivery-status-get) Returns the latest carrier delivery status for one recipient on an order, identified by the recipient's first and last name, or by their address line. ## `GET /v1/order/{id}/recipient-delivery-status` ### Parameters - `id` (path, required) - `firstName` (query) — Recipient first name (use with lastName). - `lastName` (query) — Recipient last name (use with firstName). - `address` (query) — Recipient address line (Address1 or Address2). Use instead of firstName/lastName. ### Responses - `200` — Successful operation - `400` — Something went wrong - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `404` — No matching recipient was found on the order - `409` — Multiple recipients match the supplied name or address; refine your query - `500` — Internal Server Error # Delete an order (/api-reference/orders/v1-order-id-delete) Deletes an order that has not entered production. Only orders in Draft or Needs Edits status can be deleted; an order in use as an automation template cannot be deleted. Deletion is immediate regardless of the order's size, and the deleted order then behaves exactly like a nonexistent order id on every other operation. ## `DELETE /v1/order/{id}` ### Parameters - `id` (path, required) — The Id of the order to delete. ### Responses - `200` — Successful operation - `400` — The delete was refused: the request was understood and will not be performed. `payload.reasonCode` names which rule refused it — ORDER_NOT_AVAILABLE (no such order, already deleted, or not yours — one code for all three, deliberately), ORDER_NOT_DELETABLE_STATUS (only Draft and Needs Edits are deletable; the message names the status observed), PAID_RECIPIENT_SEARCH, PAID_RADIUS_MAIL_ORDER, TEMPLATE_IN_USE — and `errors[0]` carries the human-readable sentence. Retrying does not help, with one exception: ORDER_NOT_DELETABLE_STATUS on an order still in "Payment Needed" becomes permanent only once checkout settles. - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `500` — Unexpected server error while deleting the order — not caller-fixable and not a refusal; no reason code is returned. Retry later or contact support. # Retrieve an order proof (/api-reference/orders/v1-order-id-proof-get) Renders and returns the order's proof as a PDF document. The proof is generated for the first mailable recipient on the order (the lowest-ID recipient not marked do-not-mail), substituting that recipient's mail-merge fields. ## `GET /v1/order/{id}/proof` ### Parameters - `id` (path, required) — The Id of the order to generate proof. ### Responses - `200` — The proof PDF, as raw application/pdf bytes — not JSON. Write the body straight to a file; do not parse it. The error responses on this operation ARE JSON. - `400` — Something went wrong - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `404` — Not Found - `504` — The proof could not be rendered within the server's time budget. The request is safe to retry after a few minutes. # Retrieve a recipient's proof (/api-reference/orders/v1-order-id-proof-recipient-get) Renders and returns a proof PDF for one specific recipient on the order, substituting that recipient's mail-merge fields. The recipient must belong to the order. ## `GET /v1/order/{id}/proof/{recipient}` ### Parameters - `id` (path, required) — The Id of the order to generate proof. - `recipient` (path, required) — Recipient Id to substitute mail merge fields. ### Responses - `200` — The proof PDF, as raw application/pdf bytes — not JSON. Write the body straight to a file; do not parse it. The error responses on this operation ARE JSON. - `400` — Something went wrong - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `404` — Not Found - `504` — The proof could not be rendered within the server's time budget. The request is safe to retry after a few minutes. # Retrieve order analytics (/api-reference/orders/v1-order-analytics-get) Returns engagement statistics — including per-recipient QR code scans — for the orders named in the Id query parameter (a comma-separated list of order IDs). Orders that cannot be processed are reported per-order with an error status rather than failing the whole request. ## `GET /v1/order/analytics` ### Parameters - `id` (query, required) — Comma-separated list of order IDs, e.g. 48213,48214. Sent as `Id` in earlier versions of this documentation; query parameter names are matched case-insensitively, so both spellings work. ### Responses - `200` — Successful operation - `400` — Something went wrong - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `500` — Internal Server Error # Retrieve order transactions (/api-reference/orders/v1-order-transaction-get) Returns the financial transactions recorded for the orders named in the Id query parameter (a comma-separated list of order IDs), including transaction type, amounts, and discounts. Orders that cannot be processed are reported per-order with an error status rather than failing the whole request. ## `GET /v1/order/transaction` ### Parameters - `id` (query, required) — Comma-separated list of order IDs, e.g. 48213,48214. Sent as `Id` in earlier versions of this documentation; query parameter names are matched case-insensitively, so both spellings work. ### Responses - `200` — Successful operation - `400` — Something went wrong - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `500` — Internal Server Error # Preview an order checkout (/api-reference/orders/v1-order-id-checkout-get) Calculates the line-item cost, production, and estimated delivery details for an order so they can be reviewed before checking out. This preview is side-effect-free: it does not modify the order and does not create a payment intent. Calling this endpoint is not required before executing a checkout. Pass `autoBill=false` to preview the checkout under the payment-method opt-out: if the available account balance does not cover the order in full the preview is rejected with HTTP 402 Payment Required and error code INSUFFICIENT_BALANCE — the exact rejection the checkout execute verb returns for the same order — so a preview and an execute agree on whether the order can proceed. The preview remains side-effect-free even when rejected. ## `GET /v1/order/{id}/checkout` ### Parameters - `id` (path, required) — The Id of the order to calculate checkout details. - `postage` (query) — The type of postage for the order. Available values: Standard, FirstClass. Also accepted as PostageType, the name the checkout execute body uses for the same input. - `production` (query) — The speed of production for the order. Available values: Normal, ExpeditedFaster, ExpeditedFastest. Also accepted as ProductionSpeed, the name the checkout execute body uses for the same input. - `holdUntilDate` (query) — The date to hold order production until. This date must be further in the future than the normal estimated production completion date. Query parameter names are matched case-insensitively, so the execute body's HoldUntilDate spelling has always been accepted here. - `autoBill` (query) — Whether the checkout being previewed may charge the account's default payment method for any amount not covered by the available balance. Available values: true, false. Defaults to true. Sending false requires the balance to cover the order in full and otherwise rejects the preview with HTTP 402 Payment Required and error code INSUFFICIENT_BALANCE — the same rejection the checkout execute verb returns for its autoBill body field — so a preview and an execute of the same order agree. The preview stays side-effect-free regardless. ### Responses - `200` — Successful operation - `400` — Something went wrong - `401` — Authorization key not valid - `402` — autoBill was disabled and the account balance does not cover the order in full (error code INSUFFICIENT_BALANCE; no charge is ever made by this preview) - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `500` — Internal Server Error # Execute an order checkout (/api-reference/orders/v1-order-id-checkout-post) Submits an order for production with the supplied postage type, production speed, and optional hold-until date, and accepts it for payment. Payment settles asynchronously, so a 200 means the checkout was ACCEPTED, not that the order has been charged: the response reports the order's resulting status in "orderStatus", when to start polling in "pollAfterSeconds", and when settlement is expected to have reached a terminal state in "settlementExpectedWithinSeconds" — size your polling from those rather than a bound of your own. On acceptance the order moves to "Payment Needed" immediately and stops being editable: recipient changes, order deletion, and further checkouts return 400 while payment settles. The order then reads "Paid" once settlement completes, or returns to "Draft" if payment fails (retry by checking out again). Never retry a checkout that returned 200 — the charge is already in flight, and a second checkout charges again. ## `POST /v1/order/{id}/checkout` ### Parameters - `id` (path, required) — The Id of the order to execute checkout and finalize the transaction. ### Request body Content type: `application/json` (required) - `postageType` (string (enum: Standard, FirstClass), required) — Postage class for the order. `Standard` is bundled into the base price; `FirstClass` adds a per-piece surcharge. These two are the only accepted values — `HailTrace` appears on storm-order responses but is not accepted here. - `productionSpeed` (string (enum: Normal, ExpeditedFaster, ExpeditedFastest), required) — How fast the order goes through production. Expedited speeds cost more; the surcharge appears on the checkout quote. These three are the only accepted values — `HailTrace` is an internal storm-order priority that parses but is then rejected here. - `holdUntilDate` (string | null (date-time)) — Optional date to hold the order until before it enters production, as a date-time. Must be later than the estimated production completion date returned by the checkout preview. Omit or send null to submit for production immediately. - `autoBill` (boolean) — Controls whether checkout may charge the account's default payment method for any amount not covered by the available account balance. `true` (the default) applies any available balance first, then charges the default payment method for the remaining shortfall. `false` requires the available balance to cover the order in full: if it does not, the checkout is rejected with HTTP 402 Payment Required and error code `INSUFFICIENT_BALANCE`, and no charge is made and no balance is deducted. Omitting the field is equivalent to `true`. ### Responses - `200` — Successful operation - `400` — Something went wrong, or the order is not in an editable status (for example while a previous checkout is settling) - `401` — Authorization key not valid - `402` — autoBill was disabled and the account balance does not cover the order in full (error code INSUFFICIENT_BALANCE; no charge is made) - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `500` — Internal Server Error # List an order's recipients (/api-reference/orders/v1-order-id-recipients-get) Returns the recipients on an order with their address and mail-merge data. Supports a query filter on recipient name, address, or organization, plus pageNumber/pageSize pagination. ## `GET /v1/order/{id}/recipients` ### Parameters - `id` (path, required) — The Id of the order to retrieve the recipient list from. - `query` (query) — Search filter for recipient data. Filter by recipient name, address or organization. - `pageNumber` (query) — For pagination: Page number. - `pageSize` (query) — For pagination: Number of records to retrieve. ### Responses - `200` — Successful operation - `400` — Something went wrong - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `404` — Not Found - `500` — Internal Server Error # Delete an order's recipients (/api-reference/orders/v1-order-id-recipients-delete) Removes the specified recipients from an order. Pass the IDs of the recipients to remove in the request body. The 200 response reports `requestedRecipients` (distinct ids submitted), `deletedRecipients` (rows actually removed), and `notFoundRecipientIds` (submitted ids that matched no recipient on the order) so a caller holding a stale id can detect a no-op instead of receiving a bare success. ## `DELETE /v1/order/{id}/recipients` ### Parameters - `id` (path, required) — Order id. ### Request body Content type: `application/json` (required) - `recipientsIds` (array of integer (int32), required) ### Responses - `200` — Successful operation. Even when no submitted id matched (a no-op delete), the response is 200 with `deletedRecipients: 0` and every submitted id in `notFoundRecipientIds`. - `400` — Something went wrong - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `500` — Internal Server Error # Append recipients to an order (/api-reference/orders/v1-order-id-recipients-put) Adds recipients to an existing order without replacing the ones already on it. Accepts up to 5,000 recipients per request. The order must be editable — status Draft or Edits Needed; once a checkout has been accepted (Paid, Ready For Production, and later statuses) appends are rejected with a 400 invalid-status error, matching the recipient-delete and checkout operations. ## `PUT /v1/order/{id}/recipients` ### Parameters - `id` (path, required) — The Id of the order to add recipients to. ### Request body Content type: `application/json` (required) - `recipients` (array of `ExternalOrdersRecipientVm`, required) — List of recipients to be added to order - `id` (integer (int32), required) - `address` (`ExternalOrdersRecipientAddressVm`, required) — A recipient's mailing address. Supply these component fields, or supply `metadata.fullAddress` as a single line instead — the address requirement is satisfied by either. Addresses are validated and standardized before the piece is produced. - `address1` (string | null, required) — Street line. - `address2` (string | null, required) — Unit, suite or apartment. Omit when there is none. - `city` (string | null, required) — City name. - `state` (string | null, required) — State as the two-letter US abbreviation. A full state name is accepted and converted. - `zipCode` (string | null, required) — Five-digit ZIP code. - `zip4` (string | null, required) — ZIP+4 add-on, four digits. Read-only in practice: address validation fills this in, and supplying it does not change where the piece is delivered. - `zip2` (string | null, required) — The two-digit delivery-point code, set by address validation. Supplying it has no effect. - `country` (string | null, required) — Country. US spellings and US territory names are canonicalized; other values are passed through unchanged. Omit for US addresses. - `personal` (`ExternalOrdersRecipientPersonalDataVm`, required) — The recipient's name, as it appears in the address block and greeting. - `firstName` (string | null, required) — Recipient first name. - `lastName` (string | null, required) — Recipient last name. - `toOrganization` (string | null, required) — Business name, used when mailing to an organization rather than a person. - `salutation` (string | null, required) — How the recipient is addressed in the greeting. Duplicated at `metadata.salutation`; set it in one place, not both. - `metadata` (`ExternalOrdersRecipientMetadataVm`, required) — Per-recipient personalization, return-address override and validation data. - `dpvConfirmationCode` (string | null (enum: Y, N, S, D, null), required) — USPS Delivery Point Validation result for the recipient's address. Read-only: set by address validation, and ignored if supplied on a request. Empty until the address has been validated. - `custom1` (string | null, required) — Custom merge field 1, substituted wherever the template references it. - `custom2` (string | null, required) — Custom merge field 2, substituted wherever the template references it. - `custom3` (string | null, required) — Custom merge field 3, substituted wherever the template references it. - `custom4` (string | null, required) — Custom merge field 4, substituted wherever the template references it. - `custom5` (string | null, required) — Custom merge field 5, substituted wherever the template references it. - `custom6` (string | null, required) — Custom merge field 6, substituted wherever the template references it. - `image1` (string | null, required) — URL of an image for the template's variable image slot 1. Validated against the template's slots when the recipient is added; a URL that fails validation does not block the append — the template's default image is used for that slot at render time instead. - `image2` (string | null, required) — URL of an image for variable image slot 2. Same validation and fallback as `image1`. - `image3` (string | null, required) — URL of an image for variable image slot 3. Same validation and fallback as `image1`. - `image4` (string | null, required) — URL of an image for variable image slot 4. Same validation and fallback as `image1`. - `image5` (string | null, required) — URL of an image for variable image slot 5. Same validation and fallback as `image1`. - `image6` (string | null, required) — URL of an image for variable image slot 6. Same validation and fallback as `image1`. - `salutation` (string | null, required) — How the recipient is addressed in the greeting, for example "Dave" or "Mr. Smith". - `returnTitle` (string | null, required) — Return-address title, for example "Dr.". Part of the return-address override. - `returnFirstName` (string | null, required) — Return-address first name. Must be sent together with `returnLastName` — supplying one without the other is rejected rather than partially applied. - `returnLastName` (string | null, required) — Return-address last name. Must be sent together with `returnFirstName` — supplying one without the other is rejected rather than partially applied. - `returnOrganization` (string | null, required) — Return-address organization name. Part of the return-address override. - `returnAddress1` (string | null, required) — Return-address street line. The return-address override is all-or-nothing: supplying any of `returnAddress1`, `returnAddress2`, `returnCity`, `returnState` or `returnZip` requires `returnAddress1`, `returnCity`, `returnState` and `returnZip` to all be present. A partial override is rejected, not merged with the account's default return address. - `returnAddress2` (string | null, required) — Return-address second line (unit, suite). Optional even when overriding. - `returnCity` (string | null, required) — Return-address city. Required whenever any return-address field is supplied. - `returnZip` (string | null, required) — Return-address ZIP code. Required whenever any return-address field is supplied. - `returnState` (string | null, required) — Return-address state, as the two-letter US abbreviation. Required whenever any return-address field is supplied. - `email` (string | null, required) — Recipient email address, stored against the recipient for your own reconciliation. LettrLabs does not email the recipient as part of fulfilling a mail order. - `qrUrl` (string | null, required) — Destination URL for this recipient's QR code, used when the order has QR enabled. Each recipient receives a distinct tracked code, so scans attribute to the individual recipient. - `text` (string, required) — Primary variable text block for the mailpiece. Non-nullable — send an empty string rather than null when the template has no primary text. - `text2` (string, required) — Secondary variable text block. Non-nullable in the same way as `text` — send an empty string when unused. - `radiusPinStreet` (string | null, required) — Street name of the map pin, for radius-mail pieces that print a map. - `radiusPinAddress` (string | null, required) — Full address of the map pin, for radius-mail pieces that print a map. - `fullAddress` (string, required) — The recipient's address as a single unparsed line, and an accepted alternative to the component fields under `address`: when those are incomplete, supplying this satisfies the address requirement instead of failing the recipient. Non-nullable — send an empty string when you are supplying the component fields. - `deliveryStatus` (string | null (enum: Waiting on USPS Info, Mailed, In Transit, At Local Facility, Undeliverable, Delivered, No Tracking Available, null), required) — Latest carrier delivery status for the recipient. Read-only: set from tracking updates, and ignored if supplied on a request. Empty until the piece has mailed. ### Responses - `200` — Successful operation - `400` — Something went wrong — including appends to a non-editable order (invalid status) - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `500` — Internal Server Error # List templates (/api-reference/templates/v1-templates-get) Lists the authenticated account's active templates — the set usable as templateId when creating an order. Paginated; preview image URLs are time-limited. ## `GET /v1/templates` ### Parameters - `pageNumber` (query) — Page number (0-based). Defaults to 0. - `pageSize` (query) — Page size. Defaults to 100. ### Responses - `200` — Successful operation - `400` — Something went wrong - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `500` — Internal Server Error # Retrieve a template (/api-reference/templates/v1-templates-id-get) Returns one of the authenticated account's active templates: metadata and preview image URLs only (no design/scene definition). ## `GET /v1/templates/{id}` ### Parameters - `id` (path, required) ### Responses - `200` — Successful operation - `400` — Something went wrong - `401` — Authorization key not valid - `403` — Your subscription tier does not include API access. No response body is returned — do not attempt to parse one. - `404` — Template not found for this account - `500` — Internal Server Error # List integration orders (/api-reference/automations/v1-integration-orders-get) Returns the account's integration orders (automations) — standing orders that mail each recipient added to them — with their template and configuration details. ## `GET /v1/integration-orders` ### Responses - `200` — successful operation - `401` — Authorization key not valid - `403` — Your account does not have this feature enabled. No response body is returned — do not attempt to parse one. # Add a recipient to an integration order (/api-reference/automations/v1-integration-orders-id-recipients-post) Adds one recipient to an integration order, validating and standardizing the address before queueing the mail piece for that recipient. ## `POST /v1/integration-orders/{id}/recipients` ### Parameters - `id` (path, required) — ID of integration order to add recipient to. ### Request body Content type: `application/json` (required) - `id` (integer (int32), required) — Gets or sets the ID of the recipient. - `firstName` (string | null, required) — Gets or sets the first name of the recipient. - `lastName` (string | null, required) — Gets or sets the last name of the recipient. - `toOrganization` (string | null, required) — Gets or sets the organization of the recipient. - `address1` (string | null, required) — Gets or sets the address line 1 of the recipient. - `address2` (string | null, required) — Gets or sets the address line 2 of the recipient. - `city` (string | null, required) — Gets or sets the city of the recipient. - `state` (string | null, required) — Gets or sets the state of the recipient. - `zipCode` (string | null, required) — Gets or sets the zip code of the recipient. - `country` (string | null, required) — Gets or sets the country of the recipient. - `custom1` (string | null, required) — Gets or sets the custom field 1 of the recipient. - `custom2` (string | null, required) — Gets or sets the custom field 2 of the recipient. - `custom3` (string | null, required) — Gets or sets the custom field 3 of the recipient. - `custom4` (string | null, required) — Gets or sets the custom field 4 of the recipient. - `custom5` (string | null, required) — Gets or sets the custom field 5 of the recipient. - `custom6` (string | null, required) — Gets or sets the custom field 6 of the recipient. - `image1` (string | null, required) — Gets or sets the public image URL for variable image (Mail-Merge Image) slot 1. Resolved per recipient at send time; falls back to the template default when blank/invalid. - `image2` (string | null, required) — Gets or sets the public image URL for variable image (Mail-Merge Image) slot 2. Resolved per recipient at send time; falls back to the template default when blank/invalid. - `image3` (string | null, required) — Gets or sets the public image URL for variable image (Mail-Merge Image) slot 3. Resolved per recipient at send time; falls back to the template default when blank/invalid. - `image4` (string | null, required) — Gets or sets the public image URL for variable image (Mail-Merge Image) slot 4. Resolved per recipient at send time; falls back to the template default when blank/invalid. - `image5` (string | null, required) — Gets or sets the public image URL for variable image (Mail-Merge Image) slot 5. Resolved per recipient at send time; falls back to the template default when blank/invalid. - `image6` (string | null, required) — Gets or sets the public image URL for variable image (Mail-Merge Image) slot 6. Resolved per recipient at send time; falls back to the template default when blank/invalid. - `salutation` (string | null, required) — Gets or sets the salutation of the recipient. - `birthDay` (string | null (date-time), required) — Gets or sets the birthday of the recipient. - `anniversary` (string | null (date-time), required) — Gets or sets the anniversary of the recipient. - `returnTitle` (string | null, required) — Gets or sets the return title of the recipient. - `returnFirstName` (string | null, required) — Gets or sets the return first name of the recipient. - `returnLastName` (string | null, required) — Gets or sets the return last name of the recipient. - `returnOrganization` (string | null, required) — Gets or sets the return organization of the recipient. - `returnAddress1` (string | null, required) — Gets or sets the return address line 1 of the recipient. - `returnAddress2` (string | null, required) — Gets or sets the return address line 2 of the recipient. - `returnCity` (string | null, required) — Gets or sets the return city of the recipient. - `returnState` (string | null, required) — Gets or sets the return state of the recipient. - `returnZip` (string | null, required) — Gets or sets the return zip code of the recipient. - `qrUrl` (string | null, required) — Gets or sets the QR code URL of the recipient. - `email` (string | null, required) — Gets or sets the email address of the recipient. - `fullAddress` (string | null, required) — Gets or sets the full address of the recipient. ### Responses - `200` — successful operation. Adding a recipient already on the order is an idempotent no-op: it returns 200 with the order unchanged, not a rejection. - `400` — Something went wrong - `401` — Authorization key not valid - `403` — Your account does not have this feature enabled. No response body is returned — do not attempt to parse one. - `422` — Recipient rejected. The payload carries a stable machine-readable reasonCode — one of RETURN_ADDRESS_PARTIAL, RETURN_ADDRESS_MISSING_FIELDS, RETURN_NAME_PARTIAL, ORDER_NOT_AVAILABLE, ADDRESS_NOT_VALID, DO_NOT_MAIL, MISSING_MAIL_MERGE_FIELDS, ORDER_TEMPLATE_MISSING — and errors carries the human-readable message. recipientDetails is populated where the recipient was processed, and null for rejections issued before processing begins (RETURN_ADDRESS_PARTIAL, RETURN_ADDRESS_MISSING_FIELDS, RETURN_NAME_PARTIAL, and ORDER_NOT_AVAILABLE) — always null-check it. - `500` — Unexpected server error while processing the recipient — not caller-fixable; no reason code is returned. Retry later or contact support. # Request a proximity mailing (/api-reference/automations/v1-integration-orders-id-by-proximity-post) Finds addresses around a point or address using the supplied filters and queues them as recipients on the integration order. Recipient discovery runs asynchronously after the request is accepted. ## `POST /v1/integration-orders/{id}-by-proximity` ### Parameters - `id` (path, required) — ID of integration order add recipients to. ### Request body Content type: `application/json` (required) - `Name` (string, required) — Name for the proximity search - `OwnerOrRenterFilter` (integer (enum: 0, 1, 2), required) — If null - all types of owners will be returned If Owner, only owners will be returned if Renter, only renters will be returned - `PropertyTypeFilter` (integer (enum: 0, 1, 2), required) — If null - all property types will be returned If SingleFamily, only single family will be returned If MultiFamily, only multi family will be returned - `HouseHoldIncomeFilter` (`HouseholdIncome`, required) — If null - no filter around household income will be applied - `LengthOfResidenceFilter` (`LengthOfResidence`, required) — If null - no filter around length of residence will be applied - `YearBuiltFilter` (`YearBuilt`, required) — If null - no filter around year built will be applied - `HomePriceFilter` (`HomePrice`, required) — If null - no filter around home market value will be applied - `Addresses` (array of `Address`, required) — List of Addresses to search around - `address1` (string, required) — Required You can put a full address here, or split it out If you put the full address here, you don't need to fill out the other address fields - `address2` (string, required) — Optional - `city` (string, required) — Optional - `state` (string, required) — Optional - `zip` (string, required) — Optional - `fullAddress` (string | null) — Optional A full one-line address. When it is not empty, it wins over Address1, Address2, City, State and Zip - `DesiredCount` (integer (int32), required) — Requested number of records to send to - `MultiUse` (boolean | null, required) — If true - the recipients in this order can be used multiple times - `ReturnTitle` (string | null, required) — Optional return address override - title printed with the return name (e.g. Mr., Mrs.) Applies to every recipient generated by this call - `ReturnFirstName` (string | null, required) — Optional return address override - first name Must be provided together with ReturnLastName - `ReturnLastName` (string | null, required) — Optional return address override - last name Must be provided together with ReturnFirstName - `ReturnOrganization` (string | null, required) — Optional return address override - organization name - `ReturnAddress1` (string | null, required) — Optional return address override - street address line 1 Required when any other return address field is provided - `ReturnAddress2` (string | null, required) — Optional return address override - street address line 2 - `ReturnCity` (string | null, required) — Optional return address override - city Required when ReturnAddress1 is provided - `ReturnState` (string | null, required) — Optional return address override - state Required when ReturnAddress1 is provided - `ReturnZip` (string | null, required) — Optional return address override - zip code Required when ReturnAddress1 is provided ### Responses - `200` — successful operation - `400` — Parameters does not contain proper data. Check Errors from the response for more data. - `401` — Authorization key not valid - `403` — Your account does not have this feature enabled. No response body is returned — do not attempt to parse one. # Record a QR code scan (/api-reference/order-recipient-analytics/v1-order-recipients-statistics-post) Records a QR code scan event against an order recipient, updating that recipient's engagement statistics. Processing is asynchronous — the scan is queued and applied shortly after the request succeeds. ## `POST /v1/order-recipients-statistics` ### Request body Content type: `application/json` (required) - `orderId` (integer (int32), required) — The order the statistic belongs to. - `statisticName` (integer (enum: 0), required) — The statistics that can be recorded against an order recipient. Allowed values: - `0` = `QrCodeScanCount` - `statisticValue` (string, required) — The value to record, as a string that must parse as a whole number. A value that does not parse is rejected with a 400 naming the offending value. - `orderRecipientId` (integer (int32), required) - `orderRecipient` (`OrderRecipient`, required) - `id` (integer (int32), required) - `firstName` (string | null, required) - `lastName` (string | null, required) - `toOrganization` (string | null, required) - `fullAddress` (string | null, required) - `address1` (string | null, required) - `address2` (string | null, required) - `city` (string | null, required) - `state` (string | null, required) - `zipCode` (string | null, required) - `country` (string | null, required) - `createdDate` (string (date-time), required) - `qrCode` (string | null, required) - `qrUrl` (string | null, required) - `qrCodeScanCount` (integer (int32), required) - `customerEmail` (string | null, required) - `customerName` (string | null, required) - `customerOrganization` (string | null, required) - `salespersonName` (string | null, required) ### Responses - `200` — Accepted for processing. The response body is intentionally empty — the scan is queued and applied asynchronously, so there is no result to return. Do not parse a body on success. - `400` — Something went wrong - `401` — Authorization key not valid # Add an address book entry (/api-reference/address-book/v1-zapieractions-address-book-post) Adds a recipient to the account's address book, validating and standardizing the supplied address before saving it. ## `POST /v1/zapierActions/address-book` ### Request body Content type: `application/json` (required) - `firstName` (string, required) - `lastName` (string, required) - `toOrganization` (string, required) - `address1` (string, required) - `address2` (string, required) - `city` (string, required) - `state` (string, required) - `zip` (string, required) - `phoneNumber` (string | null, required) ### Responses - `200` — successful operation - `400` — Parameters does not contain proper data. Check Errors from the response for more data. - `401` — Authorization key not valid - `403` — Your account does not have this feature enabled. No response body is returned — do not attempt to parse one. - `500` — Internal Server Error # Create an address book entry (/api-reference/address-book/v1-address-book-post) Adds a recipient to the account's address book, validating and standardizing the supplied address before saving it. This is the canonical path for the address book. The older `/v1/zapierActions/address-book` path is retained for existing integrations and behaves identically. ## `POST /v1/address-book` ### Request body Content type: `application/json` (required) - `firstName` (string, required) - `lastName` (string, required) - `toOrganization` (string, required) - `address1` (string, required) - `address2` (string, required) - `city` (string, required) - `state` (string, required) - `zip` (string, required) - `phoneNumber` (string | null, required) ### Responses - `200` — successful operation - `400` — Parameters does not contain proper data. Check Errors from the response for more data. - `401` — Authorization key not valid - `403` — Your account does not have this feature enabled. No response body is returned — do not attempt to parse one. - `500` — Internal Server Error