Receive webhooks
Here's the secret about the "hard" part: it's a single endpoint that listens for one word — paid. You've already built the whole checkout. This is the short hop that connects it to your app, and you've got everything you need.
A webhook is just HashPay making an HTTP POST to a URL you own when something happens — a payment confirms, expires, and so on. Your job is to receive it, check it's genuinely from HashPay, and act on it. Four small moves, and we'll make each one together.
Move 1 · A tiny endpoint
Start with the smallest possible thing that works: a route that accepts a POST and replies 200. We'll harden it in a moment — first just prove it's reachable.
const express = require('express')
const app = express()
app.post('/hashpay/webhook', express.json(), (req, res) => {
console.log('got a webhook:', req.body.type)
res.sendStatus(200)
})
app.listen(3000)
That's a real, working webhook receiver. Everything from here is making it trustworthy and useful.
Move 2 · Point HashPay at it
In the dashboard, open Integration, scroll to Webhook, and paste your endpoint's URL into the Webhook URL field — for example https://api.example.com/hashpay/webhook. Hit Save.
HashPay shows you a signing secret, once:
whsec_3kf8Pq2rStUvWxYzAbCdEfGh
Copy the secret now and store it somewhere safe (an environment variable like HASHPAY_WEBHOOK_SECRET). It's per-store, and it's how you'll prove each incoming request really came from HashPay — so it's shown only this once. Need to change it later? You can, with no downtime — see Rotating secrets below.
Move 3 · Verify the signature
Every webhook arrives with three headers:
| Header | What it carries |
|---|---|
X-HashPay-Event | The event type, e.g. payment.confirmed. |
X-HashPay-Delivery | A stable id for this event — your idempotency key. (It's also the payload's id, inside the signed body.) |
X-HashPay-Signature | t=<unix seconds>,v1=<hex> — the delivery timestamp plus one HMAC-SHA256 per active signing secret. |
To verify: pull t out of the header, compute HMAC-SHA256 over the string <t> + "." + <raw request body> using your secret, and compare it (constant-time) against each v1 value — accept if any matches. Then reject deliveries whose t is more than 5 minutes from your clock; because the timestamp is inside the HMAC, a captured request can't be replayed later. The one rule that trips people up: hash the raw bytes, before any JSON parsing reformats them.
The header can hold more than one v1= entry while you have several secrets active during a rotation. So collect every v1 and accept the request if any of them matches your secret. With a single secret that's just one value — but writing the check this way means a rotation never costs you a dropped delivery. Every snippet below already does this.
const crypto = require('crypto')
const SECRET = process.env.HASHPAY_WEBHOOK_SECRET
const TOLERANCE = 300 // seconds — reject deliveries older than 5 minutes
// express.raw keeps req.body as the exact bytes HashPay signed
app.post('/hashpay/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const parts = (req.get('X-HashPay-Signature') || '').split(',')
const t = (parts.find((p) => p.startsWith('t=')) || '').slice(2)
const sigs = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3))
// Recompute over "<t>.<raw body>" — the timestamp is inside the HMAC, so it can't be swapped.
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.`).update(req.body).digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= TOLERANCE
// One v1 per active secret (two during a rotation) — accept if any matches.
const ok = fresh && sigs.some((sig) =>
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
if (!ok) return res.status(400).send('bad signature')
const event = JSON.parse(req.body.toString('utf8'))
handle(event) // your logic — see Move 4
res.sendStatus(200)
})
import os, time, hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["HASHPAY_WEBHOOK_SECRET"].encode()
TOLERANCE = 300 # seconds — reject deliveries older than 5 minutes
@app.post("/hashpay/webhook")
def webhook():
body = request.get_data() # raw bytes
parts = request.headers.get("X-HashPay-Signature", "").split(",")
t = next((p[2:] for p in parts if p.startswith("t=")), "")
sigs = [p[3:] for p in parts if p.startswith("v1=")]
if not t.isdigit() or abs(time.time() - int(t)) > TOLERANCE:
abort(400) # stale timestamp = possible replay
# Recompute over "<t>.<raw body>"; one v1 per active secret — accept if any matches.
expected = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(s, expected) for s in sigs):
abort(400)
event = request.get_json()
handle(event) # your logic — see Move 4
return "", 200
<?php
$secret = getenv('HASHPAY_WEBHOOK_SECRET');
$body = file_get_contents('php://input'); // raw body
$parts = explode(',', $_SERVER['HTTP_X_HASHPAY_SIGNATURE'] ?? '');
$t = ''; $sigs = [];
foreach ($parts as $p) {
if (str_starts_with($p, 't=')) { $t = substr($p, 2); }
if (str_starts_with($p, 'v1=')) { $sigs[] = substr($p, 3); }
}
// Recompute over "<t>.<raw body>"; one v1 per active secret — accept if any matches.
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
$fresh = ctype_digit($t) && abs(time() - (int) $t) <= 300; // 5-minute replay window
$ok = false;
foreach ($sigs as $sig) { if (hash_equals($expected, $sig)) { $ok = true; break; } }
if (!$fresh || !$ok) {
http_response_code(400);
exit('bad signature');
}
$event = json_decode($body, true);
handle($event); // your logic — see Move 4
http_response_code(200);
require 'sinatra'
require 'openssl'
require 'json'
SECRET = ENV['HASHPAY_WEBHOOK_SECRET']
TOLERANCE = 300 # seconds — reject deliveries older than 5 minutes
post '/hashpay/webhook' do
body = request.body.read # raw body
parts = request.env['HTTP_X_HASHPAY_SIGNATURE'].to_s.split(',')
t = parts.find { |p| p.start_with?('t=') }.to_s.delete_prefix('t=')
sigs = parts.select { |p| p.start_with?('v1=') }.map { |p| p.delete_prefix('v1=') }
halt 400, 'bad signature' if (Time.now.to_i - t.to_i).abs > TOLERANCE
# Recompute over "<t>.<raw body>"; one v1 per active secret — accept if any matches.
expected = OpenSSL::HMAC.hexdigest('SHA256', SECRET, "#{t}.#{body}")
halt 400, 'bad signature' unless sigs.any? { |s| Rack::Utils.secure_compare(expected, s) }
event = JSON.parse(body)
handle(event) # your logic — see Move 4
status 200
end
Move 4 · Do something on "paid"
Now the payoff. The body is JSON with a predictable shape — an envelope (id, type, createdAt, apiVersion) wrapping the event data:
{
"id": "pay_66f2a19b03c84d2ea1b7c4de:payment.confirmed",
"type": "payment.confirmed",
"createdAt": "2026-06-28T12:15:30Z",
"apiVersion": "2026-07-02",
"data": {
"paymentId": "pay_66f2a19b03c84d2ea1b7c4de",
"accountId": "acct_5b3e8c1fa2d9407be6135f9c",
"storeId": "store_0c47d9e2ab35f18c6e24ba70",
"mode": "live",
"network": "base",
"token": "usdc",
"address": "0xA1b2…",
"amount": "29.00",
"amountBaseUnits": "29000000",
"decimals": 6,
"txHash": "0x9f8e…",
"blockNumber": 19283746,
"status": "CONFIRMED",
"confirmations": 12,
"metadata": { "orderId": "order_123" }
}
}
So your handler is a switch on type. For the common case, you care about one branch:
function handle(event) {
switch (event.type) {
case 'payment.confirmed': {
const { paymentId, amount, metadata } = event.data
fulfilOrder(metadata.orderId, { paymentId, amount }) // ✅ ship it
break
}
case 'payment.underpaid':
case 'payment.expired':
// nudge the buyer, release the cart, etc.
break
}
}
Pass an orderId (or any reference) through metadata when you create the checkout, and it comes right back here — that's how you tie a payment to the thing it paid for. The full list of events and fields is in the reference.
Using open-amount checkout? Check amount against what the order should cost before fulfilling, and don't trust buyer-supplied metadata for entitlement. The browser set that amount, so a valid signature proves the payment is real but not that it's the right size. (Payments created from a Price or Session carry a server-trusted amount — no extra check needed.)
Two habits that make it bulletproof
- Reply
2xxquickly. Acknowledge first, then do slow work asynchronously. If HashPay doesn't get a2xxwithin ~15 seconds, it retries with exponential backoff for up to 72 hours before marking the delivery dead — so a slow handler can turn one payment into several deliveries. Every attempt (and your endpoint's response) shows up in the Delivery log on the Integration page. - Dedupe on the event id. Because of those retries, the same event can arrive more than once. The
id(also inX-HashPay-Delivery) is stable — record it and ignore ids you've already processed. Then fulfilment happens exactly once.
Rotating signing secrets
Secrets shouldn't live forever — someone leaves the team, a value lands in a log, or you just rotate on a schedule. HashPay makes this a non-event: a store can have several signing secrets active at once, and we sign every webhook with all of them (one v1= entry each). So you roll one in without ever dropping a delivery:
- On Integration, under your webhook, click Add secret. Copy the new
whsec_…(shown once) and deploy it to your server alongside the old one — your verify step already accepts any matching signature, so both work. - Once the new secret is live everywhere, come back and revoke the old one. From then on webhooks are signed only with the new secret.
No maintenance window, no missed events — at every moment your endpoint is verifying against a secret it holds.
Extra credit · re-verify by fetching the payment
Signature verification above is all you need — a valid signature proves the request genuinely came from HashPay. If you want defense in depth for high-value orders, you can add a second, independent check: confirm the payment by reading it straight from the HashPay API with your secret key, and trust that as the source of truth.
This is optional belt-and-suspenders, not a fix for a gap — the signature already authenticates the webhook. It's worth it when the cost of acting on a bad event is high, because the re-fetch is bound to your account via your secret key (which never leaves your server) and reflects the live, authoritative status.
Take the data.paymentId from the event and read it back with your sk_… key:
// After the signature checks out, confirm the status from the API itself.
async function isReallyPaid(paymentId) {
const res = await fetch(`https://api.hashpay.dev/v1/payments/${paymentId}`, {
headers: { Authorization: `Bearer ${process.env.HASHPAY_SECRET_KEY}` }
})
if (!res.ok) return false // 404 = not your payment / doesn't exist
const payment = await res.json()
return payment.status === 'CONFIRMED'
}
If the id isn't yours (or doesn't exist) the call returns 404, so a forged event with a made-up id simply fails this check. The same endpoint works without a key as the public buyer view; adding your secret key binds the read to the store that owns the payment.
Test it end-to-end
You don't need a real payment to see this work — you've already got the trigger from Step 5.
- Expose your local server with a tunnel (e.g.
ngrok http 3000) and set that public URL as your webhook on Integration. - In Payments (test mode), click Simulate payment on a test payment.
- Watch a signed
payment.confirmedland on your endpoint, pass verification, and run your fulfilment. That's the entire loop, proven.
Flip to live
When your test run looks right, going live is a checklist, not a rewrite:
- Verify your email (if you signed up with email/password) — this unlocks live keys.
- Flip the dashboard's test / live toggle to live and swap your button's
data-pkto thepk_live_…key from Integration. - Make sure your real domain is listed under Domains (Step 2), so your live traffic is reviewed and anything foreign stands out.
- Point the webhook URL at your production server. It's per-store, not per-mode — the endpoint and
whsec_…secret you set up in test already receive live events.
Everything you built in test carries over unchanged. The only difference is that now the money is real — and it's landing straight in your wallet.
That's it — you're fully live. 🎉
Account, domain, wallets, widget, button, and a server that fulfils orders the instant a payment confirms. You just built a complete, non-custodial stablecoin checkout — and the webhook you were told to fear turned out to be a dozen lines. Go take a payment.