Gareth Wilson Gareth Wilson

Guide to Purchasely Webhooks: Features and Best Practices

Published


Purchasely webhooks send your backend a server event at each stage of an in-app subscription: a subscription starts or renews, auto-renew is turned off, a payment enters billing retry, a trial converts, a transaction is processed, or a user's entitlement should be switched on or off. If you sell subscriptions through the App Store, Google Play or the web with Purchasely, webhooks keep your own user records, CRM and revenue reporting in line with what the stores report.

This guide covers how Purchasely webhooks are configured, signed and retried, why one failed event can hold up others for the same user, and how to build a receiver that keeps the queue moving.

What are Purchasely webhooks?

Purchasely sends each server event as a POST with a JSON body to the Client webhook URL you set in the Console. The event_name field says what happened, and event_id uniquely identifies the event.

Purchasely signs every delivery with your webhook shared secret. X-PURCHASELY-REQUEST-SIGNATURE is the lowercase hex HMAC-SHA256 of the X-PURCHASELY-TIMESTAMP value immediately followed by the raw body. An older X-PURCHASELY-SIGNATURE header is deprecated, and Purchasely tells you not to use it.

Purchasely webhook features

FeatureDetails
ConfigurationPurchasely Console, [YOUR APP] > Settings > Webhooks
Endpoint URLHTTPS Client webhook URL
Event selectionToggle individual events under Subscription events
Signature headerX-PURCHASELY-REQUEST-SIGNATURE, bare lowercase hex
Timestamp headerX-PURCHASELY-TIMESTAMP, Unix seconds
Signed contentThe timestamp value, then the raw body, with no separator
SecretThe webhook shared secret
Event fieldevent_name
Idempotency keyevent_id
Success responseHTTP 200 only
RetriesUp to 25, over about 20 days 10 hours
OrderingPer user: later webhooks wait until a failed one succeeds

Common events

Purchasely groups 47 event names into lifecycle, offer, transactional and entitlement events.

CategoryEventsFires when
Activation and plan changeSUBSCRIPTION_STARTED, SUBSCRIPTION_UPGRADED, SUBSCRIPTION_DOWNGRADED, SUBSCRIPTION_CROSSGRADED, SUBSCRIPTION_REACTIVATEDA subscriber starts, changes plan or comes back
Renewal and recoverySUBSCRIPTION_RENEWED, SUBSCRIPTION_RECOVERED_FROM_BILLING_RETRY, SUBSCRIPTION_RECOVERED_FROM_GRACE_PERIODA renewal succeeds or a failed payment recovers
Cancellation and refundRENEWAL_DISABLED, RENEWAL_ENABLED, SUBSCRIPTION_TERMINATED, SUBSCRIPTION_REFUNDED_REVOKEDAuto-renew changes, access ends or a purchase is refunded
Billing issuesGRACE_PERIOD_STARTED, ENTERED_BILLING_RETRYA payment fails
PauseSUBSCRIPTION_WILL_PAUSE, SUBSCRIPTION_PAUSED, SUBSCRIPTION_UNPAUSEDA subscription is scheduled to pause, pauses or resumes
TransferSUBSCRIPTION_TRANSFERRED, SUBSCRIPTION_RECEIVEDA subscription moves between users
OffersTRIAL_STARTED, TRIAL_CONVERTED, TRIAL_NOT_CONVERTED, and the INTRO_OFFER_*, PROMO_CODE_* and PROMOTIONAL_OFFER_* equivalentsAn offer starts, converts or lapses
TransactionalTRANSACTION_PROCESSEDA transaction is processed, with revenue in the customer's currency
EntitlementsACTIVATE, DEACTIVATEAccess should be granted or removed (Backend Entitlements)

All server events share one structure. The fields you'll use most are event_name, event_id, user_id, anonymous_user_id, plan, store (such as APPLE_APP_STORE or GOOGLE_PLAY_STORE), subscription_status, purchased_at, next_renewal_at and event_created_at. TRANSACTION_PROCESSED adds revenue fields such as amount_in_customer_currency, customer_currency and plan_price_in_customer_currency.

