# Lob simulator

A local Lob for mail-sending code: addresses and postcards, idempotent sends and the cancellation window.

`lob-twin` is a local, stateful simulator of Lob's print-and-mail API for testing code that sends mail. Its addresses and postcards keep Lob's validation, cancellation window and idempotent replay, and wherever they depart from Lob, a line marked **Differs from Lob:** sits beside that behavior or Limitations lists it. It never calls Lob, renders no artwork and prints or mails nothing.

## Quickstart

<!-- BEGIN generated: quickstart (go run ./ingest/cmd/twindocs lob; do not edit) -->

Install the CLI, sign in and boot the simulator with `RYSTIC_URL` pointing at it.

On Pro you sign in with an API key from [your account](https://www.rystic.ai/account/api-keys).

```bash
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_…   # your API key
export RYSTIC_URL=$(rystic run lob-twin -d --port 0 --scenario design_studio --json | jq -r .data.url)
curl -s "$RYSTIC_URL/_rystic/version"
# -> {..."product":"lob-twin"...}
```

On Enterprise, the licensed edition signs in with a license file in place of the key: `rystic login ./rystic-license.json` ([licensing](https://www.rystic.ai/docs/licensing)).

<!-- END generated: quickstart -->

### First calls

Read the postcards sent to one client, save a new address and read it back:

```bash
curl -s -g -u test_anything: "$RYSTIC_URL/postcards?metadata[client]=silverline-cycles" | jq -c '{count, last_scan: [.data[].tracking_events[-1].name]}'
# -> {"count":2,"last_scan":["Returned to Sender","Delivered"]}
curl -s -u test_anything: "$RYSTIC_URL/addresses" -d name='Harry Zhang' -d address_line1='210 King St' -d address_city='San Francisco' -d address_state=CA -d address_zip=94107 | jq -c '{id, address_city, address_zip}'
# -> {"id":"adr_0000000000000001","address_city":"SAN FRANCISCO","address_zip":"94107-1702"}
curl -s -u test_anything: "$RYSTIC_URL/addresses/adr_0000000000000001" | jq -c '{id, name}'
# -> {"id":"adr_0000000000000001","name":"HARRY ZHANG"}
```

## Connect your client

Point the `lob_python` library at `$RYSTIC_URL` with any non-empty key. Python:

```python
import os
import lob_python

configuration = lob_python.Configuration(
    username="test_anything",
    host=os.environ["RYSTIC_URL"],
)
```

Lob's other libraries take a base URL too; [the live feed](https://www.rystic.ai/docs/concepts/observing.md#live-feed) shows whether their requests arrive.

<!-- BEGIN generated: known-clients (go run ./ingest/cmd/twindocs lob; do not edit) -->

No client has been checked against this simulator yet.

<!-- END generated: known-clients -->

## Auth

Lob takes your key as the HTTP Basic username, and the simulator accepts any non-empty key there and ignores the password (`curl -u test_anything:`).

| You send | The simulator returns |
|---|---|
| no `Authorization` header, or one under another scheme such as `Bearer` | 401 `Missing authentication` on every API route, before it reads anything else |
| a Basic header with an empty username, or a payload that is not base64 | the route's own 401 `unauthorized` error, whose message differs by route |

## API version

The simulator serves Lob's API v1, at `/v1/` and at the same paths with no prefix, so a client's host override may keep `/v1` or drop it.

A `Lob-Version` header is accepted and ignored: every response keeps the same shapes, whatever version you name, so there is nothing to pin.

**Differs from Lob:** Lob refuses an unsupported `Lob-Version` with 422 `unsupported_lob_version`.

## Errors and limits

### Error format

A Lob error body is `{"error": {…}}`, holding `message`, `status_code` and `code`. [An unserved route](https://www.rystic.ai/docs/control-plane.md#status-codes) returns the simulator's own 404, and a malformed JSON body reads as an empty one; both depart from Lob's errors ([Limitations](#limitations)).

### Rate limits

The simulator counts no requests, so it never throttles on its own ([Limitations](#limitations)). Creating an address or a postcard, a replay included, returns the `ratelimit-limit`, `ratelimit-remaining` and `ratelimit-reset` headers with fixed values. A refusal and every other request return none. [Network profiles](https://www.rystic.ai/docs/network-profiles.md) send a 429 you shape, such as Lob's `rate_limit_exceeded`.

### Pagination

The address and postcard lists take `limit`, `before` and `after`. `limit` defaults to 10.

| You send | The simulator returns |
|---|---|
| a `limit` that is not an integer from 1 to 100 | 422 `invalid` |
| both `before` and `after` | 422 `invalid` |
| a postcard's id as a postcard-list `after` | the postcards listed after it, older ones, without the cursor's own |
| a postcard's id as a postcard-list `before` | the postcards listed before it, newer ones, without the cursor's own |
| an address id as an address-list cursor | 422 `invalid`: this list takes only the token its `next_url` or `previous_url` carries |
| a postcard cursor naming no listed postcard | 422 `invalid` |

A list returns `data`, `count` (the page's length), `next_url` and `previous_url`. `include[]=total_count` adds `total_count`, which counts every match. `next_url` and `previous_url` are [links that lead back to the simulator](https://www.rystic.ai/docs/boot-configuration.md#environment-variables). Paging the address list departs from Lob ([Limitations](#limitations)).

[`/_rystic/page-cap`](https://www.rystic.ai/docs/control-plane.md#routes-by-plan) caps `limit`; name the route without `/v1`, as in `GET /addresses`.

### Idempotency

`POST /postcards` honors an `Idempotency-Key` header or an `idempotency_key` query parameter, so your library's retries are safe. A replay under a stored key returns the original postcard and sends no second one. The replay comes before validation, so a replay with another body returns the original too. `POST /addresses` takes no key. Key checks and key expiry depart from Lob ([Limitations](#limitations)).

## Coverage

The simulator covers Lob's address book and postcards. Letters, checks, self-mailers, campaigns, templates, address verification, webhooks and live mode are out of scope. Creating an address or a postcard reads a form-encoded or JSON body ([Limitations](#limitations)).

<!-- BEGIN generated: served-operations (go run ./ingest/cmd/twindocs lob; do not edit) -->

<!-- coverage-claim -->
16 routes, counted from the simulator's route table: 8 listed below, and 8 more that serve the same operations at other paths.

| Resource | Operations |
|---|---|
| Address (4) | `GET, POST /v1/addresses`; `GET, DELETE /v1/addresses/{adr_id}` |
| Postcard (4) | `GET, POST /v1/postcards`; `GET, DELETE /v1/postcards/{psc_id}` |

<!-- END generated: served-operations -->

<!-- BEGIN generated: feed-effects (go run ./ingest/cmd/twindocs lob; do not edit) -->

**Feed effects.** No write emits a feed effect; watch requests instead. Each call appears on the event feed (`/_rystic/events`) as a `request` event, and reading the record back shows what it changed.

<!-- END generated: feed-effects -->

## Scenarios

<!-- BEGIN generated: scenarios (go run ./ingest/cmd/twindocs lob; do not edit) -->

The simulator ships 1 scenario; the quickstart boots `design_studio`.

| Scenario | What it is for | What it seeds | Clock |
|---|---|---|---|
| `design_studio` (default) | Website Design Contractors' account | address 39, idempotency_key 63, postcard 63 | 2026-03-01T00:00:00Z |

What each scenario holds:

- **`design_studio`**:
  - The Portland, ME office as return address, 38 client addresses, and 63 first-class postcards off two template pairs — holiday-2025 to 34 clients on 2025-12-05, launch-thanks after each of 29 launches — tagged metadata[campaign] and metadata[client].
  - All delivered but the holiday-2025 card to Silverline Cycles, returned to sender.
  - One replay key per send.

Boot one by name, or load one into a running simulator:

```bash
rystic run lob-twin -d --port 0 --scenario design_studio
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"design_studio"}'
```

<!-- END generated: scenarios -->

### What the scenarios do not hold

- No postcard still waiting to be mailed: every seeded postcard has finished its tracking, so create one to test a card inside its cancellation window.
- No template or artwork: the simulator keeps a postcard's template ids and merge variables, not what they draw.

[`design_studio` is one company across several simulators](https://www.rystic.ai/docs/environments.md#one-company-across-simulators), with the same clients and dates in each.

### What happens as the clock moves

`design_studio` has a timeline that plays as the clock passes `2026-03-01T00:00:00Z`. Each step is a call on the API under the key `test_wdc0design0studio0key000000000`:

| After the start | The studio |
|---|---|
| 1 hour | saves Harborview Dental's second office |
| 2 hours | sends a spring-checkin card to Redwood Realty under `Idempotency-Key: spring-checkin-redwood-realty` |
| 3 hours | sends one to Atlas Moving, scheduled for `2026-03-09` |
| 24 hours | cancels the Atlas Moving card |
| 48 hours | lists the campaign's cards |

## Seeding state

A seed takes three registers: `state_address` and `state_postcard` key each record by its `id`, and `state_idempotency_key` keys each by its `idempotency_key`. Seed an address under the id `adr_seed1`, then read it back with `GET /addresses/{id}`:

```bash
curl -s -X POST "$RYSTIC_URL/_rystic/seed" -d '{"state_address":{"adr_seed1":{"id":"adr_seed1","object":"address","name":"SEED RECIPIENT","address_line1":"185 BERRY ST","address_city":"SAN FRANCISCO","address_state":"CA","address_zip":"94107"}}}'
curl -s -u test_anything: "$RYSTIC_URL/addresses/adr_seed1" | jq -c '{id, name, address_zip}'
# -> {"id":"adr_seed1","name":"SEED RECIPIENT","address_zip":"94107"}
```

[Seeding](https://www.rystic.ai/docs/concepts/worlds.md#seeding) covers the seed route's empty 204, the 400s it returns for what it refuses, boot files and the seed gate. No Lob register marks a field required, so the seed gate never flags a record as incomplete, and a field the register does not list is stored as written.

A seeded postcard's `to` and `from` may be address ids, which the simulator resolves once the seed merges:

| The id names | The postcard's `to` or `from` |
|---|---|
| a seeded address | becomes that address's object, as a created postcard embeds it |
| no address | stays as written in the list, and the simulator's log (`rystic logs <name>`) warns of it once. Reading the postcard by id returns a stub address object carrying the id. |

A seeded record missing `object`, or a seeded postcard missing `carrier`, takes Lob's value.

## Features

<!-- BEGIN generated: features (go run ./ingest/cmd/twindocs lob; do not edit) -->

Each feature has its own page:

- [Dashboard](https://www.rystic.ai/docs/simulators/lob/features/dashboard.md): The mail account drawn as Lob's dashboard in your browser, redrawn as your code calls the API, with the dashboard's own actions.
- [Mail stream](https://www.rystic.ai/docs/simulators/lob/features/mail-stream.md): A postcard's journey after the send: USPS scans you record, and the cancellation window that closes the day after its send date.

<!-- END generated: features -->

## Determinism and reset

Ids created through the API are a sequence (`adr_0000000000000001`, `psc_0000000000000001`).

**Differs from Lob:** Lob's ids are `adr_` or `psc_` plus random hex, while the ids the simulator creates are a zero-padded counter so a run repeats. Treat ids as opaque.

These requests move the clock one millisecond, and a request refused with 401 never does:

| Request | Moves the clock |
|---|---|
| create a postcard | yes, even when refused with 404 or 422 or replayed under its key |
| create an address | when it succeeds |
| read a postcard by id | when the postcard exists |
| cancel a postcard | when the postcard exists and has a `send_date`, even once its window has closed |
| record a scan | when the body has a `postcard` and a listed `name`, even if the scan is then refused |
| read an address by id | only when the address was seeded without `date_created` |
| delete an address, or either list | never |

Moving the clock drives:

- a scenario timeline's steps, as above;
- a postcard's cancellation window, which closes the day after its `send_date` ([Mail stream](https://www.rystic.ai/docs/simulators/lob/features/mail-stream.md)).

[Reset and determinism](https://www.rystic.ai/docs/concepts/reset-and-determinism.md) covers reset, the clock and the RNG.

## Limitations

<!-- BEGIN generated: known-gaps (go run ./ingest/cmd/twindocs lob; do not edit) -->

**Known issues.**

- A failed postcard can be canceled inside its send window, where Lob refuses the cancel with 422 `not_deletable` and keeps it listed.
- Some of Lob's input checks differ in the simulator. An `Idempotency-Key` sent in both the header and the query is accepted, and so are `merge_variables` keys with spaces or special characters. A date-only `date_created` filter matches nothing. A bad `use_type` beside an unknown `to` returns 404, where Lob returns 422.
- An inline `to` or `from` address on a postcard joins the address book, and the postcard embeds it with `deleted` `false` and, with no country sent, a null country. Lob keeps it out with `deleted` `true` and `UNITED STATES`. `expected_delivery_date` counts calendar days, not business days.
- Lob refuses these inputs with 422, and the simulator accepts them: an unknown parameter, over-limit metadata, a description over 255 characters, an unknown country code and an idempotency key over 256 characters. The same holds for an invalid list filter value, an `offset` parameter and a scalar include.
- Following `next_url` through the address list skips records, and a before cursor on it returns 422. The postcard list pages completely.
- An unserved route returns the simulator's own 404, where Lob returns its `unrecognized_endpoint` error. A malformed JSON body reads as an empty one, where Lob returns `bad_request`.
- A postcard stays cancelable to the end of its `send_date` day (UTC), where Lob refuses a cancel once `send_date` has passed. A deleted address returns 404, where Lob reads it back with `deleted` `true` and returns 200 for a second delete.
- No request rate is enforced, where Lob counts 150 requests per 5 seconds per endpoint and returns 429 past it. `ratelimit-remaining` always reads 149, and `ratelimit-reset` always reads 5 rather than the epoch seconds Lob sends.
- An idempotency key never expires, where Lob's expire after 24 hours.
- Creating an address or a postcard with a `multipart/form-data` body reads the body as empty and returns 422 for its first required field, where Lob accepts multipart bodies.

<!-- gaps-claim -->
Beyond the known issues, 97 of the 105 operations in the published spec are not served, 18 documented responses have no test yet and 10 behaviors are not modeled. The details follow.

**Not served.** 97 of the 105 operations in the published spec are not served.

- Not served at all: Accounts (1), Bank Accounts (5), Billing Groups (4), Booklets (4), Buckslip Orders (2), Buckslips (5), Campaigns (6), Card Orders (2), Cards (5), Checks (4), Creatives (3), Identity Validation (1), Informed Delivery Campaign (4), Intl Verifications (2), Letters (4), QR Codes (1), Resource Proofs (3), Reverse Geocode Lookups (1), Self Mailers (4), Snap Packs (4), … and 7 more

**Not tested.** 32 tests compare the simulator with the real service. 18 documented responses have no test yet, and 9 of them need a credential our test account doesn't hold (401 ×8, 403 ×1). The other 9:

| Operation | Status | Why |
|---|---:|---|
| `DELETE /v1/addresses/{adr_id}` | 429 | no request a client sends provokes it |
| `DELETE /v1/postcards/{psc_id}` | 422 | no known request reaches it |
| `DELETE /v1/postcards/{psc_id}` | 429 | no request a client sends provokes it |
| `GET /v1/addresses` | 429 | no request a client sends provokes it |
| `GET /v1/postcards` | 429 | no request a client sends provokes it |
| `GET /v1/postcards/{psc_id}` | 429 | no request a client sends provokes it |
| `POST /v1/addresses` | 429 | no request a client sends provokes it |
| `POST /v1/postcards` | 404 | no known request reaches it |
| `POST /v1/postcards` | 429 | no request a client sends provokes it |

**Not modeled.** Behavior the simulator does not model, by operation:

- 403 responses for premium-edition gating (feature_limit_reached / publishable_key_not_allowed) — documented in the error code enum but not tied to this operation by the spec. (`DELETE /v1/postcards/{psc_id}`)
- 500/503 server-side failures. (`DELETE /v1/postcards/{psc_id}`, `POST /v1/postcards`)
- Exact cursor encoding of before/after tokens (base64 of dateOffset/idOffset) and the precise next_url/previous_url construction. (`GET /v1/addresses`)
- Semantics of include values other than total_count. (`GET /v1/addresses`)
- 413 payload-too-large for oversized multipart artwork uploads. (`POST /v1/postcards`)
- Asynchronous transition of status processed -> rendered/failed (no API operation drives it). (`POST /v1/postcards`)
- billing_group_id behavior (requires special account activation). (`POST /v1/postcards`)
- … and 3 more

<!-- END generated: known-gaps -->

**By design.**

- **Nothing is rendered, printed or mailed.** `url`, `raw_url` and the thumbnail links are placeholders, and the simulator produces no PDF or image.
- **A postcard does not move on its own.** Creating a postcard returns it with `status` `processed`, and it stays there. To test tracking, [record scans](https://www.rystic.ai/docs/simulators/lob/features/mail-stream.md#record-a-scan) or boot `design_studio`. To test a later `status`, [seed it](https://www.rystic.ai/docs/simulators/lob/features/mail-stream.md#seed-a-later-stage).
- **No general address verification.** Creating an address stores the standardized address it returns, and reads return the stored values. The simulator reproduces Lob's result for 210 King St, San Francisco, with ZIP+4 94107-1702. It has no USPS lookup or deliverability check, so other addresses get no verified ZIP+4.
- **No webhooks.** No tracking or render event is delivered, including the scans a scenario records, and `GET /_rystic/egress` is always empty.

Release notes: [Changelog](https://www.rystic.ai/docs/simulators/lob/changelog.md)

---

Source: https://www.rystic.ai/docs/simulators/lob · Markdown: https://www.rystic.ai/docs/simulators/lob.md
