
How to Design a Reliable Lightning Payout System
Liam WestBuild payouts at scale with the Voltage API. Prevent duplicate withdrawals, recover from uncertain responses, and keep webhooks and your ledger in agreement.
A player requests a withdrawal. Your worker sends the payment. The connection drops before the response arrives. The queue helpfully delivers the job again.
You now have a distributed systems problem with a cash prize. Unfortunately, your company is funding it.
For an iGaming platform or crypto exchange, fast payouts matter. So does paying each withdrawal exactly once, even when customers double-click, workers restart, and notifications arrive twice. Once you process enough withdrawals, these stop being edge cases. They become Tuesday.
This guide shows how to build payouts with the Voltage API. With Voltage's managed offering, you send payments through the API and Voltage runs the payment infrastructure. Your code decides who can withdraw, prevents duplicate payouts, and keeps the ledger: the record you will eventually consult when someone asks, “Where did the money go?”
The design has three stages:
- Check that the withdrawal is allowed and save it.
- Send the payment and find out what happened.
- Record the result once, then check your records against the API to catch anything you missed.
Give every withdrawal an ID that survives a retry
Idempotency means you can receive the same withdrawal request five times and still pay the customer once. A customer refreshing a withdrawal page should retrieve the existing withdrawal, not sponsor another one.
Keep track of two IDs:
| Identity | What it identifies | Where it belongs |
|---|---|---|
| Withdrawal ID | The withdrawal your customer requested | Your application database and ledger |
| Voltage payment ID | A specific payment submitted to the Voltage API | Saved with the withdrawal before you send anything |
Use the same withdrawal ID when the client retries, the queue repeats a job, or someone in support clicks a button. Make that ID unique within its tenant and environment, and have the database enforce it. A “check whether it exists, then insert” sequence allows two workers to pass the check together. They will be very pleased with their teamwork.
When a duplicate request arrives, compare it with the stored customer, destination, amount, and currency. Return the existing withdrawal when they match. If any of those details differ, reject the request.
Keep this check even if the client sends a new idempotency key. A new key does not entitle the customer to a second copy of the same withdrawal.
Reserve funds and record work together
In one database transaction:
- Check that the customer is allowed to withdraw and has enough available funds. Lock or conditionally update the balance so two requests cannot spend the same money.
- Save the withdrawal and set aside its amount plus the applicable fee allowance.
- Generate a UUIDv4 for the Voltage payment and save the request you intend to send.
- Insert a dispatch job into an outbox table.
The outbox stores the job in the same transaction as the withdrawal. A separate worker sends that job to your queue. If the process crashes after the database commit, the job is still there. Otherwise, you can end up with money reserved for a job that exists only in the memory of a process that no longer exists. Debugging that is about as fun as it sounds.
The queue can still deliver a job twice. Use an atomic database operation so only one worker can claim it, and make every worker check the saved submission state before sending. If a worker disappears after submission starts, its replacement must look up that payment ID before deciding whether another send is safe. An expired worker lease tells you the worker stopped checking in. It does not tell you the money stayed put.
Submit a payment through the Voltage API
The following example sends a Lightning payment from a BTC-denominated wallet. Configure the organization, environment, wallet, and environment API key first. Keep the key on your backend and send it in the x-api-key header over HTTPS.
Use POST https://voltageapi.com/v1/organizations/{organization_id}/environments/{environment_id}/payments with a JSON body like this:
{
"id": "68d00852-8dd8-4c71-94d2-91c84695da78",
"wallet_id": "7a68a525-9d11-4c1e-a3dd-1c2bf1378ba2",
"currency": "btc",
"type": "bolt11",
"data": {
"payment_request": "REPLACE_WITH_RECIPIENT_INVOICE",
"amount": { "currency": "btc", "amount": 100000000 },
"max_fee": { "currency": "btc", "amount": 100000 }
},
"metadata": {
"external_id": "withdrawal-48291"
}
}Replace the example IDs with your persisted payment ID and configured wallet ID. Use a recipient invoice whose amount and network match the authorized withdrawal and wallet. Recheck invoice expiry before dispatch, including after a queue delay. The placeholder above is intentionally not payable.
BTC amounts use integer millisatoshis: 1 satoshi is 1,000 millisatoshis. This request sends 100,000 satoshis and allows up to 100 satoshis in network/provider fees. Configured processing fees are additional. Store monetary values as integers; JavaScript implementations must also guard against values outside the safe-integer range or use lossless JSON handling.
The metadata.external_id field lets you match an API payment to your withdrawal without spelunking through logs. It is not a uniqueness constraint or an idempotency switch. Your database still has a job.
USD-denominated wallets use a conversion quote and a different currency setup. Follow the quote-to-payment workflow in the Voltage API reference; changing btc to usd in this example is not the complete integration.
Accepted does not mean paid
Creating a payment returns an empty 202 Accepted response. Read the payment using the UUID you saved before submission:
GET https://voltageapi.com/v1/organizations/{organization_id}/environments/{environment_id}/payments/{payment_id}For a normal send, interpret the payment status as follows:
| Voltage payment status | Meaning for your withdrawal |
|---|---|
sending | Still in progress; keep the money reserved |
approved | Not finished yet; keep the money reserved |
completed | Payment completed; record it in the ledger once |
failed | Payment failed; check the error before trying again or releasing funds |
These are payment states, not HTTP status codes. In particular, approved does not mean the customer has been paid. The separate /payments/check operation is a policy check where approved is a final result; it is not part of this send workflow.
A timeout is not permission to pay again
A timeout tells you that your application stopped waiting. It says remarkably little about what happened to the money.
Saving the payment ID gives you something to look up when the response goes missing. It does not make replaying a POST safe. After a timeout, connection loss, or ambiguous server error, query the existing payment ID. Do not generate a fresh UUID just to make the next request feel new.
A payment can be accepted before it becomes visible to reads. Even after 202 Accepted, an immediate read can briefly return 404. Retry the read with increasing delays and a time limit. One missing result is not proof that the payment failed. Please do not turn a momentary 404 into a second withdrawal and a permanent incident report.
You could start polling after half a second, then increase the delay with some randomness up to five seconds so workers do not all poll at once. Set a deadline for how long the request handler will wait, and a timeout for each HTTP request too. These are example client settings, not Voltage performance guarantees.
When that deadline passes, keep the withdrawal pending and the money reserved, and continue checking in the background. If 404 responses persist, check the saved payment ID and whether you are querying the right organization and environment. If authentication fails, fix the key or permissions. Retrying the wrong credentials faster is just a more expensive way to remain unauthorized.
Separate request retries from new payment attempts
There are three different operations hiding behind the word “retry”:
| Operation | Safe application behavior |
|---|---|
| Customer repeats the withdrawal request | Return the existing withdrawal when its details match |
| Worker retries a status read | Query the same Voltage payment ID with bounded backoff |
| Application considers another payment | Require a confirmed failure and an explicit retry decision first |
Once the API confirms failed, read the structured error. Decide whether to fix the request, try again, or stop and return the reserved money to the customer's available balance. Keep the original payment record. If you approve another attempt, save a new payment UUID under the same withdrawal. Use an atomic database check to prevent two workers from starting replacement payments, and cap both the number of attempts and how long you will keep trying.
A replacement invoice must not create another withdrawal. Never replace and resend while an earlier payment remains unresolved or has completed. The “retry” button in your support dashboard must run these same checks. An admin session does not make a duplicate payout less duplicate.
Make webhook processing idempotent too
Voltage provides webhooks so your backend can react to payment changes without waiting for the next scheduled read. For sends, subscribe to the succeeded and failed events. The send success event is named succeeded; the completed payment's status is completed.
Verify a delivery before applying it. Voltage signs the raw request body followed by a period and the x-voltage-timestamp value, using HMAC-SHA256 with the webhook's shared secret and Base64 encoding. Preserve the raw bytes, compare signatures in constant time, and enforce a timestamp tolerance. Use the webhook ID to select the appropriate secret, but trust payment details only after verification. Route on the verified body's type and detail.event, not an unsigned event header.
After verification:
- Save the incoming work to a database or durable queue before returning HTTP
2xx. - Use
detail.data.idto find the payment and the withdrawal you saved with it. - Use a unique ledger posting key so processing the event again cannot record the same payment twice.
- Read the current payment state from the API if an old or conflicting notification could overwrite a newer result.
A duplicate success event must not settle the ledger twice. A late pending update must not undo a completed withdrawal. Webhook delivery success only means your endpoint acknowledged the request; it does not establish that a payout completed.
Use the same settlement function for webhook processing, polling, and reconciliation. Three different code paths that each “just update the balance” are an excellent way to create three subtly different balances.
Reconcile even when the webhooks look healthy
Webhooks usually get you the news quickly. You still need to compare your ledger with the Voltage API on a schedule. That comparison is reconciliation: finding the payment that completed while your webhook handler was down, stuck, or enjoying a Friday afternoon deployment.
The API contract describes how to check every payment in an environment:
- List payments with
pagination=cursor,limit=100,sort_key=created_at, andsort_order=DESC, without payment filters. - Insert or update each record by payment ID and match outgoing payments to saved withdrawals.
- Follow
next_cursorwith the same parameters untilhas_moreis false. - Repeat the full check on a schedule. If checks overlap, prevent an older result from overwriting a newer one.
Payment states can change while you are paging through results. Checking again catches changes you missed on the previous pass. The start_date and end_date filters select creation time; they are not an “updated since” feed. An older pending payment can complete today, so scanning only today's newly created payments is insufficient.
Look up pending withdrawals individually too. Investigate outgoing payments that have no matching withdrawal in your database. Record each completed payment once, keeping the amount sent and actual fees separate in their reported currencies. Release reserved funds only when the payment has definitely failed and you have decided to stop trying that withdrawal. If records are missing or disagree, keep investigating before moving any money.
As volume grows, work with Voltage to plan how often to run these checks and how much history they need to process. Checking only recent payments may run faster, but it can miss an older payment that just completed. A faster green dashboard is not much comfort if it is wrong.
Test the failures you will eventually meet
Before launch, verify the wallet's actual network and environment use test funds. A wallet named “staging” is not proof of anything beyond someone's naming intentions.
Exercise these cases deliberately:
- Two workers receive the same withdrawal simultaneously: only one dispatch is authorized.
- The API accepts a payment but the client loses the response: recovery queries the persisted ID without sending again.
- A read briefly returns
404after acceptance: the withdrawal stays reserved and pending. - The worker crashes during submission: its replacement investigates the existing attempt.
- The same success webhook arrives twice: the ledger posts settlement once.
- A webhook never reaches your application: reconciliation repairs the missing settlement.
- A stale event arrives after completion: the completed state is preserved.
- A customer or operator requests another attempt while one is unresolved: dispatch remains blocked.
Put the questions your on-call engineer will actually ask on the dashboard: How long are withdrawals taking? Which has been pending longest? How many payments have an unknown outcome? Are webhooks piling up? Does the ledger disagree with the API? Give support the withdrawal ID, every payment ID attached to it, and the next step. “Pending” should mean someone or something is checking, not that everyone has agreed to stop asking.
Make paying once the normal outcome
A reliable payout system is about paying the customer once, and recording that payment once. Save the request before you send it. Know which API status means the money arrived. Make sure a repeated job or webhook cannot turn one withdrawal into two. You cannot promise yourself a lifetime without 3 AM pages, but you can avoid building a “pay again and hope” button for your half-awake future self.
The Voltage API gives you the calls to send a payment, check its status, and receive updates. Your code keeps those results tied to the right withdrawal and ledger entries. When Grafana lights up and the company Slack starts asking questions, you should be able to look up a payment and explain what happened. Ideally before someone suggests “just retrying it.”
Start with the Voltage integration documentation and use the API contract for the exact request fields and what to do when a response goes missing. Then test the duplicate job. It is going to show up anyway.