Agent skill

PayNow Webhooks Skill

Receive and verify PayNow (paynow.gg) webhooks. Use when setting up PayNow store webhook handlers for game servers, debugging PayNow-Signature verification, or handling events like ON_ORDER_COMPLETED, ON_PAYMENT_COMPLETED, ON_REFUND, or ON_DELIVERY_ITEM_ADDED.

Install this skill

npx skills add hookdeck/webhook-skills --skill paynow-gg-webhooks


PayNow (paynow.gg) is a payments and storefront platform for game servers and creators. This skill covers its JSON (v1) store webhooks. The Discord (v1) type posts to a Discord URL and is out of scope. It is not PayNow Singapore or Paynow Zimbabwe.

When to Use This Skill

  • How do I receive PayNow webhooks?
  • How do I verify the PayNow-Signature header?
  • How do I handle ON_ORDER_COMPLETED or ON_PAYMENT_COMPLETED events?
  • Why is my PayNow webhook signature verification failing?
  • Should I reject old PayNow-Timestamp values?

Verification (core)

PayNow sends two headers: PayNow-Timestamp (Unix milliseconds) and PayNow-Signature (bare standard base64, padded, 44 characters, no prefix). The signature is base64(HMAC-SHA256(secret, timestamp + "." + raw body)). Build the message as bytes from the raw body: never parse and re-serialize JSON first. The key is the signing secret's UTF-8 bytes, used as-is.

Node:

const crypto = require('crypto');

function verify(rawBody /* Buffer */, timestamp, signature, secret) {
  if (!timestamp || !signature || !/^[A-Za-z0-9+/]{43}=$/.test(signature)) return false;
  const message = Buffer.concat([Buffer.from(timestamp, 'latin1'), Buffer.from('.'), rawBody]);
  const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(message).digest();
  const provided = Buffer.from(signature, 'base64'); // safe: format validated above
  if (provided.toString('base64') !== signature) return false; // non-canonical encoding
  return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
}

Python:

import base64, hashlib, hmac, re

def verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    if not timestamp or not re.fullmatch(r"[A-Za-z0-9+/]{43}=", signature or ""):
        return False
    message = b"".join([timestamp.encode("latin-1"), b".", raw_body])
    expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).digest()
    provided = base64.b64decode(signature, validate=True)
    if base64.b64encode(provided).decode("ascii") != signature:  # non-canonical encoding
        return False
    return hmac.compare_digest(provided, expected)

Respond 401 for a missing, malformed, or mismatched signature. Read the body as raw bytes (express.raw({ type: '*/*' }), await request.arrayBuffer(), await request.body()) and parse JSON only after the check.

Timestamp window is opt-in. PayNow's Preventing Replay Attacks page suggests ignoring requests older than 5 minutes. The examples do not do this by default, because Hookdeck retries and replays resend the original headers and timestamp, and a fixed window would reject legitimate deliveries. Set PAYNOW_WEBHOOK_TOLERANCE_SECONDS (for example 300; unset or 0 = off) to enable it. Deduplicate on event_id instead.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Event Payload and Types

Deliveries are POST requests with an envelope: event_type (string), event_id (string), and body (the object for that event). Dispatch on event_type. Handlers in the examples cover:

EventTriggered When
ON_ORDER_COMPLETEDAn order is completed
ON_PAYMENT_COMPLETEDA payment is completed
ON_PAYMENT_FAILEDA payment fails
ON_REFUNDA refund is completed
ON_SUBSCRIPTION_RENEWEDA subscription is renewed
ON_DELIVERY_ITEM_ADDEDA delivery item is added to the customer's inventory

Other events are logged. See references/overview.md for the full list, including chargeback, delivery item, subscription, and trial events.

Delivery: any 2xx acknowledges. A status above 299 is retried with exponential backoff for a reasonable period before the delivery is marked failed.

Environment Variables

PAYNOW_WEBHOOK_SECRET=your_signing_secret          # Webhook signing secret from PayNow
PAYNOW_WEBHOOK_TOLERANCE_SECONDS=0                 # Optional replay window in seconds; unset or 0 = off

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 paynow-gg --path /webhooks/paynow-gg

Use port 8000 for the FastAPI example.

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 11, 2026

View on GitHub →