# PayNow 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:

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

```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](https://docs.paynow.gg/webhooks/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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/paynow-gg-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/paynow-gg-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/paynow-gg-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/paynow-gg-webhooks/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

```bash
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

```bash
# 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](https://github.com/hookdeck/webhook-skills/blob/main/skills/paynow-gg-webhooks/references/overview.md) - PayNow webhook concepts, event list, payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/paynow-gg-webhooks/references/setup.md) - Creating a webhook and getting the signing secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/paynow-gg-webhooks/references/verification.md) - Signature details, gotchas, optional replay window