Lumeo Docs
Guides

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 initiate and confirm.
  • Keys are scoped to your API key and expire after 24 hours.
  • If you retry confirm with the same key and the payment has already been confirmed, you receive the original confirm response — 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:

failedReasonMeaningRecommended action
recipient_invalidDestination address could not be verifiedAsk the user to re-enter the recipient address
insufficient_floatVault balance too low to cover the paymentTop up the vault before retrying
compliance_holdPayment blocked by automated compliance screeningContact support — do not retry automatically
settlement_timeoutSettlement layer did not confirm within the SLA windowSafe to retry with a new Idempotency-Key after 60 s
duplicate_rejectedIdempotency key was reused with different parametersUse 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:

CodeDescription
P0801Computer software — export
P0802IT-enabled services — export
P0803Business and management consulting
P1007Legal services
P1009Accounting, 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.

On this page