Webhooks
Voltage Payments Webhooks Documentation
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
Each webhook payload has:
{
"type": "send" | "receive" | "test",
"detail": {
"event": "...",
"data": { ... }
}
}Where:
- type is the category: send, receive, or test.
- detail.event is a more specific event within that category (enums below).
Send Events
Enum: SendEventTypes
- succeeded – payment fully sent and recorded
- failed – payment failed (exhausted routes, insufficient funds, or similar)
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 payment
- succeeded – partial payment received (on‑chain only; not applicable to BOLT11)
- completed – full requested amount received
- failed – 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
Enum: TestEventTypes
- created – test webhook event used by the /test endpoint and internal tooling
Webhook Objects
Webhook Status
WebhookStatus enum:
- active – delivering events
- stopped – temporarily disabled
- deleted – removed (retained only for history)
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

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
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
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
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
Endpoints
POST /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}/start
POST /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}/stop
POST /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}/keys
POST /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 containing currency, amount, and unit.
- 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 fees and max_fee fields where available. Legacy fields such as amount_msats can 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
Identifier Map
- Webhook ID – the client-generated id supplied when the webhook is created. Use it in webhook management paths such as /webhooks/{webhook_id}. Voltage also sends it on every delivery in the x-voltage-webhook-id header, which is useful when one endpoint handles more than one webhook.
- Payment ID – detail.data.id in send and receive callbacks. This is the payment UUID supplied when the payment was created and is the value used with GET /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
Each webhook request includes:
- x-voltage-webhook-id – UUID of the webhook registration that produced this delivery. This is the same id you supplied when you created the webhook.
- x-voltage-signature – Base64‑encoded HMAC‑SHA256 of the payload and timestamp
- x-voltage-timestamp – Unix timestamp when the webhook was generated
- x-voltage-event – The event value on its own, such as completed, succeeded, or created. It matches detail.event in the body and does not include the send / receive / test category.
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_secret to 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. Use x-voltage-webhook-id to 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
The Payload schema is a tagged union:
{
"type": "send",
"detail": {
"event": "succeeded",
"data": { /* Payment */ }
}
}Where:
- type = "send" | "receive" | "test"
- detail.event = SendEventTypes / ReceiveEventTypes / TestEventTypes
- detail.data:
- For send/receive: a full Payment object
- For test: arbitrary test payload (commonly a string)
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
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
{
"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)
{
"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
Voltage signs all webhook payloads using HMAC‑SHA256 with your shared_secret. You should always verify the signature before trusting the payload.
Verification Steps
- Read headers:
- x-voltage-signature
- x-voltage-timestamp
- x-voltage-webhook-id – only needed if this endpoint handles more than one webhook
- Choose the shared_secret to verify against. If the endpoint handles a single webhook, that's simply that webhook's secret. If it handles several, look up the secret using x-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-signature using 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
Delivery Status
DeliveryStatus enum:
- attempting – currently being delivered / will be retried
- succeeded – delivered with a 2xx response
- failed – exhausted retries without success
- abandoned – manually abandoned (no further retries)
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
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}/retry
POST /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
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
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
- 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
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
- Listen for receive.completed for LN/BOLT11 success; receive.succeeded is 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 /keys periodically.
- Run a reconciliation loop as a safety net alongside your webhook handler — see the section above.