Outgoing webhooks let PaywallProject tell another system when something happens to a subscriber. Every time one of the events below fires, PaywallProject sends a JSON POST to the URL you give it. Use them with Zapier, Make, Pabbly, n8n, or your own endpoint.
Setting one up
Go to PaywallProject → Settings → Webhooks, fill in the Add a new webhook card at the bottom, and click Save changes.

URL
Where to send the request. Paste the URL your automation tool gives you — in Pabbly Connect and Zapier this is the “catch hook” or “webhooks by Zapier” trigger URL. It must be reachable from the public internet, so a localhost address will not work.
Events
Tick the events you want. Most integrations only care about one or two, and sending fewer requests keeps your automation tool’s task count down.
- All events — everything, including any event added in a future release. Good for a general-purpose log; wasteful if you only need one thing.
- Selecting nothing does the same as All events.
Two of them answer questions people often confuse. subscriber.created is a new person subscribed — use it to add someone to a mailing list. subscriber.status_changed is an existing subscription moved — use it to remove someone when they lapse, or to react to a renewal.
Signing secret
Optional but recommended if your endpoint is your own code. Enter any random string. PaywallProject will add an X-PPAccess-Signature header so your endpoint can confirm the request really came from your site — see Verifying the signature below.
Leave it blank if you are using Zapier, Make, or Pabbly. Those tools do not check signatures.
Send a test
Every webhook has its own Save and send test button. Pick an event beside it and click: the tab is saved, then a sample payload goes to that endpoint only. The “Add a new webhook” card has one too, so a new endpoint can be entered and tested in a single click.
Saving first is unavoidable — the request is sent by your site from the saved settings, so the webhook has to exist before it can be tested.
A test ignores the event list. It sends whichever event you picked whether or not that webhook is subscribed to it, so you can confirm an endpoint works before deciding what it should listen to. The confirmation message names the event and the URL it went to.
The sample payload carries every field a real one does, which is what lets Zapier, Make or Pabbly capture the field structure before you build the rest of the workflow.
The events
subscriber.created
Someone becomes a subscriber — a paid signup, a free registration, or a group member accepting an invitation.
It fires once the subscription is actually active, not when the account is created. A reader who starts checkout and abandons it at the card step produces no event. Each person produces exactly one, however they signed up.
{
"user_id": 10535,
"email": "[email protected]",
"first_name": "Alex",
"last_name": "Reader",
"level_id": 3,
"status": "active",
"expires": 1819274209,
"expires_date": "2027-08-26",
"phone": "+1 555 0100",
"shipping": {
"name": "Alex Reader",
"line1": "1 Example Street",
"line2": "",
"city": "Springfield",
"state": "IL",
"postal_code": "62704",
"country": "US"
},
"context": { "source": "group_invite" }
}
expires is a Unix timestamp; expires_date is the same moment as YYYY-MM-DD. An expires of 0 means the plan never expires — it is not a missing value. shipping is empty unless the level collects an address.
subscriber.status_changed
A subscription moves between active, past_due, canceled, expired, trialing, or pending_cancel. Carries both the old and new status, so you can react to a specific transition rather than any change.
{
"user_id": 10535,
"email": "[email protected]",
"first_name": "Alex",
"last_name": "Reader",
"new": "expired",
"old": "active",
"source": "expiration_cron",
"expires": 1819274209,
"expires_date": "2027-08-26",
"phone": "+1 555 0100",
"shipping": {
"name": "Alex Reader",
"line1": "1 Example Street",
"line2": "",
"city": "Springfield",
"state": "IL",
"postal_code": "62704",
"country": "US"
}
}
source tells you what caused it — webhook for a change driven by Stripe, expiration_cron for a subscription reaching its expiry date, gateway for a completed checkout.
transaction.recorded
A payment is recorded. This includes renewals, so a monthly subscriber produces one of these every month.
{
"user_id": 10535,
"email": "[email protected]",
"first_name": "Alex",
"last_name": "Reader",
"transaction": {
"user_id": 10535,
"level_id": 3,
"gateway": "stripe",
"gateway_txn_id": "pi_3U8dZe...",
"amount": 59.00,
"currency": "USD",
"status": "succeeded",
"created_at": "2026-08-26 12:08:58"
}
}
group.member_access_changed
Fires once per active group member when their group owner’s status changes, or when a member is removed from a group.
Group members have no billing of their own — they inherit the owner’s access — so no subscriber.status_changed ever fires for them. This is the only signal that a member gained or lost access.
{
"user_id": 10540,
"email": "[email protected]",
"first_name": "Sam",
"last_name": "Member",
"level_id": 4,
"group": {
"id": 9,
"name": "Newsroom Team",
"owner_id": 10535,
"owner_email": "[email protected]"
},
"member_has_access": false,
"reason": "owner_status_changed",
"owner_status": "expired",
"previous_owner_status": "active",
"owner_is_active": false,
"source": "expiration_cron"
}
Branch on member_has_access, not on the owner fields. When a member is removed, reason is member_removed and member_has_access is false even though the owner is still perfectly active — the owner fields would tell you the wrong thing.
Note the volume: an owner with 200 seats lapsing produces 200 requests in one go.
Request format
Every webhook is a POST with Content-Type: application/json. The event payload is wrapped in an envelope:
{
"event": "subscriber.created",
"payload": { ... },
"sent_at": 1787738939
}
sent_at is a Unix timestamp. The examples in the previous section show the contents of payload.
Verifying the signature
If you set a signing secret, each request carries an X-PPAccess-Signature header: an HMAC-SHA256 hex digest.
The signature is computed over the payload object only — not the whole request body. This catches people out. Decode the body, re-encode the payload key, and hash that:
$body = json_decode( file_get_contents( 'php://input' ), true );
$expected = hash_hmac( 'sha256', json_encode( $body['payload'] ), $your_secret );
$signature = $_SERVER['HTTP_X_PPACCESS_SIGNATURE'] ?? '';
if ( ! hash_equals( $expected, $signature ) ) {
http_response_code( 401 );
exit;
}
Delivery behaviour
Two things worth knowing before you rely on webhooks for anything critical.
- There are no retries. Requests are sent fire-and-forget with an 8-second timeout. If your endpoint is down or slow, that event is lost — it will not be resent.
- Your response is ignored. PaywallProject does not wait for or read the response, so returning an error changes nothing on our side.
If you need guaranteed delivery, have your endpoint acknowledge quickly and queue the work rather than processing it inline.
Troubleshooting
Nothing is arriving
- Use that webhook’s Save and send test button first. If the test arrives but real events do not, the URL and secret are fine and the problem is which events are ticked — a test ignores the event list, real events do not.
- Check the event actually matches. A webhook subscribed only to
subscriber.status_changedreceives nothing when a group owner lapses — the members’ events aregroup.member_access_changed, a different event. - Confirm the URL is publicly reachable, and that your host is not blocking outbound requests.
Someone signed up but no event arrived
subscriber.created fires when the subscription becomes active, so a reader who entered their email and then abandoned the card step produces no event — by design. They are not a subscriber. If the payment later succeeds, the event fires then.
Signature checks keep failing
Almost always because the signature was computed over the whole request body. It is computed over the payload object alone — see above.
Too many requests
Untick All events and select only what you use. transaction.recorded is usually the noisiest, since it fires on every renewal as well as every new signup.
Removing a webhook
Click Remove webhook on its card, then Save changes to confirm. Before you save, the button reads Undo remove if you change your mind.