Receive push notifications when receipt status changes instead of polling.
Overview#
BasaltSurge can send signed webhook events to your server when a receipt's payment status changes. This eliminates the need to poll
markup
and provides real-time updates for order fulfillment.GET /api/receipts/statusWhen to Use Webhooks vs. Polling#
| Approach | Best For |
|---|---|
| Webhooks (recommended) | Production systems, order fulfillment, real-time status updates |
| Polling markup | Rapid prototyping, environments without public endpoints |
Setup#
1. Configure Your Webhook Endpoint#
Set
markup
when creating a receipt:webhook_urlbashcurl -X POST "https://surge.basalthq.com/api/receipts" \ -H "Content-Type: application/json" \ -H "Ocp-Apim-Subscription-Key: $basaltsurge_API_KEY" \ -d '{ "id": "order_abc", "lineItems": [{ "label": "Widget", "priceUsd": 25.00 }], "totalUsd": 25.00, "webhook_url": "https://your-server.com/api/basaltsurge-webhook" }'
2. Requirements#
- HTTPS required in production (HTTP allowed in development)
- No localhost in production
- Must return markupstatus within 5 seconds
2xx - Must be idempotent (the same event may be delivered twice)
Webhook Payload#
When a receipt status changes, BasaltSurge sends a
markup
request to your POSTmarkup
:webhook_urlHeaders#
markupContent-Type: application/json X-BasaltSurge-Signature: sha256=<hmac_hex> X-BasaltSurge-Event: receipt.status_updated X-BasaltSurge-Delivery: <uuid> X-BasaltSurge-Idempotency-Key: <stable_notification_key> X-BasaltSurge-Timestamp: <unix_ms> User-Agent: BasaltSurge-Webhook/1.0
Body (Success / Paid)#
json{ "event": "receipt.status_updated", "idempotencyKey": "receipt-status:order_abc:paid:0xabc123...", "receiptId": "order_abc", "status": "paid", "previousStatus": "pending", "transactionHash": "0xabc123...", "buyerWallet": "0x1234...abcd", "merchantWallet": "0x5678...efgh", "totalUsd": 25.00, "customerTotalUsd": 26.25, "stripeSourceAmountUsd": 25.34, "token": "USDC", "timestamp": 1713200000000, "brandKey": "myshop", "stripeSessionId": "cos_8a7a48c2-6bae-4b08-9d36-35670b42dc8d", "isStripeSessionUnique": true, "transactionId": "TX-99882211", "metadata": { "orderRef": "ERP-PO-774", "customerTier": "vip" } }
Body (Failed / Declined)#
json{ "event": "receipt.status_updated", "receiptId": "order_abc", "status": "failed", "previousStatus": "pending", "failureCode": "PORTAL_PAY_INSUFFICIENT_FUNDS", "failureCategory": "card_decline", "failureReason": "The payment method was declined due to insufficient available funds.", "failureAction": "Ask the customer to retry with another card or use an alternate payment method.", "merchantWallet": "0x5678...efgh", "totalUsd": 25.00, "token": "USDC", "timestamp": 1713200000000, "brandKey": "myshop", "stripeSessionId": "cos_8a7a48c2-6bae-4b08-9d36-35670b42dc8d", "isStripeSessionUnique": true }
Payload Fields#
| Field | Type | Description |
|---|---|---|
markup | string | Webhook event name ( markup ) |
markup | string | Stable notification key, also supplied in markup . Treat it as opaque and use it to deduplicate deliveries. |
markup | string | Unique receipt ID |
markup | string | Current payment status ( markup , markup , etc.) |
markup | string | Status prior to this update |
markup | string | null | Custom BasaltSurge error code (e.g. markup , markup ) |
markup | string | null | High-level failure category ( markup , markup , markup , markup , markup , markup ) |
markup | string | null | Human-readable explanation of why the payment failed |
markup | string | null | Recommended remediation advice for the merchant |
markup | string | null | Original structured Stripe error code from a signed event or server observation, when available; distinct from the branded markup |
markup | string | null | Stripe markup reference from the server response or a diagnostic tied to the same session, when available |
markup | string | null | On-chain transaction hash (when completed) |
markup | string | null | Buyer's wallet address |
markup | string | Merchant recipient wallet address |
markup | number | Stable merchant order total in USD (the value submitted when the payment was initiated) |
markup | number | Final customer-facing receipt total in USD, including configured processing fees when available |
markup | number | Stripe Crypto Onramp markup used for settlement when available; it can differ from the order and customer totals because Stripe fees are accounted for separately |
markup | string | null | Stripe Checkout/Onramp Session ID (if applicable) |
markup | boolean | markup if the markup is unique to this single receipt; markup if shared or unpopulated |
markup | string | null | Custom transaction reference ID passed at order creation |
markup | object | null | Custom key-value JSON metadata passed at order creation |
markup | number | Event timestamp (Unix ms) |
Status Values#
The
markup
field describes the persisted, canonical receipt state. Common values are:status| Status | Description |
|---|---|
markup | No authoritative payment completion or terminal failure has been recorded |
markup | Payment has been accepted or confirmed by the server; for embedded onramp this does not alone prove the downstream on-chain transfer is complete |
markup | ACH payment accepted by Stripe; funds/settlement are still pending |
markup | Legacy alias for accepted ACH awaiting settlement |
markup | Funds verified and split distribution executed |
markup | Server-verified payment failure (see failure and provider fields) |
markup / markup | Legacy or other authoritative failure states; do not infer these from browser activity |
markup | Refund has been requested |
markup | Refund has been processed |
Browser reports such as
markup
, link_openedmarkup
, checkout_initializedmarkup
, and onramp_*markup
are checkout telemetry. They do not by themselves change the canonical payment status or send an authoritative failure webhook. errormarkup
is normalized to checkout_successmarkup
only through the server's verified status path. Closing a payment form is not proof of a failed or unpaid order.paidFor Stripe failures, signed rejection events, the background poller, and scheduled reconciliation persist the failure and queue the merchant notification together. A stale failure cannot replace an accepted/paid receipt or a receipt associated with a different payment attempt. Failure fields and provider references are
markup
on successful and other nonfailure payloads, even when the receipt retains older diagnostics internally. nullmarkup
uses the same failure-field projection.GET /api/receipts/statusBlocked transaction example#
json{ "event": "receipt.status_updated", "receiptId": "order_abc", "status": "failed", "previousStatus": "pending", "failureCode": "PORTAL_PAY_TRANSACTION_BLOCKED", "failureCategory": "compliance", "failureReason": "This transaction has been blocked.", "failureAction": "This purchase cannot continue. Contact support; do not submit another payment.", "providerErrorCode": "crypto_onramp_transaction_blocked", "providerRequestId": "req_Example123", "merchantWallet": "0x5678...efgh", "stripeSessionId": "cos_example", "totalUsd": 61.94, "timestamp": 1713200000000 }
This code does not establish fraud, sanctions, or a specific account restriction. Do not automatically retry it or turn it into a request for more KYC. Stripe documents it as non-retryable; contact support with the receipt, session, and request references. Missing provider details remain
markup
; do not infer a cause from that absence. See Stripe's error reference and merchant failure codes.nullSignature Verification#
Every webhook is signed using HMAC-SHA256. The signing secret is your existing API key (the same
markup
or Ocp-Apim-Subscription-Keymarkup
you use to call the API). No extra key to manage.x-api-keyVerification Example (Node.js)#
javascriptimport crypto from 'crypto'; import express from 'express'; const app = express(); function verifyWebhookSignature(body, signature, secret) { if (typeof signature !== 'string' || !/^sha256=[a-f0-9]{64}$/i.test(signature)) return false; const expected = crypto .createHmac('sha256', secret) .update(body) .digest('hex'); const received = signature.slice('sha256='.length); return crypto.timingSafeEqual( Buffer.from(expected, 'hex'), Buffer.from(received, 'hex') ); } // Register this route BEFORE a global express.json() middleware. // Signature verification requires the exact received bytes. app.post('/api/basaltsurge-webhook', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['X-BasaltSurge-Signature']; const rawBody = req.body; // Use your same API key for verification if (!verifyWebhookSignature(rawBody, signature, process.env.basaltsurge_API_KEY)) { return res.status(401).json({ error: 'Invalid signature' }); } const { event, receiptId, status, transactionHash, idempotencyKey } = JSON.parse(rawBody.toString('utf8')); // Persist/check idempotencyKey with your order update in one DB transaction. // A delivery ID identifies a delivery cycle, not all later retries. console.log(`Receipt ${receiptId} is now ${status}`); // Example: fulfill order when payment is confirmed if (status === 'paid' || status === 'reconciled') { fulfillOrder(receiptId, transactionHash); } res.status(200).json({ ok: true }); });
Verification Example (Python)#
pythonimport hmac, hashlib, json, os from flask import Flask, request, jsonify app = Flask(__name__) def verify_signature(body: str, signature: str, secret: str) -> bool: expected = hmac.new( secret.encode(), body.encode(), hashlib.sha256 ).hexdigest() received = signature.replace('sha256=', '') return hmac.compare_digest(expected, received) @app.route('/api/basaltsurge-webhook', methods=['POST']) def webhook(): sig = request.headers.get('X-BasaltSurge-Signature', '') raw = request.get_data(as_text=True) # Use your same API key for verification if not verify_signature(raw, sig, os.environ['basaltsurge_API_KEY']): return jsonify(error='Invalid signature'), 401 data = request.json receipt_id = data['receiptId'] status = data['status'] if status in ('paid', 'reconciled'): fulfill_order(receipt_id, data.get('transactionHash')) return jsonify(ok=True), 200
Delivery & Retry#
- Timeout: 5 seconds per attempt
- Retries: 1 automatic retry after 5 seconds if the first attempt fails
- Retry conditions: Non-2xx response, network error, or timeout
- Idempotency: Persist markup(or the body
X-BasaltSurge-Idempotency-Keymarkup) with your order update. The key is stable across retries of the same receipt/status/transaction notification.idempotencyKeymarkupidentifies a delivery cycle and can change when a worker retries it later.X-BasaltSurge-Delivery
If both attempts fail, the receipt retains a pending delivery marker. The scheduled reconciliation job can retry it later when that job is running for the receipt's brand. This is a notification of current receipt state, not an immutable event stream: if the receipt has advanced, the retry sends its current status rather than replaying an obsolete failure. Intermediate transitions can be coalesced. Delivery timing and ordering are not guaranteed; reconcile delayed/conflicting notifications against
markup
and never reverse a confirmed payment merely because an older failure arrives.GET /api/receipts/statusPartner brands receive
markup
headers and the corresponding branded user agent. X-{Brand}-*markup
compatibility aliases remain available. Verify the signature over the raw body, process idempotently, and return a X-BasaltSurge-*markup
response promptly.2xxPlatform / Partner Container Compatibility#
Webhook signing is container-stable: the API key used for signing is captured from the request header at receipt creation time and stored on the receipt document. This means:
- If a receipt is created on a partner container (e.g., markup) using API key
partner.basaltsurge.commarkup, that key is stored on the receipt.pk_abc... - When Thirdweb or Stripe webhooks later fire on the platform container, the dispatch reads the signing secret from the receipt document — not from the platform's environment.
- Result: The developer always verifies webhooks with their same API key, regardless of which container processes the event.
Key point: You use one key for everything — API authentication and webhook verification. No separate webhook secret needed.
Redirect URL (Stripe Only)#
The
markup
parameter is passed through to the Stripe Crypto Onramp session. After the buyer completes the Stripe-hosted onramp flow, Stripe redirects them to this URL.redirect_urlImportant:
markup
only works with Stripe. Other onramp providers (Coinbase, Transak, MoonPay, Ramp) open in new tabs managed by thirdweb and do not support external redirect injection. There is no portal-level auto-redirect.redirect_url| Provider | Redirect Support |
|---|---|
| Stripe Crypto Onramp | ✅ Passed through session metadata |
| Coinbase Onramp | ❌ Requires CDP domain allowlisting |
| Transak / MoonPay / Ramp | ❌ Managed internally by thirdweb |