# Guide to Purchasely Webhooks: Features and Best Practices

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

| Feature | Details |
| --- | --- |
| Configuration | Purchasely Console, **[YOUR APP] > Settings > Webhooks** |
| Endpoint URL | HTTPS Client webhook URL |
| Event selection | Toggle individual events under **Subscription events** |
| Signature header | `X-PURCHASELY-REQUEST-SIGNATURE`, bare lowercase hex |
| Timestamp header | `X-PURCHASELY-TIMESTAMP`, Unix seconds |
| Signed content | The timestamp value, then the raw body, with no separator |
| Secret | The webhook shared secret |
| Event field | `event_name` |
| Idempotency key | `event_id` |
| Success response | HTTP `200` only |
| Retries | Up to 25, over about 20 days 10 hours |
| Ordering | Per user: later webhooks wait until a failed one succeeds |

## Common events

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

| Category | Events | Fires when |
| --- | --- | --- |
| Activation and plan change | `SUBSCRIPTION_STARTED`, `SUBSCRIPTION_UPGRADED`, `SUBSCRIPTION_DOWNGRADED`, `SUBSCRIPTION_CROSSGRADED`, `SUBSCRIPTION_REACTIVATED` | A subscriber starts, changes plan or comes back |
| Renewal and recovery | `SUBSCRIPTION_RENEWED`, `SUBSCRIPTION_RECOVERED_FROM_BILLING_RETRY`, `SUBSCRIPTION_RECOVERED_FROM_GRACE_PERIOD` | A renewal succeeds or a failed payment recovers |
| Cancellation and refund | `RENEWAL_DISABLED`, `RENEWAL_ENABLED`, `SUBSCRIPTION_TERMINATED`, `SUBSCRIPTION_REFUNDED_REVOKED` | Auto-renew changes, access ends or a purchase is refunded |
| Billing issues | `GRACE_PERIOD_STARTED`, `ENTERED_BILLING_RETRY` | A payment fails |
| Pause | `SUBSCRIPTION_WILL_PAUSE`, `SUBSCRIPTION_PAUSED`, `SUBSCRIPTION_UNPAUSED` | A subscription is scheduled to pause, pauses or resumes |
| Transfer | `SUBSCRIPTION_TRANSFERRED`, `SUBSCRIPTION_RECEIVED` | A subscription moves between users |
| Offers | `TRIAL_STARTED`, `TRIAL_CONVERTED`, `TRIAL_NOT_CONVERTED`, and the `INTRO_OFFER_*`, `PROMO_CODE_*` and `PROMOTIONAL_OFFER_*` equivalents | An offer starts, converts or lapses |
| Transactional | `TRANSACTION_PROCESSED` | A transaction is processed, with revenue in the customer's currency |
| Entitlements | `ACTIVATE`, `DEACTIVATE` | Access 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.

```javascript
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:

```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](/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](/docs/retries) you control, and [Issues](/docs/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](/docs/filters) 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](/webhooks/guides/implement-webhook-idempotency) and Hookdeck's [deduplication](/docs/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](https://hookdeck.com) sits in front of your receiver, checks every Purchasely signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Purchasely webhooks reliably in minutes.