Sending Physical Mail

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

The Short Version

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)

Setup

# 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.

Your Service Must Be Registered

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.

ScopeGrants
mail:readList and read mailings
mail:sendDraft and send — spends money, mails paper
mail:templatesCreate and edit letter templates
mail:adminFull administration

Revocation takes effect on your next request; the registry is read per request, not cached.

Two Integration Shapes

Deep linkAPI
Who confirms the addressA person, on lob-send's screenYour code
Work in your appOne link, one status panelFull draft → confirm → send
Right forAnything a person triggersBatch or automated sends

Most callers want the deep link — the confirmation screen (PDF preview, address correction, USPS deliverability) is already built.

Deep link

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.

API

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.

Tracking Delivery

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.

Errors Worth Handling

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
}

Testing

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.

Test Mode Is Not Real USPS Data

WARNING: Lob's test mode returns undeliverable for 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.

What NOT to Do

WrongRight
Calling send in a retry loop on a 409409 means it already went out — stop
Polling refresh on a timerWebhooks drive status; refresh reconciles
Sending without showing the address to a humanUse confirmUrl, or render the verification result yourself
Storing your own copy of the letter PDFdocman versions it; fetch by mailing id
Passing an empty externalRef.idIt is how a mailing is found again — always set it
Treating draft as freeIt renders a PDF through docman and costs gas; list and get do not

Key Facts

  • externalRef { system, id } is how a mailing is tied back to your record — listFor(system, id) is the whole status-panel query
  • Templates live in lob-send, not docman; docman is a pure HTML→PDF renderer for it, so template authoring and version history stay in one place
  • Every sent letter records a Septor audit event, as does every delivery outcome — a mailing is evidence that notice was given
  • Legacy services that cannot mint a Bio-ID token may present a shared X-API-Key; fromEnv() picks it up from LOB_SEND_API_KEY. Prefer the Bio-ID path — it is per-service, individually scoped, and revocable
  • Machine callers can never edit their own registration, so a service cannot widen its own access

Last updated: August 18, 2026