Skip to content

Webhook

Receive real-time notifications when payment status changes.

Webhook Overview

When configured, Webhooks send HTTP POST requests to your URL on payment status changes.

Event Types

EventStatus ValueDescriptionWhen
payment.paidPAIDPayment confirmed on-chainFunds transferred directly to merchant
payment.invalidINVALIDOn-chain payment validation failedAmount, token, or recipient mismatch

On PAID, verify the order details and complete the order. On INVALID, flag the order for review.

Payload Structure

Webhook payloads are sent as a flat JSON object (no wrapper). The Content-Type header is application/json.

json
{
  "paymentId": "0xabc123...",
  "orderId": "order-001",
  "status": "PAID",
  "txHash": "0xdef789...",
  "amount": "10500000000000000000",
  "tokenSymbol": "SUT",
  "paidAt": "2024-01-26T12:35:42.000Z"
}

Headers

HeaderDescription
Content-Typeapplication/json
X-SoloPay-Signaturet=timestamp,v1=signature (present when webhook secret is configured)

Per-Event Payload Details

payment.paid

Payment confirmed on-chain. Funds have been transferred directly to the merchant wallet. This is the terminal success state.

json
{
  "paymentId": "0xabc123...",
  "orderId": "order-001",
  "status": "PAID",
  "txHash": "0xdef789...",
  "amount": "10500000000000000000",
  "tokenSymbol": "SUT",
  "paidAt": "2024-01-26T12:35:42.000Z"
}

payment.invalid

Payment detected on-chain but validation failed. The on-chain transaction did not match the expected payment parameters (amount, token, or recipient mismatch).

json
{
  "paymentId": "0xabc123...",
  "orderId": "order-001",
  "status": "INVALID",
  "txHash": "0xdef789...",
  "amount": "10500000000000000000",
  "tokenSymbol": "SUT",
  "paidAt": "2024-01-26T12:35:42.000Z"
}

Event Handler Example

typescript
async function handleWebhook(payload: any) {
  const { status, orderId, paymentId } = payload;

  switch (status) {
    case 'PAID':
      await completeOrder(orderId);
      break;
    case 'INVALID':
      await flagOrderForReview(orderId, paymentId);
      break;
  }
}

Retry Policy

Webhooks are retried up to 3 times with delays of 10s, 30s, and 90s between retries. A delivery is considered successful when your endpoint returns an HTTP 2xx status code.

HMAC Signature Verification

When a webhook signing secret is configured for your merchant, every webhook request includes the X-SoloPay-Signature header in the format t=timestamp,v1=signature. You should verify the signature before processing the payload to ensure it was sent by SoloPay and has not been tampered with.

How It Works

The header format is t=<unix_timestamp>,v1=<hmac_hex>. The signature is computed as: HMAC-SHA256(secret, "timestamp.body") where timestamp is the t value and body is the raw JSON request body.

Setting Up

  1. Go to Merchant Dashboard > Settings > Webhook Secret
  2. Click Generate Secret to create a new signing secret (whsec_...)
  3. Store the secret securely on your server

Verification Example

typescript
import crypto from 'crypto';
import { verifyWebhookSignature } from '@solo-pay/gateway-sdk';

// In your webhook handler:
const rawBody = await request.text();
const sigHeader = request.headers.get('x-solopay-signature');

const isValid = verifyWebhookSignature(rawBody, sigHeader, process.env.WEBHOOK_SECRET);
if (!isValid) {
  return new Response('Invalid signature', { status: 401 });
}

Or manually:

typescript
import crypto from 'crypto';

function verifyWebhook(rawBody: string, header: string, secret: string): boolean {
  const parts = header.split(',');
  let ts = '',
    sig = '';
  for (const p of parts) {
    const [k, v] = p.split('=', 2);
    if (k === 't') ts = v;
    else if (k === 'v1') sig = v;
  }

  const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(ts, 10));
  if (age > 300) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'));
}

Use Timing-Safe Comparison

Always use crypto.timingSafeEqual() instead of === to prevent timing attacks.

Payment Result Verification

How to verify payment information from Webhook events.

Server-Side Verification

Use the paymentId in the Webhook payload to call the SoloPay API directly from your server and confirm the payment status.

Always Verify from the Server

Do not trust Webhook payload contents directly. Always re-confirm the actual payment status via the API.

bash
curl https://gateway.dev.solonetwork.io/api/v1/payments/0xabc123... \
  -H "x-public-key: pk_xxxxx"

Verification Checklist

  • [ ] Confirm status === 'PAID' (payment success)
  • [ ] Confirm amount matches the expected amount in your order database (the widget runs client-side and the amount could be tampered with)
  • [ ] Confirm tokenAddress matches the expected token contract address
  • [ ] Confirm orderId matches orderId stored in DB
  • [ ] Prevent duplicate processing for the same paymentId
  • [ ] Complete the order after confirming PAID status

Idempotency

The same event may be sent multiple times. Use paymentId to prevent duplicate processing.

typescript
// Check if this paymentId was already processed (DB lookup)
const alreadyProcessed = await db.orders.isPaymentProcessed(data.paymentId);
if (alreadyProcessed) {
  return res.status(200).json({ received: true }); // Ignore duplicate event
}

Merchant Webhook URL

The merchant data model has a webhook_url field. Contact admin to configure it.

Next Steps

Non-custodial Web3 payment infrastructure for ERC-20 checkout, sponsored gas, and wallet-to-wallet settlement.