Verifying webhooks

We POST each event to your HTTPS endpoint as JSON, signed with the endpoint's signing secret. Verify every delivery before you act on it: a valid signature shows the delivery came from us and its body wasn't changed on the way.

What a delivery carries

The body is one event envelope. Its id is the same on every retry, so drop duplicates by it; livemode is false for a sandbox event; data depends on the type.

At launchA new endpoint receives thin payloads: the v1.1 envelope, which adds api_version and account (your institution's id), around a thin body of ids, statuses, your external reference, the milestone and timestamps, and never a borrower's contact details, a VIN or an amount. Fetch the rest of a referral's event with GET /api/gap/v1/referrals/{id} and of a membership plan event with GET /api/gap/v1/plans/enrollments/{id} (a statement's from GET /api/gap/v1/plans/statements), or poll GET /api/gap/v1/events, which lists the same thin events. An endpoint created before then keeps the full payload: it is deprecated, and we give notice of the date it ends.

Body: thin payload
{
  "id": "evt_3f1cZ9a2b7D4eXk1",
  "object": "event",
  "type": "referral.activated",
  "api_version": "1.1",
  "created_at": "2026-06-18T15:04:11.000Z",
  "livemode": true,
  "account": {
    "id": "clqz8k2p70002ab2cd3ef4gh7"
  },
  "data": {
    "referral": {
      "id": "clqz8k2p70001ab2cd3ef4gh6",
      "object": "gap.referral",
      "status": "activated",
      "external_ref": "CASE-4471",
      "created_at": "2026-06-16T14:02:00.000Z",
      "invited_at": "2026-06-16T14:02:05.000Z",
      "invitation_expires_at": "2026-06-30T14:02:05.000Z",
      "activated_at": "2026-06-18T15:04:10.000Z",
      "settled_at": null,
      "closed_at": null
    },
    "consultation_number": "K7Q2M",
    "via": "borrower_link"
  }
}
Body: full payload
{
  "id": "evt_3f1cZ9a2b7D4eXk1",
  "object": "event",
  "type": "referral.activated",
  "created_at": "2026-06-18T15:04:11.000Z",
  "livemode": true,
  "data": {
    "referral": {
      "id": "clqz8k2p70001ab2cd3ef4gh6",
      "object": "gap.referral",
      "status": "activated",
      "status_label": "Borrower activated",
      "status_tone": "green",
      "status_note": null,
      "external_ref": "CASE-4471",
      "borrower": {
        "first_name": "Jordan",
        "last_name": "Reyes",
        "email": "jordan.reyes@example.com",
        "phone": "5125550142"
      },
      "vehicle": {
        "vin": "1FTFW1ET5DFC10312",
        "year": 2021,
        "make": "Ford",
        "model": "F-150"
      },
      "claim": {
        "carrier": "Example Mutual",
        "claim_number": "EM-2026-004471",
        "loss_state": "TX",
        "date_of_loss": "2026-06-15T00:00:00.000Z",
        "initial_offer_cents": 2150000
      },
      "liability": {
        "loan_payoff_cents": 2875000,
        "deductible_cents": 50000
      },
      "program": {
        "mode": "customer_paid",
        "subsidy_type": null,
        "subsidy_value": null,
        "price_cents": 49500,
        "locked": true
      },
      "consent": {
        "mode": "invitation",
        "disclosure_confirmed_at": null,
        "disclosure_channel": null,
        "disclosure_attestor_name": null
      },
      "submitted_via": "api",
      "created_at": "2026-06-16T14:02:00.000Z",
      "invited_at": "2026-06-16T14:02:05.000Z",
      "invitation_expires_at": "2026-06-30T14:02:05.000Z",
      "activated_at": "2026-06-18T15:04:10.000Z",
      "settled_at": null,
      "closed_at": null
    },
    "consultation_number": "K7Q2M",
    "via": "borrower_link"
  }
}

