Webhooks

Signature verification in three languages, delivery guarantees, and the handler contract.

Webhooks are how you find out anything happened. Polling works and is fine for a development loop; it is not how you run a checkout.

Register an endpoint

curl -X POST $PAYGATE/v1/webhook_endpoints \
  -H "Authorization: Bearer $KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://your.app/hooks/paygate",
    "events": ["payment.paid", "payment.reversed", "payment.underpaid"]
  }'

The response carries the signing secret exactly once. Store it before you close the terminal; it cannot be read back.

An event name that is not one this gateway emits is refused at registration. That is deliberate: subscribing to payment.suceeded otherwise gives you an endpoint that is configured, enabled, and will never receive anything, with no error anywhere to explain it.

Verify the signature

Every delivery carries:

X-Paygate-Signature: t=1756300000,v1=8f3a…

The signature is HMAC-SHA256(secret, "<timestamp>.<raw body>"), hex encoded.

The timestamp is signed, not merely sent. A bare HMAC over the body is replayable for the life of the secret — anyone who ever captures one valid delivery from a proxy log or a support-ticket screenshot can resend it verbatim, and every check will pass because the body and the signature genuinely do match. Signing timestamp.body and rejecting old timestamps bounds that window to minutes.

Use a tolerance of five minutes, and compare in constant time.

Node

import crypto from 'node:crypto';

export function verify(secret, header, rawBody, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.trim().split('=', 2)),
  );
  const ts = Number(parts.t);
  const sig = parts.v1;
  if (!ts || !sig) throw new Error('malformed signature header');
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) throw new Error('signature expired');

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.`)
    .update(rawBody)
    .digest();

  const got = Buffer.from(sig, 'hex');
  if (got.length !== expected.length) throw new Error('signature mismatch');
  if (!crypto.timingSafeEqual(got, expected)) throw new Error('signature mismatch');
}

rawBody is the bytes as received. If your framework has already parsed and re-serialised the JSON, the signature will not match — key order and whitespace are part of what was signed. In Express, use express.raw({ type: 'application/json' }) on this route.

Python

import hashlib, hmac, time

def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> None:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    ts, sig = parts.get("t"), parts.get("v1")
    if not ts or not sig:
        raise ValueError("malformed signature header")
    if abs(time.time() - int(ts)) > tolerance:
        raise ValueError("signature expired")

    expected = hmac.new(
        secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, sig):
        raise ValueError("signature mismatch")

Go

This is not a snippet to copy — it is the package the gateway itself imports to SIGN your deliveries, published so you can run the same code to check them.

go get github.com/paygatehq/paygate-go
import "github.com/paygatehq/paygate-go/webhook"

// body must be the RAW bytes, read before any JSON decode.
err := webhook.Verify(secret, r.Header.Get(webhook.SignatureHeader), body,
    time.Now(), 5*time.Minute)

webhook.KnownEventTypes is the closed list of everything we can send, so a switch over it will not silently miss a family.

Delivery is at least once

Every event has a stable id. Deduplicate on it.

A handler that is not idempotent will double-credit an account the first time your 200 is lost on the way back — which is not a hypothetical, it is what happens whenever your process restarts mid-response.

The minimum viable handler:

BEGIN
  INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT DO NOTHING
  IF inserted = 0: COMMIT and return 200      -- already handled
  ...do the work...
COMMIT
return 200

Retries

A delivery that does not get a 2xx is retried with backoff for about two days, then marked failed. Inspect what happened:

curl "$PAYGATE/v1/webhook_endpoints/{id}/deliveries" -H "Authorization: Bearer $KEY"

Any event can be replayed by hand once you have fixed the bug:

curl -X POST $PAYGATE/v1/events/{id}/replay \
  -H "Authorization: Bearer $KEY" -H "Idempotency-Key: $(uuidgen)" -d '{}'

The handler contract

Local development

Localhost URLs are refused in live mode — the dispatcher will not dial a private address, because a webhook URL is an SSRF surface pointed at whatever a merchant types. In test mode on a local deployment they are allowed. For a real host, use a tunnel.