Development
How to Automate WishList Member With Webhooks and the PHP API

WishList Member automation usually has two directions. An outgoing webhook tells another system that a membership event happened. The PHP API lets trusted code read or change membership data inside WordPress.
Those paths have different guarantees. The built-in outgoing webhook has a documented product payload, but it does not document a stable event ID, signature, delivery ordering, or retry policy. If an integration requires those guarantees, add them in middleware or a custom publisher instead of pretending the native webhook provides them.
Disclosure: I work for Caseproof, the company that owns WishList Member, and I was the product's original programmer. This tutorial uses public product documentation and generic integration patterns.
Choose the direction from the source of truth
Use an outgoing webhook when WishList Member owns the event and another system needs to react. The current WishList Member Webhooks guide documents outgoing events for a member being added, removed, canceled, uncanceled, expired, or unexpired.
Use the PHP API when trusted code inside the WordPress runtime needs to perform a documented WishList Member operation, such as adding a known WordPress user to a membership level.
Use an incoming webhook when an external system must trigger one of the actions configured in WishList Member. Incoming webhooks are a separate product feature with server-side action and field mappings. Do not treat the incoming and outgoing formats as interchangeable.
Whichever path you choose, name the source of truth. If a CRM command changes WishList Member and WishList Member reports the change back to the CRM, the receiver must recognize that echo instead of starting a loop.
Start with the real outgoing payload
WishList Member's documented outgoing fields include:
trigger, with one ofadd,remove,cancel,uncancel,expire, orunexpiretrigger_levels, an array of the membership level or pay-per-post IDs involvedid, the WordPress user ID- Member fields such as
email,login,firstname, andlastname - The member's current
levelsandpay_per_postscollections
For example, the useful subset of an add notification looks like this:
{
"trigger": "add",
"trigger_levels": ["1700000000"],
"id": "42",
"email": "member@example.com"
}
This is a reduced example based on the documented payload, not a new event envelope. In WishList Member 3.34.3, the built-in integration posts the data as form fields. A receiver can also accept JSON for controlled middleware and tests, but it should not require invented fields such as event_id, event, member_id, or level from the native integration.
Ignore fields the integration does not need. In particular, do not retain or log the complete body just because the webhook includes profile data.
Parse the product payload before adapting it
The receiver should bound the request size, accept only an expected content type, catch decoding failures, and return a deliberate 4xx response for invalid input.
<?php
declare(strict_types=1);
/**
* Sends a JSON webhook response and stops processing.
*/
function webhookResponse(int $status, string $code): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['status' => $code], JSON_THROW_ON_ERROR);
exit;
}
/**
* Reads a bounded WishList Member webhook payload.
*
* @return array<string, mixed>
*/
function readWishListMemberWebhook(int $maximumBytes = 65536): array
{
$declaredLength = $_SERVER['CONTENT_LENGTH'] ?? null;
if ($declaredLength !== null) {
$length = filter_var(
$declaredLength,
FILTER_VALIDATE_INT,
['options' => ['min_range' => 0, 'max_range' => $maximumBytes]]
);
if ($length === false) {
webhookResponse(413, 'payload_too_large');
}
}
$contentType = strtolower(trim(explode(';', $_SERVER['CONTENT_TYPE'] ?? '')[0]));
if (!in_array($contentType, ['application/x-www-form-urlencoded', 'application/json'], true)) {
webhookResponse(415, 'unsupported_media_type');
}
$raw = file_get_contents('php://input', false, null, 0, $maximumBytes + 1);
if ($raw === false || strlen($raw) > $maximumBytes) {
webhookResponse(413, 'payload_too_large');
}
if ($contentType === 'application/x-www-form-urlencoded') {
parse_str($raw, $payload);
return $payload;
}
try {
$payload = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException) {
webhookResponse(400, 'invalid_json');
}
if (!is_array($payload)) {
webhookResponse(400, 'invalid_payload');
}
return $payload;
}
The declared length is only an early rejection. The receiver always reads at most one byte beyond the limit and checks the bytes it actually received, so form and JSON requests remain bounded even when CONTENT_LENGTH is absent or inaccurate. Parsing the form body locally also avoids trusting a pre-populated $_POST array whose original byte count is no longer visible.
The built-in outgoing integration does not document a request signature or configurable authentication header. Treat its body as an untrusted notification. Use HTTPS, give the receiver the least authority possible, and confirm consequential state against the source of truth. If cryptographic authenticity is required, publish a custom signed event from WordPress or put trusted middleware at the boundary and document that it is custom infrastructure.
Adapt supported fields to local commands
Validate the documented trigger, user ID, and every level ID. Map external IDs to server-owned references rather than allowing a request to select an arbitrary internal operation.
<?php
declare(strict_types=1);
final readonly class MembershipTransition
{
/**
* Stores one validated WishList Member transition.
*/
public function __construct(
public string $action,
public int $memberId,
public string $levelReference
) {
}
}
/**
* Converts a WishList Member payload into allowed local transitions.
*
* @param array<string, mixed> $payload Native webhook fields.
* @param array<string, string> $levelMap Allowed WishList Member IDs keyed to local references.
* @return list<MembershipTransition>
*/
function adaptWishListMemberWebhook(array $payload, array $levelMap): array
{
$action = isset($payload['trigger']) && is_string($payload['trigger'])
? $payload['trigger']
: '';
$memberId = filter_var(
$payload['id'] ?? null,
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]]
);
$levelIds = $payload['trigger_levels'] ?? null;
if (!in_array($action, ['add', 'remove', 'cancel', 'uncancel', 'expire', 'unexpire'], true)) {
throw new InvalidArgumentException('Unsupported WishList Member trigger.');
}
if ($memberId === false || !is_array($levelIds) || $levelIds === []) {
throw new InvalidArgumentException('Invalid member or level data.');
}
$transitions = [];
foreach ($levelIds as $levelId) {
if (!is_int($levelId) && !is_string($levelId)) {
throw new InvalidArgumentException('Invalid level identifier.');
}
$levelId = (string) $levelId;
if (!array_key_exists($levelId, $levelMap)) {
throw new InvalidArgumentException('Unknown membership level.');
}
$transitions[] = new MembershipTransition($action, $memberId, $levelMap[$levelId]);
}
return $transitions;
}
try {
$payload = readWishListMemberWebhook();
$transitions = adaptWishListMemberWebhook(
$payload,
['1700000000' => 'silver', '1700000001' => 'gold']
);
} catch (InvalidArgumentException) {
webhookResponse(422, 'invalid_payload');
}
The level IDs above are examples. Replace them with IDs from the target installation and keep the mapping in server-controlled configuration.
Be precise about idempotency and delivery
The native outgoing payload has no documented stable event ID. Do not permanently deduplicate on trigger + id + level. A legitimate sequence such as add, remove, then add would cause the final add to look like the first one.
Instead, make the local operation state-based where possible. Adding already-present access or removing already-absent access should produce the same final state without duplicate side effects. A short-lived receipt hash may dampen immediate duplicate requests, but it must not become a permanent event identity.
The native documentation also does not promise at-least-once delivery, ordering, retries, or backoff. If the workflow requires those properties, use a custom publisher or middleware that adds a unique event ID, occurrence time, schema version, signature, durable queue, and retry policy. Then store that custom event ID behind a unique database constraint and process the receipt, state transition, and outbox record in one transaction.
Without an occurrence time or sequence, a receiver cannot reliably resolve out-of-order native notifications. Periodic reconciliation is the safety net: compare the minimum required state with WishList Member and repair drift according to the declared source of truth.
Call the PHP API with its documented contract
For trusted code running in the same WordPress installation, use WishList Member's documented functions rather than writing its tables directly. The function reference for wlmapi_add_member_to_level takes the level ID first and an argument array containing Users.
Its documented result is a multidimensional array. success is 1 for a successful API operation, and members.member contains the members of the level. A guarded wrapper can validate both:
<?php
declare(strict_types=1);
/**
* Adds a WordPress user to a WishList Member level through the public helper.
*/
function addUserToWishListMemberLevel(int $userId, int $levelId): void
{
if ($userId < 1 || $levelId < 1) {
throw new InvalidArgumentException('User and level identifiers are required.');
}
if (!function_exists('wlmapi_add_member_to_level')) {
throw new RuntimeException('WishList Member API is unavailable.');
}
$result = wlmapi_add_member_to_level($levelId, ['Users' => [$userId]]);
if (!is_array($result) || (int) ($result['success'] ?? 0) !== 1) {
throw new RuntimeException('WishList Member rejected the level assignment.');
}
$members = $result['members']['member'] ?? null;
if (!is_array($members)) {
throw new RuntimeException('WishList Member returned an unexpected result.');
}
foreach ($members as $member) {
if (is_array($member) && (int) ($member['id'] ?? 0) === $userId) {
return;
}
}
throw new RuntimeException('The user was not present in the resulting membership.');
}
Keep authorization at the entry point that requested the change. A public REST callback must authenticate the caller, check the required WordPress capability, validate object access, and only then call this service. A nonce proves request intent in a browser session, not authorization by itself.
Test the failure paths against a named version
I checked the payload and API shapes above against WishList Member 3.34.3 and the linked public documentation. Before deploying against another release, run the same cases in staging:
- A valid add, remove, cancel, and expiration notification
- Every supported trigger with one and several
trigger_levels - An unknown trigger, member ID, or level ID
- Form-encoded and JSON bodies exactly at the byte limit and one byte above it
- Form-encoded and JSON bodies with
CONTENT_LENGTHabsent, proving an in-limit body succeeds and an over-limit body returns413 - Malformed JSON and unsupported-media requests
- An immediate duplicate and a legitimate add, remove, add sequence
- Delayed and out-of-order notifications
- A valid API assignment, an already-present member, an unknown user, and an invalid level
- An API response with
successmissing or zero, or without the requested member - The helper being unavailable because the plugin is inactive
- Reconciliation detecting and repairing drift
Use dedicated test members and make sure no test can charge, email, or modify production accounts. Log internal IDs, action, outcome, duration, and a privacy-safe correlation value. Do not log credentials, password values, payment tokens, or complete member payloads.
Start with one low-risk transition, observe it, and expand only after the state-based handler and reconciliation job work. Use the built-in webhook for what it documents, and add a custom event envelope only when the integration really needs stronger delivery or authentication guarantees.