# Verifying Oakstack signatures

Every request Oakstack sends to your app, whether a cron job call (clock) or a forwarded webhook (hook), carries an `Oakstack-Signature` header. Verify it to be sure the request really came from Oakstack and wasn't altered.

Each job and each webhook endpoint has its own `signingSecret` (it starts with `oks_`). You get it when you create the job or endpoint, and can view it in the dashboard. Store it as an environment variable, such as `OAKSTACK_SIGNING_SECRET`.

## With the SDK (recommended)

```ts
import { verifySignature } from "oakstack";

export async function POST(req: Request) {
  const body = await req.text(); // read the raw body BEFORE parsing it
  const ok = await verifySignature(
    process.env.OAKSTACK_SIGNING_SECRET!,
    req.headers.get("oakstack-signature"),
    body,
  );
  if (!ok) return new Response("bad signature", { status: 401 });

  const data = JSON.parse(body);
  // ... do the work
  return Response.json({ ok: true });
}
```

`verifySignature(secret, header, body, { toleranceSeconds })` accepts the body as a string, `Uint8Array`, or `ArrayBuffer`. It returns `false` (never throws) for a missing, malformed, wrong, or stale signature. The default tolerance is 5 minutes.

For binary bodies, pass the bytes: `await verifySignature(secret, header, new Uint8Array(await req.arrayBuffer()))`.

## The scheme

```
Oakstack-Signature: t=1791208931,v1=5f2b0c...
```

- `t`: Unix time in seconds when Oakstack signed the request.
- `v1`: lowercase hex HMAC-SHA256, keyed with your signing secret, of `t`, then a `.`, then the raw body bytes.

It's the same scheme Stripe uses. Reject the request if `t` is more than 5 minutes from now, which stops old requests from being replayed.

## Without the SDK

**Node.js:**

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

function verify(secret, header, rawBody /* string or Buffer */) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === expected.length && timingSafeEqual(expected, given);
}
```

**Python:**

```python
import hashlib, hmac, time

def verify(secret: str, header: str | None, raw_body: bytes) -> bool:
    if not header:
        return False
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

## Common mistakes

- **Verifying a parsed body.** `JSON.stringify(JSON.parse(body))` can differ from the original bytes. Always verify the raw body.
- **Framework body parsing.** In Express, use `express.raw({ type: "*/*" })` on the webhook route. In Next.js route handlers, call `req.text()` or `req.arrayBuffer()` once and reuse the result.
- **Wrong secret.** Each job and endpoint has its own secret. A webhook forwarded through Oakhook is signed with the endpoint's secret, not the original sender's.
- **Stripe, GitHub, and similar senders.** Oakhook forwards their original signature headers unchanged, so keep verifying those with the sender's own secret as well. Note that a _replayed_ event carries a fresh `Oakstack-Signature` but the sender's original, older signature.
