Developer Guide
Webhooks
31 min
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) 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 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" "processing" }, { "send" "succeeded" }, { "send" "failed" }, { "receive" "generated" }, { "receive" "completed" } ] setting up webhooks 1\ create a webhook endpoint post /v1/organizations/{organization id}/environments/{environment id}/webhooks body (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 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}/test rotate keys returns a new webhookplainsecret (same shape as create) test webhook expects testwebhookrequest 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 426614174002", "payload" { "type" "receive", "detail" { "event" "completed", "data" { "id" "test payment 123", "direction" "receive", "currency" "btc", "type" "bolt11", "status" "completed", "wallet id" "{wallet id}", "organization id" "{organization id}", "environment id" "{environment id}", "requested amount" { "amount" 250000, "currency" "btc", "unit" "msats" }, "data" { "payment request" "lntbs1500n1pn5w25y ", "amount" { "amount" 250000, "currency" "btc", "unit" "msats" }, "memo" "test webhook" } } } } }' this shape matches testwebhookrequest ( delivery id + generic payload ) webhook payload structure headers each webhook request includes 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 – flattened event string like send succeeded , receive completed , test created 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, "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 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 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 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}/deliveries response 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}/abandon use 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 minutes subtracting 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=0 page 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 = end that'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