# Guide to PayNow Webhooks: Features and Best Practices

PayNow webhooks tell your systems what is happening in your store: an order completes, a payment fails, a subscription renews, a chargeback opens, or an item lands in a player's inventory. If you sell ranks, items or subscriptions for a game server or online community with PayNow (paynow.gg), webhooks are how those events reach your own backend, analytics and support tools without polling the Management API.

This guide covers how PayNow's JSON webhooks are created and signed, the events you can subscribe to, how to verify a delivery, and the practices that keep a PayNow receiver reliable. It is about PayNow the gaming commerce platform, not the PayNow payment scheme in Singapore.

## What are PayNow webhooks?

A PayNow webhook is a subscription to a single event in a store. You choose a URL, the event, and a type. The `JsonV1` type, the default, sends a structured JSON `POST` to your server. The `DiscordV1` type posts a formatted embed to a Discord webhook URL instead, and isn't something you verify or process. This guide covers JSON webhooks.

Every JSON delivery has the same envelope: `event_type`, a unique `event_id`, and the event's object in `body`. PayNow signs each one. `PayNow-Timestamp` carries the time in Unix milliseconds, and `PayNow-Signature` carries a base64 HMAC-SHA256 of that timestamp, a dot and the raw body, keyed with the subscription's secret.

## PayNow webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard: **Integrations > Webhooks > Create**, or the Management API |
| Subscription model | One event per webhook |
| Types | `JsonV1` (JSON, the default), `DiscordV1` (a Discord embed) |
| Signature header | `PayNow-Signature`, bare standard base64 |
| Timestamp header | `PayNow-Timestamp`, Unix milliseconds |
| Scheme | HMAC-SHA256, base64 |
| Signed content | `<PayNow-Timestamp>.<raw body>` |
| Secret | Per subscription, returned by the API. `reset_secret` rotates it |
| Retries | Statuses above 299, with exponential backoff "for a reasonable period" |
| Resend and history | Management API endpoints for delivery history and resending events |
| Source IPs | `34.203.112.123`, `54.208.125.169`, `54.86.24.96`, subject to change |

## Common events

PayNow's webhook definitions describe these events:

| Area | Events | Fires when |
| --- | --- | --- |
| Orders and payments | `ON_ORDER_COMPLETED`, `ON_PAYMENT_COMPLETED`, `ON_PAYMENT_FAILED`, `ON_REFUND` | An order completes, a payment completes or fails, or a refund completes |
| Chargebacks | `ON_CHARGEBACK`, `ON_CHARGEBACK_CLOSED`, `ON_CHARGEBACK_LIFECYCLE` | A chargeback is created or resolved, or moves through a stage such as opened, needs a response, won or lost |
| Inventory | `ON_DELIVERY_ITEM_ADDED`, `_ACTIVATED`, `_USED`, `_RENEWED`, `_EXPIRED`, `_REVOKED` | A delivery item is added to, activates in, is used, renewed or expires in, or is revoked from a customer's inventory |
| Subscriptions and trials | `ON_SUBSCRIPTION_ACTIVATED`, `_RENEWED`, `_CANCELED`, `ON_TRIAL_ACTIVATED`, `_COMPLETED`, `_CANCELED` | A subscription or trial starts, renews, completes or is cancelled |
| Discord | `ON_DISCORD_ACCOUNT_LINKED_TO_CHECKOUT` | A Discord account is linked to a checkout |

The event type enum lists more names without a description or payload schema: connected-user events such as `ON_CONNECTED_USER_PAYOUT_COMPLETED`, and the purchase follow-up and abandoned checkout recovery events. Handle them defensively if you subscribe to them.

Event names are spelled two ways. Payloads use `ON_ORDER_COMPLETED`, while the Management API's `subscribed_to` field uses `OnOrderCompleted`. Route on the payload spelling.

PayNow publishes schemas with per-field examples rather than whole bodies. Assembled from the `ON_ORDER_COMPLETED` schema's field examples, and trimmed, a delivery looks like this:

