# Verify webhook signatures in Node.js, Python and PHP

> Copy-paste webhook handlers that verify the Anytime-Signature header on the raw body: Express, Next.js, Flask, FastAPI and PHP, and how to test them locally.

Source: https://anytime-capital.com/docs/webhook-signatures · Markdown: https://anytime-capital.com/docs/webhook-signatures.md · All docs: https://anytime-capital.com/docs/llms-full.txt

Every event we send carries an `Anytime-Signature` header such as `t=1760630536,v1=5257a869…`. `v1` is the hex HMAC-SHA256 of the timestamp, a period and the raw request body, keyed with your endpoint's whole signing secret (`whsec_…`). Verify it before you trust the event, in constant time, and reject timestamps more than 5 minutes old so a captured request can't be replayed.

While a signing secret is being [rotated with an overlap](https://anytime-capital.com/docs/webhooks.md#rotating-the-signing-secret), the header carries two `v1` values, one per secret, newest first. Accept the event if any `v1` matches: every handler below does, so you can deploy the new secret at any point in the overlap. Code that keeps only one `v1` (for example by turning the header into a map) fails during a rotation.

> **Use the raw body:** The signature covers the exact bytes we sent. Read the body before any JSON parser touches it: a body that was parsed and serialised again has different whitespace and key order, and won't verify. That's the usual reason a correct secret fails.

- Keep the secret in a server-side environment variable such as `ANYTIME_WEBHOOK_SECRET`. Each endpoint has its own.
- Answer any `2xx` within 10 seconds, then do slow work; anything else is retried. See [Webhooks](https://anytime-capital.com/docs/webhooks.md#delivery-and-retries).
- Events can arrive more than once and out of order: store each `id` you've handled and skip repeats.

## Node.js (Express)

`express.raw` keeps the body as a Buffer for this route; register it before any `express.json()` that would claim the same path.

**server.mjs**

```javascript
// server.mjs — npm install express; run: node server.mjs
import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.ANYTIME_WEBHOOK_SECRET; // whsec_…

// Read the raw body: the signature covers the exact bytes we sent.
app.post("/webhooks/anytime", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Anytime-Signature") ?? "";
  // t=<timestamp>, then one v1= per signing secret (two during a rotation).
  const pairs = header.split(",").map((part) => part.trim().split("="));
  const t = pairs.find(([key]) => key === "t")?.[1] ?? "";
  const signatures = pairs.filter(([key]) => key === "v1").map(([, value]) => value);
  const body = req.body.toString("utf8");

  const expected = Buffer.from(
    crypto.createHmac("sha256", secret).update(`${t}.${body}`).digest("hex"),
  );
  const signed = signatures.some(
    (signature) => signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), expected),
  );
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= 300;
  if (!signed || !fresh) return res.status(400).send("Invalid signature");

  const event = JSON.parse(body);
  // The same event can arrive more than once: skip ids you've already handled.
  switch (event.type) {
    case "payment.succeeded":
      // Fulfil the order for event.data.id
      break;
    case "payment.refunded":
      // event.data.amountRefundedCents is the total refunded so far
      break;
  }
  res.sendStatus(200);
});

app.listen(3000);
```

## Next.js (App Router)

**app/api/webhooks/anytime/route.ts**

```typescript
// app/api/webhooks/anytime/route.ts (Next.js App Router)
import crypto from "node:crypto";

export const runtime = "nodejs";

export async function POST(request: Request) {
  const secret = process.env.ANYTIME_WEBHOOK_SECRET!; // whsec_…
  const body = await request.text(); // the raw body, exactly as sent
  const header = request.headers.get("Anytime-Signature") ?? "";
  // t=<timestamp>, then one v1= per signing secret (two while a secret is rotated).
  const pairs = header.split(",").map((part) => part.trim().split("="));
  const t = pairs.find(([key]) => key === "t")?.[1] ?? "";
  const signatures = pairs.filter(([key]) => key === "v1").map(([, value]) => value ?? "");

  const expected = Buffer.from(crypto.createHmac("sha256", secret).update(`${t}.${body}`).digest("hex"));
  const signed = signatures.some(
    (signature) => signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), expected),
  );
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) <= 300;
  if (!signed || !fresh) return new Response("Invalid signature", { status: 400 });

  const event = JSON.parse(body);
  // Skip event.id if you've handled it before, then act on event.type.
  if (event.type === "payment.succeeded") {
    // Mark the order in event.data.metadata (or event.data.clientReference) paid.
  }
  return new Response(null, { status: 200 });
}
```

## Python (Flask)

**app.py**

```python
# app.py — pip install flask; run: flask --app app run
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["ANYTIME_WEBHOOK_SECRET"]  # whsec_…


@app.post("/webhooks/anytime")
def anytime_webhook():
    body = request.get_data()  # the raw bytes, exactly as sent
    header = request.headers.get("Anytime-Signature", "")
    # t=<timestamp>, then one v1= per signing secret (two while a secret is rotated).
    pairs = [part.strip().split("=", 1) for part in header.split(",") if "=" in part]
    timestamp = next((value for key, value in pairs if key == "t"), "")
    signatures = [value for key, value in pairs if key == "v1"]
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        abort(400)
    expected = hmac.new(SECRET.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
    if not any(hmac.compare_digest(expected.encode(), signature.encode()) for signature in signatures):
        abort(400)

    event = json.loads(body)
    # Skip event["id"] if you've handled it before, then act on event["type"].
    if event["type"] == "payment.succeeded":
        pass  # Mark the order in event["data"]["metadata"] paid.
    return "", 200
```

## Python (FastAPI)

**main.py**

```python
# main.py — pip install fastapi uvicorn; run: uvicorn main:app
import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()
SECRET = os.environ["ANYTIME_WEBHOOK_SECRET"]  # whsec_…


@app.post("/webhooks/anytime")
async def anytime_webhook(request: Request):
    body = await request.body()  # the raw bytes: don't take a parsed model here
    header = request.headers.get("Anytime-Signature", "")
    # t=<timestamp>, then one v1= per signing secret (two while a secret is rotated).
    pairs = [part.strip().split("=", 1) for part in header.split(",") if "=" in part]
    timestamp = next((value for key, value in pairs if key == "t"), "")
    signatures = [value for key, value in pairs if key == "v1"]
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        raise HTTPException(status_code=400, detail="Invalid signature")
    expected = hmac.new(SECRET.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
    if not any(hmac.compare_digest(expected.encode(), signature.encode()) for signature in signatures):
        raise HTTPException(status_code=400, detail="Invalid signature")

    event = json.loads(body)
    # Skip event["id"] if you've handled it before, then act on event["type"].
    return {"received": True}
```

## PHP

**webhook.php**

```php
<?php
// webhook.php — plain PHP 7.4+, no framework.
$secret = getenv('ANYTIME_WEBHOOK_SECRET'); // whsec_…
$payload = file_get_contents('php://input'); // the raw body, exactly as sent
$header = $_SERVER['HTTP_ANYTIME_SIGNATURE'] ?? '';

// t=<timestamp>, then one v1= per signing secret (two while a secret is rotated).
$timestamp = '';
$signatures = [];
foreach (explode(',', $header) as $part) {
    [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
    if ($key === 't') {
        $timestamp = $value;
    } elseif ($key === 'v1') {
        $signatures[] = $value;
    }
}
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
$signed = false;
foreach ($signatures as $signature) {
    $signed = $signed || hash_equals($expected, $signature);
}

if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300 || !$signed) {
    http_response_code(400);
    exit('Invalid signature');
}

$event = json_decode($payload, true);
// Skip $event['id'] if you've handled it before, then act on $event['type'].
if ($event['type'] === 'payment.succeeded') {
    // Mark the order in $event['data']['metadata'] paid.
}
http_response_code(200);
```

In Laravel, read the raw body with `$request->getContent()` and the header with `$request->header('Anytime-Signature')`, and exclude the route from CSRF protection; the check is the same.

## Node SDK

With the [Node SDK](https://anytime-capital.com/docs/node-sdk.md#webhooks) (preview), `AnytimeCapital.webhooks.constructEvent(rawBody, header, secret)` verifies and parses in one call.

## Test your handler

- **Send test event** in **Settings → Developers** sends a signed `webhook.test` event to a deployed endpoint, whatever events it subscribes to.
- On your own machine, sign a body yourself with your secret and post it to your local server:

**Sign and send a test event**

```bash
SECRET="whsec_…"   # your endpoint's signing secret
BODY='{"id":"evt_local_1","type":"webhook.test","created":"2026-10-24T12:00:00.000Z","data":{}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')

curl -X POST http://localhost:3000/webhooks/anytime \
  -H "Content-Type: application/json" \
  -H "Anytime-Signature: t=$T,v1=$SIG" \
  -d "$BODY"
```

The URL above is the Express handler's. For the others use `http://localhost:3000/api/webhooks/anytime` (Next.js), `http://localhost:5000/webhooks/anytime` (Flask) or `http://localhost:8000/webhooks/anytime` (uvicorn), or wherever your PHP script is served. Change one character of the body after signing and your handler should answer `400`.

---

Anytime Capital is a fintech company, not a bank. Payment processing, banking and payout services are provided by Anytime Capital's licensed partners. Rates shown are for US card payments; international cards, currency conversion and disputes may carry additional fees shown in your dashboard before you accept a payment.
