DevelopersWebhooksWebhooks
Webhooks

Webhooks

Paylead calls your server when an order is paid or a subscription changes. Events, payloads, signatures and retries.

Add an endpoint

In Settings → Developers → Webhooks, click Add, enter your endpoint's address (it must be public https://) and pick the events you want. Each endpoint gets its own signing secret, starting whsec_.

Click Send test to send a ping event and see the result in the event log on the same page.

Events

Event When
order.paid An order is paid, or a pay-on-delivery order is confirmed. Every payment, including subscription payments, embeds and payment links
invoice.paid An invoice is paid in full
subscription.activated A buyer pays for a subscription for the first time
subscription.renewed A subscriber pays for another period
subscription.past_due A period ended without payment. They keep access during the grace period
subscription.cancelled A subscriber cancels. They keep access until the paid period ends
subscription.expired Access has ended: not renewed in time, or a cancelled period ran out

For a paid app, subscription.activated, subscription.renewed and subscription.expired are usually all you need: turn access on, keep it on, turn it off.

What Paylead sends

A POST with a JSON body:

{
  "id": "evt_9c2kq0x7m1b4z8w3r5t6y2u1",
  "type": "subscription.activated",
  "livemode": true,
  "created_at": "2026-10-10T09:00:00+00:00",
  "store": { "id": 12, "name": "My App" },
  "data": {
    "id": "sub_8f2k1m0q9z",
    "object": "subscription",
    "livemode": true,
    "status": "active",
    "has_access": true,
    "ref": "USER_123",
    "customer": { "id": 881, "name": "Ama Mensah", "email": "[email protected]", "phone": "0240000000" },
    "product": { "id": 31, "name": "Pro plan" },
    "amount": 50,
    "currency": "GHS",
    "interval": "month",
    "current_period_end": "2026-11-10T09:00:00+00:00",
    "access_ends_at": "2026-11-13T09:00:00+00:00",
    "manage_url": "https://paylead.app/subscription/…"
  }
}

data is the same object the API returns: a subscription, an order or an invoice.

livemode is false for test-mode events, which only go to endpoints you marked Test. Live endpoints only get real events.

With these headers:

Header
X-Paylead-Event The event type
X-Paylead-Event-Id The event's id. The same on every retry
X-Paylead-Timestamp Unix time the request was signed
X-Paylead-Signature sha256= and the signature
User-Agent Paylead-Webhooks/1.0

Check the signature

The signature is an HMAC-SHA256 of the timestamp, a full stop and the raw request body, keyed with your endpoint's secret. Compute it yourself and compare. Use the raw body, before any JSON parsing.

// Node.js (Express)
const crypto = require('crypto');

app.post('/webhooks/paylead', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('X-Paylead-Timestamp');
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.PAYLEAD_WEBHOOK_SECRET)
    .update(`${timestamp}.${req.body}`)
    .digest('hex');

  const given = req.get('X-Paylead-Signature') || '';
  if (expected.length !== given.length ||
      !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) {
    return res.status(400).end();
  }

  const event = JSON.parse(req.body);
  // handle event.type …
  res.status(200).end();
});
// PHP / Laravel
$body = $request->getContent();
$expected = 'sha256='.hash_hmac(
    'sha256',
    $request->header('X-Paylead-Timestamp').'.'.$body,
    config('services.paylead.webhook_secret'),
);

abort_unless(hash_equals($expected, (string) $request->header('X-Paylead-Signature')), 400);

$event = json_decode($body, true);
# Python (Flask)
import hashlib, hmac, os

body = request.get_data()
signed = request.headers['X-Paylead-Timestamp'].encode() + b'.' + body
expected = 'sha256=' + hmac.new(os.environ['PAYLEAD_WEBHOOK_SECRET'].encode(), signed, hashlib.sha256).hexdigest()

if not hmac.compare_digest(expected, request.headers.get('X-Paylead-Signature', '')):
    abort(400)

To guard against replays, also reject requests whose X-Paylead-Timestamp is more than 5 minutes old.

Answer quickly

Reply with any 2xx status within 15 seconds. Do slow work (sending emails, calling other services) after you reply, or in a background job.

Retries

If your endpoint doesn't answer 2xx, Paylead tries again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours and 6 hours: 8 tries over about 10 hours. You can also Send again any event from the log.

The same event can arrive more than once, for example if your server timed out after saving it. Use X-Paylead-Event-Id (or the body's id) to ignore events you've already handled.

Events can arrive out of order. When it matters, trust the object's current state (status, current_period_end) over the order events arrived in, or fetch it fresh from the API.

Endpoints that keep failing

After 25 events in a row fail every retry, Paylead switches the endpoint off and shows why on the Webhooks page. Fix your endpoint, then edit it and turn it back on. Test pings don't count towards this.

Rolling your secret

Make new secret on the endpoint replaces it straight away; the old one stops working. Update your server right after.