Patient-authorized EHR integration
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. 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 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
- Read the raw body before any JSON parser touches it.
- Check the header's shape: exactly one
tmade of digits, andv1values of 64 hex characters. - Check that
tis within five minutes of now. - Compute the HMAC and compare it to each
v1value in constant time. - Parse the body and check that its
idmatchesFinchNode-Event-Id. - Store the event under its
id. If it was already stored, answer200; otherwise answer2xxwithin five seconds and do the slow work afterwards.
Answer 401 to anything that fails steps 2 to 5.
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);
});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 200events 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.
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 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.