Integrate a payout flow
Production-grade payout integration — idempotency, failure handling, and webhook-driven status updates.
The Quickstart covers the minimal happy path. This guide covers what production integrations actually need: idempotency, failure taxonomy, and a webhook-first status model.
The two-call pattern
Every payout is a two-step sequence: initiate then confirm. This is intentional — it gives you a chance to validate the payment details and display a confirmation step to your user before funds move.
// Step 1: record intent, validate fields, check balance
const payment = await lumeo.payments.initiate({
amount: 500.0,
currency: "USD",
destinationAddress: recipientAddress,
purposeCode: "P0801",
invoiceRef: invoice.id,
}, { idempotencyKey: crypto.randomUUID() });
// Step 2: trigger compliance + settlement
await lumeo.payments.confirm({
id: payment.id,
destinationAddress: recipientAddress,
amount: payment.amount.toString(),
currency: payment.currency,
vaultId: process.env.LUMEO_VAULT_ID!,
}, { idempotencyKey: crypto.randomUUID() });Idempotency
Both initiate and confirm accept an Idempotency-Key header. If you retry the same call with the same key (due to a network timeout, for example), Lumeo returns the original result instead of creating a duplicate.
Key rules:
- Generate a fresh UUID per payment intent — do not reuse keys across different payments.
- Use separate keys for
initiateandconfirm. - Keys are scoped to your API key and expire after 24 hours.
- If you retry
confirmwith the same key and the payment has already been confirmed, you receive the originalconfirmresponse — not an error.
import { randomUUID } from "crypto";
const initiateKey = randomUUID();
const confirmKey = randomUUID();Webhook-first status updates
Do not poll GET /payments/payouts/:id in a loop. In production, subscribe to events and handle status transitions as they arrive:
// Register once (or on app startup)
await lumeo.webhooks.register({
url: "https://yourapp.com/webhooks/lumeo",
events: ["payout.settled", "payout.failed", "payout.manual_review", "fira.generated"],
});
// Your handler
app.post("/webhooks/lumeo", async (req, res) => {
// Verify signature first — see Webhooks reference
const event = req.body;
switch (event.type) {
case "payout.settled":
await markInvoicePaid(event.data.paymentId);
break;
case "payout.failed":
await handlePayoutFailure(event.data.paymentId, event.data.failedReason);
break;
case "payout.manual_review":
await notifyOpsTeam(event.data.paymentId);
break;
case "fira.generated":
await storeFira(event.data.firaId, event.data.paymentId);
break;
}
res.sendStatus(200); // respond fast — do heavy work async
});Polling is supported via GET /api/v1/payments/payouts/:id as a fallback, but it adds latency and burns request quota unnecessarily.
Failure taxonomy
When a payout reaches FAILED status, the payment object includes a failedReason field:
failedReason | Meaning | Recommended action |
|---|---|---|
recipient_invalid | Destination address could not be verified | Ask the user to re-enter the recipient address |
insufficient_float | Vault balance too low to cover the payment | Top up the vault before retrying |
compliance_hold | Payment blocked by automated compliance screening | Contact support — do not retry automatically |
settlement_timeout | Settlement layer did not confirm within the SLA window | Safe to retry with a new Idempotency-Key after 60 s |
duplicate_rejected | Idempotency key was reused with different parameters | Use a new key and verify your request body |
For settlement_timeout, retry with the same payment details but a new idempotency key:
async function retryWithBackoff(paymentDetails: PaymentIntent, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const payment = await lumeo.payments.initiate(
paymentDetails,
{ idempotencyKey: randomUUID() }, // always a fresh key on retry
);
return payment;
} catch (err) {
if (attempt === maxRetries - 1) throw err;
await new Promise(r => setTimeout(r, 1000 * 2 ** attempt)); // 1s, 2s, 4s
}
}
}Purpose codes
The purposeCode field carries the RBI FEMA purpose code for the remittance. This field is used to generate the FIRA and determine GST treatment. Common codes for Indian freelancers and service exporters:
| Code | Description |
|---|---|
P0801 | Computer software — export |
P0802 | IT-enabled services — export |
P0803 | Business and management consulting |
P1007 | Legal services |
P1009 | Accounting, auditing, bookkeeping |
If you omit purposeCode, Lumeo defaults to P0802. Provide the correct code for your use case to ensure accurate FIRA generation.
Common pitfalls
Reusing an idempotency key across different payments. Keys are meant to be one-per-intent. If you generate a single key at app startup and reuse it, every subsequent initiate call returns the first payment's result instead of creating a new one — silently, with no error.
Passing id where txnId is expected, or vice versa. GET /payments/:id/status looks up by txnId; POST /payments/:id/retry looks up by id. Mixing these up returns a 404 with no hint about which identifier was expected — see Error handling for the full breakdown.
Treating confirm's amount as a number. initiate takes amount as a number; confirm takes it as a string. Sending a number to confirm may be silently coerced or rejected depending on the client — always stringify it.