# 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:

```json
{
  "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:

```json
{
  "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:

```json
"events": [
  { "send": "succeeded" },
  { "send": "failed" },
  { "receive": "generated" },
  { "receive": "completed" }
]
```

## Setting Up Webhooks

![Empty Webhooks page for the selected environment](/images/21-webhooks-empty.png)

*Webhook registrations are listed and created within the selected environment.*

![Create Webhook form with a safe example URL and event selections](/images/23-webhook-form-configured.png)

*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**

```text
POST /v1/organizations/{organization_id}/environments/{environment_id}/webhooks
```

**Body (NewWebhookRequest)**

```bash
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)**

```json
{
  "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**

```text
GET /v1/organizations/{organization_id}/webhooks?environment_ids={env_id_1},{env_id_2}
```

Returns an array of `WebhookRead` objects:

```bash
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**

```text
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`:

```json
{
  "url": "https://your-domain.com/new-webhook",
  "events": [
    { "receive": "completed" },
    { "receive": "failed" }
  ]
}
```

### 4. Start / Stop / Rotate Keys / Test

**Endpoints**

```text
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`. 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.

```bash
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](https://voltageapi.com/v1/openapi/docs.json) 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:

```json
{
  "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):

```json
{
  "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)

```json
{
  "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

```json
{
  "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)

```json
{
  "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

1. Read headers:
   - `x-voltage-signature`
   - `x-voltage-timestamp`
   - `x-voltage-webhook-id` – only needed if this endpoint handles more than one webhook
2. 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.
3. Read the raw request body (as a string).
4. Build the message: `payload` + "." + `timestamp`
5. Compute `HMAC_SHA256(shared_secret, message)` and Base64‑encode it.
6. 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):

```javascript
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**

```text
GET /v1/organizations/{organization_id}/environments/{environment_id}/webhooks/{webhook_id}/deliveries
```

Response is `WebhookDeliveries`:

```json
{
  "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**

```text
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**

```javascript
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**

```javascript
end = now()
```

**3. List payments updated in that window**

```javascript
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**

```javascript
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.
