Renders a letter template to a PDF through docman and mails it through Lob. Delivery status arrives by webhook and is tracked per mailing.
Use it when a notice has to reach someone on paper — because email and SMS failed, or because the notice is one a regulator expects to have been posted. For email and SMS see Email & SMS.
API reference: https://lob-send.tawa.pro/api/docs
import { LobSendClient } from '@insureco/lob-send'
const lobSend = LobSendClient.fromEnv() // reads LOB_SEND_URL + BIO_CLIENT_ID/SECRET
// Let a person confirm the address and the letter before it goes out
const url = lobSend.confirmUrl({
system: 'policypay-collection',
id: collection._id,
label: collection.insuredName,
to: { name, addressLine1, addressCity, addressState, addressZip },
returnUrl: `https://yourapp/collections/${collection._id}`,
})
// …or drive it yourself
const draft = await lobSend.draft({ templateId, data, to, externalRef })
const sent = await lobSend.send(draft._id)
// Status for your own screen
const mailings = await lobSend.listFor('policypay-collection', collection._id)
# catalog-info.yaml
spec:
auth:
mode: sso # provisions BIO_CLIENT_ID + BIO_CLIENT_SECRET
dependencies:
- service: lob-send
That injects LOB_SEND_URL (a Janus /i/lob-send URL, gas-metered). Deploy
once so Bio-ID provisions your OAuth client.
IMPORTANT: This step is not optional and nothing works until it is done.
A Bio-ID client_credentials token proves which client is calling, but it
carries no organisation and no roles — only client_id, scope, aud, iss.
lob-send therefore keeps its own registry mapping your verified client id to an
org and a scope set. Until an admin registers you, every call returns:
{ "success": false, "error": {
"message": "Service client client_ed55… is not registered with lob-send. An admin must register it and grant scopes before it can be used.",
"details": { "clientId": "client_ed55…" }
}}
Send that client id (tawa oauth list, or read it out of the 403) to a lob-send
admin. They register it at https://lob-send.tawa.pro/app/service-clients.
| Scope | Grants |
|---|---|
mail:read | List and read mailings |
mail:send | Draft and send — spends money, mails paper |
mail:templates | Create and edit letter templates |
mail:admin | Full administration |
Revocation takes effect on your next request; the registry is read per request, not cached.
| Deep link | API | |
|---|---|---|
| Who confirms the address | A person, on lob-send's screen | Your code |
| Work in your app | One link, one status panel | Full draft → confirm → send |
| Right for | Anything a person triggers | Batch or automated sends |
Most callers want the deep link — the confirmation screen (PDF preview, address correction, USPS deliverability) is already built.
const url = lobSend.confirmUrl({
system: 'policypay-collection', // your system
id: collection._id, // your record
label: collection.insuredName, // shown in listings
to: {
name: collection.insuredName,
addressLine1: collection.address1,
addressCity: collection.city,
addressState: collection.state,
addressZip: collection.zip,
},
returnUrl: `https://yourapp/collections/${collection._id}`,
})
Open it in a tab or modal. Nothing is mailed until the user presses Send.
const draft = await lobSend.draft({
templateId,
data: { insuredName, policyNumber, noticeDate },
to: { name, addressLine1, addressCity, addressState, addressZip },
externalRef: { system: 'policypay-collection', id: collection._id },
})
// draft.document.publicUrl — the rendered PDF
// draft.addressVerification.deliverability — show before sending
const sent = await lobSend.send(draft._id, {
to: correctedAddress, // optional; re-verified server-side
overrideUndeliverable: false, // required if not confirmed deliverable
})
send is idempotent — the mailing id is Lob's Idempotency-Key, and the
draft → queued transition is atomic, so a retry or a double-click cannot
produce a second physical letter.
draft → queued → sent → in_transit → in_local_area
→ processed_for_delivery → delivered
↘ returned_to_sender
const mailings = await lobSend.listFor('policypay-collection', collection._id)
// each carries: status, timeline[], lob.expectedDeliveryDate
Status advances from Lob webhooks. If one is missed, lobSend.refresh(id)
replays Lob's tracking history and brings the mailing current. Don't poll it on
a timer — webhooks are the mechanism; refresh is for reconciling.
import { LobSendError } from '@insureco/lob-send'
try {
await lobSend.send(mailingId)
} catch (err) {
if (err.isUnauthorised) // 401/403 — not registered, or lacks mail:send
if (err.isBilling) // 402 — the billing org is out of gas
if (err.isConflict) // 409 — already sent; NOT an error to retry
if (err.status === 400 && err.code === 'undeliverable')
// show the user, then resend with overrideUndeliverable: true if they insist
}
import { MockLobSendClient } from '@insureco/lob-send/testing'
const lobSend = new MockLobSendClient()
const draft = await lobSend.draft({ ... })
await lobSend.send(draft._id)
expect(lobSend.sent).toHaveLength(1)
The mock enforces the real contract — a mailing cannot be sent twice, an
undeliverable address needs an explicit override, a blank externalRef.id is
rejected — so a passing test reflects production. Use
new MockLobSendClient({ deliverability: 'undeliverable' }) to exercise your
override path. reset() between tests.
WARNING: Lob's test mode returns
undeliverablefor every address, including known-good ones. It is not real USPS data.
lob-send runs against a Lob test key unless configured otherwise, so test
sends always need overrideUndeliverable. It says nothing about the address.
Real deliverability signal only appears with a live key.
| Wrong | Right |
|---|---|
Calling send in a retry loop on a 409 | 409 means it already went out — stop |
Polling refresh on a timer | Webhooks drive status; refresh reconciles |
| Sending without showing the address to a human | Use confirmUrl, or render the verification result yourself |
| Storing your own copy of the letter PDF | docman versions it; fetch by mailing id |
Passing an empty externalRef.id | It is how a mailing is found again — always set it |
Treating draft as free | It renders a PDF through docman and costs gas; list and get do not |
externalRef { system, id } is how a mailing is tied back to your record —
listFor(system, id) is the whole status-panel queryX-API-Key; fromEnv() picks it up from LOB_SEND_API_KEY. Prefer the
Bio-ID path — it is per-service, individually scoped, and revocableLast updated: August 18, 2026