Enter at least two characters to search. Searching documentation… No matching sections. Try an endpoint, field or event name.
Webhooks
View as MarkdownAdd 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.
On this page
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
Copy failed. Select the code and copy it manually.
# 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
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.');
// 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);
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');
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
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
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-endpointsList the account's endpoints in the mode of the key, newest first. -
POST
/v1/webhook-endpointsAdd 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}/verifySend the challenge again. Answers with the endpoint once verified, or 422 with what it answered instead. -
POST
/v1/webhook-endpoints/{id}/testQueue 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}/secretRotate 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}/deliveriesList 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}/retrySend a delivery again, by its id, answering with it, or 409 while the endpoint is paused or not verified.
Copy failed. Select the code and copy it manually.
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"
]
}'
<?php
use VeliraPay\VeliraPayClient;
require __DIR__.'/vendor/autoload.php';
$apiKey = getenv('VELIRAPAY_API_KEY') ?: throw new RuntimeException('Set VELIRAPAY_API_KEY.');
$velirapay = new VeliraPayClient($apiKey, baseUrl: 'https://api.velirapay.com');
$endpoint = $velirapay->webhookEndpoints->create([
'url' => 'https://example.com/webhooks/velirapay',
'events' => [
'charge.paid',
'invoice.paid',
],
]);
echo json_encode($endpoint, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), PHP_EOL;
use VeliraPay\Laravel\Facades\VeliraPay;
$endpoint = VeliraPay::webhookEndpoints()->create([
'url' => 'https://example.com/webhooks/velirapay',
'events' => [
'charge.paid',
'invoice.paid',
],
]);
const secretKey = process.env.VELIRAPAY_API_KEY;
if (!secretKey) throw new Error('Set VELIRAPAY_API_KEY.');
const response = await fetch('https://api.velirapay.com/v1/webhook-endpoints', {
method: 'POST',
headers: {
Authorization: `Bearer ${secretKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://example.com/webhooks/velirapay',
events: [
'charge.paid',
'invoice.paid',
],
}),
redirect: 'error',
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
throw new Error(`VeliraPay ${response.status}: ${await response.text()}`);
}
const endpoint = response.status === 204 ? null : (await response.json()).data;
console.log(JSON.stringify(endpoint, null, 2));
import json
import os
import requests
api_key = os.environ["VELIRAPAY_API_KEY"]
response = requests.post(
"https://api.velirapay.com/v1/webhook-endpoints",
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
},
json={
"url": "https://example.com/webhooks/velirapay",
"events": [
"charge.paid",
"invoice.paid",
],
},
timeout=(5, 30),
allow_redirects=False,
)
if not 200 <= response.status_code < 300:
raise RuntimeError(f"VeliraPay {response.status_code}: {response.text}")
endpoint = None if response.status_code == 204 else response.json()["data"]
print(json.dumps(endpoint, indent=2))
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var secretKey = Environment.GetEnvironmentVariable("VELIRAPAY_API_KEY")
?? throw new InvalidOperationException("Set VELIRAPAY_API_KEY.");
using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{
Timeout = TimeSpan.FromSeconds(30),
};
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.velirapay.com/v1/webhook-endpoints");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Content = new StringContent("""
{
"url": "https://example.com/webhooks/velirapay",
"events": [
"charge.paid",
"invoice.paid"
]
}
""", Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"VeliraPay {(int)response.StatusCode}: {responseBody}");
}
JsonElement? endpoint = null;
if (response.StatusCode != HttpStatusCode.NoContent)
{
using var document = JsonDocument.Parse(responseBody);
endpoint = document.RootElement.GetProperty("data").Clone();
}
Console.WriteLine(endpoint?.GetRawText() ?? "null");
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.
Copy failed. Select the code and copy it manually.
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'
<?php
use VeliraPay\VeliraPayClient;
require __DIR__.'/vendor/autoload.php';
$apiKey = getenv('VELIRAPAY_API_KEY') ?: throw new RuntimeException('Set VELIRAPAY_API_KEY.');
$velirapay = new VeliraPayClient($apiKey, baseUrl: 'https://api.velirapay.com');
$endpoint = $velirapay->webhookEndpoints->verify('9d2e0b8a-0000-4000-8000-000000000001');
echo json_encode($endpoint, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), PHP_EOL;
use VeliraPay\Laravel\Facades\VeliraPay;
$endpoint = VeliraPay::webhookEndpoints()->verify('9d2e0b8a-0000-4000-8000-000000000001');
const secretKey = process.env.VELIRAPAY_API_KEY;
if (!secretKey) throw new Error('Set VELIRAPAY_API_KEY.');
const response = await fetch('https://api.velirapay.com/v1/webhook-endpoints/9d2e0b8a-0000-4000-8000-000000000001/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${secretKey}`,
Accept: 'application/json',
},
redirect: 'error',
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
throw new Error(`VeliraPay ${response.status}: ${await response.text()}`);
}
const endpoint = response.status === 204 ? null : (await response.json()).data;
console.log(JSON.stringify(endpoint, null, 2));
import json
import os
import requests
api_key = os.environ["VELIRAPAY_API_KEY"]
response = requests.post(
"https://api.velirapay.com/v1/webhook-endpoints/9d2e0b8a-0000-4000-8000-000000000001/verify",
headers={
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
},
timeout=(5, 30),
allow_redirects=False,
)
if not 200 <= response.status_code < 300:
raise RuntimeError(f"VeliraPay {response.status_code}: {response.text}")
endpoint = None if response.status_code == 204 else response.json()["data"]
print(json.dumps(endpoint, indent=2))
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
var secretKey = Environment.GetEnvironmentVariable("VELIRAPAY_API_KEY")
?? throw new InvalidOperationException("Set VELIRAPAY_API_KEY.");
using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{
Timeout = TimeSpan.FromSeconds(30),
};
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.velirapay.com/v1/webhook-endpoints/9d2e0b8a-0000-4000-8000-000000000001/verify");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"VeliraPay {(int)response.StatusCode}: {responseBody}");
}
JsonElement? endpoint = null;
if (response.StatusCode != HttpStatusCode.NoContent)
{
using var document = JsonDocument.Parse(responseBody);
endpoint = document.RootElement.GetProperty("data").Clone();
}
Console.WriteLine(endpoint?.GetRawText() ?? "null");
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.createdA charge was opened and its rate locked. -
charge.payment_detectedA transfer was seen on-chain and is gathering confirmations. -
charge.paidThe payment is confirmed. Fulfil the order. -
charge.underpaidA confirmed payment fell short of your tolerance. -
charge.late_paymentFunds arrived after the charge expired or was canceled, or on top of a charge already paid. -
charge.refundedYou recorded that funds were sent back to the customer. -
charge.expiredThe rate lock ran out before the customer paid. -
charge.canceledThe charge was canceled from the dashboard or the API, or replaced when the customer switched coin or its invoice was voided. -
invoice.createdAn invoice was issued. -
invoice.sentThe invoice, or a reminder of it, was emailed to the customer. -
invoice.viewedThe customer opened the invoice for the first time. -
invoice.paidOne of the invoice's charges was paid, which settles it. -
invoice.voidedThe invoice was withdrawn and can no longer be paid. -
payout.sentYour share of a paid charge was broadcast and is waiting for the chain to confirm it. -
payout.confirmedThe 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
Copy failed. Select the code and copy it manually.
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:
Copy failed. Select the code and copy it manually.
{
"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:
Copy failed. Select the code and copy it manually.
{
"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
|
string | The charge's id. The payload has no id or object field for it. |
payment_link, invoice
object|null
|
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
|
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
|
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 Prove a delivery came from us before you act on it, and rotate the secret without losing a delivery.
- Retries and failures What happens when your receiver is slow, broken or offline.