# Authentication

> How to validate the HMAC-SHA256 signature of Rapid webhooks, with replay protection and ready-made examples in Node.js, Python and PHP.

Página: https://doc.rapidchargeback.com/en/canais/webhook/autenticacao/

In the **`v2`** format, Rapid signs each webhook with **HMAC-SHA256** using your signing key (the `secret`). Rapid **generates** this key when you register the webhook and shows it **only once** in the dashboard, under **Settings › Webhook**; store it right away. There is no way to recover the key later: if you lose it, generate another one in **Settings › API** (the previous one stops working at that same moment). Your application must validate this signature before processing the payload; that is how you confirm the POST came from Rapid.

> Accounts in the **`legacy`** format (migrated from the previous system) **do not receive a signature**: authentication is done through the `clientid`/`clientkey` headers, as before. Check which format you have in [Payload](https://doc.rapidchargeback.com/en/canais/webhook/payload/).

## Signature header

```
X-Webhook-Signature: sha256=<hmac_hex>
```

Where `<hmac_hex>` is the HMAC-SHA256, in hexadecimal, of the string `${timestamp}.${raw_body}`, that is, the value of the `X-Webhook-Timestamp` header (seconds since the epoch), a dot, and the **raw request body** (the JSON received, byte for byte), using your signing key.

The timestamp is part of the signature to provide **replay protection**: without it, a captured valid POST could be resent indefinitely with a correct signature. Reject requests whose `X-Webhook-Timestamp` falls outside a tolerance window (we recommend **5 minutes**).

---

## How to validate

1. Read the raw request body. **Do not parse it as JSON before validating**: the signature is computed over the exact bytes received.
2. Build `signed = timestamp + "." + raw_body`, compute `HMAC-SHA256(secret, signed)` and encode it in hexadecimal
3. Compare it with the value in `X-Webhook-Signature` (after the `sha256=` prefix)
4. Use a timing-safe comparison to avoid timing attacks

### Examples

A complete endpoint in each language: it reads the raw body, validates the signature and the time window, and only then reads the JSON. It responds `401` to any delivery that does not pass, including one without the headers. The key comes from the `RAPID_WEBHOOK_SECRET` environment variable.

**Node.js**

```javascript
import crypto from 'node:crypto'
import express from 'express'

const TOLERANCE_SECONDS = 5 * 60

function validateSignature(rawBody, signature, timestamp, secret) {
  // 1. Tolerance window (anti-replay). Without it, a captured old POST
  //    keeps passing forever.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false

  // 2. Sign `timestamp.raw_body` (the body as received, not re-serialized).
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')

  const a = Buffer.from(expected)
  const b = Buffer.from(signature ?? '')
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

const app = express()

// express.raw, not express.json: the signature is computed over the raw body.
app.post('/webhooks/rapid', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8')
  const ok = validateSignature(
    rawBody,
    req.get('X-Webhook-Signature'),
    req.get('X-Webhook-Timestamp'),
    process.env.RAPID_WEBHOOK_SECRET,
  )
  if (!ok) return res.status(401).end()

  const event = JSON.parse(rawBody)
  // Process the `event` (preferably in a queue) and respond quickly.
  res.status(200).end()
})

app.listen(process.env.PORT ?? 3000)
```

**Python**

```python
import hashlib
import hmac
import os
import time

from flask import Flask, request

TOLERANCE_SECONDS = 5 * 60


def validate_signature(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    # 1. Tolerance window (anti-replay).
    try:
        age = abs(int(time.time()) - int(timestamp))
    except (TypeError, ValueError):
        return False
    if age > TOLERANCE_SECONDS or not signature:
        return False

    # 2. Sign `timestamp.raw_body` (the bytes received, not re-serialized).
    signed = timestamp.encode() + b"." + raw_body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), signature.encode())


app = Flask(__name__)


@app.post("/webhooks/rapid")
def rapid_webhook():
    # get_data(), not get_json(): the signature is computed over the raw body.
    raw_body = request.get_data()
    ok = validate_signature(
        raw_body,
        request.headers.get("X-Webhook-Signature", ""),
        request.headers.get("X-Webhook-Timestamp", ""),
        os.environ["RAPID_WEBHOOK_SECRET"],
    )
    if not ok:
        return "", 401

    event = request.get_json()
    # Process the `event` (preferably in a queue) and respond quickly.
    return "", 200
```

**PHP**

```php
<?php
const TOLERANCE_SECONDS = 5 * 60;

function validateSignature(string $rawBody, string $signature, string $timestamp, string $secret): bool
{
    // 1. Tolerance window (anti-replay).
    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
        return false;
    }

    // 2. Sign `timestamp.raw_body` (the bytes received, not re-serialized).
    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signature);
}

// php://input, not json_decode first: the signature is computed over the raw body.
$rawBody = file_get_contents('php://input');
$ok = validateSignature(
    $rawBody,
    $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '',
    $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '',
    getenv('RAPID_WEBHOOK_SECRET')
);
if (!$ok) {
    http_response_code(401);
    exit;
}

$event = json_decode($rawBody, true);
// Process the $event (preferably in a queue) and respond quickly.
http_response_code(200);
```

## Signature checker

Your validation does not match? Paste what you received here and compare it with the expected signature.

---

## Secret rotation

You generate a new key yourself in the dashboard, under **Settings › API**. It is shown only once, and from then on webhooks go out signed with it, **with no overlap period**: whoever validates with the previous key starts rejecting. Change the key on your servers right after, preferably in a low-traffic window.

Saving a new **URL** does not touch the key.

---

## Legacy headers

In the `legacy` format, Rapid sends the `clientid` and `clientkey` headers and does **not** send a signature, exactly like the previous system.

```
clientid: <client_id>
clientkey: <client_secret>
```

In the `v2` format these headers **do not exist**: validating by `clientid`/`clientkey` would mean receiving the API access credential on every request (less secure than signing the payload), so delivery authentication is only `X-Webhook-Signature`. Accounts on `legacy` keep receiving the headers indefinitely, so existing integrations do not break.