```json
{
  "event_type": "ON_ORDER_COMPLETED",
  "event_id": "411486491630370816",
  "body": {
    "id": "411486491630370816",
    "store_id": "411486491630370816",
    "customer": { "id": "411486491630370816", "name": "John" },
    "checkout_id": "411486491630370816",
    "type": "one_time",
    "currency": "USD",
    "subtotal_amount": 10000,
    "tax_amount": 1500,
    "discount_amount": 500,
    "total_amount": 11000,
    "product_names": "Product A, Product B",
    "created_at": "2024-01-15T10:30:00Z",
    "completed_at": "2024-01-15T10:35:00Z",
    "status": "completed",
    "lines": []
  }
}

```

Amounts are in cents, and the order `status` is one of `created`, `completed` or `canceled`. `product_names`, `product_tags` and `product_gameservers` are comma-separated strings rather than arrays. IDs are flake IDs sent as strings. Keep them as strings, because they are too large for a JavaScript number.

## Setting up PayNow webhooks

1. In the PayNow dashboard, go to **Integrations > Webhooks** and click **Create**. This needs the `webhook_read` permission and is available on every plan.
2. Choose the **JSON** type and enter your HTTPS endpoint URL.
3. In **Subscribed To**, select one event. Create another webhook for each further event, all pointing at the same URL if you like.
4. Store the subscription's signing secret server-side, for example as `PAYNOW_WEBHOOK_SECRET`. The Management API returns it as `secret` on each webhook subscription.

With the Management API, create the subscription with `POST /v1/stores/{storeId}/webhooks`, using an API key from the dashboard in the `Authorization` header as `APIKey <token>`. The body takes `url`, `subscribed_to` and `type`, and the response includes the `secret`. To rotate a secret, send `PATCH /v1/stores/{storeId}/webhooks/{webhookId}` with `reset_secret`.

If you receive PayNow webhooks through Hookdeck, create a source with the PayNow type, use its URL as the webhook URL, and paste the subscription's secret into the source. The source accepts `POST` only, which is the method PayNow uses. Every subscription has its own secret, so subscriptions that share a Hookdeck source need to share a secret, or each needs its own source.

## Securing PayNow webhooks

Read `PayNow-Timestamp` and `PayNow-Signature`. Build the signed bytes from the timestamp exactly as received, a literal `.`, and the raw request body. Compute an HMAC-SHA256 of them with the secret as a UTF-8 key, base64-encode it, and compare it with the header in constant time.

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

