> ## Documentation Index
> Fetch the complete documentation index at: https://docs.oimo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verifying signatures

> Verify Oimo webhook signatures (Standard Webhooks, HMAC-SHA256) in Node.js and Python, with the standardwebhooks library or by hand.

Every delivery is signed with the webhook's secret using the [Standard Webhooks](https://www.standardwebhooks.com) scheme. Verify before you act on a delivery: anyone can send a request to your endpoint.

* Signed content: `<webhook-id>.<webhook-timestamp>.<raw body>`.
* Key: the secret without its `whsec_` prefix, base64-decoded.
* Signature: base64 HMAC-SHA256, sent as `v1,<signature>`. Several space-separated signatures can be present; accept the delivery if one matches.
* Reject timestamps more than 5 minutes from your clock to stop replays. The libraries below do this for you.
* Verify the **raw body bytes**. Parsing and re-serializing the JSON changes the bytes and breaks the signature.

Any Standard Webhooks or Svix library verifies these deliveries unchanged (the `svix-*` headers carry the same values). To check a verifier before your first delivery, use the [test vector](#test-vector).

## Node.js with standardwebhooks

```bash theme={null}
npm install express standardwebhooks
```

```javascript Node.js theme={null}
import express from "express";
import { Webhook } from "standardwebhooks";

const webhook = new Webhook(process.env.OIMO_WEBHOOK_SECRET); // whsec_...
const app = express();

// express.raw keeps the body as the exact bytes that were signed.
app.post("/hooks/oimo", express.raw({ type: "application/json", limit: "5mb" }), (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body, req.headers);
  } catch {
    return res.status(400).send("invalid signature");
  }
  // Queue the work and answer quickly; mark event.id processed once it succeeded.
  res.sendStatus(204);
});

app.listen(3000);
```

## Python with standardwebhooks

```bash theme={null}
pip install flask standardwebhooks
```

```python Python theme={null}
import os

from flask import Flask, request
from standardwebhooks import Webhook, WebhookVerificationError

webhook = Webhook(os.environ["OIMO_WEBHOOK_SECRET"])  # whsec_...
app = Flask(__name__)

@app.post("/hooks/oimo")
def receive():
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except (WebhookVerificationError, ValueError):
        # The library raises ValueError for a malformed webhook-signature
        # (no comma, bad base64, non-ASCII) and a body that is not UTF-8.
        return "invalid signature", 400
    # Queue the work and answer quickly; mark event["id"] processed once it succeeded.
    return "", 204
```

## By hand: Node.js

No dependencies; `headers` are the request headers with lower-case names and `body` the raw body as a `Buffer`. `now` (Unix seconds) is only for checking the [test vector](#test-vector).

```javascript verify.mjs theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyOimo(secret, headers, body, now = Date.now() / 1000) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) throw new Error("missing signature headers");
  if (!/^\d+$/.test(timestamp)) throw new Error("invalid timestamp");
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS)
    throw new Error("timestamp outside tolerance");

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.`)
    .update(body)
    .digest();
  const valid = signatures.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    const given = Buffer.from(value ?? "", "base64");
    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
  if (!valid) throw new Error("no matching signature");
  return JSON.parse(body.toString("utf8"));
}
```

## By hand: Python

Standard library only; `body` is the raw request body as `bytes`. Every failure raises `ValueError`. `now` (Unix seconds) is only for checking the [test vector](#test-vector).

```python verify.py theme={null}
import base64
import hashlib
import hmac
import json
import time

TOLERANCE_SECONDS = 5 * 60

def verify_oimo(secret: str, headers: dict, body: bytes, now: float | None = None) -> dict:
    headers = {name.lower(): value for name, value in headers.items()}
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not (msg_id and timestamp and signatures):
        raise ValueError("missing signature headers")
    if not (timestamp.isascii() and timestamp.isdigit()):
        raise ValueError("invalid timestamp")
    if abs((time.time() if now is None else now) - int(timestamp)) > TOLERANCE_SECONDS:
        raise ValueError("timestamp outside tolerance")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        # Compare bytes: compare_digest refuses a str with non-ASCII characters.
        if version == "v1" and hmac.compare_digest(value.encode(), expected):
            return json.loads(body)
    raise ValueError("no matching signature")
