Webhooks
Webhooks
Configure your endpoint, verify HMAC signatures, and handle Doggie Pay events idempotently.
Webhooks
Webhooks notify your server when on-chain and ledger events complete. Prefer them over browser redirects or client-side polls when deciding to mark an order paid or ship.
Related: Getting started · Accept a payment
1. Configure the endpoint
- Set your HTTPS Webhook URL (must accept
POSTwith raw JSON body) - Save the
webhook_secretshown once (whsec_…) - You can rotate the secret later; old signatures stop validating after rotate
| Portal / API | Description |
|---|---|
PUT /v1/merchant/webhook | Set { "url": "https://…" } → returns plaintext webhook_secret once |
POST /v1/merchant/webhook/rotate-secret | New secret (plaintext once) |
DELETE /v1/merchant/webhook | Clear URL + secret |
GET /v1/merchant/webhook/events | Delivery log (portal / Secret) |
2. Delivery contract
| Item | Value |
|---|---|
| Method | POST |
| Body | Raw JSON event envelope |
| Success | Respond 2xx quickly (ack first, process async if needed) |
| Failure | Non-2xx → retried with backoff via outbox drain |
| Header | X-DoggiePay-Signature: t=<unix>,v1=<hex> |
| Content-Type | application/json |
Envelope
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "checkout.paid",
"data": { }
}
| Field | Type | Description |
|---|---|---|
id | uuid | Outbox / event id — use for idempotency |
type | string | Event name |
data | object | Event payload |
3. Verify the signature
Header format:
X-DoggiePay-Signature: t=1710000000,v1=abcdef…
Algorithm:
v1 = HMAC-SHA256( webhook_secret, t + "." + rawBody )
Compare v1 to the hex digest using a constant-time compare. Reject if:
- Header missing / malformed
|now - t|exceeds your tolerance (e.g. 5 minutes) — replay protection- Digest mismatch
Node.js example
import crypto from "node:crypto";
function verifyDoggiePaySignature(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
String(header || "")
.split(",")
.map((p) => p.trim().split("="))
.filter((kv) => kv.length === 2),
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
const ts = Number(t);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`, "utf8")
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
// Express: use express.raw({ type: "application/json" }) for this route
app.post("/webhooks/doggiepay", (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyDoggiePaySignature(raw, req.get("X-DoggiePay-Signature"), process.env.DOGGIEPAY_WEBHOOK_SECRET)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(raw);
// TODO: idempotent handle by event.id
res.status(200).json({ received: true });
});
Go example
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "." + string(rawBody)))
expected := hex.EncodeToString(mac.Sum(nil))
ok := hmac.Equal([]byte(expected), []byte(v1))
Always hash the raw request bytes. Do not re-serialize JSON before verifying.
4. Event catalog
Checkout
type | When | Typical data keys |
|---|---|---|
checkout.paid | On-chain payment confirmed for a Checkout Session | payment_id, deposit_id, circle_tx_id |
checkout.expired | Session expired unpaid | payment_id |
checkout.cancelled | Session cancelled | payment_id |
payment_id equals the Checkout Session id from create.
Customers (deposits & withdrawals)
type | When | Typical data keys |
|---|---|---|
deposit.credited | Long-lived wallet deposit credited to merchant pool | deposit_id, amount, currency, external_user_id, blockchain, tx_hash, circle_tx_id, … |
withdrawal.prepared | Portal prepare flow only | withdrawal fields |
withdrawal.requested | Withdrawal accepted | … |
withdrawal.submitted | Submitted on-chain / to rail | … |
withdrawal.complete | Success | … |
withdrawal.failed | Failed | … |
external_user_id is your Customer ref_id. Credit your user ledger on deposit.credited; Doggie Pay does not store end-user balances.
Merchant payouts & Checkout refunds
type | Rail |
|---|---|
payout.requested / submitted / complete / failed | Merchant self-custody payout |
refund.requested / submitted / complete / failed | Payment (Checkout) refund |
5. Handler best practices
- Verify signature before parsing business logic
- Idempotent on
event.id(and oftenpayment_id/deposit_id) — deliveries may retry - Return 2xx only after you have durably recorded the event (or queued work)
- Keep the HTTP handler fast; do heavy work in a worker
- Do not expose
webhook_secretto browsers - On secret rotate, deploy the new secret atomically
Minimal checkout.paid flow
verify HMAC
→ if event.id seen → 200
→ mark order paid (payment_id / metadata.order_id)
→ store event.id
→ 200
6. Testing tips
| Check | Expected |
|---|---|
| Wrong signature | Your app returns 401; no side effects |
Replay same id | Second handle is no-op |
| Local tunnel | Use HTTPS tunnel to your laptop for the webhook URL |
| Delivery log | Inspect GET /v1/merchant/webhook/events in the portal |
7. Next steps
- Complete Accept a payment end-to-end with a real
checkout.paid - Add Customers deposit handling when you ship in-app wallets
