Webhooks
React to payment and subscription events in your plugin.
PayLexer sends every event to the merchant's site, where the PayLexer plugin receives it and runs these checks, in order:
Checks the event is for this site's tenant
Drops replayed deliveries
Logs the event under PayLexer → Webhook logs
Only then does it fire your hooks. You don't register a URL or handle a secret.
Hooks
For platform acme-bookings:
add_action( 'paylexer_platform_acme-bookings_payment_completed', function ( array $event ) {
$reference = $event['data']['orderReference'];
// Mark your order paid.
} );
| Hook | Arguments |
|---|---|
paylexer_platform_{id}_{event} | array $event |
paylexer_platform_{id}_webhook | array $event, string $event_type, fired for every event |
With a platform adapter, the same events arrive at its on_event() method instead.
{event} is the event type with . replaced by _:
| Payment events | Subscription events |
|---|---|
payment_initiated | subscription_created |
payment_authorized | subscription_activated |
payment_captured | subscription_renewed |
payment_completed | subscription_payment_failed |
payment_failed | subscription_cancelled |
payment_cancelled | subscription_paused, subscription_resumed |
payment_refunded | subscription_trial_started, subscription_trial_ended |
payment_refund_voided | subscription_updated |
Fulfil orders from payment_completed.
Payload
{
"event": "payment.completed",
"timestamp": "2026-10-01T10:00:00.000Z",
"tenantId": "…",
"resourceId": "…",
"platform": "acme-bookings",
"data": {
"orderReference": "ORD-ADF79AA433405B80",
"amount": 120,
"currency": "USD",
"status": "completed",
"paymentGateway": "stripe",
"transactionId": "…",
"transactionUrl": "…",
"customer": { "email": "jo@example.com", "name": "Jo Doe" }
}
}
For the subscription data fields, see Subscriptions.
Every order an event names for your platform (data.orderReference, and a renewal's data.renewalOrderReference) becomes one your platform can refund and check, including a subscription's first and renewal orders.
Refund events
Refund events (payment.refunded, payment.refund_voided) also carry, in data:
| Field | Notes |
|---|---|
refundId | |
refundAmount | This refund. |
totalRefunded | All succeeded refunds on the order so far. |
remainingRefundable | |
orderStatus | refunded or partially_refunded; completed after a voided refund took the total back to zero. |
refundSource | Where the refund was made. |
reason |
With an adapter, read them as $event->refund (WebhookRefund).
refundSource says where the refund was made:
refundSource | Made |
|---|---|
| Your platform id | When you sent it with refund() / paylexer_refund(). You already recorded it, so skip it: $event->is_own_refund( 'your-id' ). |
A gateway code, such as stripe | In the gateway's own dashboard. |
dashboard | In the PayLexer dashboard. |
api |
Record every refund that isn't your own, full or partial, or your orders drift from the gateway. Set your refunded total from totalRefunded rather than adding refundAmount, so a repeated delivery changes nothing.
is_own_refund() needs API 1.1 or later.
Retries
| Your handler | The delivery answers | Then |
|---|---|---|
| Throws | 400 | PayLexer retries it later with backoff. |
| Runs, but no active plugin has registered the event's platform (for example, your plugin is deactivated) | 503 | PayLexer retries it a limited number of times, so events aren't lost while your plugin is briefly off. |
Throw when you couldn't save the result, for example when your database is down. If the order is simply unknown to you, return normally.
Neither the 400 nor the 503 counts toward PayLexer switching off the site's webhook after repeated failures, so one broken or deactivated plugin never stops events for the others.
Once retries run out the event is not delivered again; use paylexer_get_payment_status() to catch up on open orders.
Writing safe handlers
- Be idempotent. A retry can deliver the same event twice, so check whether the order is already in the target state before acting. Two deliveries can also run at once, so lock per order (for example MySQL
GET_LOCK()) around the check and the write. - Expect any order. A late
payment_failedcan arrive afterpayment_completed. Never move a paid order back to unpaid. - Trust only the event. Don't mark an order paid from the browser's return or redirect.