Skip to main content

Payment lifecycle

Understanding Webhooks in Ledyer

Webhooks (also called notifications) are HTTP callbacks that Ledyer sends to your system to inform you about events in the payment lifecycle. While the specific events differ slightly between products, the fundamental principles of handling them remain the same.

The Payment Lifecycle

Every payment goes through the same series of statuses, but the timing (and potentially also the sequence) between status changes can vary significantly based on several factors:

  • Payment method (invoice, card, direct bank transfer)
  • Risk assessment results (automatic approval vs manual review)
  • Customer behavior (immediate payment vs payment over time)
  • Business processes (automatic capture vs manual capture)

This variability means you cannot rely on webhooks arriving in a predictable timeframe or specific sequence.

The Golden Rule: Use Webhooks as Triggers, Not as Data

The most important principle when handling Ledyer webhooks is: never update your ERP or order management system based on the webhook content directly.

❌ WRONG APPROACH:

app.post('/webhooks/ledyer', (req, res) => {
const event = req.body;

if (event.type === 'com.ledyer.order.ready_for_capture') {
// ❌ DON'T: Updating ERP based solely on webhook
erp.updateOrder(event.data.orderId || event.data.sessionId, {
status: 'ready_for_capture',
readyToShip: true
});
}

res.sendStatus(200);
});

Problems with this approach:

  • Webhooks could be spoofed by attackers
  • Duplicate webhooks could cause incorrect state changes
  • You're trusting external data without verification
  • No guarantee the webhook represents the current state

✅ RIGHT APPROACH:

app.post('/webhooks/ledyer', async (req, res) => {
const event = req.body;

// Step 1: Extract the ID
const orderId = event.data.orderId || event.data.sessionId;

// Step 2: Fetch the ACTUAL current state from Ledyer's API
const paymentStatus = await ledyer.getPaymentStatus(orderId);

// Step 3: Update your ERP based on verified data from API
await erp.updateOrderPaymentStatus(orderId, paymentStatus);

// Step 4: Acknowledge receipt
res.sendStatus(200);
});

Why this is better:

  • You're fetching data directly from Ledyer's authoritative API
  • The data is verified and current
  • Duplicate webhooks won't cause issues (you'll just fetch the same state)
  • Spoofed webhooks will only trigger a harmless API call

Mapping Payment Status to Your ERP

The payment status of an order is very detailed on Ledyer's side, while the same amount of statuses may not be available in your system. This does not have to be a concern, the most important thing is to make sure actions are available based on what Ledyer communicates:

An order can never be captured before the status paymentConfirmed. An order with orderInitiated, orderPending or paymentPending may be communicated simply as "pending", the main point being the payment of the order is waiting for something more to happen.

Understanding Timing Variations

Your integration must handle both scenarios without making assumptions about timing:

async function handleWebhook(event) {
const orderId = event.data.orderId || event.data.sessionId;

// Don't assume this is a "recent" event
// Don't assume previous webhooks were received
// Always fetch current state

const currentStatus = await ledyer.getPaymentStatus(orderId);

// Update based on CURRENT state, not event history
await updateERPFromPaymentStatus(orderId, currentStatus);
}

Implementation Example

Here's an example of webhook handler for updating your ERP:

const express = require('express');
const app = express();

app.post('/webhooks/ledyer', async (req, res) => {
try {
const event = req.body;

// Log for debugging
console.log('Received webhook:', event.type, event.data.orderId || event.data.sessionId);

// Check if already processed (idempotency)
if (await isAlreadyProcessed(event.id)) {
return res.sendStatus(200); // Already handled
}

// Get the order/payment ID from webhook
const orderId = event.data.orderId || event.data.sessionId;

// Fetch current payment status from Ledyer API
const paymentStatus = await ledyer.getPaymentStatus(orderId);

// Update your ERP system
await erp.syncPaymentStatus(orderId, paymentStatus);

// Mark webhook as processed
await markAsProcessed(event.id);

// Acknowledge successful receipt
res.sendStatus(200);

} catch (error) {
console.error('Webhook processing error:', error);
// Return 500 or 4xx to trigger Ledyer's retry mechanism
res.sendStatus(400);
}
});

Handling Duplicate Webhooks

You may receive the same webhook multiple times. Your system should handle this gracefully:

const processedWebhooks = new Set();

async function isAlreadyProcessed(webhookId) {
return processedWebhooks.has(webhookId);
}

async function markAsProcessed(webhookId) {
processedWebhooks.add(webhookId);
// In production, store in database
await db.saveProcessedWebhook(webhookId);
}

Note: Since you're fetching the current state from the API, processing the same webhook twice will just result in fetching the same data again - which is harmless but wastes resources.

Testing Your Integration

Webhooks can easily be tested from within Ledyer merchant portal, you can view and debug webhook history even re-running them.

Test Different Timing Scenarios

  1. Rapid succession: Trigger multiple status changes quickly
  2. Delayed updates: Manually review orders to simulate delays
  3. Out-of-order webhooks: Process webhooks in non-sequential order
  4. Duplicate webhooks: Send the same webhook twice
test('handles duplicate webhooks gracefully', async () => {
const webhook = {
id: 'wh_123',
type: 'order.captured',
data: { orderId: 'order_123' }
};

// Process same webhook twice
await handleWebhook(webhook);
await handleWebhook(webhook);

// Should only update ERP once
expect(erp.getUpdateCount('order_123')).toBe(1);
});

Summary: The Three Steps

When you receive a webhook from Ledyer:

  1. Extract the order/payment ID from the webhook
  2. Fetch the current payment status from Ledyer's API
  3. Update your ERP system based on the API response

This approach ensures your system stays in sync with Ledyer while remaining secure and handling real-world variations in timing and delivery.

Additional Resources