An investment in knowledge pays the best interest.
Benjamin Franklin

Webhooks

Webhooks#

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
GET /api/receipts/status
and provides real-time updates for order fulfillment.

When to Use Webhooks vs. Polling#

ApproachBest For
Webhooks (recommended)Production systems, order fulfillment, real-time status updates
Polling
markup
GET /api/receipts/status
Rapid prototyping, environments without public endpoints

Setup#

1. Configure Your Webhook Endpoint#

Set
markup
webhook_url
when creating a receipt:
bash
curl -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
    markup
    2xx
    status within 5 seconds
  • Must be idempotent (the same event may be delivered twice)

Webhook Payload#

When a receipt status changes, BasaltSurge sends a
markup
POST
request to your
markup
webhook_url
:

Headers#

markup
Content-Type: application/json
X-BasaltSurge-Signature: sha256=<hmac_hex>
X-BasaltSurge-Event: receipt.status_updated
X-BasaltSurge-Delivery: <uuid>
X-BasaltSurge-Timestamp: <unix_ms>
User-Agent: BasaltSurge-Webhook/1.0

Body#

json
{
  "event": "receipt.status_updated",
  "receiptId": "order_abc",
  "status": "paid",
  "previousStatus": "checkout_initialized",
  "transactionHash": "0xabc123...",
  "buyerWallet": "0x1234...abcd",
  "merchantWallet": "0x5678...efgh",
  "totalUsd": 25.00,
  "token": "USDC",
  "timestamp": 1713200000000,
  "brandKey": "myshop"
}

Status Values#

The
markup
status
field will contain one of the following values:
StatusDescription
markup
link_opened
Buyer opened the payment portal
markup
buyer_logged_in
Buyer connected their wallet
markup
checkout_initialized
Buyer started the checkout flow
markup
checkout_success
Payment submitted through the widget
markup
paid
Payment confirmed on-chain
markup
reconciled
Funds verified and split distribution executed
markup
refund_requested
Refund has been requested
markup
refunded
Refund has been processed

Signature Verification#

Every webhook is signed using HMAC-SHA256. The signing secret is your existing API key (the same
markup
Ocp-Apim-Subscription-Key
or
markup
x-api-key
you use to call the API). No extra key to manage.

Verification Example (Node.js)#

javascript
import crypto from 'crypto';

function verifyWebhookSignature(body, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  
  const received = signature.replace('sha256=', '');
  
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(received, 'hex')
  );
}

// Express.js handler
app.post('/api/basaltsurge-webhook', (req, res) => {
  const signature = req.headers['x-basaltsurge-signature'];
  const rawBody = JSON.stringify(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 } = req.body;
  
  // Process the event (ensure idempotency!)
  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)#

python
import 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: Use the
    markup
    X-BasaltSurge-Delivery
    header as a unique delivery ID to deduplicate events

If both attempts fail, the event is logged but not retried further.


Platform / 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
    partner.basaltsurge.com
    ) using API key
    markup
    pk_abc...
    , that key is stored on the receipt.
  • 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
redirect_url
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.
Important:
markup
redirect_url
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.
ProviderRedirect Support
Stripe Crypto Onramp✅ Passed through session metadata
Coinbase Onramp❌ Requires CDP domain allowlisting
Transak / MoonPay / Ramp❌ Managed internally by thirdweb
Webhooks | BasaltSurge Docs | BasaltSurge