Webhooks

A webhook is Inspecta calling you. Register an HTTPS endpoint, choose the events you care about, and every matching lead and finished report is posted to you as it happens, signed so you can prove it came from us.

This is the only way to get data out of Inspecta programmatically. There is no API to poll and no key to poll it with, which means a working receiver is worth building carefully.

Set up an endpoint

  1. 1

    Build a receiver that answers quickly

    Any HTTPS URL that accepts a POST and replies with a 2xx status. Anything outside 2xx counts as a failure, including redirects, which are not followed.

  2. 2

    Add it in Settings

    Open Settings in your dashboard, add the URL, and tick the events you want. The signing secret is shown once, at that moment. Store it somewhere your application can read it and treat it like a password.

  3. 3

    Send a test event

    The Send test event button posts a small signed body straight away, so you can confirm your verification works before a real lead depends on it. A test never counts against the endpoint's health and is never retried.

  4. 4

    Submit a real assessment

    Complete one through your own widget. You should see lead_submitted immediately and report_ready shortly after.

The secret is shown once

The signing secret is returned when the endpoint is created and when you rotate it, and never again. If it is lost, rotate it: the old secret stops working the moment the new one is issued, so deploy the new value first if you cannot tolerate a gap.

Events

Two events can be subscribed to. Both describe the same lead at different moments, and a single assessment normally produces both within a minute of each other.

Subscribable events
lead_submittedNew lead

Queued as soon as a homeowner finishes the questionnaire and leaves their contact details, before the assessment has been written.

The lead, the assessment and its traffic source, and a report block whose status is still pending. The report URL is already final, so a record created now points at where the writing will appear.

report_readyReport ready

Queued once the assessment has been generated and stored, which is normally within a minute of the lead arriving.

The same lead and assessment, plus the finished report: concern level, headline, summary, recommended next step and services, urgency, and a price range on the template that shows one.

A third type, test, is sent only by the Send test event button. It cannot be subscribed to and will never arrive on its own.

The request

Every delivery is a POST with a JSON body and the headers below. Read the signature header before you read anything else.

Request headers
X-Inspecta-Signature

t=<unix seconds>,v1=<hex>

The signature over the timestamp and the raw body. Verify this before parsing anything.

X-Inspecta-Event

lead_submitted

The event type, matching the event field in the body.

X-Inspecta-Event-Id

evt_9f2c41b87d1e4a5cb0d33e6a71f0c284

Stable per event. A retry and a manual re-send both reuse it, so it is the key to deduplicate on.

X-Inspecta-Delivery

a3c8f501-62b9-4d7e-9f10-8e4b2c6d5a73

The individual delivery record. A re-send of the same event has a different one.

X-Inspecta-Attempt

1

Which attempt this is, counting from 1.

Content-Type

application/json

Always. The body is UTF-8 JSON with no wrapper.

User-Agent

Inspecta-Webhooks/1

Useful for filtering your own access logs.

The envelope

Every body starts with the same three fields, followed by the blocks that belong to that event.

Always present
id
The event ID, prefixed evt_. Stable across retries and manual re-sends. This is what you deduplicate on.
event
The event type, matching the X-Inspecta-Event header.
createdAt
When the event was queued, in UTC. This is not the time of the attempt, which can be much later.

Verify the signature

The signature header carries a timestamp and a signature: t=1757000527,v1=6f1d.... The v1 value is an HMAC-SHA256, in lowercase hex, over the string {timestamp}.{raw body} keyed with your endpoint's signing secret. We send exactly one, and exactly one secret is ever valid: rotating replaces the old one immediately, including for retries of deliveries already in flight. Split the header on commas and read the parts you know, rather than comparing the whole string, so that a scheme added alongside v1 later cannot break you.

  • Sign over the raw bytes. Parsing the JSON and re-serialising it produces different bytes and every signature will fail.
  • Reject a timestamp more than 300 seconds from now, in either direction. That is what stops a captured request being replayed at us tomorrow.
  • Compare in constant time. A plain string comparison leaks how much of the signature was correct.
  • Reply 401 and stop when verification fails. Do not process the body first.
