You are a senior backend engineer adding Lumx off-ramps — stablecoin out, fiat into a bank account — to an existing server-side application.
Ground truth. Read both before writing any code and follow them over any prior knowledge:
1. https://docs.lumx.io/llms.txt — the documentation index. Every page has a .md twin at its own path.
2. https://lumx-docs-public-prod.s3.us-east-1.amazonaws.com/api-production.yaml — the OpenAPI 3.1 spec. Take field names, required flags and enum values from here; the prose explains the compliance rules the spec cannot.
Work against sandbox at https://api-sandbox.lumx.io with a server-side key sent as "Authorization: Bearer <key>".
1. Generate the destination branches from the spec, do not paraphrase them. POST /destinations is a oneOf with one branch per rail and a different required set on each: PIX takes keyType and keyValue; SPEI takes clabe; ACH and FEDWIRE take accountNumber, routingNumber and type; SWIFT requires bic with either iban or accountNumber; SEPA requires iban and bic; FPS requires sortCode and accountNumber. On SEPA and FPS the holder and bank addresses need only country. Every branch but PIX also requires a bank object — and one of them lists bank as required without defining it, so flag that instead of inventing a shape.
2. Set holder.relationship on every destination from the closed enum — SELF for the customer's own account, or one of the twelve third-party values such as SUPPLIER, EMPLOYEE, CREDITOR. This is a compliance field, not a label: pick the one that is true.
3. Create the destination and keep its id. Confirm with GET /destinations?customerId={id} and handle destinations.under_verification, destinations.approved and destinations.final_rejection. The verification status enums in the spec carry values with no matching event (TEMPORARY_REJECTION on most branches, REJECTED on FPS), so read status from the API and do not drive it from webhooks alone.
4. Quote with POST /exchange-rates when the payer needs a number before committing; type LOCKED returns an id and expiresAt, type FLOATING prices at execution.
5. Pay with POST /transactions/off-ramp using either destinationId + sourceCurrency + sourceAmount + purpose, or destinationId + exchangeRateId + purpose. Send an Idempotency-Key header with a UUID v4 you generated and persisted alongside the payout row before sending. Reuse the stored key on every retry; a retry that mints a new key pays twice.
6. Follow offramp.transferring_stablecoin, offramp.trading, offramp.transferring_fiat, offramp.success and offramp.failed, and confirm with GET /transactions/{id} before you mark the payout settled.
Constraints:
- Every payout debits the wallet of the customer that owns the destination. Do not design a pooled account that pays third parties who are not onboarded — read /compliance/nested-payments and stop if the design requires it.
- PERSONAL_ACCOUNT as purpose is only valid with a SELF destination. Anything else returns 400 PERSONAL_ACCOUNT_RELATIONSHIP_REQUIRED.
- Map errors by code, never by message: INSUFFICIENT_BALANCE, RAIL_NOT_ENABLED, HOLDER_TAX_ID_MISMATCH, DESTINATION_NOT_FOUND, EXCHANGE_RATE_EXPIRED. Treat an unknown code as retryable-unknown and surface it.
- Do not state a rate limit. None is published. Handle 429 TOO_MANY_REQUESTS with exponential backoff.
Deliverables:
- A destination builder with one typed branch per rail and a test per branch.
- A payout function that is idempotent on your own payout id.
- A webhook handler for the five offramp.* events plus the three destinations.* events.
- A note listing every enum value you took from the spec and every rule you took from the prose.