Webhooks
Webhooks let WPLM notify an external system in real time whenever something happens — a license is created, a device is revoked, a subscription renews, and so on. WPLM POSTs a small, HMAC-signed JSON payload to a URL you control and retries automatically if your endpoint is temporarily unreachable.
Who receives a webhook? (pull vs push)
Section titled “Who receives a webhook? (pull vs push)”This is the most common point of confusion, so it’s worth being explicit:
| Who starts it | Direction | Used by | |
|---|---|---|---|
Pull — validate / activate / heartbeat | the client asks | App → WPLM | your Flutter app, your licensed plugin, a desktop app |
| Push — webhook | WPLM tells you | WPLM → your server | your backend, CRM, Slack, Zapier… |
Your customers’ apps do not receive webhooks. A Flutter app or a licensed WordPress plugin checks licensing by calling the REST API (pull). A webhook is the reverse — WPLM pushes an event to one of your own server endpoints so your systems can react automatically without polling.
Example (Flutter app): the app calls /validate on launch to unlock pro
features — no webhook involved. But when a customer’s subscription payment fails,
you want their cloud features on your server cut off instantly. WPLM fires
subscription.payment_failed → POSTs it to your backend → your backend disables
their access. The webhook URL is your server, never the phone.
Example (WordPress plugin you sell): customers’ sites call /validate to
unlock features (pull). When you revoke a license for piracy, WPLM pushes
license.revoked to your update server so it stops serving updates for that
key. The customer’s site doesn’t receive it — your backend does.
Can I use my own domain as the URL?
Section titled “Can I use my own domain as the URL?”Yes. The Target URL can be any publicly reachable HTTPS endpoint you own —
including one on the same domain where WPLM is installed. It only has to accept
a POST and return a 200. The URL must be a full address, for example
(replacing your-domain.com with your site):
https://your-domain.com/wp-json/my-app/v1/wplm-webhook ← a custom REST routehttps://your-domain.com/wplm-webhook.php ← a standalone PHP filehttps://hooks.your-domain.com/incoming ← a separate subdomain/appYou create that endpoint — WPLM only sends to it. Two easy ways to make one on your own domain:
A. A standalone PHP file placed in your site’s web root (wplm-webhook.php):
<?php$secret = 'PASTE_THE_WEBHOOK_SECRET_FROM_WPLM';$body = file_get_contents( 'php://input' );$sig = $_SERVER['HTTP_X_WPLM_SIGNATURE'] ?? '';
if ( ! hash_equals( 'sha256=' . hash_hmac( 'sha256', $body, $secret ), $sig ) ) { http_response_code( 401 ); exit; // Not a genuine WPLM request.}
$event = json_decode( $body, true ); // [ 'event' => ..., 'data' => ..., 'timestamp' => ... ]
// Do your thing, e.g. log it:file_put_contents( __DIR__ . '/wplm-events.log', $body . "\n", FILE_APPEND );
http_response_code( 200 ); // Acknowledge.Then set Target URL to https://your-domain.com/wplm-webhook.php.
B. A custom REST route in your own plugin/theme functions.php:
add_action( 'rest_api_init', function () { register_rest_route( 'my-app/v1', '/wplm-webhook', array( 'methods' => 'POST', 'permission_callback' => '__return_true', 'callback' => function ( WP_REST_Request $req ) { $secret = 'PASTE_THE_WEBHOOK_SECRET'; $body = $req->get_body(); $sig = $req->get_header( 'x-wplm-signature' ); if ( ! hash_equals( 'sha256=' . hash_hmac( 'sha256', $body, $secret ), (string) $sig ) ) { return new WP_REST_Response( 'invalid signature', 401 ); } $event = json_decode( $body, true ); // handle $event['event'] / $event['data'] … return new WP_REST_Response( 'ok', 200 ); }, ) );} );Then set Target URL to https://your-domain.com/wp-json/my-app/v1/wplm-webhook.
Reacting on the same WordPress site? If your receiver lives on the same WP install as WPLM, you don’t need a webhook at all — just hook the internal PHP actions directly (
add_action( 'wplm_license_created', … )). Webhooks are for separate apps/servers, or no-code tools like Slack, Zapier, or Make.
How it works
Section titled “How it works”- You register a webhook endpoint (a URL), choose which events it should receive, and WPLM generates a signing secret for it.
- When a subscribed event fires, WPLM builds a JSON body and signs it with
HMAC-SHA256(secret). - WPLM
POSTs the body to your URL with the signature in theX-WPLM-Signatureheader. - The delivery is logged. If it fails (network error or a non-2xx response), WPLM retries hourly (up to 5 attempts within 24 hours) via WP-Cron.
Internally, the flow is:
wplm_* action (e.g. wplm_license_created) │ ▼WebhookEventBridge ── maps the action to a dotted event slug (license.created) │ ▼WebhookService::dispatch( event, payload ) │ • filters payload through `wplm_webhook_payload` │ • signs body with the webhook's secret │ • POSTs to every active endpoint subscribed to that event ▼wp_wplm_webhook_log ── records response code + delivery time; the `wplm_retry_webhooks` cron retries failures hourlyCreating a webhook
Section titled “Creating a webhook”License Manager → Webhooks → Add New. Provide:
| Field | Notes |
|---|---|
| Name | A label for your own reference. |
| Send to | The delivery preset — Custom endpoint, Slack, Discord, or Email (see below). |
| Delivery URL / address | The URL or email address for the chosen preset. |
| Events | One or more of the events below. |
| Status | Active webhooks receive deliveries; paused ones do not. |
A signing secret is generated automatically (used by the Custom-endpoint preset).
No-code delivery presets
Section titled “No-code delivery presets”You don’t have to write a receiver. Pick a preset and just paste a URL or address:
| Preset | What to paste | What happens |
|---|---|---|
| Slack | Your Slack Incoming Webhook URL (https://hooks.slack.com/…) | WPLM posts a short message to that Slack channel on each event. No code. |
| Discord | Your Discord channel Webhook URL (https://discord.com/api/webhooks/…) | WPLM posts a message to that Discord channel. No code. |
| An email address | WPLM emails that address on each event. No code, no external service. | |
| Custom endpoint | Your own HTTPS URL | WPLM sends the signed JSON envelope (for your backend/SDK). |
Creating a Slack/Discord incoming webhook takes a minute in their app settings and gives you a URL — that’s all you paste here.
Send a test
Section titled “Send a test”After saving, open the webhook and click Send test delivery. WPLM sends a
sample test.ping event to the destination and tells you whether it succeeded —
so you can confirm Slack/Discord/Email/your endpoint works before relying on it.
Events
Section titled “Events”| Event | Fires when |
|---|---|
license.created | A license is issued. |
license.activated | A device activates a license. |
license.deactivated | A device is deactivated / a seat is freed. |
license.suspended | A license is put on temporary hold. |
license.reinstated | A suspended/revoked license is restored. |
license.revoked | A license is revoked. |
license.terminated | A license is permanently terminated. |
license.expired | A license passes its expiry (+ grace). |
license.renewed | An expired license is renewed back to active. |
machine.revoked | A single device is revoked. |
subscription.created | A subscription is created. |
subscription.renewed | A renewal payment is processed. |
subscription.payment_failed | A renewal charge fails (includes the dunning attempt). |
subscription.paused | A subscription goes on-hold. |
subscription.resumed | A paused subscription resumes. |
subscription.cancelled | A subscription is cancelled. |
subscription.expired | A subscription reaches its end date. |
Payload format
Section titled “Payload format”Every delivery has the same envelope:
{ "event": "license.created", "data": { "id": 1042, "status": 2, "product_id": 17, "order_id": 8841, "user_id": 23, "expires_at": "2027-01-01 00:00:00" }, "timestamp": 1750000000}Headers:
Content-Type: application/jsonX-WPLM-Signature: sha256=<hex hmac of the raw body>The signature scheme matches the GitHub webhook style, so most existing client libraries verify it with minimal code.
Verifying the signature
Section titled “Verifying the signature”Compute HMAC-SHA256 of the raw request body with your webhook’s secret and
compare it (in constant time) to the value in X-WPLM-Signature.
// PHP receiver$body = file_get_contents( 'php://input' );$expected = 'sha256=' . hash_hmac( 'sha256', $body, $YOUR_WEBHOOK_SECRET );$signature = $_SERVER['HTTP_X_WPLM_SIGNATURE'] ?? '';
if ( ! hash_equals( $expected, $signature ) ) { http_response_code( 401 ); exit; // Reject — not from WPLM.}
$payload = json_decode( $body, true );// $payload['event'], $payload['data'], $payload['timestamp']// Node.js receiver (Express, raw body)import crypto from 'crypto';
const expected = 'sha256=' + crypto.createHmac('sha256', YOUR_WEBHOOK_SECRET).update(rawBody).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) { return res.status(401).end();}Respond with a 2xx status code to acknowledge receipt. Any other status (or a timeout) marks the delivery as failed and schedules a retry.
Retries & delivery log
Section titled “Retries & delivery log”- Failed deliveries are retried by the
wplm_retry_webhookscron, which runs hourly. - A delivery is retried up to 5 attempts, only within 24 hours of the original event. After that it is abandoned.
- Each attempt is recorded in
wp_wplm_webhook_log(event, response code, attempt count, delivered-at time).
Managing webhooks via REST
Section titled “Managing webhooks via REST”Webhooks can also be managed through the REST API (requires an API key with the appropriate scope):
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /wp-json/wplm/v1/webhooks | List webhooks |
| POST | /wp-json/wplm/v1/webhooks | Register a webhook |
| DELETE | /wp-json/wplm/v1/webhooks/{id} | Delete a webhook |
Customising payloads
Section titled “Customising payloads”Use the wplm_webhook_payload filter to add or remove fields before delivery —
for example, to strip a sensitive field or attach your own metadata:
add_filter( 'wplm_webhook_payload', function ( array $payload, string $event ) { if ( str_starts_with( $event, 'license.' ) ) { unset( $payload['signature'] ); } return $payload;}, 10, 2 );