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

  1. In the Sovela console, open your organization and add a webhook: an https address on the public internet, and the events you want.
  2. 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.
  3. 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"
  }
}
HeaderWhat it is
Sovela-Signaturet=<unix seconds>,v1=<hex>: an HMAC-SHA256 of <t>.<body> with your signing secret.
Sovela-Event-IdThe event's id, the same as id in the body, and the same on every retry.
Sovela-Event-TypeThe event's type, as in type.
Sovela-Delivery-IdThis 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.

FieldTypeNotes
data.grantIdid (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.

FieldTypeNotes
data.grantIdid (uuid)
data.kind"attorney" | "neutral"
data.reason"ended" | "moved"

seat.given

The organization gave a client family a seat.

FieldTypeNotes
data.seatIdid (uuid)
data.grantIdid (uuid)

seat.ended

A seat ended, and why.

FieldTypeNotes
data.seatIdid (uuid)
data.grantIdid (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.

FieldTypeNotes
data.codeIdid (uuid)

referral.qualified

A referral through one of the organization's codes qualified for a reward. Never who.

FieldTypeNotes
data.codeIdid (uuid)

ping

A test, sent from the console's Test button.

FieldTypeNotes
data.webhookIdid (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.