Webhooks
Signed, retried, de-duplicable HTTPS deliveries whenever something your integration cares about changes. Verify first, parse second.
Subscriptions
A company owner creates subscriptions in the console (POST /partner-api/webhooks) with your endpoint URL and the events you want. Rules enforced at creation and re-checked on every delivery:
- HTTPS on port 443 only. Plain HTTP, other ports and URLs with embedded credentials are rejected.
- The host must resolve to a public address. Private, loopback, link-local and cloud-metadata ranges are refused, and DNS is resolved again at delivery time so a later record change cannot redirect traffic inside a network.
- New subscriptions start paused. You verify a test delivery, then the owner activates it. Paused subscriptions queue nothing.
- Each subscription has its own signing secret with a version number. Secrets rotate in two phases (below).
Envelope
Every delivery is a JSON object with the same top-level shape regardless of event:
{
"id": "evt_01J…", // event id; stable across retries
"event": "install.status_changed",
"apiVersion": "2026-08-26",
"schemaVersion": 1,
"occurredAt": "2026-09-13T17:42:10.512Z",
"resource": { "type": "installJob", "id": "job_…", "version": 7 },
"data": { … } // event-specific payload, see catalogue
}
The resource.version is the same optimistic-concurrency version the Installer API uses; if you write back, send it as expectedVersion.
Headers
| Header | Meaning |
|---|---|
x-ridge-arc-event | Event name, duplicated from the body for routing before parsing. |
x-ridge-arc-delivery-id | Unique per delivery attempt. De-duplicate on this if you want at-most-once processing. |
x-ridge-arc-timestamp | Unix seconds when we signed the payload. Reject if more than 300 seconds from your clock. |
x-ridge-arc-secret-version | Which secret version signed this delivery. Needed during rotation. |
x-ridge-arc-signature | v1=<hex>: HMAC-SHA256 over timestamp + "." + rawBody using the secret of that version. |
x-ridge-arc-legacy-signature, x-vesta-event, x-vesta-signature | Compatibility headers from the brand transition: a body-only digest and the former header names. Ignore them; they are removed one release after 2.3. Verify x-ridge-arc-signature only. |
content-type | application/json; the raw bytes are what is signed, so do not re-serialise before verifying. |
Verifying a signature
Compute the HMAC over the exact raw request body, compare in constant time, then check the timestamp window, then de-duplicate. Only then parse JSON.
// Node 18+ (Express with raw body)
import crypto from "node:crypto";
export function verifyRidgeArc(req, secretsByVersion) {
const ts = req.header("x-ridge-arc-timestamp");
const version = req.header("x-ridge-arc-secret-version");
const given = req.header("x-ridge-arc-signature") || "";
const secret = secretsByVersion[version];
if (!secret || !ts) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window
const expected = "v1=" + crypto.createHmac("sha256", secret)
.update(ts + "." + req.rawBody.toString("utf8")).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(given);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
# Python 3 (Flask / FastAPI raw body)
import hmac, hashlib, time
def verify_ridge_arc(headers, raw_body: bytes, secrets_by_version: dict) -> bool:
ts = headers.get("x-ridge-arc-timestamp", "")
secret = secrets_by_version.get(headers.get("x-ridge-arc-secret-version", ""))
if not secret or not ts or abs(time.time() - float(ts)) > 300:
return False
expected = "v1=" + hmac.new(secret.encode(), (ts + ".").encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers.get("x-ridge-arc-signature", ""))
Known-good sample for an offline test: secret whsec_test_0123456789abcdef, timestamp 1757778130, body {"id":"evt_test","event":"install.test","apiVersion":"2026-08-26","schemaVersion":1,"occurredAt":"2026-09-13T15:42:10Z","resource":{"type":"subscription","id":"sub_test","version":1},"data":{}}. Your verifier should accept the signature the sandbox sends for that delivery and reject it when any byte of the body changes.
Acknowledging and retries
- Respond 2xx within 10 seconds only after you have durably stored the delivery. Queue the work; do not do it inline.
- Any other response, a timeout or a TLS failure schedules a retry with exponential backoff starting at 1 minute and doubling, capped at 6 hours, for up to 8 attempts in total (about 2 hours of wall time before the last attempt).
- After the last failed attempt the delivery is dead-lettered. It stays visible to the company owner under the subscription's deliveries and can be replayed from the console. The subscription's health flips to degraded so someone notices.
- Retries reuse the event id and payload but get a new delivery id and a fresh signature and timestamp.
- Ordering is not guaranteed across events. Use
occurredAtandresource.versionto order updates to one resource; ignore anything older than what you already have.
Secret rotation (two phases)
- Rotate (
POST /partner-api/webhooks/:id/rotate-secret): a pending secret with the next version number is created and shown once. Deliveries are still signed with the current version. - You load the pending secret into your verifier keyed by its version.
- Activate (
POST /partner-api/webhooks/:id/activate-secretwith{"pendingSecretVersion": n}): new deliveries are signed with version n. Keep the previous version available for a short overlap so in-flight retries still verify, then drop it.
Event catalogue
The live list is at GET /partner-api/webhook-events (owner session), which also reports the signature scheme version. Events are grouped by the subscription that can receive them; your partner type limits which groups you can subscribe to (see Access & compliance).
Sales and project events
| Event | When | Typical consumer |
|---|---|---|
quote.sent | A quote was sent to the customer | Lead source attribution, CRM stage sync |
quote.viewed | The customer opened the quote link | CRM engagement |
quote.accepted | The customer accepted an option | CRM, financing, accounting |
project.stage_changed | Any pipeline stage change | CRM, reporting |
appointment.booked | An appointment was created or moved | Booking vendors, calendars |
deposit.paid / deposit.refunded | Customer deposit settled or reversed | Accounting, financing |
invoice.issued | An invoice was issued | Accounting |
rebate.status_changed | A rebate or incentive application changed state | Incentive programs |
project.completed | Project closed out | CRM, accounting, review requests |
Installation events (installer companies)
| Event | When |
|---|---|
install.job_assigned / install.job_unassigned | A job was assigned to or removed from your company |
install.job_cancelled | The job was cancelled by the company |
install.schedule_changed | Scheduled start or end changed (by either side) |
install.readiness_changed | Ready-to-install flag or blocker codes changed (permits, equipment, deposit) |
install.document_available | A scope, permit, manual, warranty or completion document is available to fetch |
install.status_changed | Job status moved |
install.completion_received | Your completion report was accepted |
install.scope_acknowledgement_required | The scope changed; your acknowledgement of the new scope version is needed before work continues |
install.test | On-demand test delivery; safe to ignore in production |
Financial journal events (accounting partners)
Journal-level events for deposits, invoices, payouts and adjustments are listed in the live catalogue and issued only to subscriptions on an accounting-class partner. They never carry customer contact details.
Idempotent consumers
Store id (event) and x-ridge-arc-delivery-id. Process an event once even if several deliveries of it arrive; acknowledge every delivery of an already-processed event with 2xx so retries stop.