# Webhooks

Add an endpoint under Developers → Webhooks and pick the events it should receive; as with API keys, that takes an owner or admin, a second factor and your password. Every delivery is a POST with a JSON body, signed with the endpoint's secret. Use the dashboard's "Send test event" button to try your receiver before going live. A key with the webhook_endpoints:write permission can do all of this through the API too.

## Where an endpoint may point

The address has to be one the open internet can reach. An endpoint on localhost, on a private network, or on one of the addresses a cloud provider answers its own metadata on is refused when you save it, and again before each delivery, because it is this platform's server that would be making the request rather than yours. Redirects are not followed for the same reason.

Give an https:// address: deliveries carry payment data, so plain http:// is refused, in test mode too.

Use a host name on port 443. An IP address, any other port, a user name or password in the address, or an address on this platform's own domain is refused. An account can have up to 10 endpoints in each mode, and up to 3 of them on one host.

Developing locally? Put a tunnel in front of your machine and give us the tunnel's address, or use the delivery log to replay a delivery once your receiver is reachable.

## Verifying the endpoint

An endpoint receives nothing until it proves it wants our deliveries. When you add it, or change its URL, we send it a signed POST whose event is webhook.verification and whose data.challenge is a random string. Answer with HTTP 200 and that string as the body, or as {"challenge": "…"}, and the endpoint is verified. The challenge is signed like any delivery, so a receiver that checks signatures needs the endpoint's secret first: copy it from the endpoint's page, then press Verify there. Events that happen while it is unverified are not sent to it, and retries of earlier deliveries wait until it is verified again.

