Webhooks
PWP delivers webhook notifications to your application's IPN URL when purchases or subscription events occur.
Configuration
Set an IPN URL and secret on your application via the admin dashboard. The URL receives POST requests with JSON payloads.
Events
| Event | Trigger |
|---|---|
subscription.activated | Subscription activated |
subscription.created | New subscription activated or renewed via payment |
subscription.expired | Subscription period ended |
purchase.completed | A paid one-time purchase completed; subscription is null |
purchase.refunded | A refund (full or partial) was recorded against a purchase; subscription is null for one-time purchases |
Payload
{
"event": "subscription.created",
"account": {
"id": "external_user_id",
"email": "user@example.com"
},
"product": {
"sku": "prod_ABC123"
},
"purchase": {
"id": "456",
"amount": "19.99",
"currency": "USD",
"coupon": "SAVE20"
},
"subscription": {
"id": "789",
"status": "ACTIVE",
"plan": "MONTH",
"expired": "2026-03-15 00:00:00"
},
"timestamp": "2026-02-15T12:00:00.000Z"
}
One-time purchases use the same account, product, purchase, and timestamp
fields, but set "subscription": null. A successful one-time purchase emits
purchase.completed; a subscription purchase continues to emit
subscription.created. Both one-time and subscription refunds emit
purchase.refunded.
purchase.amount is the sale price after any coupon discount, in purchase.currency (ISO 4217). purchase.coupon is the redeemed coupon code, or null if none was applied.
subscription.id is PWP's internal subscription identifier — stable across renewals and repeat purchases stacked onto the same subscription. Use it (not product.sku alone) to key your own entitlement records if an account can ever have more than one subscription on the same product; product.sku alone cannot distinguish two subscriptions on the same SKU. subscription.plan is the billing term (MONTH or YEAR).
On purchase.refunded, purchase.amount/purchase.currency still reflect the original sale — the payload does not carry the refunded amount, so use this event only as a clawback signal and reconcile the amount from your own records.
An additive "backfill": true field is present on a payload sent by a one-time synthetic replay (scripts/ipn_backfill_resync.php) rather than a real-time event — e.g. catching up a cache that didn't exist yet when the underlying purchase/expiry happened, or a targeted resend during reconciliation. Absent (not false) on every real-time delivery. Suppress side effects that must only fire once at the real sale (affiliate commission, marketing-automation "new customer" events) when this field is present.
Free subscription wind-down
A $0 (list price) subscription behaves like any other for webhook purposes — it still fires subscription.created on activation and on each free renewal (same event, no separate "renewed" event), and subscription.expired when its term ends. There is one tenant-controlled difference: an application can turn free subscriptions off (application.free_subscriptions = 0). Once off, the hourly renewal sweep stops auto-renewing that tenant's $0 subscriptions — each one winds down at its own next expired date (not retroactively) and fires a normal subscription.expired at that point, exactly like a lapsed paid subscription. New $0 purchases are also refused at that point (see Purchases's 403). No special payload marker distinguishes a wind-down subscription.expired from any other — treat it the same as any other expiry.
Affiliate tracking (e.g. Partnero)
PWP does not integrate with any affiliate platform directly — your application is the one that calls the affiliate vendor's API, using the fields above. For Partnero's transactions API, the mapping is:
| Partnero field | Source in this payload |
|---|---|
customer.key / customer.email | account.id / account.email |
key (transaction ID) | purchase.id |
amount | purchase.amount |
amount_units | purchase.currency |
| partner attribution | resolved from purchase.coupon on your side — Partnero's automatic coupon-sync only supports Stripe/Paddle/WooCommerce/Shopify, so a custom billing source like PWP must map the coupon code to a partner itself (e.g. via Partnero's Promotion Codes API) and pass customer.partner.key/.id/.email when creating the customer |
purchase.id is stable across a purchase's lifetime — the same purchase.id is sent on purchase.refunded as on the original subscription.created/subscription.activated sale. Partnero reverses a commission by deleting the transaction by its key, so DELETE /v1/transactions/{purchase.id} on purchase.refunded correctly reverses the matching sale without needing a refund amount.
Headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-PWP-Event | Event name (e.g. subscription.created) |
X-PWP-Delivery | Unique delivery ID |
X-PWP-Signature | HMAC-SHA512 hex digest (if IPN secret is set) |
Signature Verification
Code
import { createHmac, timingSafeEqual } from 'node:crypto';
const rawBody = await request.text();
const received = request.headers.get('X-PWP-Signature') ?? '';
const expected = createHmac('sha512', IPN_SECRET).update(rawBody).digest('hex');
const valid = /^[a-f0-9]{128}$/i.test(received)
&& timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'));
Details
When an IPN secret is configured, PWP signs each raw JSON request body with
HMAC-SHA512 and sends the hex-encoded digest in X-PWP-Signature. Read the raw
body before parsing JSON, compute the digest with your IPN_SECRET, then
compare the digests using a constant-time comparison. IPN_SECRET is separate
from the API_SECRET used when your application calls PWP.
Retry Schedule
Failed deliveries are retried up to 5 times:
| Attempt | Delay | Total Time |
|---|---|---|
| 1 | Immediate | 0 |
| 2 | +1 minute | 1 min |
| 3 | +5 minutes | 6 min |
| 4 | +30 minutes | 36 min |
| 5 | +200 minutes | ~4 hours |
A delivery is considered successful when your endpoint responds with an HTTP 2xx status code. After all retries are exhausted, the webhook is marked as FAILED.