Live events go to your live endpoints, and sandbox referral events to your test endpoints. On a full-payload endpoint the membership plan events don't carry livemode yet, so a test key's membership plan events reach your live full-payload endpoints too: tell them apart by data.enrollment.test. A thin-payload endpoint receives each by its mode, with livemode, except a membership statement's plan.statement.issued, which names no enrollment and goes to your live endpoints without it.

HeaderValueSent
X-SecondAppraisal-Signaturet=<unix seconds>,v1=<hex>Every delivery.
X-SecondAppraisal-EventThe event type, such as referral.activated.Every delivery.
X-SecondAppraisal-DeliveryThis delivery's id. A retry of the same delivery repeats it.Every delivery.
User-AgentSecondAppraisal-Webhooks/1.0Every delivery.
webhook-idThe event id.Only to an endpoint with a Standard Webhooks secret.
webhook-timestampUnix seconds: the same t as the signature header.Only to an endpoint with a Standard Webhooks secret.
webhook-signatureOne or more v1,<base64> signatures, separated by spaces.Only to an endpoint with a Standard Webhooks secret.

Signing secrets

Each endpoint has one signing secret, shown once when you create the endpoint. It comes in one of two kinds:

  • whsec_gap_…: the HMAC key is the secret's text, exactly as shown.
  • whsec_ followed by base64, a Standard Webhooks secret: the HMAC key is the base64-decoded bytes after the prefix, for both signature headers, and the endpoint also gets the three Standard Webhooks headers.

At launchA new endpoint gets a Standard Webhooks secret. An endpoint created before then keeps its whsec_gap_ secret until you rotate it.

Verify X-SecondAppraisal-Signature

  1. Read the raw request body, byte for byte. Never parse and re-serialize the JSON first: the signature covers the bytes we sent.
  2. Split the header at commas into t (unix seconds) and v1 (hex).
  3. Compute HMAC-SHA256 over <t>.<raw body> with the secret's HMAC key, as hex.
  4. Compare it with v1 in constant time.
  5. Refuse a delivery whose t is more than five minutes from your clock. Every retry is signed afresh, with the time of that attempt.
Node.js
const crypto = require("node:crypto");

// A Standard Webhooks secret (whsec_ and base64) signs with its decoded
// bytes; a whsec_gap_ secret signs with its text.
function signingKey(secret) {
  if (secret.startsWith("whsec_") && !secret.startsWith("whsec_gap_")) {
    return Buffer.from(secret.slice("whsec_".length), "base64");
  }
  return Buffer.from(secret, "utf8");
}

// rawBody: the request body exactly as received, before any JSON parsing.
// header: the X-SecondAppraisal-Signature header.
// secrets: your endpoint's signing secret, or [new, previous] during a rotation.
function verifySecondAppraisalSignature(rawBody, header, secrets, nowSeconds = Math.floor(Date.now() / 1000)) {
  const parts = String(header || "").split(",");
  const t = (parts.find((part) => part.startsWith("t=")) || "").slice(2);
  const signatures = parts.filter((part) => part.startsWith("v1=")).map((part) => part.slice(3));
  if (!/^[0-9]+$/.test(t) || signatures.length === 0) return false;
  // Refuse a signature made more than five minutes from now.
  if (Math.abs(nowSeconds - Number(t)) > 300) return false;
  for (const secret of [].concat(secrets)) {
    const expected = crypto.createHmac("sha256", signingKey(secret)).update(t + ".").update(rawBody).digest();
    for (const signature of signatures) {
      const candidate = Buffer.from(signature, "hex");
      if (candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected)) return true;
    }
  }
  return false;
}

module.exports = { verifySecondAppraisalSignature };
Python
import base64
import hashlib
import hmac
import re
import time


def _signing_key(secret: str) -> bytes:
    # A Standard Webhooks secret (whsec_ and base64) signs with its decoded
    # bytes; a whsec_gap_ secret signs with its text.
    if secret.startswith("whsec_") and not secret.startswith("whsec_gap_"):
        return base64.b64decode(secret[len("whsec_"):])
    return secret.encode("utf-8")


