takemypay

Webhooks

The webhook is the primary and only reliable channel telling you a payment happened. The customer landing on return_url is not proof: the tab can be closed while the money still goes through.

On every status change we POST to the webhook URL of the project that owns the payment. The address is set on the project page.

Headers

HeaderValue
X-SignatureHMAC-SHA256 over the raw request body, hex-encoded.
X-Key-Prefix First 10 characters of the signing key. Needed during rotation — see below.
X-Payment-IdThe payment ID.
X-Webhook-VersionFormat version, currently 2.

Body

application/json
{
  "id": "uuid",
  "external_order_id": "order-123",
  "amount": "1000.00",
  "amount_net": "820.00",
  "fee": "180.00",
  "status": "succeeded",
  "is_test": false,
  "paid_at": "2026-05-19T12:01:32Z",
  "timestamp": "2026-05-19T12:01:32.512Z",
  "nonce": "8f3a..."
}
  • amount is what the customer was charged (gross); amount_net is what you receive (gross minus fee). In "customer pays the fee" mode reconcile against amount_net — it equals the amount you passed to create.
  • is_test is true for payments made before the account was activated. Accept and verify such a webhook, but never fulfil an order on it.
  • timestamp and nonce are the replay defence — see below.

Verifying the signature

The signature is computed as:

X-Signature = HMAC_SHA256(key = hex(SHA256(api_key)), msg = raw_body) → hex

The HMAC secret is not the key itself but its SHA-256 hash in hex. That is deliberate: the hash is all we store — we do not know the key and cannot recover it.

🔴 Compute the signature over the raw request body, before parsing JSON. Parsing and re-serialising changes key order and whitespace, and the signature will not match. This is the single most common integration mistake.

Signing uses the project's newest active key. So during rotation, while a project holds several active keys, use X-Key-Prefix to decide which one to verify against.

Verify in Node.js

javascript
import crypto from "node:crypto";

function verifyWebhook(rawBody, signature, apiKey) {
  const signingSecret = crypto.createHash("sha256").update(apiKey).digest("hex");
  const expected = crypto.createHmac("sha256", signingSecret)
    .update(rawBody)
    .digest("hex");
  if (expected !== signature) return false;

  // Replay defense: reject bodies older than 5 minutes.
  const body = JSON.parse(rawBody);
  const ts = Date.parse(body.timestamp);
  if (Date.now() - ts > 5 * 60 * 1000) return false;

  return true;
}

Verify in Python

python
import hmac, hashlib, json
from datetime import datetime, timezone

def verify_webhook(raw_body: bytes, signature: str, api_key: str) -> bool:
    signing_secret = hashlib.sha256(api_key.encode()).hexdigest()
    expected = hmac.new(signing_secret.encode(), raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature):
        return False

    body = json.loads(raw_body)
    ts = datetime.fromisoformat(body["timestamp"])
    age = (datetime.now(timezone.utc) - ts).total_seconds()
    return age <= 300  # reject if > 5 minutes old

Replay defence

The signed body carries timestamp (ISO 8601) and nonce. Reject anything older than 5 minutes — otherwise a valid request captured once can be replayed indefinitely, signature and all.

What counts as delivery

Delivery means a 2xx response. Everything else is a reason to retry, redirects included: 301 and 302 are not success and we do not follow them.

Check this specifically. An endpoint that answers with a redirect — typically a framework moving /webhook to /webhook/ or to https — looks fine in a browser while accepting no notifications at all. It usually surfaces as unfulfilled orders, not as an error.

Six attempts, until a 2xx arrives:

AttemptAfter
1immediately
230 seconds
35 minutes
430 minutes
52 hours
612 hours

After the sixth failure delivery stops. Recover state with GET /api/v1/payments/{id}.

Your handler must be idempotent

The same payment can reach you more than once: a retry after a network timeout that swallowed your response is normal. Key off the payment id and whether the order is already processed, not off the fact that a request arrived.