Setting up Purchasely webhooks

  1. In the Purchasely Console, open [YOUR APP] > Settings > Webhooks.
  2. Enter your HTTPS endpoint as the Client webhook URL.
  3. Scroll to Subscription events and turn on the events you want.
  4. Copy the webhook shared secret and store it server-side, for example as PURCHASELY_WEBHOOK_SECRET. Purchasely's docs don't say where the secret is shown, so check the Webhooks settings page or ask Purchasely support.

As soon as a Client webhook URL is set, Purchasely expects an HTTP 200 for every event. Deploy an endpoint that returns 200 before you save the URL.

If you receive Purchasely webhooks through Hookdeck, use the Hookdeck source URL as the Client webhook URL, create a source with the Purchasely type and paste the shared secret. The Purchasely source accepts POST only, which is how Purchasely delivers events.

Securing Purchasely webhooks

Read X-PURCHASELY-TIMESTAMP and X-PURCHASELY-REQUEST-SIGNATURE. Concatenate the timestamp value and the raw body bytes with nothing between them, compute an HMAC-SHA256 with the shared secret, and compare the hex digest with the header in constant time.

const crypto = require('crypto');
const express = require('express');

function verifyPurchaselySignature(rawBody, signatureHeader, timestampHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !timestampHeader || !secret) return false;
  if (!/^[0-9a-fA-F]{64}$/.test(signatureHeader)) return false;

  // Optional replay window. Off by default (see below).
  if (toleranceSeconds > 0) {
    const timestamp = Number(timestampHeader);
    if (!Number.isFinite(timestamp)) return false;
    if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  }

  // The timestamp FIRST, then the raw body, with no separator.
  const message = Buffer.concat([Buffer.from(timestampHeader, 'latin1'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(message).digest();
  const received = Buffer.from(signatureHeader, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/purchasely', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.PURCHASELY_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const valid = verifyPurchaselySignature(
    req.body,
    req.get('x-purchasely-request-signature'),
    req.get('x-purchasely-timestamp'),
    secret
  );
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.event_id, route on event.event_name, then process asynchronously.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac
import re
import time


def verify_purchasely_signature(raw_body: bytes, signature_header, timestamp_header, secret: str,
                                tolerance_seconds: int = 0) -> bool:
    if not signature_header or not timestamp_header or not secret:
        return False
    if not re.fullmatch(r"[0-9a-fA-F]{64}", signature_header):
        return False
    # Optional replay window. Off by default (see below).
    if tolerance_seconds > 0:
        try:
            timestamp = float(timestamp_header)
        except ValueError:
            return False
        if abs(time.time() - timestamp) > tolerance_seconds:
            return False
    # The timestamp FIRST, then the raw body, with no separator.
    message = timestamp_header.encode("latin-1") + raw_body
    expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).digest()
    return hmac.compare_digest(bytes.fromhex(signature_header), expected)


# In FastAPI: raw_body = await request.body()
# verify_purchasely_signature(raw_body, request.headers.get("x-purchasely-request-signature"),
#                             request.headers.get("x-purchasely-timestamp"),
#                             os.environ["PURCHASELY_WEBHOOK_SECRET"])

Purchasely's samples compare with === and ==, which aren't constant-time. The JavaScript sample also compares with a variable, xPurchaselySignature, that it never defines (the header value is declared as xPurchaselyRequestSignature), so it doesn't run as written.

Purchasely leaves the timestamp tolerance to you. The samples above make the check optional and leave it off by default. Hookdeck applies no timestamp tolerance, and its retries and replays resend Purchasely's original headers, so a window on your endpoint would reject late ones. If you want one, pass toleranceSeconds and rely on event_id for duplicates.

Purchasely publishes its own test vector, which the code above passes. Use the secret foobar, an X-PURCHASELY-TIMESTAMP of 1698322022 and this exact body:

{"a_random_key":"a_random_value_ad"}

The header should be:

X-PURCHASELY-REQUEST-SIGNATURE: f3c2a452e9ea72f41107321aeaf7999f1054148866a710c9b23f9f501785e2a4

Make Purchasely webhooks production-ready. Hookdeck Event Gateway verifies the X-PURCHASELY-REQUEST-SIGNATURE header on every delivery, returns the 200 Purchasely expects, and queues each subscription event until your service has processed it.

Purchasely webhook limitations and pain points

One failure pauses a user's events

The Problem: A user's subscription status stops updating, even though other users are fine.

Why It Happens: To keep events in order, Purchasely won't send later webhooks for a user until the first failed one gets a 200. Purchasely's example is an ACTIVATE followed by a DEACTIVATE, which would leave the user with access if they arrived the other way round. A handler that keeps failing on one event holds back every later event for that user until the retries run out, which can take about 20 days.

Workarounds:

  • Return 200 for any event your code can't process yet, and store it for later rather than failing it.
  • Separate verification and storage from business logic, so a bug in a handler doesn't turn into a non-200.
  • Alert on non-200 responses so a stuck user is noticed quickly.

How Hookdeck Can Help: Hookdeck returns 200 to Purchasely as soon as it has stored a delivery, so the per-user queue keeps moving. Events that fail in your service wait in Hookdeck with retries you control, and Issues tell you when they do.

Anonymous users block the real user

The Problem: You never receive events for a logged-in user.

Why It Happens: Purchasely may send webhooks for an anonymous user, even if your app isn't meant to have them. If you don't return 200, Purchasely keeps retrying the anonymous user's webhook and, because of the ordering rule, the logged-in user's event never arrives.

Workarounds:

  • When user_id is empty and only anonymous_user_id is set, return 200 and ignore the event, as Purchasely advises.
  • Wait for the event that arrives after the user logs in and the subscription is associated with them.

How Hookdeck Can Help: A Hookdeck filter can drop anonymous-user events before they reach your service, while Purchasely still gets its 200.

Only 200 counts

The Problem: Events are retried even though your endpoint handled them.

Why It Happens: Purchasely treats any status other than 200 as a failure. A 201, 202 or 204 from a framework default triggers retries.

Workarounds:

  • Return exactly 200 from the webhook route.
  • Deduplicate on event_id in case a retry gets through.

How Hookdeck Can Help: Hookdeck answers Purchasely with 200 whatever your service returns, and your service can use whichever 2xx it likes.

Finding the shared secret

The Problem: Signature verification can't be set up because the secret isn't where you expect.

Why It Happens: Purchasely's Webhook page calls the key the webhook shared secret but doesn't say where the Console shows it, and the Webhook Messages Authentication page it links to returned a 404 when we checked.

Workarounds:

  • Look under [YOUR APP] > Settings > Webhooks first.
  • Ask Purchasely support if it isn't there.

How Hookdeck Can Help: Once you have the secret, store it on the Hookdeck source. Deliveries signed with a different secret stay in the request log marked as failed verification, which makes a wrong secret easy to diagnose.

Best practices

Verify against the raw body

Purchasely says to use the raw body, for example req.rawBody.toString() and not JSON.stringify(req.body). In Express, use express.raw({ type: '*/*' }). In Next.js, use Buffer.from(await request.arrayBuffer()). In FastAPI, use await request.body().

Deduplicate on event_id

Purchasely calls event_id the field to use for idempotency. Record the IDs you've processed and skip repeats. See our guide to webhook idempotency and Hookdeck's deduplication.

Use ACTIVATE and DEACTIVATE for access

If you grant access from your backend, follow Purchasely's Backend Entitlements guide and drive access from ACTIVATE and DEACTIVATE, rather than inferring it from lifecycle events.

Read the header names your framework gives you

Rails exposes the signature as HTTP_X_PURCHASELY_REQUEST_SIGNATURE, and Node frameworks lower-case it to x-purchasely-request-signature. Make sure you read REQUEST-SIGNATURE, not the deprecated X-PURCHASELY-SIGNATURE.

Conclusion

Purchasely's signature is easy to check, and Purchasely publishes a vector for it. The harder part is its per-user ordering: one event that never gets a 200 holds up everything after it for that user, including events for anonymous users you don't care about. Acknowledge fast, ignore what you don't need with a 200, and keep failures inside your own system.

Hookdeck Event Gateway sits in front of your receiver, checks every Purchasely signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle Purchasely webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.