JavaScript
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.INSPECTA_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

function verify(rawBody, header) {
  if (!header) return false;

  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const eq = part.indexOf("=");
    if (eq < 0) continue;
    const key = part.slice(0, eq).trim();
    const value = part.slice(eq + 1).trim();
    if (key === "t") timestamp = Number(value);
    else if (key === "v1") signatures.push(value);
  }
  if (timestamp === null || !Number.isFinite(timestamp) || signatures.length === 0) return false;

  // Reject a replay of a captured request.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(timestamp + "." + rawBody)
    .digest("hex");

  // Compare in constant time, and never with ===.
  return signatures.some((candidate) => {
    const a = Buffer.from(candidate, "utf8");
    const b = Buffer.from(expected, "utf8");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

// express.raw keeps the exact bytes that were signed. Parsing first and
// re-serialising produces different bytes and every signature fails.
app.post("/hooks/inspecta", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  if (!verify(rawBody, req.get("X-Inspecta-Signature"))) {
    return res.status(401).json({ error: "bad signature" });
  }

  const event = JSON.parse(rawBody);

  // Retries and manual re-sends reuse the event id, so store it and ignore repeats.
  if (alreadyProcessed(event.id)) return res.status(200).end();

  // Answer quickly. Anything slower than 8 seconds is recorded as a failure
  // and retried, even if your handler eventually finishes.
  res.status(200).end();
  queueForProcessing(event);
});

app.listen(3000);
Node with Express. The same logic ports directly to any language with an HMAC library.
cURL
# Send a correctly signed request to your own endpoint, to check your
# verification code before pointing Inspecta at it.

SECRET='whsec_your_signing_secret'
BODY='{"id":"evt_test","event":"test","createdAt":"2026-09-04T15:20:03.006Z"}'
TIMESTAMP=$(date +%s)

SIGNATURE=$(printf '%s' "$TIMESTAMP.$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" -hex \
  | sed 's/^.* //')

curl -sS -X POST https://your-app.example.com/hooks/inspecta \
  -H 'Content-Type: application/json' \
  -H "X-Inspecta-Signature: t=$TIMESTAMP,v1=$SIGNATURE" \
  -H 'X-Inspecta-Event: test' \
  -H 'X-Inspecta-Event-Id: evt_test' \
  -H 'X-Inspecta-Attempt: 1' \
  -H 'User-Agent: Inspecta-Webhooks/1' \
  --data-raw "$BODY"
Signs a body the same way we do, so you can exercise your receiver before registering it.

Payloads

Both real events carry the same four blocks: company, widget, assessment and lead. They differ in the report block. widget is null when the assessment did not come from one.

lead_submitted

The homeowner has just finished the questionnaire. The written assessment does not exist yet, so report.status is pending and only the token and URL are present. Use this event to create the record and alert whoever calls people back.

JSON
{
  "id": "evt_9f2c41b87d1e4a5cb0d33e6a71f0c284",
  "event": "lead_submitted",
  "createdAt": "2026-09-04T15:12:07.884Z",
  "company": {
    "id": "1f4a6d2c-9b77-4c1e-8a3f-2d5b0c7e9a11",
    "name": "Ridgeline Mold and Restoration",
    "slug": "ridgeline-mold"
  },
  "widget": {
    "id": "6b1d0e93-4c72-4f18-9a55-7c2e8b30d4a6",
    "publicId": "V1StGXR8Z5jdHi6B",
    "slug": "homepage",
    "name": "Homepage assessment",
    "templateKey": "remediation_estimate"
  },
  "assessment": {
    "id": "a3c8f501-62b9-4d7e-9f10-8e4b2c6d5a73",
    "templateKey": "remediation_estimate",
    "startedAt": "2026-09-04T15:08:52.310Z",
    "source": {
      "pageUrl": "https://ridgelinemold.com/services/mold-removal",
      "referrer": "https://www.google.com/",
      "utmSource": "google",
      "utmMedium": "cpc",
      "utmCampaign": "spring-remediation"
    }
  },
  "lead": {
    "id": "0d7e5b41-3a92-4f6c-8b15-9c3d7e2a4b68",
    "firstName": "Dana",
    "lastName": "Whitfield",
    "fullName": "Dana Whitfield",
    "email": "dana.whitfield@example.com",
    "phone": "+1 503 555 0148",
    "status": "new",
    "consent": true,
    "consentedAt": "2026-09-04T15:12:07.402Z",
    "submittedAt": "2026-09-04T15:12:07.402Z",
    "dashboardUrl": "https://getinspecta.com/leads/0d7e5b41-3a92-4f6c-8b15-9c3d7e2a4b68"
  },
  "report": {
    "token": "kQ8x2ZuR7ADmVnJ4pLc1TfWe",
    "url": "https://getinspecta.com/report/kQ8x2ZuR7ADmVnJ4pLc1TfWe",
    "status": "pending"
  }
}

report_ready

The assessment has been written. Everything from the first event is repeated, so a receiver that only handles this one still has the full picture. estimate is null unless the widget uses the template that shows a price range and the assessment produced both bounds. isFallback is true when the summary is a generic one written without the full model output, which is worth flagging for a human to read.

JSON
{
  "id": "evt_5b70ad91c2f34e8daa61c0e7b39d5f42",
  "event": "report_ready",
  "createdAt": "2026-09-04T15:12:41.117Z",
  "company": {
    "id": "1f4a6d2c-9b77-4c1e-8a3f-2d5b0c7e9a11",
    "name": "Ridgeline Mold and Restoration",
    "slug": "ridgeline-mold"
  },
  "widget": {
    "id": "6b1d0e93-4c72-4f18-9a55-7c2e8b30d4a6",
    "publicId": "V1StGXR8Z5jdHi6B",
    "slug": "homepage",
    "name": "Homepage assessment",
    "templateKey": "remediation_estimate"
  },
  "assessment": {
    "id": "a3c8f501-62b9-4d7e-9f10-8e4b2c6d5a73",
    "templateKey": "remediation_estimate",
    "startedAt": "2026-09-04T15:08:52.310Z",
    "source": {
      "pageUrl": "https://ridgelinemold.com/services/mold-removal",
      "referrer": "https://www.google.com/",
      "utmSource": "google",
      "utmMedium": "cpc",
      "utmCampaign": "spring-remediation"
    }
  },
  "lead": {
    "id": "0d7e5b41-3a92-4f6c-8b15-9c3d7e2a4b68",
    "firstName": "Dana",
    "lastName": "Whitfield",
    "fullName": "Dana Whitfield",
    "email": "dana.whitfield@example.com",
    "phone": "+1 503 555 0148",
    "status": "new",
    "consent": true,
    "consentedAt": "2026-09-04T15:12:07.402Z",
    "submittedAt": "2026-09-04T15:12:07.402Z",
    "dashboardUrl": "https://getinspecta.com/leads/0d7e5b41-3a92-4f6c-8b15-9c3d7e2a4b68"
  },
  "report": {
    "token": "kQ8x2ZuR7ADmVnJ4pLc1TfWe",
    "url": "https://getinspecta.com/report/kQ8x2ZuR7ADmVnJ4pLc1TfWe",
    "status": "ready",
    "concernLevel": "elevated",
    "concernLabel": "Elevated concern",
    "headline": "Visible growth on a bathroom wall with an active moisture source",
    "summary": "The homeowner describes a dark patch spreading across drywall next to a shower, present for about three weeks, with a fan that has never worked. The photographs show staining consistent with sustained moisture rather than a one-off spill.",
    "leadSummary": "Roughly 12 square feet of affected drywall in a second-floor bathroom. Extraction fan reported non-functional, so the moisture source is likely ongoing. Homeowner is available weekday mornings.",
    "recommendedNextStep": "Book an on-site inspection to confirm the extent behind the wall surface before quoting.",
    "recommendedServices": ["Moisture assessment", "Drywall removal and remediation", "Ventilation repair"],
    "urgency": "prompt",
    "estimate": {
      "low": 1450,
      "high": 2600,
      "currency": "USD"
    },
    "isFallback": false
  }
}

test

The diagnostic body. It has the envelope and the company block, and nothing else, so a receiver written only against a test event will not be ready for a real one.

JSON
{
  "id": "evt_c41d8f0a63b74e2f9a5d2e7b81c6034e",
  "event": "test",
  "createdAt": "2026-09-04T15:20:03.006Z",
  "company": {
    "id": "1f4a6d2c-9b77-4c1e-8a3f-2d5b0c7e9a11",
    "name": "Ridgeline Mold and Restoration",
    "slug": "ridgeline-mold"
  },
  "message": "This is a test event from Inspecta. Real events carry the same envelope with lead, assessment and report details.",
  "dashboardUrl": "https://getinspecta.com/settings"
}

Retries and failures

A delivery succeeds on any 2xx. Everything else is a failure and is retried up to four attempts in total, after which the delivery is closed as failed and nothing further is sent automatically.

Attempt schedule
Attempt 1
Immediately, in the request that produced the event.
Attempt 2
1 minute after the first attempt failed.
Attempt 3
5 minutes after the second attempt failed.
Attempt 4
30 minutes after the third attempt failed.
  • A 3xx is a failure. Redirects are not followed, so register the final address rather than one that bounces.
  • A response has 8 seconds to arrive, and an attempt is abandoned entirely after 16. Acknowledge first and do your work afterwards.
  • Retries are attempted as later requests pass through the server rather than by a scheduler, so a delay can be slightly longer than the table says. It is never shorter.

Delivery statuses

A delivery is only ever in one of three states. There is no separate retrying state: a delivery waiting for its next attempt still reads as pending.

pending
Queued or waiting for its next attempt. A delivery that has already failed twice and is waiting to be tried again still reads as pending.
delivered
A 2xx response arrived. Nothing further will be sent.
failed
Every attempt was used up. Nothing further will be sent unless you re-send it by hand.

Endpoints that keep failing are paused

When five events in a row use up all their attempts, the endpoint is paused automatically and the reason is recorded against it. A paused endpoint receives nothing: events are not queued for it, and any delivery that falls due while it is paused is closed as failed rather than held. Resuming it in Settings clears the failure count and starts delivering again, but the events missed in between are not replayed.

Test events never affect this. They report their own result and leave the endpoint's health untouched.

Delivery history

Settings shows the 25 most recent deliveries for each endpoint, with the event type, the status, the attempt count, the response code, the error message where there was one, and how long the request took. The payload can be expanded, truncated to the first 4,000 characters.

Any delivery can be re-sent by hand. A re-send creates a new delivery record with a fresh attempt budget and keeps the original event ID, so the history still shows what happened the first time and a receiver that already stored the event can recognise the repeat and discard it.

Limits

Webhook limits
Attempts per delivery
4
Signature timestamp tolerance
300 seconds
Time allowed for a response
8 seconds
Hard ceiling on one attempt
16 seconds
Endpoints per company
5
Consecutive failed events before an endpoint is paused
5
Deliveries shown in the history
25
Maximum endpoint URL length
600 characters

Endpoint URLs must be HTTPS, publicly resolvable, and free of credentials in the address. The rules are the same ones applied at connect time, and they are listed in full under errors, limits and security.

Writing a receiver that behaves

  • Verify before you parse. An unverified body is data from a stranger.
  • Deduplicate on the event ID. Delivery is at least once. A retry, a re-send, and a response that was lost on the way back to us all produce the same event ID twice.
  • Answer, then work. Return 2xx as soon as the body is verified and stored, and process asynchronously. A handler that talks to a slow CRM before replying will be retried while it is still working.
  • Do not depend on ordering. report_ready can arrive before lead_submitted if the first delivery needed a retry. Key on the lead ID and let either event create the record.
  • Tolerate new fields. Fields are added to payloads over time. Ignore what you do not recognise rather than rejecting the body.
  • Fail loudly on your side. A receiver that returns 200 and quietly drops the event looks perfectly healthy in your delivery history.

Next