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.
{
"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"
}
}{
"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.
| Header | Value | Sent |
|---|---|---|
| X-SecondAppraisal-Signature | t=<unix seconds>,v1=<hex> | Every delivery. |
| X-SecondAppraisal-Event | The event type, such as referral.activated. | Every delivery. |
| X-SecondAppraisal-Delivery | This delivery's id. A retry of the same delivery repeats it. | Every delivery. |
| User-Agent | SecondAppraisal-Webhooks/1.0 | Every delivery. |
| webhook-id | The event id. | Only to an endpoint with a Standard Webhooks secret. |
| webhook-timestamp | Unix seconds: the same t as the signature header. | Only to an endpoint with a Standard Webhooks secret. |
| webhook-signature | One 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
- Read the raw request body, byte for byte. Never parse and re-serialize the JSON first: the signature covers the bytes we sent.
- Split the header at commas into
t(unix seconds) andv1(hex). - Compute HMAC-SHA256 over
<t>.<raw body>with the secret's HMAC key, as hex. - Compare it with
v1in constant time. - Refuse a delivery whose
tis more than five minutes from your clock. Every retry is signed afresh, with the time of that attempt.
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 };
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:
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
idand theX-SecondAppraisal-Deliveryid. 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
- referral.invited: The borrower was sent the activation invitation.
- referral.activated: The borrower activated: the consultation exists.
- referral.declined: The borrower declined.
- consultation.status_changed: The referral's provider-facing milestone moved.
- appraisal.completed: Our appraisal report is complete.
- settlement.updated: The final settlement was recorded.
- charge.created: A charge for the referral was created.
- plan.enrollment.activated: A membership enrollment became active.
- plan.enrollment.past_due: A direct-collect membership's renewal is failing.
- plan.enrollment.cancelled: A membership enrollment was cancelled or lapsed.
- plan.benefit.redeemed: A member's consultation was drawn against their membership.
- plan.benefit.completed: A member consultation finished.
- plan.statement.issued: A monthly membership statement was issued.
- review.completed: The referral's review completed: its review record has a new version.
- review.updated: A review record already complete or closed has a new version.
- referral.closed: The referral closed: its review record's new version gives the close reason.