Concepts
The testSpeed knob
Compress, simulate, or skip real-world wait times for async test-mode operations. The X-Matter-Test-Speed header tells the service layer how to behave when test-mode work would normally take minutes or days.
Last updated
Real Delaware filings take 1–3 business days. Real ACH transfers settle T+1 to T+3. Real EIN issuance can take a week. Test mode gives you a knob to compress these waits to seconds, fractions of a second, or skip them entirely — without changing the shape of any other request.
The header
Every async test-mode endpoint accepts X-Matter-Test-Speed:
| Value | Behaviour |
|---|---|
instant (default) | Run the pipeline inline; the response body is already in the terminal state. CI / demos / fast iteration. |
fast | Run the pipeline inline with a 1-second setTimeout delay. Lets the dashboard observe pending → completed transitions during local dev. |
real | Enqueue a BackgroundJob with a delay calibrated to real-world turnaround. The cron worker picks it up and emits the corresponding webhooks at that cadence. Used to stress-test customer webhook handlers against a realistic async cadence without paying real-world wait time. |
Live-mode bearers passing this header receive 400 invalid_header_for_live_mode.
The header has no effect on synchronous endpoints (e.g. POST /v1/entities
returns 201 inline regardless of speed).
Which endpoints honour it
Every service that has an async pipeline implements the same knob. The
pattern is intentional — once you know how submit-entity handles it,
you know how bank-transfer handles it.
| Resource | Endpoint | Real-world cadence | Pipeline summary |
|---|---|---|---|
| Entity | POST /v1/entities/{id}/submit | 1–3 business days | DemoFilingProvider stubs file number + EIN; Entity.externalStatus walks pending → registered. |
| BankAccount | POST /v1/banking/accounts/{id}/verify (planned) | Micro-deposits typically 1–2 days | Simulator marks status: active immediately under instant. |
| BankTransfer | POST /v1/banking/transfers | ACH T+1, wire same-day | Source debit + destination credit happen in one Prisma transaction under instant; balances visible on next read. |
| Filing | POST /v1/filings (planned) | Varies per SoS | Same as Entity submit. |
Endpoints not in this table either complete synchronously (no real-world wait to simulate) or aren't yet wired through the knob (tracked in the project's banking / board / mail rewrites).
How it works under the hood
Service-layer functions accept testSpeed as a request-body field
(translated from the header by the HTTP shell). The default is instant
for test-mode bearers; live-mode bearers ignore the field and always
enqueue.
// inside @repo/banking-service/src/create-transfer.ts (excerpt)
const testSpeed = args.auth.livemode ? undefined : body.testSpeed ?? "instant";
const willSimulateInline = !args.auth.livemode;
if (willSimulateInline) {
// Adjust balances atomically inside the same transaction as the
// transfer row's status update. Source decrement, destination
// increment — both balances are correct on the response.
await tx.bankAccount.update({ … decrement … });
await tx.bankAccount.update({ … increment … });
return tx.bankTransfer.update({ … settled … });
}
// Live path: stop at `submitted`. The queue worker drives `settled`
// once the upstream provider confirms.The three speeds aren't three completely separate code paths — they're
the same path with a different executeAfter on the BackgroundJob row.
instant skips the queue entirely; fast runs the queue handler
inline with a 1s delay; real schedules the job with a delay that
matches real-world turnaround for that resource type.
Adding the knob to a new resource service
When you add an async pipeline to a new resource, follow this checklist:
- Add
testSpeed: z.enum(["real", "fast", "instant"]).optional()to the create-request Zod schema. - In the service function:
- Compute
testSpeed = auth.livemode ? undefined : body.testSpeed ?? "instant". - Branch on
willSimulateInline = !auth.livemode(which istruewhenevertestSpeedresolved to anything — only live mode hits the queue unconditionally). - Under
instant, write the terminal state inline in the same transaction. - Under
fast, wrap the same write insetTimeout(1000). - Under
real, enqueue a BackgroundJob withexecuteAfter = now + <real-world delay>.
- Compute
- In the HTTP shell:
- Read
X-Matter-Test-Speedfrom the request header. - Reject with
400 invalid_header_for_live_modewhenauth.livemode === true && header set. - Forward the header value as the request-body
testSpeedfield into the service call.
- Read
- In the BackgroundJob handler:
- The handler is the same code regardless of speed — it's just invoked at different cadences. The handler is mode-aware and calls the right provider (DemoFilingProvider / DemoBankingSimulator / LiveFilingProvider).
The pattern is in packages/entity-service/src/submit-entity.ts.
Lift it for new services rather than re-inventing the dispatch logic.
See also
- Test, sandbox & live mode — the overall mode contract.
- Webhooks — how the speed knob interacts with the webhook fan-out (deliveries fire when the simulator settles, just like live).
- Idempotency — replaying the same key under different speeds is still a replay; the key is the source of truth for "have I processed this?", not the speed.