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_, _RENEWED, _CANCELED, ON_TRIAL_ACTIVATED, _COMPLETED, _CANCELED | A subscription or trial starts, renews, completes or is cancelled |
| Discord | ON_ | 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_, 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:
{
"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
- In the PayNow dashboard, go to Integrations > Webhooks and click Create. This needs the
webhook_readpermission and is available on every plan. - Choose the JSON type and enter your HTTPS endpoint URL.
- In Subscribed To, select one event. Create another webhook for each further event, all pointing at the same URL if you like.
- Store the subscription's signing secret server-side, for example as
PAYNOW_WEBHOOK_SECRET. The Management API returns it assecreton 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.
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:
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 verifies the base64
PayNow-Signatureover 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 ortime.time() * 1000in 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
200once the event is stored, and process afterwards. - After an outage, use the delivery history endpoint and
POST /v1/stores/{storeId}/webhooks/resendto 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 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.
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.
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 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 for free and handle PayNow webhooks reliably in minutes.