Payments
Webhooks
Voltage Payments Webhooks Documentation
Section titled “Voltage Payments Webhooks Documentation”Overview
Section titled “Overview”Webhooks allow your application to receive real-time notifications about events happening in your Voltage payment system. Instead of polling for updates, Voltage will send HTTP POST requests to your specified endpoint whenever relevant events occur.
Event Types
Section titled “Event Types”Each webhook payload has:
{ "type": "send" | "receive" | "test", "detail": { "event": "...", "data": { ... } }}Where:
typeis the category:send,receive, ortest.detail.eventis a more specific event within that category (enums below).
Send Events
Section titled “Send Events”Enum: SendEventTypes
succeeded– payment fully sent and recordedfailed– payment failed (exhausted routes, insufficient funds, or similar)
Receive Events
Section titled “Receive Events”Enum: ReceiveEventTypes
generated– a receive payment was created (invoice/address/BIP21 generated)- detected - payment activity was detected before the receive payment is fully completed
refreshed– receive request was refreshed (e.g. address rotation)expired– invoice/address expired before full paymentsucceeded– partial payment received (on‑chain only; not applicable to BOLT11)completed– full requested amount receivedfailed– receive flow failed (error generating, compliance issue, etc.)
For most Lightning (BOLT11) integrations, you’ll primarily listen for receive.completed. For BOLT11/Lightning receives, treat detail.event: “completed” plus detail.data.status: “completed” as the success signal. generated with status: “receiving” means the invoice or payment request was created, but no completed payment has occurred yet. detail.event describes what happened in this webhook event; detail.data.status is the current payment status. Receive payment statuses are generating, receiving, expired, failed, and completed.
Test Events
Section titled “Test Events”Enum: TestEventTypes
created– test webhook event used by the /test endpoint and internal tooling
Webhook Objects
Section titled “Webhook Objects”Webhook Status
Section titled “Webhook Status”WebhookStatus enum:
active– delivering eventsstopped– temporarily disableddeleted– removed (retained only for history)
Webhook JSON Shape
Section titled “Webhook JSON Shape”WebhookRead looks like:
{ "id": "b0fc9829-f139-4035-bb14-4a4b6cd58f0e", "organization_id": "b0684ab8-1130-46af-8f70-71519442f108", "environment_id": "123e4567-e89b-12d3-a456-426614174000", "url": "https://your-domain.com/webhook", "name": "Production Payment Webhook", "events": [ { "send": "succeeded" }, { "send": "failed" }, { "receive": "completed" }, { "receive": "failed" } ], "status": "active", "created_at": "2025-04-29T17:09:11.299Z", "updated_at": "2025-04-29T17:09:11.299Z", "stopped_at": null, "deleted_at": null}The events array is an array of EventTypes objects: each object has exactly one of send, receive, or test whose value is the corresponding enum (SendEventTypes, ReceiveEventTypes, TestEventTypes).
To subscribe to multiple events you include multiple entries:
"events": [ { "send": "succeeded" }, { "send": "failed" }, { "receive": "generated" }, { "receive": "completed" }]Setting Up Webhooks
Section titled “Setting Up Webhooks”
Webhook registrations are listed and created within the selected environment.

