Simulators
Lob simulator
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
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 .
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 ).
First calls
Read the postcards sent to one client, save a new address and read it back:
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:
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 shows whether their requests arrive.
No client has been checked against this simulator yet.
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 returns the simulator’s own 404, and a malformed JSON body reads as an empty one; both depart from Lob’s errors (Limitations).
Rate limits
The simulator counts no requests, so it never throttles on its own (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 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. Paging the address list departs from Lob (Limitations).
/_rystic/page-cap 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).
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).
16 routes, counted from the simulator’s route table: 8 listed below, and 8 more that serve the same operations at other paths.
Show all operations
| 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} |
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.
Scenarios
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:
rystic run lob-twin -d --port 0 --scenario design_studio
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"design_studio"}'
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, 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}:
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 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
Each feature has its own page:
- Dashboard: 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: A postcard’s journey after the send: USPS scans you record, and the cancellation window that closes the day after its send date.
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).
Reset and determinism covers reset, the clock and the RNG.
Limitations
Known issues.
- A failed postcard can be canceled inside its send window, where Lob refuses the cancel with 422
not_deletableand keeps it listed. - Some of Lob’s input checks differ in the simulator. An
Idempotency-Keysent in both the header and the query is accepted, and so aremerge_variableskeys with spaces or special characters. A date-onlydate_createdfilter matches nothing. A baduse_typebeside an unknowntoreturns 404, where Lob returns 422. - An inline
toorfromaddress on a postcard joins the address book, and the postcard embeds it withdeletedfalseand, with no country sent, a null country. Lob keeps it out withdeletedtrueandUNITED STATES.expected_delivery_datecounts 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
offsetparameter and a scalar include. - Following
next_urlthrough 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_endpointerror. A malformed JSON body reads as an empty one, where Lob returnsbad_request. - A postcard stays cancelable to the end of its
send_dateday (UTC), where Lob refuses a cancel oncesend_datehas passed. A deleted address returns 404, where Lob reads it back withdeletedtrueand 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-remainingalways reads 149, andratelimit-resetalways 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-databody reads the body as empty and returns 422 for its first required field, where Lob accepts multipart bodies.
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.
Show all limitations
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
By design.
- Nothing is rendered, printed or mailed.
url,raw_urland 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
statusprocessed, and it stays there. To test tracking, record scans or bootdesign_studio. To test a laterstatus, seed it. - 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/egressis always empty.
Release notes: Changelog