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
| Event | Status Value | Description | When |
|---|---|---|---|
payment.paid | PAID | Payment confirmed on-chain | Funds transferred directly to merchant |
payment.invalid | INVALID | On-chain payment validation failed | Amount, 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.
{
"paymentId": "0xabc123...",
"orderId": "order-001",
"status": "PAID",
"txHash": "0xdef789...",
"amount": "10500000000000000000",
"tokenSymbol": "SUT",
"paidAt": "2024-01-26T12:35:42.000Z"
}Headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-SoloPay-Signature | t=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.
{
"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).
{
"paymentId": "0xabc123...",
"orderId": "order-001",
"status": "INVALID",
"txHash": "0xdef789...",
"amount": "10500000000000000000",
"tokenSymbol": "SUT",
"paidAt": "2024-01-26T12:35:42.000Z"
}Event Handler Example
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
- Go to Merchant Dashboard > Settings > Webhook Secret
- Click Generate Secret to create a new signing secret (
whsec_...) - Store the secret securely on your server
Verification Example
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:
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.
curl https://gateway.dev.solonetwork.io/api/v1/payments/0xabc123... \
-H "x-public-key: pk_xxxxx"Verification Checklist
- [ ] Confirm
status === 'PAID'(payment success) - [ ] Confirm
amountmatches the expected amount in your order database (the widget runs client-side and the amount could be tampered with) - [ ] Confirm
tokenAddressmatches the expected token contract address - [ ] Confirm
orderIdmatches orderId stored in DB - [ ] Prevent duplicate processing for the same
paymentId - [ ] Complete the order after confirming
PAIDstatus
Idempotency
The same event may be sent multiple times. Use paymentId to prevent duplicate processing.
// 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
- Event Details - Detailed event payload documentation
- Payment Status - Check status via polling
- Refunds - Request a refund for a completed payment
- API Reference - Full API spec
- Error Codes - Error handling