Payment Webhook Events
Overview
SyncPay sends webhook notifications to your server when payment events occur. Webhooks allow you to:
- Get real-time updates when payments complete
- Automate order fulfillment
- Update your database when payment status changes
- Avoid polling the API constantly
Use webhooks for production applications - they're more reliable and efficient than polling.
Setup
1. Create a Webhook Endpoint
Create an HTTPS endpoint on your server that can receive POST requests:
// Express.js example
app.post('/webhooks/syncpay', async (req, res) => {
const event = req.body;
// Process event
await handleWebhookEvent(event);
// Respond quickly (within 5 seconds)
res.status(200).json({ received: true });
});
Important: Your endpoint must:
- Accept POST requests
- Be publicly accessible via HTTPS
- Respond with
200 OKwithin 5 seconds - Process events asynchronously (don't block the response)
2. Configure Webhook URL
Add your webhook URL in the SyncPay dashboard:
- Go to Settings → Webhooks
- Click Add Webhook Endpoint
- Enter your webhook URL (e.g.,
https://yoursite.com/webhooks/syncpay) - Select events to subscribe to
- Save
You'll receive a webhook secret - save this securely for signature verification.
3. Verify Webhook Signatures
Always verify that webhooks came from SyncPay:
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
app.post('/webhooks/syncpay', (req, res) => {
const signature = req.headers['x-syncpay-signature'];
const webhookSecret = process.env.SYNCPAY_WEBHOOK_SECRET;
if (!verifyWebhookSignature(req.body, signature, webhookSecret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process webhook
// ...
});
Webhook Event Structure
All webhooks follow this format:
{
"event_id": "evt_1a2b3c4d5e6f",
"event_type": "charge.completed",
"created_at": "2026-01-24T14:35:00.000Z",
"data": {
// Event-specific data
}
}
| Field | Type | Description |
|---|---|---|
event_id |
string | Unique event identifier |
event_type |
string | Type of event (see event types below) |
created_at |
string | ISO 8601 timestamp of event |
data |
object | Event-specific payload |
Payment Event Types
charge.completed
Sent when a payment successfully completes.
{
"event_id": "evt_1a2b3c4d5e6f",
"event_type": "charge.completed",
"created_at": "2026-01-24T14:35:00.000Z",
"data": {
"charge_id": "chr_1a2b3c4d5e6f",
"checkout_id": "chk_9x8y7z6w5v",
"organization_id": "org_abc123",
"customer_id": "cust_xyz789",
"amount": "75000.00",
"currency": "NGN",
"settlement_currency": "NGN",
"settlement_amount": "74250.00",
"status": "COMPLETED",
"provider_reference": "REF-12345",
"metadata": {
"order_id": "ORD-12345",
"product_sku": "PREMIUM-ANNUAL"
},
"created_at": "2026-01-24T14:30:00.000Z",
"completed_at": "2026-01-24T14:35:00.000Z"
}
}
When to use:
- Fulfill orders
- Grant access to products/services
- Update order status to "paid"
- Send confirmation emails
charge.failed
Sent when a payment fails.
{
"event_id": "evt_2b3c4d5e6f7g",
"event_type": "charge.failed",
"created_at": "2026-01-24T14:35:00.000Z",
"data": {
"charge_id": "chr_1a2b3c4d5e6f",
"checkout_id": "chk_9x8y7z6w5v",
"organization_id": "org_abc123",
"amount": "75000.00",
"currency": "NGN",
"status": "FAILED",
"failure_reason": "Insufficient funds",
"metadata": {
"order_id": "ORD-12345"
},
"created_at": "2026-01-24T14:30:00.000Z",
"failed_at": "2026-01-24T14:35:00.000Z"
}
}
When to use:
- Notify customer of failure
- Offer retry or alternative payment method
- Update order status to "payment_failed"
- Log for analytics
charge.pending
Sent when a charge is awaiting payment or confirmation.
{
"event_id": "evt_3c4d5e6f7g8h",
"event_type": "charge.pending",
"created_at": "2026-01-24T14:30:30.000Z",
"data": {
"charge_id": "chr_1a2b3c4d5e6f",
"checkout_id": "chk_9x8y7z6w5v",
"organization_id": "org_abc123",
"amount": "75000.00",
"currency": "NGN",
"status": "AWAITING_PAYMENT",
"payment_provider": null,
"provider_reference": "REF-12345",
"metadata": {
"order_id": "ORD-12345"
}
}
}
When to use:
- Show "payment pending" status to customer
- Update UI to show payment is processing
- Start polling as backup (in case completion webhook fails)
Example Implementation
Complete Webhook Handler
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
// Webhook secret from dashboard
const WEBHOOK_SECRET = process.env.SYNCPAY_WEBHOOK_SECRET;
// Signature verification
function verifySignature(payload, signature) {
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(JSON.stringify(payload))
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Main webhook handler
app.post('/webhooks/syncpay', async (req, res) => {
// 1. Verify signature
const signature = req.headers['x-syncpay-signature'];
if (!verifySignature(req.body, signature)) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Respond immediately (don't wait for processing)
res.status(200).json({ received: true });
// 3. Process event asynchronously
const event = req.body;
try {
await processWebhookEvent(event);
} catch (error) {
console.error('Webhook processing error:', error);
// Log error but don't retry - SyncPay will retry automatically
}
});
---
## Best Practices
### 1. Respond Quickly
Always respond with `200 OK` within 5 seconds:
```javascript
app.post('/webhooks/syncpay', async (req, res) => {
// Respond immediately
res.status(200).json({ received: true });
// Process asynchronously
processWebhookInBackground(req.body);
});
2. Handle Idempotency
Webhooks may be delivered multiple times. Make your handler idempotent:
async function handleChargeCompleted(data) {
const chargeId = data.charge_id;
// Check if already processed
const existing = await db.processedWebhooks.findOne({ chargeId });
if (existing) {
console.log(`Charge ${chargeId} already processed, skipping`);
return;
}
// Process event
await fulfillOrder(data);
// Mark as processed
await db.processedWebhooks.create({
chargeId,
processedAt: new Date()
});
}
3. Use Metadata Wisely
Include order IDs and other identifiers in metadata when creating checkouts:
// When creating checkout
const checkout = await createCheckout({
// ...
metadata: {
order_id: 'ORD-12345',
user_id: 'user_789',
product_id: 'PREMIUM-PLAN'
}
});
// In webhook
async function handleChargeCompleted(data) {
const orderId = data.metadata.order_id;
const userId = data.metadata.user_id;
// Use these to update your system
}
4. Test Webhook Integration
Use test mode to verify your webhook handler:
- Create test checkout
- Complete payment in test mode
- Verify webhook received
- Check webhook logs in dashboard
- Verify your system updated correctly
Webhook Retries
If your endpoint doesn't respond with 200 OK, SyncPay will retry:
- Retry schedule: 1 min, 5 min, 30 min, 1 hour, 6 hours, 24 hours
- Maximum attempts: 7
- Backoff strategy: Exponential
If all retries fail:
- Event is marked as failed in dashboard
- You can manually replay the webhook from dashboard
- Or fetch charge details via API as fallback
Security Considerations
Always Verify Signatures
Never process webhooks without verifying the signature:
if (!verifySignature(req.body, req.headers['x-syncpay-signature'])) {
return res.status(401).json({ error: 'Invalid signature' });
}
Use HTTPS Only
Webhook URLs must use HTTPS in production. HTTP is not allowed.
Validate Event Data
Don't trust webhook data blindly:
async function handleChargeCompleted(data) {
// Verify charge exists and matches expected state
const charge = await syncpay.getCharge(data.charge_id);
if (charge.status !== 'COMPLETED') {
throw new Error('Charge status mismatch');
}
if (charge.amount !== data.amount) {
throw new Error('Amount mismatch');
}
// Now safe to process
await fulfillOrder(data);
}
Troubleshooting
Webhooks Not Received
Check:
- Webhook URL is correct and publicly accessible
- Endpoint responds with
200 OK - Firewall isn't blocking SyncPay's IP
- SSL certificate is valid
- Webhook events are enabled in dashboard
Duplicate Events
Solution: Implement idempotency check (see Best Practices #2)
Signature Verification Fails
Check:
- Using correct webhook secret from dashboard
- Verifying against raw request body (before JSON parsing)
- Using correct HMAC algorithm (SHA-256)
Related Documentation
- Create Checkout - Include metadata for webhooks
- Get Charge Status - Fallback if webhook fails
- Payout Webhooks - Webhook events for withdrawals
Next Steps
- Set up webhook endpoint on your server
- Configure webhook URL in dashboard
- Test in test mode thoroughly
- Monitor webhook logs in production
- Implement fallback polling for critical payments