Webhooks
Sovela can tell your systems when something happens in your organization, so you don't have to keep asking.
Ids, never a family's records. An event says which grant, seat or referral code it is about, and why. It never names a family or a parent. If your systems need a name your admins may see, read it with an API key.
Setting one up
- In the Sovela console, open your organization and add a webhook: an https address on the public internet, and the events you want.
- Copy its signing secret (
whsec_…) into your endpoint's configuration. It's shown once; you can make a new one at any time, and the old one stops at once. - Press Test to send a
ping, and check it arrives and verifies. The console shows every delivery and what your endpoint answered.
An organization's owners (or anyone given a role that may manage webhooks) can set up to ten. Addresses must use https and resolve only to public addresses; Sovela checks again before every delivery, and doesn't follow redirects.
What a delivery looks like
Each event is a POST with a JSON body:
{
"id": "5f0c2a1e-8d4b-4c7a-9e21-3b6f0d9a7c14",
"type": "client.started",
"createdAt": "2026-10-09T18:30:00.000Z",
"organization": {
"id": "b81e7f02-3c95-4d1a-8f6e-2a7d9c04e5b3"
},
"data": {
"grantId": "0d4a96c3-71e8-4b25-a3f9-6c1e8b72d940",
"kind": "attorney"
}
}| Header | What it is |
|---|---|
Sovela-Signature | t=<unix seconds>,v1=<hex>: an HMAC-SHA256 of <t>.<body> with your signing secret. |
Sovela-Event-Id | The event's id, the same as id in the body, and the same on every retry. |
Sovela-Event-Type | The event's type, as in type. |
Sovela-Delivery-Id | This delivery to this webhook, as shown in the console's log. |
Checking the signature
Check every delivery before acting on it: compute the HMAC of the timestamp, a dot and the body exactly as received (before parsing it), compare in constant time, and refuse timestamps more than five minutes from now so a captured delivery can't be replayed. These functions are tested against Sovela's own signing.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
// Checks a delivery's Sovela-Signature header. `body` is the request body exactly as received
// (a string or Buffer, before parsing it); `whsec` is your webhook's signing secret.
export function verifySovelaSignature(body, header, whsec, toleranceSeconds = 300) {
const parts = Object.fromEntries(String(header ?? "").split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
// Refuse old deliveries, so a captured one can't be replayed later.
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", whsec).update(`${t}.${body}`).digest();
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
Python
import hashlib
import hmac
import time
def verify_sovela_signature(body: bytes, header: str, whsec: str, tolerance_seconds: int = 300) -> bool:
"""Checks a delivery's Sovela-Signature header. `body` is the raw request body, before
parsing it; `whsec` is your webhook's signing secret."""
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
# Refuse old deliveries, so a captured one can't be replayed later.
if abs(time.time() - t) > tolerance_seconds or "v1" not in parts:
return False
expected = hmac.new(whsec.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Answering, retries and order
- Answer 2xx within 10 seconds. Do slow work afterwards. Anything else (an error, a redirect, no answer) counts as a failure.
- Failures are retried with backoff, from 10 seconds doubling to an hour, twelve attempts over about three and a half hours. Then the delivery is marked as given up; you can send it again from the console's log.
- The same event can arrive more than once. Keep the ids you've handled and ignore repeats.
- Order isn't guaranteed. A retry can arrive after a later event. Use
createdAt, or read the current state with an API key. - A paused webhook's waiting deliveries are skipped, not saved for later.
Events
client.started: A family started working with the organization: one of its professionals was given access, under the organization.client.ended: A family stopped working with the organization: access ended, or the professional moved the work out of it.seat.given: The organization gave a client family a seat.seat.ended: A seat ended, and why.referral.signed_up: Someone signed up with one of the organization's referral codes. Never who.referral.qualified: A referral through one of the organization's codes qualified for a reward. Never who.ping: A test, sent from the console's Test button.
client.started
A family started working with the organization: one of its professionals was given access, under the organization.
| Field | Type | Notes |
|---|---|---|
data.grantId | id (uuid) | The consent grant, as in the clients roster |
data.kind | "attorney" | "neutral" |
client.ended
A family stopped working with the organization: access ended, or the professional moved the work out of it.
| Field | Type | Notes |
|---|---|---|
data.grantId | id (uuid) | |
data.kind | "attorney" | "neutral" | |
data.reason | "ended" | "moved" |
seat.given
The organization gave a client family a seat.
| Field | Type | Notes |
|---|---|---|
data.seatId | id (uuid) | |
data.grantId | id (uuid) |
seat.ended
A seat ended, and why.
| Field | Type | Notes |
|---|---|---|
data.seatId | id (uuid) | |
data.grantId | id (uuid) | |
data.reason | "released" | "grant_ended" | "seats_reduced" | released: the organization ended it; grant_ended: the family ended access; seats_reduced: fewer seats |
referral.signed_up
Someone signed up with one of the organization's referral codes. Never who.
| Field | Type | Notes |
|---|---|---|
data.codeId | id (uuid) |
referral.qualified
A referral through one of the organization's codes qualified for a reward. Never who.
| Field | Type | Notes |
|---|---|---|
data.codeId | id (uuid) |
ping
A test, sent from the console's Test button.
| Field | Type | Notes |
|---|---|---|
data.webhookId | id (uuid) |
New event types may be added; a webhook only receives the types it subscribed to.
Not yet sent: client.ended when access simply reaches an end date set in advance (a mediator's, for example), rather than being ended. Until it is, check endsAt in the clients roster.