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.