function verifyPaynowSignature(rawBody, signatureHeader, timestampHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !timestampHeader || !secret) return false;

  // Optional replay window, off by default. PayNow-Timestamp is in MILLISECONDS.
  if (toleranceSeconds > 0) {
    if (!/^\d+$/.test(timestampHeader)) return false;
    if (Math.abs(Date.now() - Number(timestampHeader)) > toleranceSeconds * 1000) return false;
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(`${timestampHeader}.`, 'utf8'), rawBody]))
    .digest('base64');

  // Compare the base64 strings exactly: base64 is case-sensitive.
  const received = Buffer.from(signatureHeader.trim(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

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

  const valid = verifyPaynowSignature(
    req.body,
    req.get('paynow-signature'),
    req.get('paynow-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_type, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import time

def verify_paynow_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
    # Optional replay window, off by default. PayNow-Timestamp is in milliseconds.
    if tolerance_seconds > 0:
        if not (timestamp_header.isascii() and timestamp_header.isdigit()):
            return False
        if abs(time.time() * 1000 - int(timestamp_header)) > tolerance_seconds * 1000:
            return False
    signed = timestamp_header.encode("utf-8") + b"." + raw_body
    expected = base64.b64encode(
        hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    )
    return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)

# In FastAPI: raw_body = await request.body()
# verify_paynow_signature(raw_body, request.headers.get("paynow-signature"),
#                         request.headers.get("paynow-timestamp"),
#                         os.environ["PAYNOW_WEBHOOK_SECRET"])

```

PayNow's validation page describes the steps but doesn't name the hash or the encoding. Both come from its example receivers in JavaScript and Go, which use HMAC-SHA256 and standard base64. The JavaScript example decodes both signatures from base64 and passes them to `crypto.timingSafeEqual` without a length check, which throws on a malformed header. Comparing the base64 strings, as above, avoids that.

PayNow recommends "ignoring webhook requests older than 5 minutes", and its examples reject them. The samples above leave that check off unless you pass a tolerance. Remember that `PayNow-Timestamp` is in milliseconds, so a check written for seconds silently accepts or rejects everything. Hookdeck applies no timestamp tolerance, and its retries and replays resend PayNow's original `PayNow-Timestamp`, so a 5-minute window in your own code would reject a legitimate retry that arrives later. PayNow's other suggestion, storing `event_id` and ignoring duplicates, works for retries of any age.

> **Make PayNow webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the base64 `PayNow-Signature` over the timestamp and raw body, stores every order, payment and chargeback event, and delivers it to your service with retries you control.

## PayNow webhook limitations and pain points

### One event per subscription, one secret per subscription

**The Problem:** A store that tracks a dozen events ends up with a dozen subscriptions and a dozen secrets.

**Why It Happens:** `subscribed_to` holds a single event, and every subscription has its own signing secret.

**Workarounds:**

* Point every subscription at the same URL and route on `event_type`.
* Keep a map from subscription to secret, or try each known secret in turn if you can't tell which subscription sent a request.

**How Hookdeck Can Help:** Give each subscription its own Hookdeck source with its own secret, and connect them all to one destination. Each request is verified against the right secret before your service sees it.

### Milliseconds, not seconds

**The Problem:** A timestamp check rejects every request, or accepts all of them.

**Why It Happens:** `PayNow-Timestamp` is in Unix milliseconds. Code that compares it with seconds is off by a factor of 1,000.

**Workarounds:**

* Compare with `Date.now()` in JavaScript or `time.time() * 1000` in Python.
* Sign the header string exactly as received, without converting it.

**How Hookdeck Can Help:** Hookdeck's PayNow source signs the header value as it arrived and applies no tolerance, so units never affect verification. Hookdeck answers PayNow with a `200` either way, then records a request that fails the check as failed verification, so it never reaches your destination.

### The retry period is unstated

**The Problem:** You don't know how long PayNow keeps retrying before it gives up.

**Why It Happens:** PayNow retries statuses above 299 with exponential backoff "for a reasonable period before being marked as failed", with no count or schedule.

**Workarounds:**

* Acknowledge with a `200` once the event is stored, and process afterwards.
* After an outage, use the delivery history endpoint and `POST /v1/stores/{storeId}/webhooks/resend` to recover what failed.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, then delivers to your service with [retries](/docs/retries) you configure, so your own downtime doesn't depend on PayNow's schedule.

### No whole example bodies

**The Problem:** You have schemas but no realistic payload to test against.

**Why It Happens:** PayNow documents each event as an OpenAPI schema with per-field examples, and several enum events have no schema at all.

**Workarounds:**

* Subscribe a test store, place a test order, and record the delivery.
* Use the delivery-by-ID endpoint, which returns a delivery's payload and attempts.

**How Hookdeck Can Help:** Hookdeck stores the full body of every request, so the first real deliveries are there to inspect and replay against your handler.

## Best practices

### Verify against the raw body

The signature covers the exact bytes PayNow sent. 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`

PayNow recommends storing `event_id` and ignoring repeats. Do it before any side effect, such as granting an item. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Treat IDs as strings

Flake IDs exceed JavaScript's safe integer range. Keep `event_id` and every object ID as strings in your code and database.

### Acknowledge fast and process asynchronously

Verify, persist the event, return `200`, and run fulfilment or Discord notifications afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Use IP allowlisting only as an extra layer

PayNow publishes three source IPs but says they may change, and recommends the signature as your primary check.

## Conclusion

PayNow sends one event per subscription as a JSON envelope of `event_type`, `event_id` and `body`, signed with a base64 HMAC-SHA256 of the millisecond timestamp, a dot and the raw body. Keep each subscription's secret, verify the raw bytes, deduplicate on `event_id`, and mind the milliseconds if you add a freshness check.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every PayNow signature, and holds each store event until your service has processed it.

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