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-Signatureheader? - How do I handle
ON_ORDER_COMPLETEDorON_PAYMENT_COMPLETEDevents? - Why is my PayNow webhook signature verification failing?
- Should I reject old
PayNow-Timestampvalues?
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_(for exampleWEBHOOK_ TOLERANCE_ SECONDS 300; unset or0= off) to enable it. Deduplicate onevent_idinstead.
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:
| Event | Triggered When |
|---|---|
ON_ORDER_COMPLETED | An order is completed |
ON_PAYMENT_COMPLETED | A payment is completed |
ON_PAYMENT_FAILED | A payment fails |
ON_REFUND | A refund is completed |
ON_SUBSCRIPTION_RENEWED | A subscription is renewed |
ON_DELIVERY_ITEM_ADDED | A 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
- references/overview.md - PayNow webhook concepts, event list, payload shape
- references/setup.md - Creating a webhook and getting the signing secret
- references/verification.md - Signature details, gotchas, optional replay window