Local emulators. Real API contracts. Part of Mockingbird.
Stateful emulator of the RxVortex (Strive) compounding-pharmacy API for test suites: the client-credentials token, order submit, status, cancel, the recovery lookup by sender order id, the preset catalog, and the signed status webhooks the pharmacy posts back. Orders move only when a test says so (an admin transition or an auto-advance path on the emulator clock), so an eRx suite that waited up to 60 s on the real sandbox resolves in milliseconds.
- Operation coverage: SUPPORT.md
- The vendor publishes no spec: the contract (
openapi.yaml) is hand-authored from the wire shapes our consumer reads and writes, and every field fallback it relies on is served.
Install
npm install -D @crvouga/mockingbird-service-rxvortexESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with
npx mockingbird-rxvortex serve, createServer from ./server (Node), or createRuntime with
any Fetch server.
Usage
Point RXVORTEX_API_URL at the emulator. Set RXVORTEX_WEBHOOK_SECRET in the app and pass the same
value as --webhook-secret.
npx mockingbird-rxvortex serve --port 8791 \
--webhook-url http://127.0.0.1:3000/prescriptions/webhooks/rxvortex \
--webhook-secret "$RXVORTEX_WEBHOOK_SECRET" \
--auto-advance "2000:Fill,Shipping,Delivered" \
--catalog ./preset-catalog.json--catalog loads your own preset catalog into every namespace: a JSON array of rows, or the
{data: [...]} body GET /api/v1/preset-catalog-items answers with, so a recorded sandbox
response loads as-is. Only catalog_id and medication_name are required. POST /__admin/reset
returns to these rows. Pass --unknown-presets accept to take any well-formed preset id instead.
import { createServer } from "@crvouga/mockingbird-service-rxvortex/server"
// Test setup: a local RxVortex that posts its status webhooks to the app.
const server = await createServer({
webhooks: { url: "http://127.0.0.1:3000/prescriptions/webhooks/rxvortex", secret: "whsec-test" },
})
const baseUrl = server.url // the app's RXVORTEX_API_URL
const post = (path: string, body: unknown, headers: Record<string, string> = {}) =>
fetch(`${baseUrl}${path}`, {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify(body),
})
// Application code: the requests it sends to RxVortex.
const { access_token } = (await (
await post("/api/v1/generate-access-token", { client_id: "acme", client_secret: "s" })
).json()) as { access_token: string }
// …then POST /api/v1/orders with Authorization: Bearer <access_token> and sender_order_id "pay_123"…
// Test control: move that order the way the pharmacy would; each step emits the signed status webhook.
await post("/__admin/orders/pay_123/transition", { to: "Shipping", trackingnumber: "1Z999" })
await post("/__admin/orders/pay_123/transition", { to: "Delivered" })
await server.close()Routes
| Route | Behaviour |
|---|---|
POST /api/v1/generate-access-token |
JSON {client_id, client_secret} → {access_token, token_type: "Bearer", expires_in: 86400}. Any pair works unless clients is set (PUT /__admin/settings). Tokens stay valid 24 h on the emulator clock; our client caches for 24 h and never refreshes on 401. |
POST /api/v1/orders |
Validates the submit payload against the contract; a violation is 422 {message, errors: {"patient.phone": ["…"]}}. An inactive or unknown preset_catalog_id is 422 (under unknownPresets: "accept" an unknown UUID becomes an active catalog row named after the request's medication_name). A repeated order.sender_order_id is 409. Success: {success: true, order_tracking_id: "RXV-…", sender_order_id, status: "Created"} (the tracking id is always a string). |
GET /api/v1/orders/{id} |
id is the tracking id or the sender order id (our payment id, the recovery lookup). Returns rxstatus, orderstatus, shipping_status, delivered_date, trackingnumber, shippingservice, shippingcarrier, shipmenttrackingurl, cancellable, and the id under order_tracking_id, tracking_id and orderReferenceID. |
DELETE /api/v1/orders/{id} |
Cancels while cancellable (until shipped), emitting the webhook; otherwise 409. |
GET /api/v1/preset-catalog-items |
{data: [...]} rows with catalog_id, medication_name, medication_strength, package_size, quantity, quantity_units, medication_form, route, states, status. Includes the custom-cream anchor preset e404ad76-0f82-4b04-8f25-841650e2e819 and one inactive row. |
Webhooks
Every status change posts {event: "order.status_updated", orderReferenceID, order_tracking_id, tracking_id, sender_order_id, rxstatus, orderstatus, shipping_status, delivered_date, trackingnumber, shippingcarrier, shippingservice, shipmenttrackingurl, updated_at} with header
x-rxvortex-webhook-secret: <secret> (plain equality, as our receiver checks). Non-2xx answers
are retried (immediately, 5 s, 5 min, 30 min, 2 h). GET /__admin/webhooks lists deliveries,
GET /__admin/webhooks/events the payloads, POST /__admin/webhooks/flush runs pending retries
now, and PUT /__admin/webhook-endpoints sets per-namespace receivers.
Admin (beyond the standard contract)
| Route | Effect |
|---|---|
POST /__admin/orders/:id/transition |
{to, trackingnumber?, shippingcarrier?, shippingservice?, delivered_date?}. to is a vendor status: Fill, PV1 Complete, Compound, Out of Stock, On Hold, Shipping, Delivered, Cancelled, Rejected, Error, or any string (used verbatim). The three status fields move together; shipping generates tracking when none is given. |
PUT /__admin/catalog |
{items: [{catalog_id, medication_name, medication_strength?, package_size?, quantity?, quantity_units?, medication_form?, route?, states?, status?}], mode?: "replace" | "merge"} for the calling namespace (replace is the default; merge upserts by catalog_id). Answers {count}, the namespace's row count. items may also be sent as data. |
GET /__admin/catalog |
The namespace's rows, as {data: [...]}. |
PUT /__admin/settings |
{tokenTtlSeconds?, staticTokens?, clients?, autoAdvance?: {afterMs, path} | null, unknownPresets?: "reject" | "accept"} for the calling namespace. unknownPresets defaults to reject, as the sandbox does. staticTokens admits RXVORTEX_API_TOKEN (the catalog client's static bearer). |
POST /__admin/tick |
Apply every auto-advance step that is due on the emulator clock (the served emulator also ticks every 100 ms). |
GET /__admin/orders |
The namespace's orders. |
Fault presets (POST /__admin/faults {"preset": "<name>", "count"?: n}; GET /__admin/faults/presets):
duplicate_sender_order_id, created_but_500 (creates, then 500; recovery succeeds),
numeric_tracking_id, token_expired, stale_error_with_delivered_date,
validation_errors_array, validation_errors_object, validation_errors_empty, server_error,
webhook_duplicate, webhook_reorder, webhook_drop.
Namespaces
Our backend's fetch cannot add headers, so a namespace can be chosen three ways:
x-mockingbird-namespace, a /__admin/ns/<name> prefix on RXVORTEX_API_URL, or by client id:
PUT /__admin/credentials {"credentials": {"<RXVORTEX_CLIENT_ID>": "<namespace>"}} (tokens carry
the client id they were issued to).
Deliberately not modelled
- Real fulfilment timing: nothing moves on its own unless
autoAdvanceis set. - Patient and prescriber details are validated, never stored or echoed back.
- The live catalog: the default rows are synthesised in the live client's field names (no
sandbox recording exists). Load recorded rows with
serve --catalog,PUT /__admin/catalog, or thecatalogoption. - Refills and multi-order shipments.
API
| Export | Kind | Description |
|---|---|---|
RxVortexAPI |
class | The in-process emulator: fetch(request), reset(), transition(id, {to, …}), tick(), orders(). Options: sqlite, now, namespace, catalog, settings, onWebhook. |
createRuntime |
function | The emulator with the full service contract (health, admin, namespaces, credentials, presets, webhooks). Options: webhooks: {url, secret, retryDelaysMs?, fetch?}, settings, catalog, tickMs, clock, seed, adminKey, onLog. |
RXVORTEX_PRESETS |
object | Every named fault preset. |
RXVORTEX_NAMESPACE |
string | The service name, "rxvortex". |
tokenCredential |
function | The client id a bearer token was issued to (how credentials map to namespaces). |
CUSTOM_CREAM_ANCHOR_PRESET_ID |
string | The sandbox custom-cream anchor preset id. |
DEFAULT_CATALOG |
array | The default preset catalog rows. |
parseCatalog, parseCatalogItem |
functions | Catalog rows from a JSON array or a recorded {data: [...]} body (missing optional fields default to null, states to [], status to "active"); throw on a malformed row. |
document, operationIds, supportedOperationIds |
values | The vendored OpenAPI contract and its operation ids. |
createServer, serveTarget, DEFAULT_PORT, loadCatalogFile (./server) |
Node | Serve over node:http (auto-advance ticks every 100 ms); the serve CLI target; port 8791; read a --catalog file. |
Part of mockingbird.