```

## Test vector

Check a verifier offline, before any delivery arrives, with this fixed delivery. Its signature was made by the code that signs real deliveries.

| Input | Value |
| - | - |
| Secret | `whsec_17oYahOTvWm18K0SwR0L3XU9qgiEEx00` |
| `webhook-id` | `test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760` |
| `webhook-timestamp` | `1767225600` (2026-01-01T00:00:00.000Z) |
| Body | The exact bytes below: one line, no trailing newline, UTF-8. |
| `webhook-signature` | `v1,42fyIJTVCYN3Kj+AcWKMoKP9NXr4+GTuuRrvM3UsfX4=` |

```json Body theme={null}
{"version":"webhook-test.v1","type":"webhook.test","id":"test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760","createdAt":"2026-01-01T00:00:00.000Z","test":true,"message":"Oimo webhook connection test"}
```

The vector was signed at a fixed instant in the past, 2026-01-01T00:00:00.000Z, so a verifier that checks the timestamp against a real clock rejects it as too old. It passes only with that check disabled or the clock pinned to `1767225600`. The hand-written verifiers above take the clock as their last argument:

```javascript Node.js, by hand theme={null}
import { verifyOimo } from "./verify.mjs";

const body = Buffer.from("{\"version\":\"webhook-test.v1\",\"type\":\"webhook.test\",\"id\":\"test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760\",\"createdAt\":\"2026-01-01T00:00:00.000Z\",\"test\":true,\"message\":\"Oimo webhook connection test\"}");
const headers = {
  "webhook-id": "test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760",
  "webhook-timestamp": "1767225600",
  "webhook-signature": "v1,42fyIJTVCYN3Kj+AcWKMoKP9NXr4+GTuuRrvM3UsfX4=",
};
console.log(verifyOimo("whsec_17oYahOTvWm18K0SwR0L3XU9qgiEEx00", headers, body, 1767225600));
```

```python Python, by hand theme={null}
from verify import verify_oimo

body = "{\"version\":\"webhook-test.v1\",\"type\":\"webhook.test\",\"id\":\"test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760\",\"createdAt\":\"2026-01-01T00:00:00.000Z\",\"test\":true,\"message\":\"Oimo webhook connection test\"}".encode()
headers = {
    "webhook-id": "test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760",
    "webhook-timestamp": "1767225600",
    "webhook-signature": "v1,42fyIJTVCYN3Kj+AcWKMoKP9NXr4+GTuuRrvM3UsfX4=",
}
print(verify_oimo("whsec_17oYahOTvWm18K0SwR0L3XU9qgiEEx00", headers, body, now=1767225600))
```

The standardwebhooks libraries have no clock option. Compute the signature with their `sign` and compare it with the expected value instead:

```javascript Node.js, standardwebhooks theme={null}
import { Webhook } from "standardwebhooks";

const body = "{\"version\":\"webhook-test.v1\",\"type\":\"webhook.test\",\"id\":\"test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760\",\"createdAt\":\"2026-01-01T00:00:00.000Z\",\"test\":true,\"message\":\"Oimo webhook connection test\"}";
const signature = new Webhook("whsec_17oYahOTvWm18K0SwR0L3XU9qgiEEx00").sign(
  "test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760",
  new Date(1767225600 * 1000),
  body,
);
console.log(signature === "v1,42fyIJTVCYN3Kj+AcWKMoKP9NXr4+GTuuRrvM3UsfX4=");
```

```python Python, standardwebhooks theme={null}
from datetime import datetime, timezone

from standardwebhooks import Webhook

body = "{\"version\":\"webhook-test.v1\",\"type\":\"webhook.test\",\"id\":\"test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760\",\"createdAt\":\"2026-01-01T00:00:00.000Z\",\"test\":true,\"message\":\"Oimo webhook connection test\"}"
signature = Webhook("whsec_17oYahOTvWm18K0SwR0L3XU9qgiEEx00").sign(
    "test_6d1f3c2e-4b5a-4e8f-9a7b-1c2d3e4f5a6b_29453760",
    datetime.fromtimestamp(1767225600, tz=timezone.utc),
    body,
)
print(signature == "v1,42fyIJTVCYN3Kj+AcWKMoKP9NXr4+GTuuRrvM3UsfX4=")
```

Changing any byte of the body, the id or the timestamp must make your verifier reject the vector.

## When verification fails

* **The body was parsed first.** Frameworks that parse JSON before your handler change the bytes. Read the raw body (`express.raw`, `request.get_data()`).
* **The wrong secret.** Each webhook has its own. After a rotation, use the new secret; the old one also verifies for 24 hours.
* **The prefix.** Strip `whsec_` and base64-decode the rest to get the key; do not use the string itself as the key.
* **Clock skew.** A timestamp more than 5 minutes off is rejected. Keep your server's clock synchronized.

Send a test event from the webhook's settings and compare with its entry under **Deliveries**.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.