# Webhooks: signed USDT and USDC payment events

Stablevoy tells your server about every change of an invoice with a signed HTTP request. Mark orders paid from these events, never from the browser.

## 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](https://stablevoy.com/docs/webhooks/#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](https://www.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'] ?? '');
}
```

Documentation

## More in the docs

Source: https://stablevoy.com/docs/webhooks/
