Webhooks
ButterPay sends webhooks as HTTP POST requests to your registered endpoint whenever a relevant on-chain event is observed and confirmed. Each delivery includes a signed payload so you can verify it originated from ButterPay before acting on it.
Overview
Register a webhook URL in the dashboard (Settings → Webhook URL) or by calling
PATCH /v1/merchants/me. When a relevant event fires, the backend serializes a JSON payload,
signs it with your webhook secret, and POSTs it to that URL.
Headers sent on every delivery
| Header | Value |
|---|---|
Content-Type |
application/json |
X-ButterPay-Signature |
t=<unix_timestamp>,v1=<hex_hmac> |
X-ButterPay-Event |
Event name, e.g. payment.confirmed |
ButterPay considers a delivery successful when your endpoint returns any 2xx status code within
10 seconds. Non-2xx responses and connection errors both trigger the retry schedule described
in Retry policy.
Idempotency: network conditions may cause the same event to be delivered more than once,
particularly when retries fire close together. Use the webhook log id field (available via
GET /v1/webhooks/logs) as a deduplication key in your handler. Processing the same id twice
should be a no-op.
Event types
All events listed below are emitted only after the corresponding on-chain event has reached the configured confirmation depth. ButterPay does not deliver speculative or unconfirmed events.
| Event | When it fires |
|---|---|
merchant.registered |
A MerchantRegistered event was observed for your merchant on-chain |
payment.confirmed |
A PaymentProcessed event for a one-time invoice was observed and verified |
payment.expired |
An invoice's TTL (30 minutes) elapsed before any payment was detected |
plan.created |
A PlanCreated event was observed for one of your plans |
plan.deactivated |
A PlanActiveChanged(false) event was observed for one of your plans |
subscription.created |
A Subscribed event was observed (new subscriber) |
subscription.charged |
A Charged event was observed for a billing cycle |
subscription.cancelled |
A Cancelled event was observed (note the double-L spelling) |
subscription.expired |
An ExpiredByFailure event was observed (subscriber's allowance or balance ran out and the on-chain subscription terminated) |
subscription.resubscribed |
A Resubscribed event was observed (a previously cancelled or expired subscriber re-subscribed) |
ButterPay does not currently fire a per-cycle
subscription.charge_failedwebhook. When a subscriber's allowance or balance runs out, the on-chain contract eventually emitsExpiredByFailure, which ButterPay delivers assubscription.expired. Use that event to detect involuntary churn.The test endpoint (
POST /v1/webhooks/test) lets you send any of the events above to your webhook URL with synthetic data. See Testing webhooks.
Payload shape
Every event uses the same envelope:
{
"event": "<event_name>",
"data": { ... }
}
The shape of data is determined by the event field. The full table of data fields per
event type is below; an example payload for each event is shown in
Example payloads.
| Event | data fields |
||
|---|---|---|---|
merchant.registered |
merchantId, onChainMerchantId, txHash, chainId |
||
payment.confirmed |
invoiceId, txHash, amountPaid, merchantNet |
||
payment.expired |
invoiceId |
||
plan.created |
planId, externalPlanCode, chainId, txHash |
||
plan.deactivated |
planId, chainId |
||
subscription.created |
subscriptionId, planId, subscriber, anchorTime, anchorDay |
||
subscription.charged |
subscriptionId, cyclesCharged, amount, merchantNet, txHash |
||
subscription.cancelled |
subscriptionId, cancelledBy ("user" \ |
"merchant" \ |
"plan_closed") |
subscription.expired |
subscriptionId, lastCyclesCharged, reason |
||
subscription.resubscribed |
subscriptionId, newAnchorTime |
Common field semantics
| Field | Type | Description |
|---|---|---|
event |
string | Event name (see table above) |
data |
object | Event-specific payload |
merchantId |
string | Internal ButterPay merchant ID (mer_…) |
onChainMerchantId |
string | The merchantId value emitted by the contract (decimal string) |
chainId |
number | EVM chain ID, e.g. 42161 for Arbitrum One |
txHash |
string | On-chain transaction hash that produced the event |
invoiceId |
string | One-time invoice ID — internal UUID or on-chain bytes32 depending on context |
amountPaid |
string | Token amount transferred by the payer (smallest unit, e.g. USDC's 6-decimal base units) |
merchantNet |
string | Token amount delivered to the merchant after fees (smallest unit) |
planId |
string | Plan identifier — for plan and subscription events this is the on-chain bytes32 plan ID |
externalPlanCode |
string | Merchant-supplied plan code (may be empty if the plan was created on-chain only) |
subscriptionId |
string | Subscription identifier — internal UUID or on-chain subscription ID |
subscriber |
string | Subscriber wallet address (0x…) |
anchorTime |
string | ISO 8601 timestamp of the subscription anchor point |
anchorDay |
number | Day-of-month (1-28) used for monthly anchoring |
cyclesCharged |
number | Total number of cycles charged for this subscription so far |
amount |
string | Token amount charged this cycle (smallest unit) |
cancelledBy |
string | "user", "merchant", or "plan_closed" |
lastCyclesCharged |
number | Final cycle count at the moment the subscription expired |
reason |
string | Raw bytes32 reason from the contract event (hex-encoded), or a known label |
newAnchorTime |
string | ISO 8601 timestamp of the new anchor after a re-subscribe |
Token amounts are reported in the token's smallest base unit (e.g. for USDC on Arbitrum,
"10000000"represents10.000000USDC). Convert using the token'sdecimalsvalue.
Example payloads
merchant.registered
{
"event": "merchant.registered",
"data": {
"merchantId": "mer_01hwz4m8y3g9c5d7f8h0j2kn",
"onChainMerchantId": "42",
"txHash": "0xabc123def456...",
"chainId": 42161
}
}
payment.confirmed
{
"event": "payment.confirmed",
"data": {
"invoiceId": "inv_01hwz4m8y3g9c5d7f8h0j2kn",
"txHash": "0xabc123def456...",
"amountPaid": "49990000",
"merchantNet": "49590200"
}
}
payment.expired
{
"event": "payment.expired",
"data": {
"invoiceId": "inv_01hwz4m8y3g9c5d7f8h0j2kn"
}
}
plan.created
{
"event": "plan.created",
"data": {
"planId": "0x1111111111111111111111111111111111111111111111111111111111111111",
"externalPlanCode": "pro-monthly",
"chainId": 42161,
"txHash": "0xcd34cd34cd34cd34cd34cd34cd34cd34cd34cd34cd34cd34cd34cd34cd34cd34"
}
}
plan.deactivated
{
"event": "plan.deactivated",
"data": {
"planId": "0x1111111111111111111111111111111111111111111111111111111111111111",
"chainId": 42161
}
}
subscription.created
{
"event": "subscription.created",
"data": {
"subscriptionId": "sub_01hwz9p4q5r6s7t8u9v0w1xy",
"planId": "0x1111111111111111111111111111111111111111111111111111111111111111",
"subscriber": "0x2222222222222222222222222222222222222222",
"anchorTime": "2026-05-01T00:00:00.000Z",
"anchorDay": 1
}
}
subscription.charged
{
"event": "subscription.charged",
"data": {
"subscriptionId": "sub_01hwz9p4q5r6s7t8u9v0w1xy",
"cyclesCharged": 3,
"amount": "19990000",
"merchantNet": "19830080",
"txHash": "0xefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef"
}
}
subscription.cancelled
{
"event": "subscription.cancelled",
"data": {
"subscriptionId": "sub_01hwz9p4q5r6s7t8u9v0w1xy",
"cancelledBy": "user"
}
}
subscription.expired
{
"event": "subscription.expired",
"data": {
"subscriptionId": "sub_01hwz9p4q5r6s7t8u9v0w1xy",
"lastCyclesCharged": 4,
"reason": "0x0000000000000000000000000000000000000000000000000000000000000000"
}
}
subscription.expired is fired when the on-chain contract emits ExpiredByFailure — typically
because the subscriber's allowance or balance ran out and a scheduled charge could not settle.
Treat this as terminal involuntary churn for the subscription.
subscription.resubscribed
{
"event": "subscription.resubscribed",
"data": {
"subscriptionId": "sub_01hwz9p4q5r6s7t8u9v0w1xy",
"newAnchorTime": "2026-06-01T00:00:00.000Z"
}
}
Error codes
Most ButterPay webhook events do not carry an error code. Event delivery on V2 follows the
on-chain state machine: a payment either reaches payment.confirmed (the PaymentProcessed
event was observed) or payment.expired (the invoice TTL elapsed without a transaction).
Reverted on-chain transactions produce no event and therefore no webhook — your integration
should rely on the absence of payment.confirmed plus the eventual payment.expired, not on a
failure event.
Similarly for subscriptions: an individual cycle that fails to settle does not produce a
webhook. ButterPay only delivers subscription.expired once the on-chain contract emits
ExpiredByFailure, which terminates the subscription. The data.reason field on
subscription.expired carries the raw bytes32 reason from the contract event; treat
unrecognized values as opaque.
If you observe stuck or unexpected behavior, query GET /v1/webhooks/logs and GET /v1/invoices
or GET /v1/subscriptions to inspect the underlying state, and contact support with the
relevant txHash.
Signature verification
Every delivery includes an X-ButterPay-Signature header in the format:
X-ButterPay-Signature: t=1745763121,v1=a3f2c1d9...
This follows the same convention as Stripe's webhook signatures. If you already have Stripe webhook verification logic, the algorithm is identical.
Algorithm
- Split the header value on
,to extractt(Unix timestamp, seconds) andv1(hex HMAC). - Construct the signed content:
<t>.<raw_request_body>— concatenate the timestamp string, a literal., and the raw, unmodified request body bytes. - Compute
HMAC-SHA256(webhook_secret, signed_content)and encode it as lowercase hex. - Compare your computed HMAC with the
v1value using a constant-time comparison function. - Reject the request if
Math.abs(Date.now() / 1000 - t) > 300(5-minute replay window).
Your webhook secret is available at GET /v1/webhooks/secret (requires X-Api-Key).
Critical: compute the HMAC over the raw request body bytes, not a re-serialized JSON object. Any whitespace or key-ordering difference will produce a different HMAC and cause verification to fail.
Node.js / Express
const crypto = require("crypto");
app.post(
"/webhooks/butterpay",
express.raw({ type: "application/json" }), // preserve raw bytes
(req, res) => {
const webhookSecret = process.env.BUTTERPAY_WEBHOOK_SECRET;
const sigHeader = req.headers["x-butterpay-signature"];
if (!sigHeader) {
return res.status(400).send("Missing signature header");
}
// Parse t=... and v1=...
const parts = Object.fromEntries(
sigHeader.split(",").map((p) => p.split("="))
);
const timestamp = parts["t"];
const receivedHmac = parts["v1"];
if (!timestamp || !receivedHmac) {
return res.status(400).send("Malformed signature header");
}
// Replay-window check (5 minutes)
const nowSecs = Math.floor(Date.now() / 1000);
if (Math.abs(nowSecs - parseInt(timestamp, 10)) > 300) {
return res.status(400).send("Timestamp outside replay window");
}
// Compute expected HMAC over "<timestamp>.<rawBody>"
const signedContent = `${timestamp}.${req.body}`;
const expectedHmac = crypto
.createHmac("sha256", webhookSecret)
.update(signedContent)
.digest("hex");
// Constant-time comparison
const expected = Buffer.from(expectedHmac, "hex");
const received = Buffer.from(receivedHmac, "hex");
if (
expected.length !== received.length ||
!crypto.timingSafeEqual(expected, received)
) {
return res.status(401).send("Signature mismatch");
}
const event = JSON.parse(req.body);
// Handle event ...
return res.sendStatus(200);
}
);
Python / Flask
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
@app.route("/webhooks/butterpay", methods=["POST"])
def butterpay_webhook():
webhook_secret = os.environ["BUTTERPAY_WEBHOOK_SECRET"].encode()
sig_header = request.headers.get("X-ButterPay-Signature", "")
if not sig_header:
abort(400, "Missing signature header")
# Parse t=... and v1=...
parts = dict(p.split("=", 1) for p in sig_header.split(","))
timestamp = parts.get("t")
received_hmac = parts.get("v1")
if not timestamp or not received_hmac:
abort(400, "Malformed signature header")
# Replay-window check (5 minutes)
if abs(time.time() - int(timestamp)) > 300:
abort(400, "Timestamp outside replay window")
# Compute expected HMAC over "<timestamp>.<rawBody>"
raw_body = request.get_data() # raw bytes — do NOT call request.json first
signed_content = f"{timestamp}.".encode() + raw_body
expected_hmac = hmac.new(webhook_secret, signed_content, hashlib.sha256).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(expected_hmac, received_hmac):
abort(401, "Signature mismatch")
event = json.loads(raw_body)
# Handle event ...
return "", 200
Retry policy
If an initial delivery fails, the backend schedules up to three retries at the following intervals after each failed attempt:
| Attempt | Delay after previous failure |
|---|---|
| 1st retry | 10 seconds |
| 2nd retry | 60 seconds |
| 3rd retry | 300 seconds (5 minutes) |
After the third retry fails, the delivery is marked failed and no further attempts are made.
The nextRetryAt field in the webhook log is set to null once all retries are exhausted.
The URL is re-validated immediately before every retry attempt (including the original send). If the URL no longer passes validation at retry time — for example because DNS now resolves to a private address — the attempt is skipped without counting as a failure.
Retrieve delivery history and retry status with:
curl https://api.butterpay.io/v1/webhooks/logs \
-H "X-Api-Key: bp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
The response includes attempts, statusCode, success, nextRetryAt, and the raw
response body returned by your endpoint (truncated to 500 characters).
URL requirements
ButterPay validates your webhook URL both at registration time and immediately before each send (including retries). The dual-validation approach narrows the DNS rebind window that would otherwise exist between a passing registration check and the actual HTTP call.
Protocol
- In production (
NODE_ENV=production): HTTPS is required. HTTP URLs are rejected. - In development: HTTP is permitted.
Blocked IP ranges
The backend resolves the hostname with a DNS lookup and rejects the URL if the resolved address falls in any of the following ranges:
| Range | Classification |
|---|---|
10.0.0.0/8 |
RFC 1918 private |
172.16.0.0/12 |
RFC 1918 private |
192.168.0.0/16 |
RFC 1918 private |
127.0.0.0/8 |
Loopback |
169.254.0.0/16 |
Link-local / AWS IMDS |
100.64.0.0/10 |
CGNAT (RFC 6598) |
224.0.0.0/4 |
Multicast |
240.0.0.0/4 |
Reserved |
0.0.0.0/8 |
"This network" |
::1 |
IPv6 loopback |
fc00::/7 |
IPv6 ULA (covers fc00:: and fd00::) |
fe80::/10 |
IPv6 link-local |
ff00::/8 |
IPv6 multicast |
::ffff:0:0/96 |
IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) |
Any URL whose hostname resolves to an address in these ranges is rejected with a 400 error
at registration time. At send time, the delivery is silently skipped with a warning logged.
Idempotency
The retry schedule means the same event payload may arrive at your endpoint more than once. Design your handler to be idempotent.
The recommended deduplication key is the webhook log id returned by GET /v1/webhooks/logs.
Each initial delivery attempt creates one log record; retries update that same record rather
than creating new ones. Storing the log id and skipping processing when you see a repeated
value is sufficient for most use cases.
For payment events, the combination of invoiceId + event is also unique per invoice
lifecycle (an invoice can only be confirmed or expired once), and can serve as a simpler
deduplication strategy if you do not wish to query the logs endpoint.
For subscription events, subscriptionId + event + cyclesCharged (where applicable) is a
stable per-cycle key.
Testing webhooks
POST /v1/webhooks/test sends a synthetic payload to your currently configured webhook URL.
The request is authenticated with your API key and the payload is signed with your webhook
secret, so your verification logic runs exactly as it would for a real event.
You may select any V2 event type listed in Event types. The test endpoint
populates the data object with placeholder values that match the production schema.
curl -X POST https://api.butterpay.io/v1/webhooks/test \
-H "X-Api-Key: bp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"event":"payment.confirmed"}'
Response
{
"success": true,
"statusCode": 200,
"url": "https://your-server.example.com/webhooks/butterpay",
"response": "OK"
}
If your URL is not configured or fails validation, the endpoint returns a 400 before any
delivery is attempted:
{
"error": "No webhook URL configured. Set one in Settings first."
}
Test deliveries also include an X-ButterPay-Test: true header so your handler can short-circuit
business logic when receiving them in production. The synthetic payloads use placeholder IDs
(e.g. inv_test_…, sub_test_…) and a deterministic txHash of repeating bytes, which are
safe to receive as long as your handler checks for the test header (or the placeholder IDs)
before performing real-world side effects.
Troubleshooting
Signature mismatch
The most common cause is parsing the JSON body before computing the HMAC. Any JSON library may
produce different whitespace or key ordering than the original payload. Always pass the raw
request body bytes to the HMAC function. In Express, use express.raw({ type: "application/json" })
on your route instead of express.json(). In Flask, call request.get_data() before calling
request.json.
401 Unauthorized on webhook logs or test endpoint
Your API key may have been rotated or revoked. Retrieve a new key from the dashboard (Settings → API Key → Rotate) and update your server's environment variables.
URL rejected at registration
Registration fails with 400 if:
- The URL uses HTTP in production (HTTPS is required).
- The hostname resolves to a private, loopback, link-local, or CGNAT address.
- The hostname cannot be resolved at all.
Use a public HTTPS endpoint. For local development, tools such as ngrok or Cloudflare Tunnel expose a local server under a public HTTPS URL.
Expected payment.failed or subscription.charge_failed event never arrives
V2 does not deliver per-attempt failure webhooks. Reverted on-chain transactions emit no event
and produce no webhook; for invoices, the absence of payment.confirmed followed by
payment.expired indicates the payment did not settle. For subscriptions, repeated cycle
failures eventually trigger ExpiredByFailure on-chain, which is delivered as
subscription.expired. If you need per-attempt visibility, poll GET /v1/invoices or
GET /v1/subscriptions.
Retries exhausted — delivery marked failed
Check GET /v1/webhooks/logs to see the response field from your server and the final
statusCode. Common causes:
- Your server returned a non-
2xxstatus. - Your server took longer than 10 seconds to respond (the per-request timeout).
- A TLS certificate error prevented the connection.
Fix the underlying issue, then handle any missed events by replaying the payloads from the
logs response or by querying the affected invoices via GET /v1/invoices or subscriptions via
GET /v1/subscriptions.