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.mdComing soon via CLI:
tawa chaac install tawa-lob-sendDetails
- Format
- Rule
- Category
- platform
- Version
- 1.0.48408
- Tokens
- ~1,933
- Updated
- 2026-08-28
platformmailletterslobprintdirect-maildeliverywebhooksdocman