Offer gold and silver in your app
Lumos lets your customers buy, hold and sell physical gold and silver, fully backed by metal held in a secure vault in the UAE. You keep the customer and the experience. Lumos handles prices, the metal, the records and the reconciliation.
How it works
Your server talks to Lumos. Your mobile app talks only to your server and never holds a Lumos secret. A typical buy is three calls: get a quote, place it, and confirm payment. Lumos then records the metal against your customer and tells your server about every change by webhook.
You build
Your screens, customer onboarding and KYC, taking your customers' payments and paying their sells, and a webhook endpoint.
Lumos provides
Live prices, quotes, metal allocation, the ledger, holdings, invoices, daily settlement statements and webhooks.
Coming next
A hosted page you can open from your app for buy and sell screens, and a mobile SDK. Both use the same account and keys.
How money moves
Money moves one way only: through you. Your customer pays you for a buy, and you pay your customer for a sell. A customer never pays Lumos and is never paid by Lumos.
| Step | Who | What happens |
|---|---|---|
| Buy | You and your customer | You take the payment in your own wallet, card or bank flow, then complete the buy with your paymentReference. |
| Sell | You and your customer | You pay your customer, then complete the sell with your paymentReference. |
| Settlement | You and Lumos | Lumos sends a statement every day. The net of buys and sells is paid between you and Lumos, in whichever direction it falls, on the schedule agreed for your account. It can be settled by you or by a company that settles for you. |
Getting started
- Get your keysLumos sends you a
clientIdand aclientSecretfor the sandbox, through a secure channel. Keep the secret on your server only. Production keys follow once you pass the go-live checklist. - Share your webhook URLAn HTTPS endpoint on your server that accepts
POSTrequests. You can change it later. - Make a signed callCall
GET /v1/partner. A200with your settings means your signing works. The Postman collection does this for you. - Handle webhooks and errorsVerify webhook signatures, de-duplicate events, and handle every error in Errors.
- Go liveWork through the go-live checklist with your Lumos contact.
Environments
| Environment | Base URL | Money |
|---|---|---|
| Sandbox | Sent with your sandbox keys | Test only. Prices move like the market; no metal or money changes hands. |
| Production | Sent with your production keys | Real metal and real money. |
All paths in this guide start with /v1 and are added to the base URL.
Conventions
| Topic | Rule |
|---|---|
| Format | JSON request and response bodies, UTF-8. Send Content-Type: application/json on any call with a body. Unknown request fields are ignored. |
| Money | A string in AED with 2 decimals, for example "100.00". Never send money as a floating-point number. |
| Metal | A string in grams with 6 decimals, for example "0.210062". Metal is Gold or Silver. |
| Rounding | Grams are rounded down to the nearest 0.000001 g. When a customer asks for a quantity, the AED amount is rounded up to the nearest fils. Always show the customer the values Lumos returns. |
| Times | ISO 8601 in UTC, for example 2026-10-19T11:47:30.513Z. Settlement days follow Dubai time (UTC+4). |
| Dates | YYYY-MM-DD. Countries use two-letter ISO 3166-1 codes, for example AE. |
| Customers | You address customers by your own identifier. It is unique within your account. |
| Lists | page (from 1) and limit query parameters. Responses include a pagination object with page, limit, total and totalPages. |
| Request ID | Every response carries a requestId. Quote it when you contact Lumos about a call. |
The legacy price fields price24K, sellPrice24K, silverPrice and sellPriceSilver are numbers in AED per gram, kept for display only. Quotes always use strings.
Signing requests
Every request carries three headers. Lumos rejects a request with a wrong signature, an unknown client, or a timestamp more than 300 seconds from Lumos's clock.
| Header | Value |
|---|---|
X-Client-Id | Your client ID |
X-Timestamp | Current Unix time in seconds, for example 1792400000 |
X-Signature | Lowercase hex HMAC-SHA256 of the string to sign, keyed with your client secret |
The string to sign
Five lines joined by a single newline (\n), with no newline at the end:
- The HTTP method in upper case, for example
POST. - The path only, without the host or query, for example
/v1/buy/preview. - The query string with its
key=valuepairs sorted by key (then by value), joined by&, values exactly as sent. An empty line if there is no query. - The SHA-256 hex digest of the exact body bytes you send. For a call with no body, the digest of the empty string.
- The same timestamp you send in
X-Timestamp.
Hash the exact bytes you send. If your HTTP client re-serialises JSON after you sign it, the signature will not match. Serialise once, sign that string, and send that string.
Worked example
With the test secret sk_test_example_do_not_use and timestamp 1792400000, this request:
POST /v1/buy/preview
{"customerIdentifier":"cust-1042","metal":"Gold","amount":"100.00"}has this string to sign (the third line is empty because there is no query):
POST
/v1/buy/preview
c7014d25957683fa0361479d4edf508328dbb9337686377f9aa0f00806fa9429
1792400000and this signature:
f038e3e1458de3c0469f5e9709591715599ba5298462add50d7ae42149b2027aNode.js
import crypto from 'node:crypto';
export function signRequest(secret, method, path, query, body, timestamp) {
const sortedQuery = query
? query.split('&').filter(Boolean)
.map((p) => { const i = p.indexOf('='); return i < 0 ? [p, ''] : [p.slice(0, i), p.slice(i + 1)]; })
.sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) : a[0] < b[0] ? -1 : 1))
.map(([k, v]) => `${k}=${v}`).join('&')
: '';
const bodyHash = crypto.createHash('sha256').update(body ?? '', 'utf8').digest('hex');
const toSign = [method.toUpperCase(), path, sortedQuery, bodyHash, timestamp].join('\n');
return crypto.createHmac('sha256', secret).update(toSign, 'utf8').digest('hex');
}
// Usage
const body = JSON.stringify({ customerIdentifier: 'cust-1042', metal: 'Gold', amount: '100.00' });
const ts = String(Math.floor(Date.now() / 1000));
const res = await fetch(`${BASE_URL}/v1/buy/preview`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': CLIENT_ID,
'X-Timestamp': ts,
'X-Signature': signRequest(CLIENT_SECRET, 'POST', '/v1/buy/preview', '', body, ts),
},
body,
});Python
import hashlib, hmac, json, time, requests
def sign_request(secret, method, path, query, body, timestamp):
pairs = [p.partition("=")[::2] for p in query.split("&") if p]
sorted_query = "&".join(f"{k}={v}" for k, v in sorted(pairs))
body_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
to_sign = "\n".join([method.upper(), path, sorted_query, body_hash, timestamp])
return hmac.new(secret.encode(), to_sign.encode(), hashlib.sha256).hexdigest()
body = json.dumps({"customerIdentifier": "cust-1042", "metal": "Gold", "amount": "100.00"}, separators=(",", ":"))
ts = str(int(time.time()))
res = requests.post(
f"{BASE_URL}/v1/buy/preview",
data=body.encode("utf-8"),
headers={
"Content-Type": "application/json",
"X-Client-Id": CLIENT_ID,
"X-Timestamp": ts,
"X-Signature": sign_request(CLIENT_SECRET, "POST", "/v1/buy/preview", "", body, ts),
},
)Checking Lumos's responses
Every response from Lumos carries its own X-Timestamp and X-Signature. The signature is the HMAC-SHA256, with your client secret, of STATUS\nTIMESTAMP\nRAW_BODY, where STATUS is the HTTP status code, for example 201. Checking it proves the response came from Lumos and was not changed on the way.
Rotating your secret
Ask your Lumos contact for a new secret. During a rotation, both the old and the new secret are accepted, so you can switch your servers over without downtime. Then ask Lumos to retire the old one.
Retries and idempotency
Networks fail. To retry a POST safely, send an Idempotency-Key header with a unique value per action, for example a UUID, of at most 255 characters.
- Same key and same body within 24 hours: you get the stored response of the first call. Nothing happens twice.
- Same key with a different body:
409 idempotency_conflict. - Same key while the first call is still running:
409 idempotency_in_progress. Wait a moment and retry.
Independently of the key, a paymentReference can be used only once in your account (409 duplicate_payment_reference), and a quote can be placed only once (409 quote_used). These protect your customers from being charged twice.
If a call times out and you did not send a key, check the outcome before you retry: GET /v1/customers/{identifier}/transactions lists the customer's latest transactions.
Customers
Create a customer once, using your own ID as identifier. Calling create again with the same identifier returns the existing customer (200) instead of a new one (201). Use PATCH to change any detail later, including a renewed ID or a new KYC result.
KYC is your responsibility. You verify the customer and send Lumos the result.
| Field | Description | |
|---|---|---|
identifier | required | Your customer ID |
firstName, lastName | required | As on the ID document |
dateOfBirth | required | YYYY-MM-DD |
nationality, country | required | ISO country codes. country is the country of residence. |
email | required | |
phone | required | { "countryCode": "+971", "number": "501234567" } |
address | required | Used on invoices and for delivery. A single string or an object. |
idType | required | EmiratesId, Passport or NationalId |
kyc.kycId | required | Your KYC provider's reference |
kyc.kycStatus | required | Approved allows trading |
kyc.issuingAuthority, kyc.issuedAt, kyc.expiresAt | required | From the ID document. Dates as YYYY-MM-DD. |
kyc.documentType, kyc.documentNumber | required | The document number is encrypted at rest |
termsAccepted, termsVersion | required | true and the version of the Lumos terms the customer accepted |
gender | optional |
{
"identifier": "cust-1042",
"firstName": "Mariam",
"lastName": "Al Hashimi",
"dateOfBirth": "1990-04-12",
"nationality": "AE",
"country": "AE",
"email": "mariam@example.com",
"phone": { "countryCode": "+971", "number": "501234567" },
"address": "Apartment 1204, Marina Gate 1, Dubai Marina, Dubai",
"idType": "EmiratesId",
"kyc": {
"kycId": "kyc_88231",
"kycStatus": "Approved",
"issuingAuthority": "ICP",
"issuedAt": "2023-05-01",
"expiresAt": "2028-04-30",
"documentType": "EmiratesId",
"documentNumber": "784-1990-1234567-1"
},
"termsAccepted": true,
"termsVersion": "2026-10"
}The response is the customer, with Lumos's customerId, a status of Active, Frozen or Closed, and createdAt and updatedAt.
When a customer can trade
A customer can get a quote only when all of these are true. Otherwise the quote call returns 400 and names what is missing.
- Every required field is present.
kyc.kycStatusisApproved.- The ID has not expired (
kyc.expiresAtis in the future). termsAcceptedistrue.- The account
statusisActive.
Other customer calls
Your customers, one page at a time.
One customer.
Send only the fields that change. The same fields as create, except identifier.
The customer's transactions, newest first. Filter with status, type (Buy, Sell), and from and to (date or date-time, inclusive).
Prices
Current buy and sell prices per gram, for display. To trade, always get a quote: the quote's price is the one the customer gets.
{
"id": "6ad60347abac68b6e7345dd6",
"price24K": 476.05,
"sellPrice24K": 461.97,
"silverPrice": 5.67,
"sellPriceSilver": 5.33,
"currency": "AED",
"createdAt": "2026-10-19T11:47:19.240Z",
"updatedAt": "2026-10-19T11:47:19.240Z",
"requestId": "req_hiK6XxBMFwQ8Eaf5"
}Daily average buy prices, oldest first, for charts. range is one of 7d, 30d, 6m, 1y, 3y, 5y, max.
When prices pause
If Lumos's price feed stops updating, Lumos pauses buying and selling for that metal rather than trade on an old price. Quote calls return 503 prices_paused, and your webhook receives prices.paused, then prices.resumed when trading restarts. Show the customer a short "prices are updating" message and let them retry.
Buying
A buy is a quote, then an order, then payment. The quote fixes the price for 3 minutes.
| Field | Description | |
|---|---|---|
customerIdentifier | required | Your customer ID |
metal | required | Gold or Silver |
amount | one of | AED value of metal to buy. The platform fee and VAT are added on top. |
quantity | one of | Grams to buy |
platformFee | optional | AED. Accepted only within the floor and ceiling agreed for your account; otherwise your default fee applies. |
Send exactly one of amount or quantity.
{
"quoteId": "q_lv2jobxizuC-CowSZgrysg",
"metal": "Gold",
"currency": "AED",
"price": "476.05",
"quantity": "0.210062",
"total": "100.00",
"vat": "0.00",
"platformFee": "0.00",
"grandTotal": "100.00",
"expiresAt": "2026-10-19T11:50:30.505Z",
"requestId": "req_nT1vh8hYRkZOe7ar"
}total is the metal value, grandTotal is what the customer pays. With a platform fee of AED 1.00 and VAT at 5% on the fee, the same buy has a grandTotal of AED 101.05.
| Field | Description | |
|---|---|---|
quoteId | required | From the preview. Must not be expired or used. |
status | optional | Completed if you have already taken payment; otherwise leave it out and the buy starts Pending. |
paymentReference | with Completed | Your payment ID. Unique in your account. |
paymentMethod | optional | Free text for your records, for example wallet or card |
{
"id": "txn_6ad60352abac68b6e7345dda",
"type": "Buy",
"metal": "Gold",
"quantity": "0.210062",
"currency": "AED",
"quotedPrice": "476.05",
"status": "Pending",
"vat": "0.00",
"platformFee": "0.00",
"total": "100.00",
"grandTotal": "100.00",
"requestId": "req_g5U6zk8TomveGUPr"
}Confirming payment
You take the payment, then tell Lumos. Either place the buy with status: "Completed" and your paymentReference in one call, or place it Pending and confirm with PATCH once payment clears. If payment fails, send Failed or Cancelled instead.
%%{init: {"theme":"base","themeVariables":{"darkMode":false,"background":"#FFFFFF","primaryColor":"#EEF5FC","primaryBorderColor":"#3B1EFF","primaryTextColor":"#2B2D3D","lineColor":"#2B2D3D","textColor":"#2B2D3D","actorBkg":"#EEF5FC","actorBorder":"#3B1EFF","actorTextColor":"#2B2D3D","actorLineColor":"#D7E8FA","signalColor":"#2B2D3D","signalTextColor":"#2B2D3D","labelBoxBkgColor":"#EEF5FC","labelBoxBorderColor":"#3B1EFF","noteBkgColor":"#5DF9C0","noteBorderColor":"#2B2D3D","activationBkgColor":"#D7E8FA","stateBkg":"#EEF5FC","stateBorder":"#3B1EFF","transitionColor":"#2B2D3D","transitionLabelColor":"#2B2D3D","edgeLabelBackground":"#FFFFFF","nodeBorder":"#3B1EFF","nodeTextColor":"#2B2D3D","stateLabelColor":"#2B2D3D","loopTextColor":"#2B2D3D","noteTextColor":"#2B2D3D","sequenceNumberColor":"#FFFFFF"}}}%%
sequenceDiagram
participant App as Your app
participant S as Your server
participant L as Lumos
App->>S: Buy AED 100 of gold
S->>L: POST /v1/buy/preview
L-->>S: quoteId, quantity, grandTotal
S-->>App: Show quote (valid 3 minutes)
App->>S: Confirm
S->>S: Take payment
S->>L: POST /v1/buy (status Completed, paymentReference)
L-->>S: 201, status Completed
L--)S: Webhook transaction.created
{
"status": "Completed",
"paymentReference": "pay-77120931",
"paymentMethod": "wallet"
}status is Completed, Failed or Cancelled. paymentReference is required for Completed. An optional notes field is kept with the transaction.
Limits
Your account has a minimum order, and can have a maximum order and a daily volume limit if agreed with Lumos. An order that would cross one returns 400 limit_exceeded. GET /v1/partner shows your limits.
Selling
A sell is a quote, then an order. When the sell is placed, Lumos sets the grams aside so they cannot be sold twice. They come back to the customer if the sell fails, is cancelled or goes stale.
The same fields as a buy preview, without platformFee. amount is the AED value of metal to sell; quantity is grams. grandTotal is what the customer receives.
{
"quoteId": "q_NiV5zfV7Sja9hezaMx16LA",
"metal": "Gold",
"currency": "AED",
"price": "461.97",
"quantity": "0.050000",
"total": "23.09",
"vat": "0.00",
"platformFee": "0.00",
"grandTotal": "23.09",
"expiresAt": "2026-10-19T11:50:30.529Z",
"requestId": "req_hKzcxfJLz5tqYcIN"
}Fees on sells
Sells carry no platform fee. A small-sell fee applies to sells below AED 100, with VAT on the fee, and is taken from the payout. For example, selling AED 50 of gold with a AED 2.00 fee and 5% VAT pays the customer AED 47.90. Nothing beyond the grams sold is ever taken from the customer's metal.
Send the quoteId. The response has the same shape as a buy.
Paying your customer
You pay your customer. Place the sell Pending, pay the customer, then send PATCH /v1/transactions/{id} with Completed and your paymentReference. If you pay at once, place the sell with status: "Completed" and your paymentReference in one call. If the payout fails, send Failed or Cancelled and the grams go back to the customer.
Transaction statuses
| Status | Meaning |
|---|---|
Pending | Placed, waiting for payment or payout. A pending sell holds its grams. |
Completed | Final. The metal and money have moved. It never changes again; only a reversal can undo it. |
Failed | Payment or payout failed. Final. |
Cancelled | Cancelled before completion. Final. |
Stale | Still Pending after 10 minutes, so Lumos closed it. Final. Sell grams are released. |
%%{init: {"theme":"base","themeVariables":{"darkMode":false,"background":"#FFFFFF","primaryColor":"#EEF5FC","primaryBorderColor":"#3B1EFF","primaryTextColor":"#2B2D3D","lineColor":"#2B2D3D","textColor":"#2B2D3D","actorBkg":"#EEF5FC","actorBorder":"#3B1EFF","actorTextColor":"#2B2D3D","actorLineColor":"#D7E8FA","signalColor":"#2B2D3D","signalTextColor":"#2B2D3D","labelBoxBkgColor":"#EEF5FC","labelBoxBorderColor":"#3B1EFF","noteBkgColor":"#5DF9C0","noteBorderColor":"#2B2D3D","activationBkgColor":"#D7E8FA","stateBkg":"#EEF5FC","stateBorder":"#3B1EFF","transitionColor":"#2B2D3D","transitionLabelColor":"#2B2D3D","edgeLabelBackground":"#FFFFFF","nodeBorder":"#3B1EFF","nodeTextColor":"#2B2D3D","stateLabelColor":"#2B2D3D","specialStateColor":"#3B1EFF","innerEndBackground":"#3B1EFF"}}}%%
stateDiagram-v2
[*] --> Pending
[*] --> Completed: placed with payment
Pending --> Completed
Pending --> Failed
Pending --> Cancelled
Pending --> Stale: after 10 minutes
Any other change returns 409 invalid_status_transition. Complete pending transactions within 10 minutes, and always act on the status Lumos returns.
The full transaction, including paymentReference, the latest reversal if any, and a settlement block: Lumos's and your share (lumosAmount, partnerAmount), fees, VAT, and which settlement statement it is on.
Holdings
Per metal, in grams and AED.
| Field | Meaning |
|---|---|
currentQuantity | All grams the customer owns, including grams set aside for a pending sell |
availableQuantity | Grams the customer can sell now |
pendingQuantity | Grams in pending sells |
lockedQuantity | Grams locked for another purpose. 0 today. |
avgBuyPrice, invested | Average cost per gram and total cost of the grams held |
currentValue | Grams at today's buy price |
sellValue | Grams at today's sell price: what the customer would receive |
returns, pnlPercent | sellValue minus invested, in AED and percent |
{
"currency": "AED",
"gold": {
"currentQuantity": "0.210062",
"availableQuantity": "0.160062",
"pendingQuantity": "0.050000",
"lockedQuantity": "0.000000",
"avgBuyPrice": "476.04",
"invested": "100.00",
"currentValue": "100.00",
"sellValue": "97.04",
"returns": "-2.96",
"pnlPercent": -2.96
},
"silver": { … }
}Straight after a buy, returns is slightly negative: it is the gap between the buy and sell price.
Webhooks
Lumos sends a POST to your webhook URL whenever something changes. Use webhooks to update your app at once, and the API to read the current state.
| Event | Sent when | payload |
|---|---|---|
transaction.created | A buy or sell is placed | The transaction |
transaction.status_changed | A transaction changes status, or a reversal is done | The transaction |
customer.created | A customer is created | The customer |
customer.updated | A customer changes | The customer |
settlement.ready | A daily settlement statement is ready | The statement |
prices.paused | Trading in a metal pauses | { "metal", "at" } |
prices.resumed | Trading in a metal resumes | { "metal", "at" } |
{
"eventType": "transaction.status_changed",
"payload": {
"id": "txn_6ad60352abac68b6e7345dda",
"type": "Buy",
"status": "Completed",
"customerIdentifier": "cust-1042",
"metal": "Gold",
"quantity": "0.210062",
"grandTotal": "100.00",
…
}
}Headers and verification
| Header | Value |
|---|---|
X-Event-Id | Unique per event, the same on every retry. Use it to ignore duplicates. |
X-Timestamp | Unix seconds when this attempt was sent |
X-Signature | Lowercase hex HMAC-SHA256, with your client secret, of TIMESTAMP\nRAW_BODY |
import crypto from 'node:crypto';
export function isFromLumos(secret, timestamp, rawBody, signature) {
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}\n${rawBody}`, 'utf8').digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
return fresh && signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}Delivery and retries
- Reply with any
2xxstatus quickly, then do your work. Anything else counts as a failure. - Lumos retries after 10 seconds, 30 seconds, 1 minute, 5 minutes, 15 minutes, 30 minutes, then every hour, for up to 24 hours.
- Events can arrive more than once and out of order. De-duplicate by
X-Event-Id, and if order matters, read the transaction withGET /v1/transactions/{id}. - Missed events can be replayed. Ask your Lumos contact today; the partner portal will offer this as a button.
Settlement and invoices
Lumos makes a statement for you every day. Each statement covers one Dubai day and lists every transaction completed that day, with what each side owes, netted into one amount in one direction. The money itself moves on the schedule agreed for your account, for example every day, every two days or weekly, and one payment can cover several statements. It is paid by you, or by the company that settles for you.
{
"settlementId": "stl_…",
"date": "2026-10-19",
"currency": "AED",
"totalLumos": "…",
"totalPartner": "…",
"net": "…",
"direction": "PartnerPaysLumos",
"status": "Open"
}| Field | Meaning |
|---|---|
totalLumos | Owed to Lumos by you on this statement |
totalPartner | Owed to you by Lumos on this statement |
net, direction | The difference, and who pays it: PartnerPaysLumos or LumosPaysPartner |
status | Open until a payment covering it is confirmed, then Paid |
The statement with its lines: one per completed transaction, with owedToLumos and owedToPartner. A reversal appears as a negative line on a later statement. Your webhook receives settlement.ready when a statement is ready.
Invoices
A link to the transaction's PDF invoice, valid for 10 minutes. The invoice is created when the transaction completes; before that the call returns 409 invoice_not_ready. After a reversal, the response also carries creditNoteUrl.
Refunds and reversals
A completed transaction never changes status. To undo one, for example after a duplicate payment, ask for a reversal. Lumos operations review it, and two people at Lumos must approve before it is done. Lumos puts the customer's metal position back and adjusts your next settlement statement; you settle any refund with your customer.
{ "reason": "Customer paid twice by mistake" }The response has a reversalId and a status: Requested, then Approved or Rejected, then Done. The transaction stays Completed; its reversal.status of Done shows it was undone, and you receive transaction.status_changed. A transaction can have one reversal request (409 reversal_exists).
All endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/partner | Your settings, limits and fees |
| POST | /v1/customers | Create a customer |
| GET | /v1/customers | List customers |
| GET | /v1/customers/{identifier} | One customer |
| PATCH | /v1/customers/{identifier} | Update details or KYC |
| GET | /v1/customers/{identifier}/holdings | Holdings |
| GET | /v1/customers/{identifier}/transactions | Transaction history |
| GET | /v1/prices/latest | Current prices |
| GET | /v1/prices/history | Daily price history |
| POST | /v1/buy/preview | Buy quote |
| POST | /v1/buy | Place a buy |
| POST | /v1/sell/preview | Sell quote |
| POST | /v1/sell | Place a sell |
| GET | /v1/transactions/{transactionId} | One transaction |
| PATCH | /v1/transactions/{transactionId} | Complete, fail or cancel a pending transaction |
| GET | /v1/transactions/{transactionId}/invoice | Invoice PDF link |
| POST | /v1/transactions/{transactionId}/reversal | Ask for a reversal |
| GET | /v1/settlements | Settlement statements |
| GET | /v1/settlements/{settlementId} | One statement with lines |
A full machine-readable reference (OpenAPI 3) is available alongside your sandbox at /docs/reference.
Errors
Errors use standard HTTP status codes and the same body. Branch on code; show or log message.
{
"code": "insufficient_balance",
"message": "Not enough gold: 0.160062 g available",
"requestId": "req_JKb6unQPMTH0YiKR"
}| Status | Code | What to do |
|---|---|---|
| 400 | validation_error | A field is missing or malformed. The message names it. |
| 400 | kyc_required | KYC is not Approved or the ID has expired. Send updated KYC with PATCH. |
| 400 | insufficient_balance | The customer does not have enough available grams. |
| 400 | limit_exceeded | Below the minimum order, or above the maximum order or daily volume limit. |
| 401 | invalid_client | Missing or unknown X-Client-Id. |
| 401 | invalid_signature | Signature missing or wrong. Check the string to sign and the body bytes. |
| 401 | invalid_timestamp | Timestamp missing or more than 300 seconds off. Check your server clock. |
| 403 | customer_not_active | The customer is Frozen or Closed. |
| 403 | feature_disabled, partner_suspended | Buying or selling is switched off for your account. Contact Lumos. |
| 404 | not_found | The customer, quote, transaction or statement does not exist in your account. |
| 409 | quote_used | The quote was already placed. Get a new quote. |
| 409 | duplicate_payment_reference | This paymentReference was used before. |
| 409 | invalid_status_transition | The transaction is no longer Pending. Read it to see its status. |
| 409 | idempotency_conflict, idempotency_in_progress | See Retries and idempotency. |
| 409 | reversal_exists, invoice_not_ready | See Reversals and Invoices. |
| 410 | quote_expired | More than 3 minutes passed. Get a new quote and show it to the customer. |
| 429 | rate_limited | Too many requests. Back off and retry. |
| 503 | prices_paused | Trading in this metal is paused. Retry after prices.resumed. |
| 500 | internal_error | Retry with the same Idempotency-Key. If it persists, contact Lumos with the requestId. |
Postman collection
Postman lets your engineers try every call without writing code. The collection signs each request automatically and passes ids from one call to the next, so you can run a full customer, buy and sell sequence in a few minutes.
- In Postman, choose Import, then Raw text, and paste the collection. Or save the file and import it.
- Open the collection's Variables tab and set
baseUrl,clientIdandclientSecretto your sandbox values. - Run the folders from top to bottom: Partner, Customers, Prices, Buy and sell, Transactions, Settlement.
Keep your client secret out of shared Postman workspaces. Use a personal environment or the variable's current value, which Postman does not sync.
Go-live checklist
Work through these in the sandbox, then share the results with your Lumos contact.
- Requests are signed on your server. No Lumos secret is in your app, your website or your logs.
- Your server clock is synced (NTP), so timestamps stay within 300 seconds.
- You verify Lumos's signature on webhooks, de-duplicate by
X-Event-Id, and reply2xxquickly. - Customers are created with every required field, and KYC updates and ID renewals are sent with
PATCH. - Your app shows the quote's
quantity,priceandgrandTotal, and asks for a new quote after 3 minutes. - Every pending transaction is completed, failed or cancelled within 10 minutes.
- You send an
Idempotency-Keyon everyPOSTand a uniquepaymentReferenceper payment. - You handle every error code above, including
prices_pausedandquote_expired. - Your finance team can read the daily settlement statements and match them to your own records.
- You have named a technical contact and an operations contact for incidents.