# Receive and verify

> Verify each webhook's signature against the raw body, ignore duplicates, and answer within five seconds.

## Set up your endpoint

Add a webhook URL to your application in the console under [Applications](/console/applications). It must be a public `https` URL. Once it's saved, the application's Webhook signatures panel shows the signing secret, which starts with `whsec_`. Store it as `FINCHNODE_WEBHOOK_SECRET`.

Your application is subscribed to all eight event types. See [Webhook events](/docs/api/webhook-events) for the list.

## Read the request

A `POST` with a JSON body and two FinchNode headers:

| Header | Value |
| --- | --- |
| `FinchNode-Event-Id` | The event's ID, such as `evt_Qm3xT8vK2pLa9Z`. |
| `FinchNode-Signature` | `t=<unix seconds>,v1=<hex HMAC>` |

The signature is an HMAC-SHA256 of `t`, a period, and the raw body bytes, keyed with your secret. It covers the body, not the headers.

## Verify, then answer

1. Read the raw body before any JSON parser touches it.
2. Check the header's shape: exactly one `t` made of digits, and `v1` values of 64 hex characters.
3. Check that `t` is within five minutes of now.
4. Compute the HMAC and compare it to each `v1` value in constant time.
5. Parse the body and check that its `id` matches `FinchNode-Event-Id`.
6. Store the event under its `id`. If it was already stored, answer `200`; otherwise answer `2xx` within five seconds and do the slow work afterwards.

Answer `401` to anything that fails steps 2 to 5.

Node.js (Express):

```js
import crypto from 'node:crypto';
import express from 'express';

const secret = process.env.FINCHNODE_WEBHOOK_SECRET;
if (!secret?.startsWith('whsec_')) throw new Error('Set FINCHNODE_WEBHOOK_SECRET');

// Returns the event if the request is genuine, otherwise null.
function verify(rawBody, signatureHeader, eventIdHeader) {
  if (!Buffer.isBuffer(rawBody)) return null;
  const pairs = (signatureHeader || '').split(',').map((part) => part.trim().split('='));
  const times = pairs.filter(([key]) => key === 't').map(([, value]) => value);
  const signatures = pairs.filter(([key]) => key === 'v1').map(([, value]) => value);
  if (times.length !== 1 || !/^[0-9]{1,12}$/.test(times[0])) return null;
  if (!signatures.length || !signatures.every((value) => /^[a-f0-9]{64}$/.test(value))) return null;
  if (Math.abs(Date.now() / 1000 - Number(times[0])) > 300) return null;

  const expected = crypto.createHmac('sha256', secret).update(`${times[0]}.`).update(rawBody).digest();
  if (!signatures.some((value) => crypto.timingSafeEqual(Buffer.from(value, 'hex'), expected))) return null;

  const event = JSON.parse(rawBody.toString('utf8'));
  return event?.id && event.id === eventIdHeader ? event : null;
}

const app = express();

app.post('/webhooks/finchnode', express.raw({ type: 'application/json' }), async (req, res) => {
  const event = verify(req.body, req.get('FinchNode-Signature'), req.get('FinchNode-Event-Id'));
  if (!event) return res.sendStatus(401);
  const isNew = await events.insertOnce(event.id, event);
  return res.sendStatus(isNew ? 204 : 200);
});
```
Python (Flask):

```python
import hashlib
import hmac
import json
import os
import re
import time

from flask import Flask, request

SECRET = os.environ["FINCHNODE_WEBHOOK_SECRET"]
app = Flask(__name__)

def verify(raw, signature_header, event_id_header):
    """Returns the event if the request is genuine, otherwise None."""
    pairs = [part.strip().partition("=") for part in (signature_header or "").split(",")]
    times = [value for key, _, value in pairs if key == "t"]
    signatures = [value for key, _, value in pairs if key == "v1"]
    if len(times) != 1 or not re.fullmatch(r"[0-9]{1,12}", times[0]):
        return None
    if not signatures or not all(re.fullmatch(r"[a-f0-9]{64}", s) for s in signatures):
        return None
    if abs(time.time() - int(times[0])) > 300:
        return None

    expected = hmac.new(SECRET.encode(), times[0].encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not any(hmac.compare_digest(expected, s) for s in signatures):
        return None

    event = json.loads(raw)
    return event if event.get("id") and event["id"] == event_id_header else None

@app.post("/webhooks/finchnode")
def finchnode_webhook():
    event = verify(
        request.get_data(),
        request.headers.get("FinchNode-Signature"),
        request.headers.get("FinchNode-Event-Id"),
    )
    if event is None:
        return "", 401
    is_new = events.insert_once(event["id"], event)
    return "", 204 if is_new else 200
```

`events` stands for your own durable store. `insertOnce` inserts under a unique key on the event ID and says whether the row is new. Use the database's unique constraint, not a read and then a write: two retries can arrive at once, and both would pass a read.

Why check the body's `id` and not just the header? Someone who captured a signed request could resend it with a new `FinchNode-Event-Id`. The signature would still match, and a header-based check would treat it as new.

> **Log the ID, not the event**
> Event bodies can include subjects and external IDs. Log the event ID and type; don't log the body.

## Rotate the secret

Rotating the secret in the console makes the old one stop working at once. Deploy the new secret right after you rotate, or verification fails until you do.

## Test it

[Conformance runs](/docs/testing/conformance) send five `conformance.probe` events: a valid one, a bad signature, another valid one, one signed an hour ago, and a replay of the first. Each has `data.subject: null`. Store and acknowledge them like any other event. With the examples above, they get `204`, `401`, `204`, `401`, and `200`.
