← Back to docs

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

EventTrigger
subscription.activatedSubscription activated
subscription.createdNew subscription activated or renewed via payment
subscription.expiredSubscription period ended
purchase.completedA paid one-time purchase completed; subscription is null
purchase.refundedA 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 fieldSource in this payload
customer.key / customer.emailaccount.id / account.email
key (transaction ID)purchase.id
amountpurchase.amount
amount_unitspurchase.currency
partner attributionresolved 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

HeaderDescription
Content-Typeapplication/json
X-PWP-EventEvent name (e.g. subscription.created)
X-PWP-DeliveryUnique delivery ID
X-PWP-SignatureHMAC-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:

AttemptDelayTotal Time
1Immediate0
2+1 minute1 min
3+5 minutes6 min
4+30 minutes36 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.