# Verifying signatures

> Verify the Standard Webhooks HMAC-SHA256 signature on every Ody delivery, in Node, Python, or any language.

Source: https://dev.ody.co/webhooks/signatures

Your webhook URL is public, so anyone can POST to it. Every Ody delivery is signed with the endpoint's secret. Verify the signature before you trust the payload.

## The headers

| Header | Example | Meaning |
|---|---|---|
| `webhook-id` | `evt_4f9b2c0d…` | The event ID (same as the body's `id`) |
| `webhook-timestamp` | `1792087542` | Unix seconds when this attempt was signed |
| `webhook-signature` | `v1,K5oZfz…=` | `v1,` followed by the base64 HMAC-SHA256 signature |

Deliveries also carry `content-type: application/json` and `user-agent: Ody-Webhooks/1.0`.

## The algorithm

```text
key       = base64_decode( secret without the "whsec_" prefix )
message   = webhook-id + "." + webhook-timestamp + "." + raw_request_body
signature = "v1," + base64( HMAC_SHA256(key, message) )
```

Then:

1. Compare your signature with the header value using a **constant-time** comparison. The header may hold several space-separated signatures; accept if any one matches.
2. Reject the request if `webhook-timestamp` is more than **5 minutes** from your clock. This stops replays.

> **Use the raw body:**
Sign the bytes exactly as received. If a framework parses the JSON and you re-serialise it, whitespace and key order change and the signature won't match. In Express, use `express.raw()` on this route. In Next.js route handlers, use `await req.text()`.

## Code

Node (SDK):

```ts
import express from "express";
import { verifyWebhookSignature } from "@ody/sdk";

const app = express();

app.post("/ody/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyWebhookSignature(process.env.ODY_WEBHOOK_SECRET!, req.headers, req.body)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString("utf8"));
  res.status(200).end();           // acknowledge fast…
  void handleEvent(event);         // …then do the work
});
```
Node (no dependencies):

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, headers: Record<string, string>, rawBody: string): boolean {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigHeader = headers["webhook-signature"];
  if (!id || !ts || !sigHeader) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64");

  return sigHeader.split(" ").some((part) => {
    const [version, sig] = part.split(",");
    if (version !== "v1" || !sig) return false;
    const a = Buffer.from(sig);
    const b = Buffer.from(expected);
    return a.length === b.length && timingSafeEqual(a, b);
  });
}
```
Python (SDK):

```python
import json, os
from flask import Flask, request
from ody import verify_webhook_signature

app = Flask(__name__)

@app.post("/ody/webhooks")
def ody_webhook():
    if not verify_webhook_signature(os.environ["ODY_WEBHOOK_SECRET"], request.headers, request.get_data()):
        return "", 401
    event = json.loads(request.get_data())
    # enqueue event for processing, then acknowledge
    return "", 200
```
Python (standardwebhooks):

```python
# pip install standardwebhooks
from standardwebhooks.webhooks import Webhook

wh = Webhook(os.environ["ODY_WEBHOOK_SECRET"])  # accepts the whsec_ secret as-is

def handle(raw_body: bytes, headers: dict):
    event = wh.verify(raw_body, headers)  # raises on a bad signature or stale timestamp
    return event
```
Next.js route handler:

```ts
import { verifyWebhookSignature } from "@ody/sdk";

export async function POST(req: Request) {
  const raw = await req.text();
  const ok = verifyWebhookSignature(process.env.ODY_WEBHOOK_SECRET!, Object.fromEntries(req.headers), raw);
  if (!ok) return new Response(null, { status: 401 });
  const event = JSON.parse(raw);
  // …
  return new Response(null, { status: 200 });
}
```

Because Ody follows Standard Webhooks, the official [`standardwebhooks`](https://github.com/standard-webhooks/standard-webhooks) libraries (JavaScript, Python, Go, Ruby, PHP, Java, Rust, C#…) verify Ody deliveries unchanged.

## Test it

Send yourself a signed ping:

```bash
curl -X POST https://api.ody.co/v1/api/webhooks/$WEBHOOK_ID/test \
  -H "Authorization: Bearer $ODY_API_KEY"
```

`"delivered": true` means your endpoint returned `2xx`. If you get `401` from your own verifier, check that you're using the raw body and the right endpoint's secret.

## Rotating secrets

[`POST /webhooks/:id/rotate`](https://dev.ody.co/api/webhook-endpoints#rotate-the-signing-secret) issues a new secret, and the old one stops working **immediately**. To rotate without dropping events:

1. Rotate, and put the new secret in your config **next to** the old one.
2. Make your verifier accept a signature from either secret.
3. Once the new secret is deployed everywhere, remove the old one.

Rotate straight away if a secret might have leaked.
