# HashPay > Non-custodial stablecoin payments for websites and SaaS. Accept USDC and USDT across eight chains (Ethereum, Base, Arbitrum, Polygon, Avalanche, BNB Chain, Solana, and USDT on Tron), with every payment going straight to the seller's own wallet — HashPay never holds funds or keys. An account is fully configurable over HTTP with one secret API key. To set HashPay up for someone: they sign up at https://app.hashpay.dev, create a secret key on the Integration page, and give it to you — ideally as an environment variable, not pasted into chat. Then follow the setup guide below. Take receiving wallet addresses only from the seller, read each one back in full before adding it, and never invent or "fix" one: payments to a wrong address can't be recovered. Send an `Idempotency-Key` header on every POST, and use `GET /v1/stores/{storeId}/setup` to resume and to verify you finished. ## Agent setup - [Setup guide](https://hashpay.dev/agent/hashpay-setup/SKILL.md): end-to-end walkthrough — account, receiving wallets, webhook endpoint and handler, catalog, checkout embed, test payment, setup checklist - [Webhook handler reference](https://hashpay.dev/agent/hashpay-setup/references/webhook-handler.md): event catalog, payload shape, signature scheme, and the rules a correct receiver follows - [Express handler template](https://hashpay.dev/agent/hashpay-setup/assets/handler-express.js): verified Node/Express receiver to copy and adapt - [FastAPI handler template](https://hashpay.dev/agent/hashpay-setup/assets/handler-fastapi.py): verified Python/FastAPI receiver to copy and adapt - [OpenAPI 3.1 spec](https://api.hashpay.dev/openapi.json): every endpoint, request, response and error code - [Claude Code plugin](https://github.com/hashpay-dev/claude-plugin): `/plugin marketplace add hashpay-dev/claude-plugin`, then `/plugin install hashpay@hashpay` ## Docs - [Set up with an AI agent](https://hashpay.dev/docs/agent): what the seller does, what the agent does - [Introduction](https://hashpay.dev/docs): how HashPay works and the six setup steps - [Add your wallets](https://hashpay.dev/docs/wallets): address validation and wallet change notices - [Receive webhooks](https://hashpay.dev/docs/webhooks): building the receiver - [API overview](https://hashpay.dev/docs/api): base URL, conventions, idempotency, limits - [Authentication](https://hashpay.dev/docs/api/authentication): publishable vs secret keys, test vs live - [Errors](https://hashpay.dev/docs/api/errors): the error envelope and codes - [Checkout](https://hashpay.dev/docs/api/checkout): creating payments - [Checkout Sessions](https://hashpay.dev/docs/api/sessions): server-decided amounts - [Products & Prices](https://hashpay.dev/docs/api/catalog): catalog and subscriptions - [Webhooks reference](https://hashpay.dev/docs/api/webhooks): events and signatures - [Embed SDK](https://hashpay.dev/docs/api/embed): the checkout script - [Chains & tokens](https://hashpay.dev/docs/api/chains): supported networks and tokens ## Optional - [Full text](https://hashpay.dev/llms-full.txt): this index, the setup guide and the webhook reference in one file - [Setup guide as a skill .zip](https://hashpay.dev/agent/hashpay-setup.zip): for uploading as a Claude skill (needs network access to api.hashpay.dev) --- --- name: hashpay-setup description: Set up and integrate HashPay — non-custodial USDC/USDT payments that settle straight to the seller's own wallets — end to end over HTTP with only a secret API key (sk_live_… / sk_test_…). Covers the account, stores, receiving wallets, the webhook endpoint and the handler that receives payment events, products/prices/payment links, the checkout embed, and a verified test payment. Use when asked to set up, configure, integrate, script, or automate HashPay, or to check or finish an existing HashPay setup. --- # Set up HashPay with a secret key HashPay lets a site accept USDC and USDT on eight chains, with every payment going **straight to the seller's own wallet** — HashPay never holds funds or keys. Every part of an account is configurable over HTTP with one **secret API key**; this skill walks you through it from an empty account to a proven, live-ready integration. Machine-readable contract: `https://api.hashpay.dev/openapi.json` (OpenAPI 3.1). Human docs: `https://hashpay.dev/docs`. When this file and the API disagree, the API wins — say so to the seller. ## Bundled files | File | Use it for | |------|------------| | `references/webhook-handler.md` | **Read before writing any webhook receiver.** Event catalog, payload shape, signature scheme, the rules a correct handler follows. | | `assets/handler-express.js` | Working Node/Express receiver — copy and adapt. | | `assets/handler-fastapi.py` | Working Python/FastAPI receiver — copy and adapt. | ## What only the seller can give you Ask for these up front; you can't produce any of them yourself. 1. **An account.** They sign up at `https://app.hashpay.dev` (Google, or email + password — then they must click the verification link; live keys answer `403 email_unverified` until they do). 2. **A secret key.** Dashboard → **Integration** → **Create** under *Secret key*; it's shown once. Ask them to put it in an environment variable (e.g. `export HASHPAY_SECRET_KEY=sk_live_…`) rather than pasting it into the conversation, and refer to it as `$SK` / `$HASHPAY_SECRET_KEY`. 3. **Their receiving wallet addresses** — see Step 3. Never invent, derive, or "correct" one. 4. **Where their site and backend code live**, if you're also writing the webhook handler and embed. ## Ground rules - **Base URL:** `https://api.hashpay.dev/v1`. **Auth on every request:** `Authorization: Bearer $SK`. - **Never print, log, or commit the key** (or any `whsec_` webhook secret). Read it from the environment; put secrets the app needs into its own secret store / `.env`, never into source. - **Scope:** keys belong to a store, but any of the account's secret keys — `sk_test_` included — authenticates as the **whole account**: profile, every store, wallets, keys. Treat each as a production credential. - **Retries:** send `Idempotency-Key: ` on every `POST`. A retry with the same key within 24h replays the first response (header `Idempotent-Replayed: true`) instead of creating a second store, price or key. Same key with a different body → `409 idempotency_key_reused`; still running → `409 idempotency_in_progress` (wait and retry). 5xx responses aren't stored, so retrying after one re-executes. - **Errors** are `{"error": {"code": "...", "message": "..."}}`. `401 unauthorized` = bad/revoked key; `403 account_disabled`; a store outside the key's account answers `404 store_not_found`. - **Shown-once secrets:** `sk_` keys and `whsec_` signing secrets appear in plaintext exactly once, in the response that mints them. Capture them immediately; afterwards only a masked `display` (`sk_live_••••wxyz`) is listed. Publishable keys (`pk_`) aren't secret and list in full. - **Money** on this surface (prices, sessions, plan fees) is **integer USD cents** (`unitAmountCents`, `amountCents`). On-chain decimals are handled server-side — never convert yourself. - **Networks:** `ethereum`, `polygon`, `avalanche`, `bsc`, `arbitrum`, `base`, `tron`, `solana`. **Tokens:** `usdc`, `usdt`. Valid pairs come back in `supportedPairs` on `GET /v1/me`. - **Security notices:** every change to a receiving wallet, the webhook endpoint, a webhook signing secret or an API key emails the account owner's sign-in address (`securityEmail` on `GET /v1/me`), naming the key that made it. They can't be turned off. Tell the seller to expect a few during setup. ## Step 0 — verify the key, find the store, resume ```bash curl -s https://api.hashpay.dev/v1/me -H "Authorization: Bearer $SK" curl -s https://api.hashpay.dev/v1/stores -H "Authorization: Bearer $SK" curl -s https://api.hashpay.dev/v1/stores/$STORE/setup -H "Authorization: Bearer $SK" ``` `/v1/me` returns `accountId`, the profile, `status`, `emailVerified`, `securityEmail`, `tier`, `usage`, and `supportedPairs`. Every account starts with one store (`Default store`); use it unless the seller wants several. `GET /v1/stores/{storeId}/setup` is the **checklist** — computed from live state on every call, so use it to resume a half-done setup and to prove you finished: ```json {"storeId":"store_…","readyForLive":true,"next":"webhook_endpoint","steps":[ {"id":"email_verified","required":true,"done":true,"detail":"…"}, {"id":"wallets","required":true,"done":true,"networks":["base","solana"],"detail":"…"}, {"id":"webhook_endpoint","required":false,"done":false,"url":null,"activeSecrets":0,"detail":"…"}, {"id":"test_payment","required":false,"done":false,"paymentId":null,"detail":"…"}, {"id":"webhook_verified","required":false,"done":false,"lastConfirmedDelivery":null,"detail":"…"}, {"id":"embed_installed","required":false,"done":false,"seenAt":null,"detail":"…"}]} ``` `readyForLive` means live checkout will work (verified email + a wallet). `next` is the first step not done. Each `detail` says what's missing and which call fixes it. The "recommended" steps are what a correct integration looks like; skip one only if the seller deliberately doesn't need it (e.g. a seller using payment links alone, with no backend, has no webhook). ## Step 1 — account profile ```bash curl -s -X PUT https://api.hashpay.dev/v1/account -H "Authorization: Bearer $SK" \ -d '{"businessName":"Acme","supportEmail":"ops@acme.com","country":"US","website":"https://acme.com"}' ``` ## Step 2 — stores (optional) - `GET /v1/stores`, `POST /v1/stores` `{"name":"My shop"}` → 201 store summary, `GET`/`PUT /v1/stores/{storeId}` `{"name":…, "openAmountDisabled":true|false}`. - A new store gets its four keys (pk/sk × test/live) but their plaintext is **not** returned — mint the ones you need in Step 6. - `openAmountDisabled: true` is strict mode: the store refuses browser-supplied amounts and only sells server-defined prices and sessions. Recommend it whenever the seller only sells catalog items. ## Step 3 — receiving wallets (required) Payments go straight to these addresses, and a wrong one loses every payment quoted on it — irreversibly. A store can only quote a (network, token) it has a wallet for. **Addresses come from the seller, verbatim.** Ask for them; never invent, derive, guess, or "fix" one, and never reuse an address from an example or another project. Before posting, **read each address back in full** and get an explicit yes. One EVM address is the same wallet on all six EVM chains *if it's an ordinary wallet*; a smart-contract wallet (a Safe, for example) exists only on the chains it was deployed to — ask which. Tron (`T…`) and Solana (base58) each need their own address. - `POST /v1/stores/{storeId}/wallets` `{"network":"base","address":"0x…","label":"treasury"}`, or `{"networks":["ethereum","base","arbitrum"],"address":"0x…"}` to add one address on several networks in one call (one notice email instead of one per network). Send `network` or `networks`, not both. All-or-nothing → 201 store summary. - `400 bad_address` — the reason is in `message`; relay it as-is. Checked: chain format; the EIP-55 checksum for mixed-case `0x…`; Tron's base58check checksum; zero/burn addresses; Solana program ids; a USDC/USDT token contract or mint pasted in place of a wallet. A checksum failure means a character is wrong: ask the seller to copy the address again **from their wallet app** — never try variations. It **can't** catch a typo that forms another valid address (any Solana typo, an all-lowercase EVM one) — that's what the read-back is for. - `409 duplicate_address` — already on this store. - `POST /v1/stores/{storeId}/wallets/copy` `{"fromStoreId":"store_…"}` copies a sibling store's wallets. - `GET /v1/stores/{storeId}/wallets`; `DELETE /v1/stores/{storeId}/wallets?network=base&address=0x…` (query params, not a body). ## Step 4 — webhook endpoint + handler The webhook is the **only** authoritative signal that money arrived — the buyer can close the checkout mid-payment, or pay long after leaving the page. An integration that fulfils orders anywhere else is broken. Build and deploy the handler **before** registering it; registering an endpoint that isn't live just burns 72 hours of retries. ### 4a. Build the handler **Read `references/webhook-handler.md` first**, and start from `assets/handler-express.js` or `assets/handler-fastapi.py` (complete, verified against real HashPay signatures) instead of writing verification from scratch — it's the part integrations most reliably get subtly wrong. In short: 1. **Verify the signature over the RAW body bytes.** `X-HashPay-Signature: t=,v1=` carries one `v1` per active secret (two during a rotation), each `HMAC-SHA256(".")` in hex. Constant-time compare against **any** `v1`; reject `t` older than 5 minutes. A re-serialized JSON body never matches. 2. **Dedupe on the event `id`** (also sent as `X-HashPay-Delivery`) — delivery is at-least-once. 3. **Fulfil only on `payment.confirmed`, and only when `data.mode === "live"`.** `payment.detected` isn't settled money, and test payments fire real webhooks at the real endpoint. 4. **Return 2xx within 15s, then do the work.** Non-2xx is retried with backoff for 72h. 5. **Ignore unknown event types; don't assume ordering.** ### 4b. Register it ```bash curl -s -X PUT https://api.hashpay.dev/v1/stores/$STORE/webhook \ -H "Authorization: Bearer $SK" -d '{"url":"https://acme.com/hashpay/hooks"}' ``` - Must be a **public** URL (`400 invalid_url` otherwise; `localhost` won't register). For local development, expose the port with a tunnel (`cloudflared tunnel --url http://localhost:3000`, `ngrok http 3000`) and register that. - The first registration returns `secretId` and the one-time `webhookSecret` (`whsec_…`): put it straight into the handler's secret store (e.g. `HASHPAY_WEBHOOK_SECRETS`), never into source. - Rotation: `POST /v1/stores/{storeId}/webhook/secrets` → 201 with a one-time `secret` (at most 2 active, `409 too_many_secrets`), deploy it, then `DELETE …/webhook/secrets/{secretId}` the old one. - Debugging: `GET /v1/stores/{storeId}/webhook/deliveries?status=dead&type=payment.confirmed` — status, attempts, last HTTP status/error/response, next retry, and the exact payload. ## Step 5 — domains and branding (optional) - Live checkout is **blacklist-only**: any origin may transact; a domain that produced a live payment is surfaced for review. `GET /v1/stores/{storeId}/domains`, `POST …/domains` `{"domain":"shop.acme.com"}` (allow-list it), `POST …/domains/accept` / `…/domains/reject`, `DELETE …/domains?domain=host`. - Checkout look: `PUT /v1/stores/{storeId}/branding` `{"displayName":"Acme","logoUrl":"https://…","color":"#251160"}`. ## Step 6 — keys for the integration - `GET /v1/stores/{storeId}/keys` → `keyId`, `kind` (`publishable`|`secret`), `mode` (`test`|`live`), masked `display`, `createdAt`. - `POST /v1/stores/{storeId}/keys` `{"kind":"publishable","mode":"live"}` → 201 `{"key":"pk_live_…"}`, plaintext once. `DELETE /v1/stores/{storeId}/keys/{keyId}` revokes. - The site needs a **publishable** key (`pk_test_` while testing, `pk_live_` in production). If the seller's backend calls HashPay (checkout sessions, payment re-fetch), mint it **its own** secret key and put that in the backend's secret store — don't reuse the key the seller handed you. ## Step 7 — catalog: products → prices → payment links Order matters: a price references a product; a payment link references a price. - `POST /v1/stores/{storeId}/products` `{"name":"Pro plan","description":"…"}` → 201 `productId`. `PUT …/products/{productId}` partially updates `name`/`description`/`active`. - `POST /v1/stores/{storeId}/prices` `{"productId":"prod_…","unitAmountCents":1999,"acceptedNetworks":["base","solana"],"acceptedTokens":["usdc"]}` → 201 `priceId`. Empty/omitted accepted-lists mean no restriction. **Prices are immutable except `active`**: to change an amount, create a new price and `PUT …/prices/{priceId}` `{"active":false}` on the old one. Add `"type":"recurring","interval":"month"` (or `"year"`) for a subscription price: checkout then collects the buyer's email and HashPay emails renewal links before each anniversary — never an auto-charge. `GET /v1/stores/{storeId}/subscriptions`, `POST …/subscriptions/{id}/cancel`. - `POST /v1/stores/{storeId}/payment_links` `{"priceId":"price_…","collectEmail":true}` → 201 `paymentLinkId` — a hosted checkout the seller can share without any code. ## Step 8 — put checkout on the site ```html ``` For server-decided amounts, the backend creates a session with its secret key — `POST /v1/checkout/sessions` `{"amountCents":4200,"successUrl":"https://…","metadata":{…}}` → 201 `sessionId` (24h) — and the page opens `HashPay.checkout({ sessionId })`. Poll `GET /v1/checkout/sessions/{id}` until `status:"complete"` carries the `paymentId`, but fulfil from the webhook. Use `pk_test_` until Step 9 passes. ## Step 9 — prove it in test mode (no real money) 1. With a `pk_test_` key, create a checkout the way the widget would (test mode accepts any origin): ```bash curl -s -X POST https://api.hashpay.dev/v1/checkout -H "Origin: https://anything.example" \ -d '{"publishableKey":"'$PK_TEST'","network":"base","token":"usdc","priceId":"'$PRICE'"}' ``` Exactly one of `amount` | `priceId` | `paymentLinkId` | `sessionId`. 2. Settle it: `POST /v1/test/payments/{paymentId}/simulate`. 3. `GET /v1/stores/{storeId}/setup` should now show `test_payment` and `webhook_verified` done — the latter only if the handler answered 2xx. Confirm the handler fulfilled **exactly once** (replaying a delivery must not fulfil twice), then swap in the `pk_live_` key. ## Billing (only if the seller asks) `GET /v1/billing/plans` lists tiers and fees; `GET /v1/billing/usage` shows this month's volume against the plan ceiling. `POST /v1/billing/subscribe` `{"tier":"starter","interval":"month","network":"base","token":"usdc"}` returns a fee payment (`payTo`, `amount`, `amountBaseUnits`, `expiresAt`) that **the seller pays from their own wallet** — you can't. Never subscribe without the seller asking. ## Finish 1. `GET /v1/stores/{storeId}/setup` — report each step to the seller in plain words, especially any step not done and why. 2. Tell them which keys you created and where you stored them, and remind them about the security emails they received. 3. Suggest they **revoke the secret key they handed you** (Integration page, or `DELETE /v1/stores/{storeId}/keys/{keyId}`) once nothing they run depends on it. --- # Building the webhook handler The webhook is how the seller's backend learns that money actually arrived. Nothing else is authoritative: the browser widget can be closed mid-payment, and the buyer can pay after leaving the page. **If the integration fulfils orders anywhere other than this handler, it is wrong.** Working receivers to copy and adapt live next to this file: `assets/handler-express.js` (Node) and `assets/handler-fastapi.py` (Python). Both are complete — signature verification, replay window, idempotency, fast ack — and both have been verified against a signature produced by HashPay's own signer. ## What HashPay sends `POST` to the store's webhook URL, `content-type: application/json`. | Header | Value | |-----------------------|-----------------------------------------------------------------------| | `X-HashPay-Event` | the event type, e.g. `payment.confirmed` | | `X-HashPay-Delivery` | the event id — **the idempotency key** | | `X-HashPay-Signature` | `t=,v1=` — **one `v1` per active signing secret** | Body: ```json { "id": "pay_9f3…:payment.confirmed", "type": "payment.confirmed", "createdAt": "2026-07-13T10:04:11Z", "apiVersion": "2026-07-02", "data": { "…": "see below" } } ``` `id` is inside the signed body *and* mirrored in `X-HashPay-Delivery`, so it is authenticated — safe to use as the dedupe key. It is derived deterministically from the state transition (`:`), never from the clock, so **a redelivery carries the same `id` as the original**. `apiVersion` is bumped only on a breaking payload change. Ignore it, or reject a shape you don't know — but never fail closed on an *unknown event type* (see the rules). ## Event catalog Payment events — the integration surface: | Type | Meaning | What the handler should do | |----------------------|------------------------------------------------|--------------------------------------------------| | `payment.detected` | Seen on-chain, **not yet confirmed** | Show "processing". **Do not fulfil.** | | `payment.confirmed` | Confirmed at depth — **the money is final** | **Fulfil here.** This is the only settlement event. | | `payment.underpaid` | Buyer sent less than the quoted amount | Reconcile / partial-refund by hand | | `payment.overpaid` | Buyer sent more than the quoted amount | Reconcile / refund the excess | | `payment.expired` | The payment window closed unpaid | Release the cart / mark abandoned | | `payment.canceled` | Buyer cancelled in the widget | Release the cart | Subscription events (`subscription.created`, `.activated`, `.renewed`, `.past_due`, `.expired`, `.canceled`, `.reminder`) fire when a buyer checks out one of the store's **recurring prices** — key fulfilment off them: grant access on `.activated`/`.renewed`, revoke on `.expired`, and use the event's `metadata` (echoed from the checkout) to find your own user. Their `data` carries `subscriptionId`, `storeId`, `mode`, `priceId`, `status`, `network`, `token`, `amount`, `interval`, `buyerEmail`, `nextDueAt`, `renewUrl`, `metadata`, and `daysUntilDue` on reminders. HashPay emails the buyer the reminders and hosted renew link (never an auto-charge). ## `data` on a payment event | Field | Notes | |------------------------|--------------------------------------------------------------------------| | `paymentId` | HashPay's id for the payment | | `accountId`, `storeId` | Who it belongs to | | `mode` | `"live"` or `"test"` — **test payments fire real webhooks** | | `network`, `token` | e.g. `base`, `usdc` | | `address` | The receiving wallet (the seller's own) | | `amount` | Display string, e.g. `"19.99"` — for humans | | `amountBaseUnits` | Integer as a **string** — the exact value; compare/persist **this** | | `decimals` | Token decimals (6 everywhere except BSC, where USDC/USDT are 18) | | `txHash`, `blockNumber`| The settling transfer | | `status` | Upper-case enum, e.g. `CONFIRMED` | | `metadata` | Whatever you attached at checkout — **your correlation handle** | | `originHost` | Present when the checkout came from a browser origin | | `confirmations` | Present once > 0 | `payment.underpaid` / `payment.overpaid` add `expectedAmount`, `receivedAmount`, and `receivedAmountBaseUnits`. Never do money math on `amount` (a decimal string, and floats lose cents). Use `amountBaseUnits` — integer, exact — and pair it with `decimals` if you must display it. `metadata` is how a payment ties back to the seller's own order. Set it at checkout (`{"orderId":"…"}`); it round-trips here untouched. For server-driven checkout sessions HashPay also stamps the `sessionId` into `metadata` for exactly this purpose. ## The rules **1. Verify the signature over the RAW body.** The HMAC is computed over the exact bytes on the wire. A body that has been parsed into a dict and re-serialized will **not** match — key order and whitespace change. In Express this means `express.raw({type:'application/json'})` on the route *before* any global `express.json()`; in FastAPI, `await request.body()`. Get this wrong and every signature fails, or (worse) you "fix" it by not verifying at all. **2. Reject stale timestamps.** The `t` in the header is bound into the signature, which is what makes a replay detectable. Reject anything outside a **5-minute** tolerance. `t` is the *send* time, and a retry is re-signed fresh, so a legitimate retry never ages out of the window. **3. Accept *any* `v1`, in constant time.** During a rotation a store has **two** active secrets and the header then carries two `v1` entries; a receiver holding either one must verify, so no delivery is dropped mid-rotation. Compare with a constant-time primitive (`crypto.timingSafeEqual`, `hmac.compare_digest`) — a `==` on an HMAC leaks it a byte at a time. **4. Dedupe on the event id.** Redeliveries are expected and normal: a retry after your 500, a network blip, or a status that gets published twice by parallel producers. The `id` is stable across all of them. Claim it atomically (a unique index / `INSERT … ON CONFLICT DO NOTHING` / `SETNX`) and drop the event if it was already claimed. **Delivery is at-least-once; the id is what makes your processing exactly-once.** **5. Fulfil only on `payment.confirmed`, and only when `mode === "live"`.** `payment.detected` means "a transfer appeared", not "the money is yours" — a reorg can still take it away. And test-mode payments send real webhooks to your real endpoint, so a handler that doesn't check `mode` will ship goods for a simulated payment. **6. Return 2xx fast, then do the work.** HashPay gives the request **15 seconds** and treats any non-2xx (or timeout) as a failure. A slow handler doesn't just stall — it manufactures duplicate deliveries. Ack first, process after (background task / queue). **7. Ignore unknown event types, and don't assume order.** New types get added; an unrecognised one must be a no-op `200`, not a 400. Deliveries are not ordered — `payment.detected` can land *after* `payment.confirmed` when the first attempt is retried. Make each handler independent of the others rather than building a state machine that assumes a sequence. ## Verifying the signature ``` header: t=1751456773,v1=5257a869e7ec…[,v1=…] signed: "" + "." + each v1: hex( HMAC-SHA256(secret = whsec_…, message = signed) ) ``` 1. Split the header on `,` into `t=…` and one or more `v1=…`. 2. Reject if `|now − t| > 300s`. 3. Build `signed = t + "." + rawBody`. 4. For each of your active secrets compute the hex HMAC-SHA256 and constant-time-compare it against each `v1`. Any match ⇒ authentic. 5. No match ⇒ **400 and stop.** Do not parse, log, or act on an unverified body. Fail closed: with no secret configured, verification must return false, not skip. ## What happens when the handler fails A non-2xx or a timeout is retried with backoff — 30s, 2m, 10m, 30m, 1h, 2h, 4h, 8h, then every 8h — for up to **72 hours** (max 20 attempts), after which the delivery is marked **dead** and dropped. Failures are therefore survivable: fix the endpoint inside 72h and the event still lands. Inspect exactly what was sent and what came back: ```bash curl -s "https://api.hashpay.dev/v1/stores/$STORE/webhook/deliveries" \ -H "Authorization: Bearer $SK" ``` The last 50 rows, each with status, attempt count, the last HTTP status/error, the next retry time, and the full payload — the first place to look when "the webhook isn't firing". ## Trust, but verify (optional Layer 2) The signature already proves the event came from HashPay. For high-value fulfilment you can additionally re-fetch the payment server-side before shipping: ```bash curl -s https://api.hashpay.dev/v1/payments/$PAYMENT_ID -H "Authorization: Bearer $SK" ``` With a secret key the re-fetch is **scoped**: the key must belong to the store that owns the payment (a foreign id answers `404`), and a revoked key fails loudly with `401 invalid_key` instead of silently degrading to the public buyer view. The response body is the same one the public endpoint returns, and the on-chain re-check is throttled either way — the value here is the scoping and the loud failure, not a "fresher" answer. ## Testing it without real money The endpoint must be a **public HTTPS URL** — HashPay refuses private/internal hosts (`400 invalid_url`), so `localhost` will not register. For local development, expose the port with a tunnel (`cloudflared tunnel --url http://localhost:3000`, `ngrok http 3000`) and register the tunnel URL. Then drive a full test-mode payment end to end: ```bash # 1. Create a test checkout (test mode accepts any Origin). PAYMENT=$(curl -s -X POST https://api.hashpay.dev/v1/checkout \ -H "Origin: https://anything.example" \ -d '{"publishableKey":"'$PK_TEST'","network":"base","token":"usdc", "priceId":"'$PRICE'","metadata":{"orderId":"test-1"}}' | jq -r .paymentId) # 2. Settle it — fires the real webhooks at your endpoint. curl -s -X POST https://api.hashpay.dev/v1/test/payments/$PAYMENT/simulate # 3. See what your endpoint actually answered. curl -s "https://api.hashpay.dev/v1/stores/$STORE/webhook/deliveries" -H "Authorization: Bearer $SK" | jq ``` A green run is: `payment.confirmed` in the delivery log with HTTP 200, and the order fulfilled exactly once in the seller's database — then re-run step 2's delivery (or replay the same body) and confirm the order is **not** fulfilled twice. ## Pitfalls that bite - **Re-serialized body** → every signature fails. Verify raw bytes. (Rule 1.) - **A global `express.json()` mounted before the webhook route** — same thing, and the usual cause. - **Fulfilling on `payment.detected`** → goods shipped for money that can still vanish. - **No `mode` check** → a test payment ships a real order. - **No dedupe** → the buyer is charged once and fulfilled three times. - **Heavy work before the ack** → 15s timeout → retries → duplicates. - **`==` on the HMAC** → timing side-channel. Use the constant-time compare. - **Only checking the first `v1`** → deliveries drop the moment a secret is rotated.