# Verdant Dock API - guide to the main flows

Verdant Dock is a fictional shop (self-driving "AutoPlanter" dome planters, rare plants, books and accessories) built
as a realistic system under test. Nothing is real: no goods, no charges, no deliveries. Automated testing and
moderate load are welcome. Use invented data only.

This guide is written so that a person or an LLM can build working requests (Postman, curl, k6, LoadRunner...)
without reading the whole API description. Every step names the OpenAPI `operationId`.

- API base URL: `https://api.verdant-v1.testthis.now` (all paths below start with `/v1`)
- Full API description (OpenAPI 3.2.1, 111 operations, real examples): https://shop.verdant-v1.testthis.now/openapi-3.2.json
  (YAML: https://shop.verdant-v1.testthis.now/openapi-3.2.yaml). The same description as OpenAPI 3.0.3, for tools that
  don't read 3.2 yet (Postman import): https://shop.verdant-v1.testthis.now/openapi.json and /openapi.yaml.
- The main flows as executable Arazzo 1.1.0 workflows: https://shop.verdant-v1.testthis.now/arazzo.yaml (JSON: /arazzo.json).
  Run them with an Arazzo runner, e.g.
  `npx @redocly/cli respect https://shop.verdant-v1.testthis.now/arazzo.yaml -w fullJourney -i email=arazzo.<n>@verdant-v1.testthis.now -i staffEmail=arazzo.staff.<n>@verdant-v1.testthis.now`
- Postman collection that runs these flows end to end:
  https://shop.verdant-v1.testthis.now/postman/verdant-dock.postman_collection.json
  and environment https://shop.verdant-v1.testthis.now/postman/verdant-dock.postman_environment.json

## Conventions

- JSON in and out (`Content-Type: application/json`). Errors are RFC 7807 `application/problem+json` with a stable
  `code` (for example `VALIDATION_FAILED`, `PAYMENT_DECLINED`) and, for validation errors, `errors: [{field, message}]`.
- Money is integer cents, AUD, GST-inclusive (`totalCents: 263900` = $2,639.00). GST is total / 11, rounded.
- Dates are `YYYY-MM-DD` in Australia/Melbourne time. Times are ISO 8601 UTC.
- Ids: `usr_...`, `adr_...`, `ord_...`, `C-yymmdd-XXXXX` (cases), `CL-...` (claims). Order numbers `VD-yymmdd-XXXXX`.
- Optional headers: `X-Correlation-Id` (echoed back, for tracing) and `X-Test-Partition: <tag>` (groups accounts
  created from several load generators so staff can see them - see "Staff visibility").
- Access tokens last 15 minutes. A 401 with `code: TOKEN_EXPIRED` means: call `refreshToken` and retry once.
- Every response names its trace: `X-Vd-Trace-Id` (and `Server-Timing: app;dur=<ms inside the handler>`); an error
  body has `traceId`. See "Observability" at the end for reading a trace or a whole test run back.

## Authentication model

Three kinds of bearer token, each only accepted where it belongs:

| Token | Comes from | Use for |
|---|---|---|
| `enrolmentToken` | `verifyEmail` (or `login` before MFA is set up) | `startMfaEnrolment`, `activateMfa` only |
| `mfaToken` | `login` | the `mfaToken` field of `loginMfa` (body, not a header) |
| `accessToken` + `refreshToken` | `activateMfa`, `loginMfa`, `refreshToken` | `Authorization: Bearer <accessToken>` on customer calls |

Staff (`/v1/staff/...`) and warehouse (`/v1/warehouse/...`) use the same pattern with their own tokens; customer
tokens are rejected there and vice versa.

### TOTP (two-step verification is mandatory)

RFC 6238: HMAC-SHA1, 6 digits, 30-second time step. The key is the base32 `secret` returned by `startMfaEnrolment`.
A code can be used once per account: a second sign-in in the same 30-second step needs the next code (wait), or a
recovery code (`recoveryCodes` from `activateMfa`, sent as `recoveryCode` instead of `code`).

JavaScript (Node, or Postman with crypto-js):

```js
function totp(base32Secret, epochMs = Date.now()) {
  const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let bits = '';
  for (const c of base32Secret.replace(/=+$/, '').toUpperCase()) bits += A.indexOf(c).toString(2).padStart(5, '0');
  const key = Buffer.from(bits.match(/.{8}/g).map((b) => parseInt(b, 2)));
  const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(Math.floor(epochMs / 30000)));
  const h = require('crypto').createHmac('sha1', key).update(msg).digest();
  const o = h[h.length - 1] & 15;
  return String((h.readUInt32BE(o) & 0x7fffffff) % 1000000).padStart(6, '0');
}
```

Python: `import pyotp; pyotp.TOTP(secret).now()`.

No code? `getTestTotp` - `GET /v1/test-tools/totp?secret=<base32>` returns `{ "code", "nextCode", "secondsRemaining" }` for a
secret you already hold (it reveals nothing you couldn't compute). The API also accepts `nextCode` (one step of clock
drift), so use `code` to activate MFA and `nextCode` for a sign-in straight afterwards.

### Email codes: the test mailbox

Sign up with any address at `@verdant-v1.testthis.now` (for example `ava.1760000000@verdant-v1.testthis.now`). No
inbox is needed: read the mail with `getLatestMail`:

`GET /v1/test-mailbox/latest?to=<address>&subject=verify&wait=10` ->

```json
{ "messageId": "0101019a...", "to": "ava.1760000000@verdant-v1.testthis.now", "subject": "Verify your email - code 643217",
  "receivedAt": "2026-10-12T01:02:03.000Z", "code": "643217", "links": [], "text": "Hi Ava, ... Code: 643217 ...", "html": "...",
  "attachments": [] }
```

Mail with attachments (order confirmations carry the invoice PDF) lists each one:
`{ "index": 0, "filename": "INV-261012-RJXYE.pdf", "contentType": "application/pdf", "size": 8140, "sha256": "...",
"stored": true, "downloadUrl": "<API>/v1/test-mailbox/attachment?to=...&messageId=...&index=0", "textUrl": "<downloadUrl>&format=text" }`.

`wait` (seconds, max 10) long-polls for the mail; `subject` filters (`verify`, `password`, `confirmed`, `code`).
No key is needed unless the stack was deployed with `MailboxAccess=key` (then send `X-Mailbox-Key`).

## Flow 1 - customer sign-up

1. `signup` - `POST /v1/auth/signup`
   `{ "email": "ava.1760000000@verdant-v1.testthis.now", "password": "Plant3r!Dock2026", "givenName": "Ava", "familyName": "Nguyen", "mobile": "0412 345 678", "acceptTerms": true }`
   -> `202 { "status": "VERIFICATION_SENT" }`. Password: 12+ characters with upper, lower, digit and symbol.
2. `getLatestMail` - `GET /v1/test-mailbox/latest?to=<email>&subject=verify&wait=10` -> read `code`.
3. `verifyEmail` - `POST /v1/auth/verify-email` `{ "email": "...", "code": "643217" }` -> `200 { "enrolmentToken": "..." }`
4. `startMfaEnrolment` - `POST /v1/auth/mfa/enrol` with `Authorization: Bearer <enrolmentToken>`, no body
   -> `200 { "secret": "B7QEWYQMONL3URJQ...", "otpauthUri": "otpauth://...", "qrSvg": "<svg..." }`. Keep `secret`.
5. `activateMfa` - `POST /v1/auth/mfa/activate`, Bearer enrolmentToken, `{ "code": "<totp(secret)>" }`
   -> `200 { "status": "ACTIVE", "recoveryCodes": [...8], "accessToken": "...", "refreshToken": "..." }`. Signed in.

## Flow 2 - sign in and refresh

1. `login` - `POST /v1/auth/login` `{ "email", "password" }` -> `200 { "status": "MFA_REQUIRED", "mfaToken": "..." }`
2. `loginMfa` - `POST /v1/auth/login/mfa` `{ "mfaToken": "...", "code": "<totp>" }` (or `"recoveryCode": "CHQM-W85C"`)
   -> `200 { "accessToken", "refreshToken", "expiresIn": 900 }`
3. `refreshToken` - `POST /v1/auth/token/refresh` `{ "refreshToken": "..." }` -> new pair. Refresh tokens rotate:
   always keep the newest; replaying an old one ends the session.
4. `logout` - `POST /v1/auth/logout` (Bearer) -> `204`; the access token stops working at once.
   Five wrong passwords lock the account for 15 minutes.

## Flow 3 - truck order (planter + plant)

Rules: every plant needs an AutoPlanter of the same size in the same order (`PLANT_REQUIRES_PLANTER`); at most 4
planters per order; quantity 0-4 per line. Truck delivery only to Victorian addresses within 150 km of Melbourne.

1. `listCatalogue` - `GET /v1/catalogue` (no sign-in; `?type=PLANTER|PLANT|ACCESSORY`). SKUs:
   - `VD-AP-S`, `VD-AP-M`, `VD-AP-L` - AutoPlanter Small / Medium / Large ($1,490 / $2,390 / $3,990)
   - `VD-PL-DB-S|M|L` Dragon's Blood Tree, `VD-PL-MT-S|M|L` Monstera 'Thai Constellation', `VD-PL-BB-S|M|L` Grandidier's Baobab
   - `VD-BK-CT` - AutoPlanter Coffee Table Book ($89); `VD-NC-DLX` - Deluxe Nutrient Cartridge ($349)
2. `putCartItem` - `PUT /v1/cart/items/VD-AP-M` `{ "quantity": 1, "coverPlan": "VC-2Y" }` (cover optional:
   planters `VC-2Y|VC-3Y|VC-5Y`, plants `TC-1Y|TC-2Y`; insuring a planter and a plant earns a free expert check-in).
   Then `PUT /v1/cart/items/VD-PL-MT-M` `{ "quantity": 1, "coverPlan": "TC-1Y" }`. The quantity you send is the new
   line quantity (not an increment); 0 removes the line.
3. `createAddress` - `POST /v1/me/addresses`
   `{ "label": "Home", "line1": "12 Fern Street", "suburb": "Glen Waverley", "state": "VIC", "postcode": "3150", "phone": "0412 345 678", "siteAccess": "GROUND", "instructions": "Side gate, code 4821" }`
   -> `201 { "addressId": "adr_...", "serviceArea": { "serviceable": true, ... } }`. Suburb + state + postcode must be a
   real Australian locality (`listLocalities?postcode=3150` lists them).
4. `quoteCheckout` - `POST /v1/checkout/quote` `{ "addressId": "adr_..." }`
   -> `{ "canCheckout": true, "cartVersion": 2, "totals": { "totalCents": 263900, ... }, "delivery": { "method": "TRUCK", ... } }`
5. `listDeliverySlots` - `GET /v1/delivery/slots?addressId=adr_...`
   -> `{ "method": "TRUCK", "days": [ { "date": "2026-10-14", "closed": false, "windows": [ { "code": "AM", "available": true }, ... ] } ] }`.
   Pick the first day with `closed: false` and a window with `available: true` - or simply use
   `firstAvailable: { "date": "2026-10-14", "window": "AM" }` (null if nothing is free).
6. `placeOrder` - `POST /v1/orders` with header `Idempotency-Key: <8-64 letters, digits, - or _, new per order>`
   `{ "addressId": "adr_...", "cartVersion": 2, "expectedTotalCents": 263900, "delivery": { "date": "2026-10-14", "window": "AM" }, "payment": { "nameOnCard": "Ava Nguyen", "cardNumber": "4111 1111 1111 1111", "expiryMonth": "12", "expiryYear": "2030", "cvv": "123" } }`
   -> `201` order with `orderId`, `orderNumber`, `status: CONFIRMED`, `delivery.truck`, `invoice.invoiceNumber`.
   Mock cards: any Visa/Mastercard number passing Luhn with a future expiry; a CVV ending in 0 is declined
   (`402 PAYMENT_DECLINED`). `cartVersion`/`expectedTotalCents` are optional guards (409 if the cart or price changed).
   Replaying the same Idempotency-Key returns the stored response with `Idempotent-Replayed: true`.
   A successful order empties the cart.
7. `getOrder` - `GET /v1/orders/{orderId}`; `getInvoicePdf` - `GET /v1/orders/{orderId}/invoice.pdf` (PDF marked
   NOT A REAL TAX INVOICE, GST 10%); `listOrders` - `GET /v1/orders`.
8. Confirmation email: `getLatestMail` with `subject=confirmed` -> `attachments[0]` is the invoice PDF;
   `getMailAttachment` - `GET /v1/test-mailbox/attachment?to=<email>&messageId=<messageId>&index=0&format=text` returns
   its text as JSON (drop `format=text` for the PDF itself), one line per row, items separated by two spaces:

   ```
   NOT A REAL TAX INVOICE
   ...
   Verdant Dock (fictional)  Invoice number  INV-261012-RJXYE
   Distribution Centre, 40 Logistics Drive, Dandenong South VIC 3175  Invoice date  2026-10-12
   ABN 51 824 753 556  Order number  VD-261012-RJXYE
   ...
   Total excluding GST  $2,399.09
   GST (10%)  $239.91
   Total including GST  $2,639.00
   ```

   The invoice number is the order number with `INV-` in place of `VD-`.

## Flow 4 - courier order (books and accessories only)

If the cart contains only accessories (`VD-BK-CT`, `VD-NC-DLX`), delivery is by courier: flat $25 (2500 cents) to any
Australian address, no delivery slot, no technician. `quoteCheckout` returns `delivery.method: "COURIER"` with
`dispatchDate` and `estimatedDate`; `listDeliverySlots` returns `method: "COURIER"` with no days; `placeOrder` needs
no `delivery` object (anything sent is ignored) and the order gets `delivery.trackingNumber` (`SPC...`). With a
planter or plant in the same order, accessories go on the truck instead.

Example: empty the cart (`clearCart` - `DELETE /v1/cart`), `PUT /v1/cart/items/VD-BK-CT` `{ "quantity": 1 }`, then
`createAddress` `{ "label": "Perth office", "line1": "10 Hay Street", "suburb": "Perth", "state": "WA", "postcode": "6000" }`
(`siteAccess` and `instructions` are optional and only matter for truck deliveries), `quoteCheckout`, and
`placeOrder` `{ "addressId": "adr_...", "payment": { ... } }`.

## Flow 5 - after the order

- `updateDeliveryDetails` - `PATCH /v1/orders/{orderId}/delivery-details` `{ "recipientName"?, "phone"?, "instructions"? }`
  (truck: until the day before delivery; courier: until dispatch; afterwards `409 DELIVERY_DETAILS_LOCKED`).
- `cancelOrder` - `POST /v1/orders/{orderId}/cancel` `{ "reason": "..." }` (truck: up to 2 business days before
  delivery; courier: before dispatch) -> `200` with `status: CANCELLED` and a `refund`.

### The test clock (see the future without waiting)

`setTestClock` - `PUT /v1/test-clock` `{ "offsetDays": 12 }` moves the business date for this session only.
Orders then show their projected status (`DISPATCH_SCHEDULED`, `OUT_FOR_DELIVERY`, `DELIVERED`, flagged
`projected: true`) and cover becomes active after delivery. Nothing projected is saved or emailed. Every customer
response carries `X-Business-Date` and `X-Simulated-Offset-Days`. `resetTestClock` - `DELETE /v1/test-clock`; signing
in again also resets it. Security time (tokens, TOTP, lockouts) never moves.

### Cover, check-ins and claims

1. With the clock past delivery: `listPolicies` - `GET /v1/policies` (one policy per insured unit, `ACTIVE`).
2. Free expert check-in (planter + plant both insured): `getCheckin`, `listCheckinSlots`, `bookCheckin`
   `{ "date", "window": "AM|PM" }`, `cancelCheckinBooking`.
3. Claims: `listClaims` (`showTab: true` once a policy is active) -> `getClaimForm` (`GET /v1/policies/{policyId}/claim-form`)
   -> for each photo `createClaimUpload` `{ "fileName": "dome.png", "contentType": "image/png", "size": <bytes> }`
   -> upload the file with an HTTP form POST (multipart/form-data) to the returned `url`, sending every returned
   `fields` entry as a form field and the file last as `file` (S3 presigned POST: JPEG/PNG/WebP/HEIC, max 10 MB)
   -> `createClaim` `{ "policyId", "incidentType": "ACCIDENTAL_DAMAGE", "incidentDate": "<on or after the cover start>", "description": "...", "stillUsable": "PARTLY", "contactPhone": "0412 345 678", "bestTime": "AM", "addressId", "declaration": true, "uploadIds": ["upl_..."] }`
   -> `201 { "claimId": "CL-..." }` -> `getClaim`. One claim per policy.

## Flow 6 - support cases

- `createCase` - `POST /v1/cases` `{ "type": "SUPPORT|ENQUIRY|COMPLAINT", "subject": "...", "message": "...", "orderId"? }` -> `201 { "caseId": "C-..." }`
- `replyToCase` `{ "message" }`, `getCase`, `listCases`, `closeCase`, `reopenCase` (within 7 days of closing).
- By email: `sendTestMailToSupport` - `POST /v1/test-mailbox/send` `{ "from": "<address @verdant-v1.testthis.now>", "subject", "text" }`
  sends a real email to support-verdant-v1@testthis.now, which opens (or, with `[C-...]` in the subject, updates) a case.
- AI assistant (signed-in customers): `chat` - `POST /v1/chat` `{ "messages": [ { "role": "user", "content": "When is my delivery?" } ] }`
  -> `{ "reply", "conversationId" }`; send the whole conversation each time; `endChat` files the transcript as a case.

## Flow 7 - staff portal and warehouse

Staff enrolment mirrors customer sign-up under `/v1/staff/...`: `staffEnrol` (`{ email, password, givenName,
familyName, jobTitle, acceptTerms }`) -> `getLatestMail` (`subject=code`) -> `staffVerifyEmail` -> `staffStartMfaEnrolment`
-> `staffActivateMfa` -> staff tokens. Then: `staffOverview`, `staffListCustomers`, `staffGetCustomer`, `staffListOrders`,
`staffGetOrder`, `staffListDeliveries`, `staffListCases`, `staffReplyToCase` `{ "message", "status"? }`,
`staffSetCaseStatus`, `staffListClaims`, `staffSetClaimStatus` `{ "status": "INVESTIGATING|ACCEPTED|REJECTED", "comment" }`,
`staffOrderReplacement` `{ "date", "window", "comment" }` (accepted claims; a $0 replacement order).

Warehouse (`/v1/warehouse/...`) uses the same workforce account: `warehouseLogin` -> `warehouseLoginMfa`; if the
account lacks warehouse access the reply is `APP_ACCESS_AVAILABLE` with a `grantToken` -> `warehouseGrantAccess`
`{ "grantToken", "confirm": true }`. Then `warehouseGetSchedule` (`?from=YYYY-MM-DD&days=7`) and `warehouseSetTrucks`
`{ "date", "trucks" }`.

### Staff visibility

Staff only see customers whose account activity came from the same network as the staff member (same IPv4 address,
or the same IPv6 /64) - so create the staff user from the machine that created the customers. Load tests that run
customers from several generators can send `X-Test-Partition: <tag>` on every customer and staff request instead;
everything with the same tag is visible together. Out-of-view customers return 404.

## Observability - read back what your requests did

Every request is traced with OpenTelemetry into AWS X-Ray, and the traces are public (cleaned, by id only):

- `X-Vd-Trace-Id` on every response is the trace of that request; `X-Vd-Trace-Sampled: 1` means Lambda also
  recorded its own segments (Init on a cold start, Overhead), `X-Vd-Auth-Trace-Id` (protected routes) is the JWT
  authorizer's own trace. `GET /v1/observe/traces/{traceId}` (`getTraces`; several ids comma-separated) returns the
  trace as JSON - where the time went, what stands out (cold starts, slow or repeated calls, throttles), every step -
  or `202` with `status: pending` while X-Ray is still receiving it (retry after 10 s). The same page for people:
  https://shop.verdant-v1.testthis.now/observe.html#/trace/{traceId}.
- Send `X-Vd-Run: <run id>` (3-64 letters, digits, `.`, `:`, `-`, `_`) with every request of a test run, plus
  `X-Vd-Action: <id>` to group the requests of one step and `X-Vd-Interaction: <label>` to name it, and read the run
  back with `GET /v1/observe/runs/{runId}` (`getRun`): per-route p50/p95/max, every step with its traces, the
  outliers. Page: https://shop.verdant-v1.testthis.now/observe.html#/run/{runId}. The Postman collection does this.
- `GET /v1/observe` (`getObservability`) lists the headers and links. 60 lookups a minute per IP address; 30 days.

## Common errors

| Status | code | Meaning / fix |
|---|---|---|
| 400 | `MALFORMED_JSON` | Body is not a JSON object |
| 401 | `TOKEN_EXPIRED` | Refresh the access token and retry |
| 401 | `MFA_CODE_INVALID` | Wrong or reused TOTP code: wait for the next 30 s step |
| 402 | `PAYMENT_DECLINED` | CVV ended in 0 (mock gateway) |
| 409 | `SLOT_UNAVAILABLE`, `CART_CHANGED`, `PRICE_CHANGED` | Re-read slots / cart / quote |
| 409 | `CANCELLATION_NOT_ALLOWED`, `DELIVERY_DETAILS_LOCKED` | Too close to delivery (or already dispatched) |
| 422 | `VALIDATION_FAILED` | See `errors[].field` |
| 422 | `CART_INVALID`, `ADDRESS_NOT_SERVICEABLE` | Plant without planter; truck address outside the area |
| 423 | `ACCOUNT_LOCKED` | Too many wrong passwords; wait 15 minutes |
| 428 | `IDEMPOTENCY_KEY_REQUIRED` | `placeOrder` needs an `Idempotency-Key` header |
| 429 | `RATE_LIMITED`, `RESEND_TOO_SOON` | Slow down (sign-up, login and resend limits; deployments for load tests may turn them off) |
