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 OK within 5 seconds
  • Process events asynchronously (don't block the response)

2. Configure Webhook URL

Add your webhook URL in the SyncPay dashboard:

  1. Go to SettingsWebhooks
  2. Click Add Webhook Endpoint
  3. Enter your webhook URL (e.g., https://yoursite.com/webhooks/syncpay)
  4. Select events to subscribe to
  5. 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:

  1. Create test checkout
  2. Complete payment in test mode
  3. Verify webhook received
  4. Check webhook logs in dashboard
  5. 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:

  1. Webhook URL is correct and publicly accessible
  2. Endpoint responds with 200 OK
  3. Firewall isn't blocking SyncPay's IP
  4. SSL certificate is valid
  5. Webhook events are enabled in dashboard

Duplicate Events

Solution: Implement idempotency check (see Best Practices #2)

Signature Verification Fails

Check:

  1. Using correct webhook secret from dashboard
  2. Verifying against raw request body (before JSON parsing)
  3. Using correct HMAC algorithm (SHA-256)


Next Steps

  1. Set up webhook endpoint on your server
  2. Configure webhook URL in dashboard
  3. Test in test mode thoroughly
  4. Monitor webhook logs in production
  5. Implement fallback polling for critical payments