Production access is free and self-serve with an account. · Synthetic demo · no account needed

FinchNode

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

  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)
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)
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.

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.