These receivers verify the raw body before reading JSON, answer the challenge and reject other events with 503 until you add durable payment handling. First save the signature helper for your language, then copy the receiver below. Laravel uses the official package middleware instead of a separate helper. [Get the signature helper](https://velirapay.com/docs/webhooks/signatures)

```bash
# Bash, cURL and OpenSSL; use the same test secret in your receiver.
set -euo pipefail
: "${VELIRAPAY_WEBHOOK_SECRET:?Set the test signing secret for your local receiver}"
receiver_url='http://127.0.0.1:8080/webhooks/velirapay'
body='{"id":"local-verification","event":"webhook.verification","mode":"test","test":true,"data":{"challenge":"local-challenge"}}'
timestamp=$(date +%s)
signature=$(printf '%s.%s' "$timestamp" "$body" |
  openssl dgst -sha256 -hmac "$VELIRAPAY_WEBHOOK_SECRET" -r)
signature=${signature%% *}

curl "$receiver_url" \
  --fail-with-body --silent --show-error --max-time 15 \
  -H 'Content-Type: application/json' \
  -H 'X-VeliraPay-Event: webhook.verification' \
  -H 'X-VeliraPay-Delivery: local-verification' \
  -H "X-VeliraPay-Signature: t=$timestamp,v1=$signature" \
  --data-binary "$body"
# Expected: {"challenge":"local-challenge"}
```

```php
<?php

require __DIR__.'/verify.php';

$secret = getenv('VELIRAPAY_WEBHOOK_SECRET') ?: throw new RuntimeException('Set VELIRAPAY_WEBHOOK_SECRET.');
$body = file_get_contents('php://input');

if (! verifyVeliraPaySignature($body, $_SERVER['HTTP_X_VELIRAPAY_SIGNATURE'] ?? '', $secret)) {
    http_response_code(400);
    exit('Invalid signature.');
}

try {
    $delivery = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
    http_response_code(400);
    exit('Invalid JSON.');
}

if (($delivery['event'] ?? null) === 'webhook.verification') {
    $challenge = $delivery['data']['challenge'] ?? null;
    if (! is_string($challenge)) {
        http_response_code(400);
        exit('Invalid challenge.');
    }

    header('Content-Type: application/json');
    echo json_encode(['challenge' => $challenge], JSON_THROW_ON_ERROR);
    exit;
}

http_response_code(503);
exit('Add durable event handling before accepting payments.');
```

```php
// routes/api.php; the default URL includes the /api prefix.
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Route;
use VeliraPay\Laravel\Http\Middleware\VerifyWebhookSignature;

Route::post('/webhooks/velirapay', function (Request $request): JsonResponse|Response {
    try {
        $delivery = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);
    } catch (\JsonException) {
        return response('Invalid JSON.', 400);
    }

    if (($delivery['event'] ?? null) === 'webhook.verification') {
        $challenge = $delivery['data']['challenge'] ?? null;

        return is_string($challenge)
            ? response()->json(['challenge' => $challenge])
            : response('Invalid challenge.', 400);
    }

    return response('Add durable event handling before accepting payments.', 503);
})->middleware(VerifyWebhookSignature::class);
```

```javascript
import express from 'express';
import { verifyVeliraPaySignature } from './verify.mjs';

const secret = process.env.VELIRAPAY_WEBHOOK_SECRET;
if (!secret) throw new Error('Set VELIRAPAY_WEBHOOK_SECRET.');

const app = express();
// Register before any express.json() middleware; keep the original bytes.
app.post('/webhooks/velirapay', express.raw({ type: 'application/json', inflate: false, limit: '1mb' }), (req, res) => {
  if (!verifyVeliraPaySignature(req.body, req.get('X-VeliraPay-Signature') ?? '', secret)) {
    return res.status(400).send('Invalid signature.');
  }

  let delivery;
  try {
    delivery = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.status(400).send('Invalid JSON.');
  }

  if (delivery?.event === 'webhook.verification') {
    const challenge = delivery.data?.challenge;
    return typeof challenge === 'string'
      ? res.json({ challenge })
      : res.status(400).send('Invalid challenge.');
  }

  return res.status(503).send('Add durable event handling before accepting payments.');
});

app.listen(8080, '127.0.0.1');
```

```python
import json
import os
from flask import Flask, jsonify, request
from verify import verify_velirapay_signature

secret = os.environ["VELIRAPAY_WEBHOOK_SECRET"]
if not secret:
    raise RuntimeError("Set VELIRAPAY_WEBHOOK_SECRET.")

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024

@app.post("/webhooks/velirapay")
def webhook():
    body = request.get_data()
    if not verify_velirapay_signature(body, request.headers.get("X-VeliraPay-Signature", ""), secret):
        return "Invalid signature.", 400

    try:
        delivery = json.loads(body)
    except (ValueError, UnicodeDecodeError):
        return "Invalid JSON.", 400

    if isinstance(delivery, dict) and delivery.get("event") == "webhook.verification":
        data = delivery.get("data")
        challenge = data.get("challenge") if isinstance(data, dict) else None
        if not isinstance(challenge, str):
            return "Invalid challenge.", 400
        return jsonify(challenge=challenge)

    return "Add durable event handling before accepting payments.", 503
```

```csharp
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = 1024 * 1024);
var app = builder.Build();
var secret = Environment.GetEnvironmentVariable("VELIRAPAY_WEBHOOK_SECRET");
if (string.IsNullOrEmpty(secret)) throw new InvalidOperationException("Set VELIRAPAY_WEBHOOK_SECRET.");

app.MapPost("/webhooks/velirapay", async (HttpRequest request) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer, request.HttpContext.RequestAborted);
    var body = buffer.ToArray();
    if (!VeliraPaySignature.Verify(body, request.Headers["X-VeliraPay-Signature"].ToString(), secret))
    {
        return Results.BadRequest("Invalid signature.");
    }

    try
    {
        using var document = JsonDocument.Parse(body);
        var delivery = document.RootElement;
        if (delivery.ValueKind == JsonValueKind.Object
            && delivery.TryGetProperty("event", out var eventName)
            && eventName.ValueKind == JsonValueKind.String
            && eventName.GetString() == "webhook.verification")
        {
            if (!delivery.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object
                || !data.TryGetProperty("challenge", out var challenge) || challenge.ValueKind != JsonValueKind.String)
            {
                return Results.BadRequest("Invalid challenge.");
            }

            return Results.Json(new { challenge = challenge.GetString() });
        }
    }
    catch (JsonException)
    {
        return Results.BadRequest("Invalid JSON.");
    }

    return Results.StatusCode(503);
});

app.Run();
```

### Run the receiver locally

Set VELIRAPAY_WEBHOOK_SECRET in the receiver process, using a test-only secret for local checks. The cURL tab needs Bash and OpenSSL and sends that receiver a signed challenge; it does not verify an endpoint in your VeliraPay account. Keep its receiver_url aligned with the address below.

- **PHP:** save the receiver as webhook.php beside verify.php, then run php -S 127.0.0.1:8080 webhook.php.
- **Laravel:** install velirapay/velirapay-laravel, set VELIRAPAY_WEBHOOK_SECRET in .env and put the custom route in your registered routes/api.php. Run php artisan serve --port=8080 and set receiver_url to http://127.0.0.1:8080/api/webhooks/velirapay. API routes avoid session CSRF checks; keep the signature middleware attached.
- **Node.js:** install Express 5 with npm install express@5, save webhook.mjs beside verify.mjs, then run node webhook.mjs.
- **Python:** install Flask 3 with python -m pip install "Flask>=3,<4", save webhook.py beside verify.py, then run python -m flask --app webhook run --port 8080.
- **C#:** run dotnet new web -n VeliraPayWebhook, replace Program.cs with the receiver and add VeliraPaySignature.cs from the signature page. Run dotnet run --project VeliraPayWebhook --urls http://127.0.0.1:8080.

Use these development servers only for local checks. Before accepting payment events, replace the 503 response with durable event storage and processing, deduplicate by event_id and return a 2xx only after that work is safely recorded.

The Laravel package and the WooCommerce plugin answer the challenge for you. With the PHP library, check $event->isVerification() and return $event->challengeResponse() as JSON after verifying the signature. [See the PHP example](https://velirapay.com/docs/libraries)

## Endpoints through the API

To see from your code why deliveries are not arriving, read the endpoints: one receives nothing while verified or active is false. Each carries id, url, description, events, active, verified, verified_at, deliveries_count and failed_deliveries_count (of the deliveries kept for 30 days) and created_at. Reading them takes webhook_endpoints:read.

Adding, changing and deleting them takes webhook_endpoints:write, a sensitive permission: an endpoint receives every payment it is subscribed to, customers included, so full access leaves it out and it has to be ticked on a restricted key. The rules are the dashboard's: the address has to be one the internet can reach, the limits per mode and per host are the same, and an endpoint added or given a new URL is sent its challenge at once and receives nothing until it answers.

- GET `/v1/webhook-endpoints` List the account's endpoints in the mode of the key, newest first.
- POST `/v1/webhook-endpoints` Add an endpoint: url and events, optionally description and active. Answers 201 with its secret, shown this once, and meta.verification_error, null once it answered the challenge.
- GET `/v1/webhook-endpoints/{id}` Retrieve one endpoint.
- PATCH `/v1/webhook-endpoints/{id}` Change its url, events, description or active, only those given. A new url is verified again, with meta.verification_error as above.
- DELETE `/v1/webhook-endpoints/{id}` Delete it and its delivery history. Answers 204.
- POST `/v1/webhook-endpoints/{id}/verify` Send the challenge again. Answers with the endpoint once verified, or 422 with what it answered instead.
- POST `/v1/webhook-endpoints/{id}/test` Queue a sample charge.paid event marked test to a verified endpoint, answering 201 with the delivery, or 409 while it is not verified.
- POST `/v1/webhook-endpoints/{id}/secret` Rotate the signing secret. Answers with the new one, shown this once, and meta.previous_secret_expires_at, until when the old one signs too.
- GET `/v1/webhook-endpoints/{id}/deliveries` List what the endpoint was sent, newest first, kept for 30 days. Filter with ?status= (pending, succeeded or failed) and ?event=.
- POST `/v1/webhook-endpoints/{id}/deliveries/{id}/retry` Send a delivery again, by its id, answering with it, or 409 while the endpoint is paused or not verified.

```bash
curl -X POST 'https://api.velirapay.com/v1/webhook-endpoints' \
  --fail-with-body --silent --show-error --max-time 30 \
  -H "Authorization: Bearer ${VELIRAPAY_API_KEY:?Set VELIRAPAY_API_KEY}" \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://example.com/webhooks/velirapay",
    "events": [
      "charge.paid",
      "invoice.paid"
    ]
  }'
```

### Request verification

After saving the returned signing secret in your receiver, replace the endpoint ID below with yours. This asks VeliraPay to send a real verification challenge to the endpoint URL.

```bash
curl -X POST 'https://api.velirapay.com/v1/webhook-endpoints/9d2e0b8a-0000-4000-8000-000000000001/verify' \
  --fail-with-body --silent --show-error --max-time 30 \
  -H "Authorization: Bearer ${VELIRAPAY_API_KEY:?Set VELIRAPAY_API_KEY}" \
  -H 'Accept: application/json'
```

Only the answers that add an endpoint or rotate its secret carry secret. Store it then: it is never returned again, and a request repeated with the same Idempotency-Key answers 409 rather than replaying it. Verifying, testing and retrying share the dashboard's limit of 10 a minute per key.

Each delivery carries its id (the X-VeliraPay-Delivery header), event, event_id, test, status, attempts, response_status (what the endpoint last answered), error, charge or invoice, created_at, last_attempted_at and delivered_at. What was sent and what came back are not returned: read the event itself with GET /v1/events/{id}.

## Live and test mode

An endpoint belongs to the mode you were in when you added it, and stays in it. A payment taken in test mode is only ever announced to test endpoints, and a live payment only to live ones, so trying something out can never create a real order. Switch the dashboard to the other mode to see and add that mode's endpoints.

Every payload also carries a mode of live or test, so your receiver can tell real money from a rehearsal without looking anything up.

## Event types

- `charge.created` A charge was opened and its rate locked.
- `charge.payment_detected` A transfer was seen on-chain and is gathering confirmations.
- `charge.paid` The payment is confirmed. Fulfil the order.
- `charge.underpaid` A confirmed payment fell short of your tolerance.
- `charge.late_payment` Funds arrived after the charge expired or was canceled, or on top of a charge already paid.
- `charge.refunded` You recorded that funds were sent back to the customer.
- `charge.expired` The rate lock ran out before the customer paid.
- `charge.canceled` The charge was canceled from the dashboard or the API, or replaced when the customer switched coin or its invoice was voided.
- `invoice.created` An invoice was issued.
- `invoice.sent` The invoice, or a reminder of it, was emailed to the customer.
- `invoice.viewed` The customer opened the invoice for the first time.
- `invoice.paid` One of the invoice's charges was paid, which settles it.
- `invoice.voided` The invoice was withdrawn and can no longer be paid.
- `payout.sent` Your share of a paid charge was broadcast and is waiting for the chain to confirm it.
- `payout.confirmed` The chain confirmed the payout, so the money has reached your address.

Charge events carry the charge under data.charge; invoice events carry the invoice under data.invoice; payout events carry the charge under data.charge and its payout under data.payout. A paid invoice produces both charge.paid and invoice.paid: fulfil on one of them, not both.

charge.payment_detected goes out when a transfer to the charge is first seen on-chain, so you can tell the customer their payment is on its way. Transfers are looked for once a minute, so it may already have confirmations by then, and on a fast chain charge.paid can follow within the same minute; a simulated test payment skips it altogether. It and charge.late_payment also carry that transfer under data.transaction: txid, amount, confirmations, required_confirmations, credited, explorer_url and seen_at. Do not fulfil on it: the charge is only paid once charge.paid arrives.

charge.late_payment covers a transfer to a charge that expired or was canceled, which waits for you to accept it, and a second transfer to a charge that is already paid, which is counted automatically. charge.canceled is sent when you cancel a charge, and also when the customer switches to another coin on the checkout or its invoice is voided.

payout.sent goes out when your share of a paid charge has been broadcast, and payout.confirmed when the chain has confirmed it. data.payout is the object GET /v1/payouts/{id} returns, as it stood at that moment, so its status is sending on the first and sent on the second. Book the money on payout.confirmed: a broadcast payout can still be dropped or fail. It goes out again once that is sorted out, so payout.sent can arrive more than once for a charge, while payout.confirmed arrives once. network_fee on payout.sent can be the most the chain may charge, where payout.confirmed has what it took, and a USDT or USDC payout left unmined bids again under a new payout_txid, so the one that counts is the one in payout.confirmed.

Each payload carries an event_id, except a test delivery. The same events are kept in the timeline of the charge or invoice and can be read back with GET /v1/events (filter with ?type=, ?charge= and ?invoice=) or GET /v1/events/{id}, which is handy for catching up after your endpoint was down. What an event records beyond the object, such as a refund's amount, txid and reason, whether an invoice email was a reminder, or which charge paid an invoice, is only in that event's details, not in the payload.

## Payload and headers

**Delivery**

```http
POST /your/endpoint
Content-Type: application/json
User-Agent: VeliraPay-Webhooks/1.0
X-VeliraPay-Event: charge.paid
X-VeliraPay-Delivery: 9d2e0b8a-…-5f3a
X-VeliraPay-Signature: t=1789603512,v1=4f1c…ab09

{
  "id": "9d2e0b8a-…-5f3a",
  "event_id": "9d2e0b89-…-77c1",
  "event": "charge.paid",
  "mode": "live",
  "created_at": "2026-09-17T00:05:12.000000Z",
  "data": {
    "charge": {
      "code": "2t4bcuqkpkoy",
      "status": "paid",
      "fiat_amount": "150.00",
      "fiat_currency": "USD",
      "coupon": null,
      "discount_amount": null,
      "asset": "BTC",
      "asset_amount": "0.002500000000000000",
      "received_amount": "0.002500000000000000",
      "customer_email": "buyer@example.com",
      "customer": {
        "email": "buyer@example.com",
        "ip_address": "203.0.113.7",
        "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) …",
        "country": "FR",
        …
      },
      "description": "Order #1042",
      "metadata": {"order_id": "1042"},
      "payment_link": null,
      "invoice": null,
      "paid_at": "2026-09-17T00:05:10.000000Z",
      …
    }
  }
}
```

A charge.payment_detected delivery adds the transfer that was seen:

**Delivery**

```json
{
  "event": "charge.payment_detected",
  "mode": "live",
  "data": {
    "charge": {"code": "2t4bcuqkpkoy", "status": "pending", …},
    "transaction": {
      "txid": "9a4f…c21e",
      "amount": "0.002500000000000000",
      "confirmations": 0,
      "required_confirmations": 2,
      "credited": false,
      "explorer_url": "https://mempool.space/tx/9a4f…c21e",
      "seen_at": "2026-09-17T00:02:41.000000Z"
    }
  },
  …
}
```

A payout.confirmed delivery adds the payout that landed:

**Delivery**

```json
{
  "event": "payout.confirmed",
  "mode": "live",
  "data": {
    "charge": {"code": "2t4bcuqkpkoy", "status": "paid", …},
    "payout": {
      "id": "2t4bcuqkpkoy",
      "object": "payout",
      "charge": "2t4bcuqkpkoy",
      "status": "sent",
      "asset": "BTC",
      "gross_amount": "0.0025",
      "fee_amount": "0.000075",
      "net_amount": "0.002425",
      "network_fee": "0.0000044",
      "sent_amount": "0.002425",
      "payout_address": "bc1qxy2k…0wlh",
      "payout_txid": "cc9a…cacf",
      "explorer_url": "https://mempool.space/tx/cc9a…cacf",
      "sent_at": "2026-09-17T00:06:02.000000Z",
      "confirmed_at": "2026-09-17T00:31:00.000000Z",
      …
    }
  },
  …
}
```

## How the payload differs from the API objects

data.charge has the fields of the charge object, apart from these:

| Field | Type | Description |
| --- | --- | --- |
| `code` | string | The charge's id. The payload has no id or object field for it. |
| `payment_link, invoice` | object\|null | {code, title} of the payment link and {code, number} of the invoice, where the API gives the id alone. |
| `asset_amount, received_amount, remaining_amount, refunded_amount, exchange_rate` | string\|null | Not trimmed: up to 18 decimal places, such as "0.002500000000000000" where the API says "0.0025". The same goes for the amount of each transfer. Compare them as decimals, not as text. |
| `metadata, customer.metadata` | object | An empty array, [], rather than {} when there is nothing in them. |
| `refunds, timeline, checkout_url, receipt_url` | — | Not in the payload. Read the charge with GET /v1/charges/{id} when you need them. |

data.invoice has code, number, status, amount, currency, items, customer_name, customer_email, memo, due_at, hosted_url, charges, sent_at, viewed_at, paid_at, voided_at and created_at: the invoice object with code for its id and for each charge's id, and without object, overdue or document_url.

data.payout is the payout object, the same as the API returns it.

## Test deliveries

"Send test event" posts a sample charge.paid, whatever events the endpoint subscribes to. Its payload carries "test": true and no event_id, and its charge is a short sample without received_amount, remaining_amount, overpaid, refunded_amount, transactions, customer_name, customer, custom_fields or invoice. A failed test delivery never counts towards switching the endpoint off.

## Rotating the signing secret

Rotating an endpoint's secret, in the dashboard or with POST /v1/webhook-endpoints/{id}/secret, signs every delivery from then on, retries of older ones included, with the new secret. For the next 24 hours each delivery carries a second v1 signature made with the old secret, so your receiver keeps accepting deliveries while you give it the new one.

## Next

- [Verifying signatures](https://velirapay.com/docs/webhooks/signatures) Prove a delivery came from us before you act on it, and rotate the secret without losing a delivery.
- [Retries and failures](https://velirapay.com/docs/webhooks/retries) What happens when your receiver is slow, broken or offline.