def verify_secondappraisal_signature(raw_body: bytes, header: str, secrets, now=None) -> bool:
    """raw_body: the request body exactly as received, before any JSON parsing.
    header: the X-SecondAppraisal-Signature header.
    secrets: your endpoint's signing secret, or [new, previous] during a rotation."""
    parts = (header or "").split(",")
    t = next((part[2:] for part in parts if part.startswith("t=")), "")
    signatures = [part[3:].encode("utf-8") for part in parts if part.startswith("v1=")]
    if not re.fullmatch(r"[0-9]+", t) or not signatures:
        return False
    now = int(time.time()) if now is None else now
    # Refuse a signature made more than five minutes from now.
    if abs(now - int(t)) > 300:
        return False
    for secret in [secrets] if isinstance(secrets, str) else secrets:
        expected = hmac.new(_signing_key(secret), t.encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest().encode("ascii")
        if any(hmac.compare_digest(expected, signature) for signature in signatures):
            return True
    return False

Verify the Standard Webhooks headers

An endpoint with a Standard Webhooks secret can verify webhook-signature instead. It holds one or more v1,<base64> signatures, separated by spaces, each HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<raw body> with the decoded secret: accept the delivery when any one of them matches. Any library that implements the Standard Webhooks specification verifies it, or:

Node.js
const crypto = require("node:crypto");

// rawBody: the request body exactly as received, before any JSON parsing.
// headers: the request's headers, names in any case.
// secret: your endpoint's Standard Webhooks signing secret (whsec_ and base64).
function verifyStandardWebhook(rawBody, headers, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
  const header = (name) => {
    for (const [key, value] of Object.entries(headers)) {
      if (key.toLowerCase() === name) return value;
    }
    return undefined;
  };
  const id = header("webhook-id");
  const timestamp = header("webhook-timestamp");
  const signatures = header("webhook-signature");
  if (!id || !timestamp || !signatures || !/^[0-9]+$/.test(timestamp)) return false;
  if (!secret.startsWith("whsec_") || secret.startsWith("whsec_gap_")) return false;
  // Refuse a delivery timestamped more than five minutes from now.
  if (Math.abs(nowSeconds - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = Buffer.from(
    crypto.createHmac("sha256", key).update(id + "." + timestamp + ".").update(rawBody).digest("base64"),
  );
  // One or more "v1,<base64>" entries, space-separated: any one may match.
  return signatures.split(" ").some((entry) => {
    const comma = entry.indexOf(",");
    if (comma <= 0 || entry.slice(0, comma) !== "v1") return false;
    const candidate = Buffer.from(entry.slice(comma + 1));
    return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
  });
}

module.exports = { verifyStandardWebhook };

Rotating a secret

At launchYou can rotate an endpoint's secret on the portal's API & Webhooks page and choose how long the old one overlaps the new. Until you promote the new secret, or the overlap ends, X-SecondAppraisal-Signature stays signed with the previous secret, so verify with both secrets during the overlap, as the code above does with a list. webhook-signature carries a signature from each Standard Webhooks secret in force: both, when the previous secret was a Standard one; the new one alone, when the previous secret was a whsec_gap_ one.

Acknowledging, retries and duplicates

  • Answer any 2xx within 10 seconds to acknowledge. Anything else, or no answer, is retried: after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours, seven attempts over about 35 hours in all.
  • At launchEach retry's delay is spread by up to 20% either way, so retries after an outage don't all arrive at once.
  • A retry repeats the event id and the X-SecondAppraisal-Delivery id. Do the work once per event id.
  • We add event types over time: answer 2xx to a type you don't recognise, and ignore it. An endpoint takes the types you select for it. One with no event types selected takes every type: on a thin-payload endpoint, types added later too; on a full-payload endpoint, the first 13 types listed below.
  • An endpoint URL is https:// on port 443, with a public hostname.

Event types