Skip to main content

Documentation Index

Fetch the complete documentation index at: https://lyelpay.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

Webhooks are HTTP POST requests that Lyel Pay sends to your server when something happens — a payment completes, fails, or expires. They are the recommended way to trigger business logic like fulfilling orders or sending receipts.

How to set up a webhook endpoint

1. Create the endpoint

Your endpoint must:
  • Accept POST requests
  • Return 2xx within a reasonable time (we recommend < 5 seconds)
  • Process the raw request body (not parsed JSON) for signature validation
// Express
import express from 'express';
import { LyelPay } from '@lyel/lyel-pay-node';

const lyel = new LyelPay(process.env.LYELPAY_SECRET_KEY!);
const app = express();

app.post(
  '/webhooks/lyelpay',
  express.raw({ type: 'application/json' }), // ← raw body, not express.json()
  async (req, res) => {
    const payload = req.body.toString();
    const signature = req.headers['lyel-signature'] as string;

    let event;
    try {
      event = lyel.webhooks.constructEvent(
        payload,
        signature,
        process.env.LYELPAY_WEBHOOK_SECRET!,
      );
    } catch (err) {
      console.error('Webhook signature verification failed:', err.message);
      return res.sendStatus(400);
    }

    // Handle the event
    switch (event.type) {
      case 'payment.completed':
        await handlePaymentCompleted(event.data.paymentIntent);
        break;
      case 'payment.failed':
        await handlePaymentFailed(event.data.paymentIntent);
        break;
      case 'payment.expired':
        await handlePaymentExpired(event.data.paymentIntent);
        break;
      default:
        console.log('Unhandled event type:', event.type);
    }

    res.sendStatus(200);
  }
);

2. Register your URL in the dashboard

Go to your dashboard → Settings → Webhooks → Add endpoint. Copy the Webhook Secret shown — you’ll need it for signature validation.

Signature validation

Every webhook request includes a lyel-signature header:
lyel-signature: t=1716000000,v1=3d3d2b5...
PartDescription
tUnix timestamp (seconds) when the event was sent
v1HMAC-SHA256 signature
The signature is computed as:
HMAC-SHA256(secret, "{t}.{raw_payload}")
constructEvent() handles this automatically, including:
  • Parsing the header
  • Recomputing the signature
  • Rejecting events older than 5 minutes (to prevent replay attacks)
  • Using timingSafeEqual to prevent timing attacks
If you parse the body with express.json() before the raw body middleware, the signature check will fail because the body will be re-serialized and the bytes will differ.

Event types

payment.completed

Fired when a payment intent reaches COMPLETED status.
{
  "id": "evt_01HX...",
  "type": "payment.completed",
  "created": 1716000000,
  "data": {
    "paymentIntent": {
      "id": "pi_01HX...",
      "amount": "5000",
      "currency": "XAF",
      "status": "COMPLETED",
      "mode": "LIVE",
      "sessionToken": "tok_...",
      "description": "Order #1042",
      "metadata": { "orderId": "1042", "customerId": "cust_abc" },
      "expiresAt": "2026-05-17T11:00:00.000Z",
      "createdAt": "2026-05-17T10:00:00.000Z"
    }
  }
}

payment.failed

Fired when a payment attempt fails (e.g. insufficient balance, wrong OTP).

payment.expired

Fired when a payment intent passes its expiry time without being completed.

Idempotency

Webhooks may be delivered more than once. Design your handler to be idempotent — processing the same event twice should not cause double charges or duplicate fulfillments.
async function handlePaymentCompleted(pi: PaymentIntent) {
  const already = await db.orders.findByPaymentIntentId(pi.id);
  if (already?.status === 'paid') return; // already processed

  await db.orders.markPaid(pi.id, pi.metadata.orderId);
  await emailService.sendReceipt(pi.metadata.customerId, pi.amount);
}

Retries

If your endpoint returns a non-2xx status, Lyel Pay will retry the delivery. The retry schedule is:
AttemptDelay
1st retry5 minutes
2nd retry30 minutes
3rd retry2 hours
4th retry12 hours
After 4 failed retries, the event is marked as undelivered. You can manually replay events from your dashboard.

Testing webhooks locally

Use a tunneling tool to expose your local server:
# Using ngrok
ngrok http 3000

# Using cloudflare tunnel
cloudflare tunnel --url http://localhost:3000
Register the generated HTTPS URL as your webhook endpoint in the dashboard during development.