Webhook ingestion
Stubly receives x402 payment events via signed HTTP POST. Each accepted event becomes a permanent, audit-ready record and produces a downloadable PDF receipt automatically. This page is everything your engineer needs to integrate.
Quickstart
- Create an endpoint in your dashboard. Save the URL and signing secret — the secret is shown exactly once.
- POST signed events to your endpoint URL when your x402 server confirms a payment.
- Watch incoming events in your dashboard's Recent events panel and the per-endpoint Delivery attempts table.
Payload
POST a JSON body with these fields:
| Field | Type | Description |
|---|---|---|
| external_id | string (1–255) | Your unique ID for this payment. Used for deduplication: sending the same external_id twice returns the original event without duplicating. |
| amount_usd | string | USD value as a decimal string with up to 6 decimal places (e.g., "1.234567"). String, not number, to preserve precision. |
| amount_raw | string (1–78) | Original chain-denominated amount as a string (e.g., "1234567" for 1.234567 USDC with 6 decimals). |
| currency | string (1–20) | Currency code, e.g., "USDC", "EURC". |
| network | string (1–50) | Chain identifier, e.g., "base", "solana", "polygon". |
| payer_address | string (1–128) | Address that paid. |
| pay_to_address | string (1–128) | Address that received. |
| resource_path | string (1–2048) | The HTTP path or resource the payer was paying for. |
| payment_timestamp | ISO 8601 | When the payment was confirmed on-chain. |
| payer_email | string, optional | If present, Stubly auto-emails the receipt PDF to this address shortly after generation. Format is checked at email-send time; malformed values surface as "Invalid email" on your dashboard rather than rejecting the payment. Omit to skip auto-email. See Auto-email delivery below. |
| raw_facilitator_response | any JSON, optional | If provided, stored verbatim in the audit record. Useful for forensics; doesn't affect dedup or display. |
Signing the payload
Stubly verifies every request with HMAC-SHA256 using your endpoint's signing secret.
- Header:
X-Stubly-Signature: t=<unix-timestamp>,v1=<hex-sha256-hmac> - HMAC input:
<unix-timestamp>.<exact-raw-body-bytes> - Tolerance: timestamps must be within 5 minutes of server time (replay protection).
Node.js
import crypto from "node:crypto";
function signPayload(secret, body) {
const ts = Math.floor(Date.now() / 1000).toString();
const sig = crypto.createHmac("sha256", secret)
.update(`${ts}.${body}`)
.digest("hex");
return `t=${ts},v1=${sig}`;
}
const body = JSON.stringify({
external_id: "order-12345",
amount_usd: "0.50",
amount_raw: "500000",
currency: "USDC",
network: "base",
payer_address: "0xabc...",
pay_to_address: "0xdef...",
resource_path: "/api/things/42",
payment_timestamp: new Date().toISOString(),
});
await fetch("https://getstubly.com/api/webhooks/<endpointId>", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Stubly-Signature": signPayload(SECRET, body),
},
body,
});Python
import hashlib, hmac, json, time
import requests
def sign_payload(secret, body):
ts = str(int(time.time()))
sig = hmac.new(
secret.encode(),
f"{ts}.{body}".encode(),
hashlib.sha256,
).hexdigest()
return f"t={ts},v1={sig}"
body = json.dumps({
"external_id": "order-12345",
"amount_usd": "0.50",
"amount_raw": "500000",
"currency": "USDC",
"network": "base",
"payer_address": "0xabc...",
"pay_to_address": "0xdef...",
"resource_path": "/api/things/42",
"payment_timestamp": "2026-04-27T18:00:00Z",
})
requests.post(
"https://getstubly.com/api/webhooks/<endpointId>",
data=body,
headers={
"Content-Type": "application/json",
"X-Stubly-Signature": sign_payload(SECRET, body),
},
)curl
SECRET='whsec_...'
BODY='{"external_id":"order-12345","amount_usd":"0.50","amount_raw":"500000","currency":"USDC","network":"base","payer_address":"0xabc...","pay_to_address":"0xdef...","resource_path":"/api/things/42","payment_timestamp":"2026-04-27T18:00:00Z"}'
TS=$(date +%s)
SIG=$(printf '%s' "${TS}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST https://getstubly.com/api/webhooks/<endpointId> \
-H 'Content-Type: application/json' \
-H "X-Stubly-Signature: t=${TS},v1=${SIG}" \
-d "$BODY"Translating from your facilitator
Your x402 facilitator (Coinbase CDP, etc.) returns its own response shape. Map it to Stubly's canonical schema in a few lines. Field names will vary — adapt to whichever facilitator you use.
// Adapt field names to whichever facilitator you use.
function toStublyEvent(facilitatorResponse, request) {
return {
external_id: request.id,
amount_usd: facilitatorResponse.amount_usd_normalized.toFixed(6),
amount_raw: facilitatorResponse.amount.toString(),
currency: facilitatorResponse.asset_symbol,
network: facilitatorResponse.network,
payer_address: facilitatorResponse.from,
pay_to_address: facilitatorResponse.to,
resource_path: request.path,
payment_timestamp: facilitatorResponse.confirmed_at,
// Optional — preserve the original blob for forensics
raw_facilitator_response: facilitatorResponse,
};
}Response codes
| Status | Body | What to do |
|---|---|---|
| 200 | {"event_id":"...","duplicate":false,"received_at":"...",. Header X-Stubly-Event-Id repeats the event id. | Event accepted. The receipt URL is immediately valid as a handle but returns 404 until generation completes (typically 5–10 seconds) — see Receipts below. |
| 200 | Same shape with duplicate: true. The original event_id and the existing receipt's current status + URL are returned. | Your retry was idempotent — no action needed. |
| 401 | {"error":"request rejected"} | Could be invalid endpoint URL, revoked endpoint, missing/wrong signature, expired timestamp, malformed payload, or schema validation failure. The exact reason is in your dashboard's per-endpoint Delivery attemptstable. Don't retry until fixed — the same request will keep failing. |
| 503 | {"error":"Service temporarily unavailable"} with Retry-After: 30 header. | Transient infrastructure issue on our end — retry after the indicated delay. |
The uniform 401 across all rejection paths is intentional: it prevents external probing from distinguishing "wrong signature" from "wrong endpoint id." The full diagnostic stays available to you (and only you) in your dashboard.
Rate limits
Stubly throttles ingestion to protect against runaway loops and abuse. Two sliding-window limits apply, both with a one-second window:
- 50 requests per second per endpoint — counted against the endpoint id in the URL. Absorbs realistic burst traffic from one x402 server while shedding load on hot loops.
- 200 requests per second per source IP across all your endpoints — separate axis. Catches clients that fan out across endpoint ids but still flood from one source.
When a request would exceed either limit, Stubly returns:
HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json
{"error":"rate_limited","retry_after_seconds":1}The Retry-After header value is the seconds until the oldest in-window request ages out. Recommended client behavior:
- Honor
Retry-Afterwhen present. - If absent for any reason, fall back to exponential backoff (e.g., 1s, 2s, 4s) capped at ~30s.
- Don't retry-storm on 429 — sustained traffic at the limit just keeps the limit engaged.
Need higher limits for legitimate burst traffic? Email jeff@getstubly.com.
Receipts
Every accepted (non-duplicate) payment event produces a single-page PDF receipt automatically. There's nothing to opt into. Generation runs as a background job after Stubly returns 200 to your webhook, so your ingestion latency stays low (typically <100 ms) regardless of receipt rendering.
Receipt status values
| Field | Type | Description |
|---|---|---|
| pending | string | The receipt has not yet been generated. The receipt_url, if non-null, will return 404 until generation completes. |
| ready | string | PDF is generated and stored. The receipt_url returns a 302 redirect to the PDF. |
| failed | string | Generation exhausted retries and failed. Rare. The receipt_url will continue to return 404. Visit your dashboard for the failure detail; contact support to regenerate. |
The receipt_url field
receipt_url is a stable Stubly-hosted URL of the form https://getstubly.com/receipts/<receiptId>. It's safe to store and reuse — even if the underlying storage backend changes, this URL stays valid. GETting it returns:
- 302 redirect to the PDF when the receipt is
ready. Browsers follow transparently; HTTP clients should follow redirects. - 404when the receipt isn't ready yet, has failed, or doesn't exist.
- 503 with
Retry-After: 30if our database is briefly unreachable.
Polling pattern: if you need the PDF immediately after a payment lands, poll the URL every 2–3 seconds for ~15 seconds. A 302 means done. A persistent 404 past 30 seconds usually means a stuck or failed generation — check your dashboard.
Successful redirect responses include Cache-Control: public, max-age=3600, so repeated fetches of a ready receipt are fast.
What's on a v1 receipt
A single US Letter page, system fonts, no customer branding (yet). Content:
- Stubly + "RECEIPT" header, receipt ID, issue date
- Amount (large, prominent) + currency + raw chain amount
- Payer wallet address and payee wallet address, full, monospace
- Network, payment timestamp (UTC), resource path, your
external_id - Transaction hash if present in your
raw_facilitator_response(we look fortx_hash,transaction_hash,txHash, orhash) - Footer: "Generated by Stubly · getstubly.com"
Future template versions will add customer branding and additional fields. Existing receipts continue rendering with their original template version — receipts you have today will look the same forever.
Auto-email delivery
Include a payer_email field on your payload and Stubly will email the receipt directly to that address once the PDF is ready. The email contains a brief HTML body with a payment summary, a link to the Stubly receipt_url, and the PDF as an attachment.
- Sender: all emails come from
receipts@getstubly.com. Customer-domain sending (using your own address as the From) is not currently supported — that's an enterprise-tier feature that requires per-customer DNS verification. - Subject:"Receipt for your payment to [your business name] — $X.XX [CURRENCY]".
- Optional: omit
payer_emailfrom your payload to skip auto-email. Existing integrations that don't send the field continue working unchanged. - Format failures don't block payments. A malformed email address (e.g.,
"not-an-email") still results in a 200 response from the webhook and a successfully recorded payment event. The email side is marked Invalid email on your dashboard with the specific format issue. - Bounces and complaints are surfaced.If the recipient's mail server rejects the message or marks it as spam, the dashboard shows a Bouncedbadge with the reason. Bounced receipts don't affect the underlying payment record, which remains valid; the receipt itself is also still accessible via the Stubly URL.
- Send timing: typically 5–15 seconds after the webhook is accepted (PDF generation + Resend send + recipient mail server acknowledgement).
If you don't have the payer's email at payment time, fetching the PDF yourself via receipt_url and forwarding it through your own system remains a fully supported path.
Security model
The receipt URL contains a high-entropy CUID (~150 bits). Anyone with the URL can fetch the PDF — that's by design, so you can embed receipts in emails, payer portals, or accountant exports without per-fetch authentication. Treat receipt URLs as semi-sensitive (similar to a password reset link): don't post them publicly, and don't share them more broadly than you need to. We can't revoke a leaked URL without re-issuing the receipt.
Idempotency
external_id is the dedup key, scoped to your organization. Sending the same external_id twice returns 200 with duplicate: true and the original event_id — useful for safe retries from your webhook delivery system. Both attempts are recorded in your delivery attempts table.
Pick external_id values that uniquely identify a payment in your system: a request ID, a transaction hash, or your internal payment ID all work.
Testing your integration
Before sending real production events:
- Use the test script. The Stubly repo includes
scripts/test-webhook.tswith 15 scenarios covering every success and failure path — including end-to-end verification that the receipt PDF lands, that the receipt URL resolves, and that auto-email delivery transitions through the full Resend round-trip (Delivered, Bounced, Invalid email). Runnpm run test:webhook -- --endpoint <url> --secret <secret> --scenario all. All 15 should pass. - Watch your dashboard. Each event you send appears in Recent events within seconds. Failures appear in the Delivery attempts table on the per-endpoint detail page with the exact internal reason.
Once your integration is stable, point your real x402 server at the same endpoint URL.