← Back to Skills

Sending Physical Mail

Renders a letter template to a PDF through docman and mails it through [Lob](https://lob.com). Delivery status arrives by webhook and is tracked per mailing.

Ruleplatform

About

Renders a letter template to a PDF through docman and mails it through [Lob](https://lob.com). Delivery status arrives by webhook and is tracked per mailing.

Skill Content

This is the raw markdown that gets installed as a Claude Code rule.

# Sending Physical Mail

Renders a letter template to a PDF through docman and mails it through
[Lob](https://lob.com). 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](/reference/relay).

**API reference:** <https://lob-send.tawa.pro/api/docs>

## The Short Version

```javascript
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

```yaml
# 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

**This step is not optional and nothing works without it.**

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:

```json
{ "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.

## Two Integration Shapes

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

### Deep link

```javascript
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

```javascript
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
```

```javascript
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

```javascript
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

```javascript
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

lob-send runs against a Lob **test** key unless configured otherwise, and Lob's
test mode returns `undeliverable` for **every** address — including known-good
ones. Test sends therefore always need `overrideUndeliverable`. It says nothing
about the address. Real deliverability signal only appears with a live key.

## What NOT to Do

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

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

Install

Copy the skill content and save it to:

~/.claude/rules/tawa-lob-send.md
Download .md

Coming soon via CLI:

tawa chaac install tawa-lob-send

Details

Format
Rule
Category
platform
Version
1.0.48408
Tokens
~1,933
Updated
2026-08-28
platformmailletterslobprintdirect-maildeliverywebhooksdocman