Webhooks
Events: invoice.paid, invoice.overpaid, invoice.underpaid, invoice.expired, invoice.paid_late, invoice.on_hold, invoice.payment_flagged (a later transfer to a settled invoice was flagged by sanctions screening; the invoice keeps its status, the payment is in data.payment), invoice.cancelled (you cancelled an unpaid invoice), store.payout_attention (the EVM payout wallet of the account became unconfirmed, so new invoices leave out the EVM networks listed in data.payout.blockedNetworks, or it is fine again; data.payout.status is the new evmPayoutStatus of GET /v1/store), api_key.leaked and webhook_secret.leaked (see Leaked keys), webhook.test. Delivery is at least once with exponential retries for about a day; deduplicate by the X-Stablevoy-Event-Id header. Every request carries X-Stablevoy-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 of t + "." + raw body with your signing secret.
The webhook address must be https on the standard port 443 with a public domain name. Deliveries are sent from Cloudflare's network, so if your firewall only accepts known senders, allow Cloudflare's published address ranges (cloudflare.com/ips); every request also carries a CF-Worker header. Redirects are not followed, and an answer that takes longer than 10 seconds counts as failed and is retried. Trust a request because its signature checks out, not because of the address it came from.
A store has one webhook. In the dashboard it is in the card of the integration that uses it on the store's Integrations tab: the API card for your own code, where you set the address, rotate the secret, send test events and read the delivery log; the plugin's card when a WordPress plugin is connected, because the plugin keeps the secret and a new connect sets both again. Disconnecting the plugin clears the webhook so the store is free for another shop.
A store can send up to 10 test events and 10 redeliveries per hour. Over that the API answers 429 rate_limited with a Retry-After header, and GET /v1/stores/{storeId}/webhook/limits tells when the next one is allowed.
Payout wallet events
Your EVM payout address is read on every network when you set it, when an invoice is created, before every forwarding and regularly in between. When it is not a regular wallet everywhere and differs from what you confirmed in the dashboard, its status is unacknowledged and new invoices leave out the EVM networks; Tron is not affected. Every active store with a webhook then gets one store.payout_attention event, and another one with the new status once it is fine again (plain or acknowledged). The same status is in evmPayoutStatus and evmNetworksBlocked of GET /v1/store, so a plugin can read it at any time and use the event only to refresh.
{
"id": "0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"type": "store.payout_attention",
"createdAt": "2026-11-05T18:40:00.000Z",
"data": {
"store": { "id": "8b2c3d4e-5f60-4b7c-9d8e-0f1a2b3c4d5e" },
"payout": {
"family": "evm",
"address": "0x5cc5bc5cc5bc5cc5bc5cc5bc5cc5bc5cc5bc5cc5",
"status": "unacknowledged",
"blockedNetworks": ["base", "arbitrum"],
"profile": {
"networks": [
{ "key": "arbitrum", "displayName": "Arbitrum One", "kind": "none", "delegate": null },
{ "key": "base", "displayName": "Base", "kind": "delegated", "delegate": "0xdededededededededededededededededededede" }
],
"needsAcknowledgement": true
},
"dashboardUrl": "https://stablevoy.com/app/"
}
}
}Leaked keys
Keys that start with svk_live_ or svk_test_ and webhook secrets that start with svwhsec_ are registered with GitHub secret scanning. When one is pushed to a public repository, GitHub tells us within minutes. A leaked API key is revoked at once and the store gets an api_key.leaked event: the key no longer works, so create a new one in the dashboard and put it in your shop. A leaked webhook secret is not rotated for you, because that would stop your shop from checking signatures without warning: the store gets a webhook_secret.leaked event with data.webhookSecret.prefix, and you rotate the secret in the dashboard. Both are also shown in the dashboard until you dismiss them, with the place where the secret was found in data.leak.url. Remove it from there as well. Keys in the older sk_ and whsec_ formats are not covered.
{
"id": "3e4f5a6b-7c8d-4e9f-8a0b-1c2d3e4f5a6b",
"type": "api_key.leaked",
"createdAt": "2026-11-09T14:05:00.000Z",
"data": {
"store": { "id": "8b2c3d4e-5f60-4b7c-9d8e-0f1a2b3c4d5e" },
"apiKey": { "id": "5d6e7f80-9a1b-4c2d-8e3f-4a5b6c7d8e9f", "prefix": "svk_live_4f9a" },
"leak": {
"id": "6f7a8b9c-0d1e-4f2a-9b3c-4d5e6f7a8b9c",
"url": "https://github.com/example/shop/blob/1a2b3c/config.php",
"source": "content",
"foundAt": "2026-11-09T14:05:00.000Z"
},
"dashboardUrl": "https://stablevoy.com/app/"
}
}Verify in Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the exact request body bytes, before any JSON parsing.
export function verifyStablevoySignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? '', 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}Verify in PHP
function stablevoy_verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
$parts = [];
foreach (explode(',', $header) as $pair) {
[$k, $v] = array_pad(explode('=', $pair, 2), 2, '');
$parts[$k] = $v;
}
$t = (int) ($parts['t'] ?? 0);
if ($t === 0 || abs(time() - $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}