Choose the required events and use an HTTPS callback you control. The example form shown here was not submitted.
Configure environment-scoped webhook registrations from the selected environment’s Webhooks page or manage the same registrations through the Payments API. Use the dashboard for initial setup and inspection, and use the API for repeatable automation.
1. Create a Webhook
Section titled “1. Create a Webhook”Create one webhook registration for each environment and callback URL. Generate a UUID for the webhook id; this identifies the webhook registration itself, not an organization, environment, payment, or delivery.
Endpoint
POST /v1/organizations/{organization_id}/environments/{environment_id}/webhooksBody (NewWebhookRequest)
curl "https://voltageapi.com/v1/organizations/{organization_id}/environments/{environment_id}/webhooks" \ --request POST \ --header "Content-Type: application/json" \ --header "x-api-key: YOUR_SECRET_TOKEN" \ --data '{ "id": "b0fc9829-f139-4035-bb14-4a4b6cd58f0e", "organization_id": "{organization_id}", "environment_id": "{environment_id}", "url": "https://your-domain.com/webhook", "name": "Production Payment Webhook", "events": [ { "send": "succeeded" }, { "send": "failed" }, { "receive": "generated" }, { "receive": "completed" }, { "receive": "failed" } ] }'Note: organization_id and environment_id in the body must match the path parameters.
Response (202 – WebhookPlainSecret)
{ "id": "b0fc9829-f139-4035-bb14-4a4b6cd58f0e", "shared_secret": "vltg_GDtRrrJFJ6afRrAYMW3t9RpxgCdcT8zp"}Save shared_secret securely – it’s only returned once and is required for signature verification.
The response id is the same webhook UUID supplied in the create request. The shared_secret is returned only when the webhook is created or its keys are rotated. It is never included in callback bodies.
2. List Webhooks
Section titled “2. List Webhooks”Endpoint
GET /v1/organizations/{organization_id}/webhooks?environment_ids={env_id_1},{env_id_2}Returns an array of WebhookRead objects:
curl "https://voltageapi.com/v1/organizations/{organization_id}/webhooks?environment_ids={environment_id}" \ --header "x-api-key: YOUR_SECRET_TOKEN"3. Get / Update / Delete a Webhook
Section titled “3. Get / Update / Delete a Webhook”Endpoints
GET /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}PATCH /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}DELETE /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}Update body (UpdateWebhookRequest) – currently you can update url, name, and events:
{ "url": "https://your-domain.com/new-webhook", "events": [ { "receive": "completed" }, { "receive": "failed" } ]}4. Start / Stop / Rotate Keys / Test
Section titled “4. Start / Stop / Rotate Keys / Test”Endpoints
POST /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}/startPOST /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}/stopPOST /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}/keysPOST /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}/testRotate keys returns a new WebhookPlainSecret (same shape as create).
Test webhook expects TestWebhookRequest. For a connectivity check, send the simple test payload below. Use a new UUID for each delivery_id and replace the identifiers in the URL with your organization, environment, and webhook IDs.
curl "https://voltageapi.com/v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}/test" \ --request POST \ --header "Content-Type: application/json" \ --header "x-api-key: YOUR_SECRET_TOKEN" \ --data '{ "delivery_id": "123e4567-e89b-12d3-a456-426614174004", "payload": { "detail": { "data": "This is a test webhook delivery to verify endpoint configuration and connectivity", "event": "created" }, "type": "test" }}'The v6.10.0 API reference corrects the send, receive, and simple test examples. When building payment-event fixtures:
- Use a single string for
detail.event, such as"succeeded"for a successful send or"completed"for a completed receive. - Use lowercase currency values (
"btc"or"usd") and structured amount objects containingcurrency,amount, andunit. - Copy the full payment object from the current API reference’s send or receive test example, including its current status,
payment_category, and receipt or outflow fields. Do not substitute a partial payment object for the complete example. - For sends, use the structured
feesandmax_feefields where available. Legacy fields such asamount_msatscan still appear alongside structured amounts.
The current OpenAPI contract includes these examples under the test_webhook operation. A test delivery checks webhook handling; it does not create or complete a payment.
Webhook Payload Structure
Section titled “Webhook Payload Structure”Identifier Map
Section titled “Identifier Map”- Webhook ID – the client-generated
idsupplied when the webhook is created. Use it in webhook management paths such as/webhooks/{webhook_id}. Voltage also sends it on every delivery in thex-voltage-webhook-idheader, which is useful when one endpoint handles more than one webhook. - Payment ID –
detail.data.idin send and receive callbacks. This is the payment UUID supplied when the payment was created and is the value used withGET /payments/{payment_id}. - Organization and environment IDs – identify the owning scope. They are separate from both the webhook ID and payment ID.
- Delivery ID – identifies one delivery attempt. It is used for delivery inspection, retry, and deduplication; it is not the webhook ID or payment ID.
- Shared secret – not an ID and not callback data. Store it securely and use it only to verify the webhook signature headers.
Headers
Section titled “Headers”Each webhook request includes:
x-voltage-webhook-id– UUID of the webhook registration that produced this delivery. This is the sameidyou supplied when you created the webhook.x-voltage-signature– Base64‑encoded HMAC‑SHA256 of the payload and timestampx-voltage-timestamp– Unix timestamp when the webhook was generatedx-voltage-event– The event value on its own, such ascompleted,succeeded, orcreated. It matchesdetail.eventin the body and does not include thesend/receive/testcategory.
How much you need this header depends on how you’ve set up your endpoints:
- One endpoint per webhook. Your endpoint already knows which registration it serves, so it already knows which
shared_secretto verify against. You can ignore the header. - One endpoint for several webhooks. Each registration has its own
shared_secret, so your endpoint needs to know which one applies before it can verify anything. Usex-voltage-webhook-idto look up the right secret. The same applies when several registrations point at the same callback URL.
A single payment event can match more than one registration. Voltage builds and signs a separate delivery for each match, so every delivery carries the ID of the registration it belongs to.
x-voltage-webhook-id is sent on every delivery, including test deliveries.
If you do use the header, treat it as untrusted input. It is not covered by the signature, so use it to pick which secret to check, and let the signature check decide whether to trust the request.
x-voltage-event carries the event value without its category, so succeeded and failed can each arrive from either a send or a receive. When you need to tell those apart, use type and detail.event from the verified body rather than the header on its own. The full set of values is listed under Event Types above.
Generic Payload
Section titled “Generic Payload”The Payload schema is a tagged union:
{ "type": "send", "detail": { "event": "succeeded", "data": { /* Payment */ } }}Where:
type= “send” | “receive” | “test”detail.event=SendEventTypes/ReceiveEventTypes/TestEventTypesdetail.data:- For
send/receive: a fullPaymentobject - For
test: arbitrary test payload (commonly a string)
- For
Payment Shape (inside detail.data)
Section titled “Payment Shape (inside detail.data)”Webhook send / receive payloads embed the same Payment object you get from the Payments API. Example (Lightning receive):
{ "id": "11ca843c-bdaa-44b6-965a-39ac550fcef7", "direction": "receive", "wallet_id": "{wallet_id}", "organization_id": "{organization_id}", "environment_id": "{environment_id}", "created_at": "2024-11-21T18:47:04.008Z", "updated_at": "2024-11-21T18:47:04.008Z", "currency": "btc", "type": "bolt11", "status": "receiving", "requested_amount": { "amount": 150000, "currency": "btc", "unit": "msats" }, "data": { "payment_request": "lntbs1500n1pn5w25y...", "amount_msats": 150000, "amount": { "currency": "btc", "amount": 150000, "unit": "msats" }, "memo": "testing" }, "error": null}amount_msats and/or amount_sats may be present for backwards compatibility, but the recommended shape is the amount object (amount + currency + unit) wherever available.
Receive amount fields: requested_amount is the amount requested by the invoice or payment request. data.amount is the payment object’s amount field, but on early events such as generated it may be 0 and should not be treated as paid funds. For accounting or business logic, wait for receive.completed and reconcile against the final payment object. Amount units are base units: BTC uses msats; USD uses cents.
How to match a webhook to a payment: use detail.data.id as the payment ID. If you supplied id when creating the payment, webhook payloads use that same ID. Use that ID with GET /payments/{payment_id} to fetch current or final state. You can also store your own order or reference ID alongside the payment ID in your system.
Generated receive example note: a generated receive webhook is not a payment completion. If data.amount is 0, receipts is empty, and status is receiving, wait for a later completed event before marking the payment paid.
Example Webhook Payloads
Section titled “Example Webhook Payloads”Successful Send (Lightning)
Section titled “Successful Send (Lightning)”{ "type": "send", "detail": { "event": "succeeded", "data": { "id": "payment-123", "direction": "send", "currency": "btc", "type": "bolt11", "status": "completed", "wallet_id": "{wallet_id}", "data": { "payment_request": "lntbs1500n1pn5w25y...", "amount": { "amount": 100000, "currency": "btc", "unit": "msats" }, "amount_msats": 100000, "max_fee": { "amount": 1000, "currency": "btc", "unit": "msats" }, "max_fee_msats": 1000, "memo": "Coffee payment" } } }}Partial On‑Chain Receive
Section titled “Partial On‑Chain Receive”{ "type": "receive", "detail": { "event": "succeeded", "data": { "id": "payment-456", "direction": "receive", "currency": "btc", "type": "onchain", "status": "succeeded", "requested_amount": { "amount": 250000, "currency": "btc", "unit": "msats" }, "data": { "address": "tb1pzkhtj4ld8...", "amount": { "amount": 150000, "currency": "btc", "unit": "msats" }, "amount_msats": 150000, "description": "Partial payment for invoice", "receipts": [ { "amount_sats": 1500, "height_mined_at": 1888021, "tx_id": "a22ec88f7a84a705..." } ] } } }}Full Payment Completed (Lightning)
Section titled “Full Payment Completed (Lightning)”{ "type": "receive", "detail": { "event": "completed", "data": { "id": "payment-789", "direction": "receive", "currency": "btc", "type": "bolt11", "status": "completed", "requested_amount": { "amount": 250000, "currency": "btc", "unit": "msats" }, "data": { "payment_request": "lntbs1500n1pn5w25y...", "amount": { "amount": 250000, "currency": "btc", "unit": "msats" }, "amount_msats": 250000, "description": "Invoice for services", "payment_hash": "a1b2c3d4..." } } }}Security & Signature Verification
Section titled “Security & Signature Verification”Voltage signs all webhook payloads using HMAC‑SHA256 with your shared_secret. You should always verify the signature before trusting the payload.
Verification Steps
Section titled “Verification Steps”- Read headers:
x-voltage-signaturex-voltage-timestampx-voltage-webhook-id– only needed if this endpoint handles more than one webhook
- Choose the
shared_secretto verify against. If the endpoint handles a single webhook, that’s simply that webhook’s secret. If it handles several, look up the secret usingx-voltage-webhook-id. While a key rotation is in flight, keep the old and new secrets as candidates and accept a match against either. - Read the raw request body (as a string).
- Build the message:
payload+ ”.” +timestamp - Compute
HMAC_SHA256(shared_secret, message)and Base64‑encode it. - Compare to
x-voltage-signatureusing a timing‑safe comparison.
The signed message covers only the body and the timestamp. It does not include x-voltage-webhook-id or x-voltage-event, so after verification use type and detail.event from the body as authoritative.
Example in Node.js (unchanged, just names aligned):
const crypto = require("crypto");
function verifyWebhookSignature(payload, signature, timestamp, sharedSecret) { const message = `${payload}.${timestamp}`; const hmac = crypto.createHmac("sha256", sharedSecret); hmac.update(message); const expectedSignature = hmac.digest("base64");
return crypto.timingSafeEqual( Buffer.from(expectedSignature), Buffer.from(signature) );}Managing Webhook Deliveries
Section titled “Managing Webhook Deliveries”Delivery Status
Section titled “Delivery Status”DeliveryStatus enum:
attempting– currently being delivered / will be retriedsucceeded– delivered with a 2xx responsefailed– exhausted retries without successabandoned– manually abandoned (no further retries)
List Deliveries
Section titled “List Deliveries”Endpoint
GET /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}/deliveriesResponse is WebhookDeliveries:
{ "items": [ { "id": "delivery-1", "webhook_id": "b0fc9829-f139-4035-bb14-4a4b6cd58f0e", "url": "https://your-domain.com/webhook", "status": "succeeded", "status_code": 200, "payload": { "type": "receive", "detail": { "event": "completed", "data": { "...": "full Payment object" } } }, "attempt_count": 1, "error": null, "created_at": "2025-04-29T17:09:11.299Z", "updated_at": "2025-04-29T17:09:11.400Z" } ], "offset": 0, "limit": 50, "total": 1}Get / Retry / Abandon a Delivery
Section titled “Get / Retry / Abandon a Delivery”Endpoints
GET /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}/deliveries/{delivery_id}POST /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/retryPOST /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/abandonUse these to inspect, retry, or stop retrying individual deliveries.
Reconciliation Loop
Section titled “Reconciliation Loop”Webhooks are the primary way to stay in sync with payment state. However, no delivery mechanism is 100% guaranteed — a network blip, a deployment, or a brief outage on your end can cause you to miss an event. A lightweight reconciliation loop acts as your safety net: it periodically sweeps the Payments API for anything your webhook handler might have missed.
How It Works
Section titled “How It Works”You store one value in your database: last_reconciled_at (a timestamp). A cron job runs on a regular interval (e.g. every 1–5 minutes) and does the following:
1. Set a start time with overlap
start = last_reconciled_at - 10 minutesSubtracting 10 minutes creates an overlap window. This is intentional — it ensures you never miss a payment that was updated right around the boundary of your last run.
2. Set an end time
end = now()3. List payments updated in that window
GET /v1/organizations/{organization_id}/environments/{environment_id}/payments ?start_date={start} &end_date={end} &sort_key=updated_at &sort_order=ASC &limit=100 &offset=0Page through all results by incrementing offset until you’ve consumed every page (i.e. offset >= total).
4. Upsert each payment
For each payment returned, upsert it into your database keyed on payment.id — insert if it’s new, update if it already exists. Because you’re keying on a unique ID, this is fully idempotent: seeing the same payment twice is harmless.
5. Advance the cursor
last_reconciled_at = endThat’s it. On the next cron run the process repeats from the new cursor position.
Why This Works
Section titled “Why This Works”- Webhooks handle the real-time path. Most of the time your system is already up to date before the reconciliation loop even runs.
- The overlap window makes it safe. By reaching 10 minutes into the past you cover any payments that were in flight during the previous run.
- Idempotent upserts make it simple. You don’t need to track which payments you’ve already seen — just write them all. Duplicates are a non-issue.
- One timestamp is all the state you need. No complex bookkeeping, no message queues to manage — just a single cursor marching forward.
Recommended Cadence
Section titled “Recommended Cadence”A 1–5 minute cron interval works well for most integrations. Shorter intervals give you tighter consistency; longer intervals reduce API calls. Choose whatever makes sense for your use case — the overlap window keeps things safe regardless.
Best Practices
Section titled “Best Practices”- Listen for
receive.completedfor LN/BOLT11 success;receive.succeededis only for partial on‑chain receives. - Always verify signatures using your
shared_secret. - Respond quickly (within ~10 seconds); offload heavy work to background jobs.
- Treat webhook processing as idempotent – dedupe with delivery IDs.
- Monitor delivery stats and retry/abandon failed deliveries as needed.
- Use HTTPS endpoints and rotate secrets via
/keysperiodically. - Run a reconciliation loop as a safety net alongside your webhook handler — see the section above.