Inspection Support Network

Developer documentation / Webhooks

ISN webhooks

Register a URL under one office and ISN sends it a JSON payload the moment something happens in that company — an order gets created, scheduled, paid, completed, rescheduled. No polling, no footprint queue to drain.

Registering a webhook

  1. In ISN, go to Settings → Webhooks (/{companyKey}/webhooks) and choose Add Webhook.

  2. Give it a name, the endpoint URL ISN should call, a method — POST (recommended: the payload goes in a JSON body) or GET — and the events it should receive. One webhook can listen to several of the event types below.

  3. Turn on the two options, both recommended.

    Track deliveries and retry failures: ISN keeps a history of every delivery and retries a failed one for about a day. Needs an https:// URL. See Tracking and retries.

    Add signing secret: every request carries an X-ISN-Signature header your endpoint can check, so it only accepts requests from ISN. See Verifying signatures.

  4. Save. The webhook is live immediately. From its page you can send a test event, see its deliveries, pause it, roll its secret, or delete it.

A new URL must be https://: payloads carry client names, addresses, emails and phone numbers. An existing plain-http:// webhook keeps working and can still be saved unchanged, but moving it to a new URL, or turning on tracking, needs https://.

Delivery

Reliability
At-least-once. A webhook can receive the same event more than once — treat delivery as a queue, not a single guaranteed call.
Deduplication
Every payload carries an event_id, generated once per event and stable across any redelivery of it, and it is also sent as the X-ISN-Event-Id header. Record the ones you have processed and skip a repeat rather than reprocessing it.
Success
Any 2xx response. Anything else — including a redirect on a tracked webhook, which is not followed — counts as a failed attempt. Respond quickly and do your work afterwards.
Timeout
ISN waits 8 seconds for a tracked webhook and 5 seconds for an untracked one, then counts the attempt as failed.
Scope
A webhook fires only for events in the office it was registered under.
Ordering
Not guaranteed across events. Use each payload's own timestamp (utc/local) if you need to sequence what you receive.
Test events
Send test event delivers a real payload, sampled from the office's most recent matching record, with "test": true added. It is signed, tracked and retried exactly like a live event, so it proves the whole path — filter on test if your endpoint must not act on it.

event_id only protects against the same delivery arriving twice. It does not deduplicate two genuinely separate saves in ISN that happen to represent the same real-world change — each save fires its own event with its own event_id.

Request format

POST (default)
A JSON body, Content-Type: application/json. A few long-standing integrations receive multipart/form-data instead; ISN sets that per webhook.
GET
The payload fields as a querystring. If your URL already has a querystring, the payload is appended to it with &, and any #fragment is dropped.
X-ISN-Event-Id
The payload's event_id, for deduplicating without parsing the body.
X-ISN-Signature
Present when the webhook has a signing secret. See Verifying signatures.

Tracking and retries

With Track deliveries and retry failures on, ISN records every attempt and owns the retries:

Attempts
8 in total — the first delivery plus 7 retries, spread over about a day. The gaps roughly double each time, from about 10–15 minutes up to 8–16 hours, so a struggling endpoint is not hammered.
HTTPS only
A tracked webhook is only ever delivered to an https:// URL, and redirects are not followed, so a delivery can never end up in plaintext. A 3xx is a failed attempt.
History
The webhook's page shows each event with its status (Delivered, Retrying, Failed), the HTTP status, the attempt count, when it was last tried and when it will be retried, plus exactly what was sent.
Pausing
A paused webhook sends nothing until you resume it, and drops any delivery still waiting to be retried.

A retry sends the same payload (same event_id) with a fresh timestamp and signature. An untracked webhook has no delivery history in ISN, and ISN does not schedule retries for it.

Verifying signatures

A webhook with a signing secret sends this header on every request:

X-ISN-Signature: t=1790368200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
t
When the request was signed, in Unix seconds. Each attempt, retries included, is signed at the moment it is sent.
v1
Lowercase hex HMAC-SHA256, keyed with your signing secret, of t, a full stop, and the exact bytes ISN sent: "{t}.{signed bytes}".
Signed bytes
The raw request body for a POST — JSON or multipart, exactly as received, before any parsing. For a GET, the whole querystring as sent (everything after ?), including any parameters that were already in your URL.
Secret
64 hex characters, shown on the webhook's page (Reveal, Copy). Use the string itself as the HMAC key; do not hex-decode it.

To verify a request:

  1. Read the raw body (or querystring) before your framework parses it. Re-serialising parsed JSON will not reproduce the bytes ISN signed.

  2. Split the header on , into t and v1. Reject the request if t is more than 5 minutes from your clock — that stops a captured request from being replayed later.

  3. Compute HMAC-SHA256 of "{t}." + raw bytes with your secret, and compare it with v1 in constant time. Accept only on a match.

Node.js

const crypto = require('crypto');

// rawBody: the request body as a Buffer (for a GET: the querystring as received).
function isnSignatureValid(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    String(header || '').split(',').map((p) => p.trim().split('=', 2))
  );
  const t = Number(parts.t);
  const v1 = parts.v1 || '';
  if (!Number.isInteger(t) || !v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = crypto.createHmac('sha256', secret)
    .update(`${t}.`).update(rawBody).digest('hex');
  return expected.length === v1.length
    && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

PHP

function isn_signature_valid(
    string $rawBody,
    string $header,
    string $secret,
    int $tolerance = 300
): bool {
    $parts = [];
    foreach (explode(',', $header) as $pair) {
        [$key, $value] = array_pad(explode('=', trim($pair), 2), 2, '');
        $parts[$key] = $value;
    }
    $t = (int) ($parts['t'] ?? 0);
    $v1 = $parts['v1'] ?? '';
    if ($t === 0 || $v1 === '' || abs(time() - $t) > $tolerance) {
        return false;
    }

    return hash_equals(hash_hmac('sha256', $t . '.' . $rawBody, $secret), $v1);
}

// POST: $rawBody = file_get_contents('php://input');
// GET:  $rawBody = $_SERVER['QUERY_STRING'];
// $header  = $_SERVER['HTTP_X_ISN_SIGNATURE'] ?? '';

Python

import hashlib, hmac, time

def isn_signature_valid(raw: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        t = int(parts.get("t", ""))
    except ValueError:
        return False
    v1 = parts.get("v1", "")
    if not v1 or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
Rolling the secret takes effect immediately. Roll secret… on the webhook's page replaces it, and the old one stops working straight away — the next request is signed with the new secret. Update your endpoint first, or accept both secrets briefly while you switch.

Event types

Every payload shares the envelope fields event_id, ck (your company key), domain, action, utc and local. What else it carries depends on the event, grouped below by shape.

EventFires whenStatus
ORDER_CREATEDA new order is saved for the first time.Live
ORDER_SCHEDULEDAn inspector is assigned to an order that did not have one, marking it scheduled.Live
ORDER_SIGNEDThe order's inspection agreement is signed.Live
ORDER_PAIDThe amount due on the order reaches zero.Live
ORDER_NOT_PAIDAn order that was fully paid becomes unpaid again (a refund, a fee added after payment).Live
ORDER_COMPLETEDThe order is marked complete.Live
ORDER_UPDATEDAny save of the order — the general-purpose "something changed" event. Fires in addition to the more specific events above when they also apply.Live
ORDER_DATETIMEOnce, shortly after the inspection's scheduled date/time arrives (checked hourly; will not fire twice for the same order).Live
FOOTPRINT_CREATEDISN creates a new footprint for an inspector.Live
ORDER_NOTE_CREATEDA note is added to an order.Live
ORDER_NOTE_UPDATEDAn existing note's text is edited.Live
ORDER_NOTE_DELETEDA note is deleted (soft or hard).Live
ORDER_STATUS_CHANGEDThe order's status changes (active, on hold, canceled, scheduled, completed, deleted) — or, as a separate transition, the inspection is rescheduled.Live
ORDER_ASSIGNMENT_CHANGEDWho created, scheduled, or is inspecting the order changes.Live

ISN's Zapier app uses this same delivery mechanism internally for its own trigger set, configured through the Zapier app rather than the webhooks page above — it is not part of this list.

Payload — order events

ORDER_CREATED, ORDER_SCHEDULED, ORDER_SIGNED, ORDER_PAID, ORDER_NOT_PAID, ORDER_COMPLETED, ORDER_UPDATED and ORDER_DATETIME all share the shape below; only action and the timestamp fields that make sense for that event differ.

{
  "event_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
  "ck": "yourcompany",
  "domain": "inspectionsupport.com",
  "action": "ORDER_COMPLETED",
  "order_id": "a055b99f-0dee-48ec-ae2d-852356cd7b2d",
  "order_oid": 23522,
  "type": "Home Inspection",
  "created_by": "39cb3358-a59c-4e9a-aba7-7bb1eb929a12",
  "created_by_name": "Eve Manager",
  "scheduled_by": "164b361a-3d49-4fcf-8c9c-e26372d7e3bc",
  "scheduled_by_name": "Roger Office",
  "completed_by": "27375b47-0e11-426a-bf4f-abea5df096c7",
  "completed_by_name": "Bill Inspector",
  "datetime": "2027-02-14 08:00:00",
  "created": "2027-01-01 08:00:00",
  "scheduled": "2027-02-13 08:00:00",
  "completed": "2027-02-15 08:00:00",
  "address": "9921 Barrier Reef Drive, Las Vegas, NV 89117",
  "address1": "9921 Barrier Reef Drive",
  "city": "Las Vegas",
  "state": "NV",
  "zip": "89117",
  "inspector": "Rick Inspector",
  "client_first": "Bob",
  "client_last": "Smith",
  "client_email": "help@inspectionsupport.net",
  "buyers_agent_id": "2f03291f-a34c-46cb-bb9a-ec104d86fa0b",
  "buyers_agent_first": "Sally",
  "buyers_agent_last": "Agent",
  "buyers_agent_agency": "Sally's Agency",
  "sellers_agent_id": "a403291f-234c-46cb-bb9a-65104d86fd0a",
  "sellers_agent_first": "Richard",
  "sellers_agent_last": "Boston",
  "report_number": "27-0450",
  "total": 450.00,
  "taxes": 0.00,
  "coupons": 0.00,
  "fee": 450.00,
  "utc": "2027-02-15T16:05:00+00:00",
  "local": "2027-02-15T09:05:00-07:00"
}

Identity

order_id (UUID) and order_oid (the numeric id shown in ISN) both identify the order; use whichever you already key on.

People

Each of created_by, scheduled_by, completed_by pairs a user UUID with a _name display name. inspector is a display name only.

Buyer's / seller's agent

buyers_agent_* and sellers_agent_* repeat the same shape (id, first, last, email, mobile, agency, inspection count, total fees, tags) for each side, when present.

Money

total, taxes, coupons and fee (the grand total) are decimal, not strings.

Legacy aliases

A few fields are duplicated under older names kept for backwards compatibility — id/oid alongside order_id/order_oid, order_report_number alongside report_number, and so on. Prefer the names shown above.

Payload — footprint created

{
  "event_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "ck": "yourcompany",
  "order": "a055b99f-0dee-48ec-ae2d-852356cd7b2d",
  "oid": 23522,
  "inspector": "27375b47-0e11-426a-bf4f-abea5df096c7",
  "datetime": "2027-02-14 08:00:00"
}

Payload — notes, status and assignment

Order notes

ORDER_NOTE_CREATED, ORDER_NOTE_UPDATED, ORDER_NOTE_DELETED.

{
  "event_id": "5c9a4e3a-8f2b-4b6e-9c1d-2a7b3e4f5061",
  "ck": "yourcompany",
  "domain": "inspectionsupport.com",
  "action": "ORDER_NOTE_CREATED",
  "note_id": 918273,
  "order_id": "a055b99f-0dee-48ec-ae2d-852356cd7b2d",
  "order_oid": 23522,
  "text": "Client requested a call before the inspector arrives.",
  "author": "Roger Office",
  "author_id": "164b361a-3d49-4fcf-8c9c-e26372d7e3bc",
  "created": "2027-02-14 09:12:00",
  "user": "164b361a-3d49-4fcf-8c9c-e26372d7e3bc",
  "utc": "2027-02-14T16:12:00+00:00",
  "local": "2027-02-14T09:12:00-07:00"
}

Order status changed

One event covers every status transition, distinguished by transition. A plain status move:

{
  "event_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
  "ck": "yourcompany",
  "domain": "inspectionsupport.com",
  "action": "ORDER_STATUS_CHANGED",
  "transition": "STATUS_CHANGED",
  "order_id": "a055b99f-0dee-48ec-ae2d-852356cd7b2d",
  "order_oid": 23522,
  "old_status": "Scheduled",
  "new_status": "Completed",
  "old_datetime": null,
  "new_datetime": null,
  "changed_by": "27375b47-0e11-426a-bf4f-abea5df096c7",
  "changed_by_name": "Bill Inspector",
  "utc": "2027-02-15T16:05:00+00:00",
  "local": "2027-02-15T09:05:00-07:00"
}

An inspection date/time change on an already-scheduled order is the same event with transition: "RESCHEDULED" — status stays Scheduled on both sides, and the old/new appointment times are populated instead:

{
  "event_id": "7b2e9c1a-4d6f-4a3b-8e5c-1f0a2b3c4d5e",
  "ck": "yourcompany",
  "domain": "inspectionsupport.com",
  "action": "ORDER_STATUS_CHANGED",
  "transition": "RESCHEDULED",
  "order_id": "a055b99f-0dee-48ec-ae2d-852356cd7b2d",
  "order_oid": 23522,
  "old_status": "Scheduled",
  "new_status": "Scheduled",
  "old_datetime": "2027-02-13 09:00:00",
  "new_datetime": "2027-02-15 14:00:00",
  "changed_by": "164b361a-3d49-4fcf-8c9c-e26372d7e3bc",
  "changed_by_name": "Roger Office",
  "utc": "2027-02-12T18:00:00+00:00",
  "local": "2027-02-12T11:00:00-07:00"
}

old_status/new_status are one of Active, On Hold, Canceled, Deleted, Scheduled, Completed. changed_by is null when the change happens outside a signed-in session (a scheduled job, for example).

Order assignment changed

Fires once per field that changes — created_by, scheduled_by, or any inspector slot (inspector1 through inspector9, since an order can carry more than one inspector).

{
  "event_id": "7b2e9c1a-4d6f-4a3b-8e5c-1f0a2b3c4d5e",
  "ck": "yourcompany",
  "domain": "inspectionsupport.com",
  "action": "ORDER_ASSIGNMENT_CHANGED",
  "field": "inspector1",
  "order_id": "a055b99f-0dee-48ec-ae2d-852356cd7b2d",
  "order_oid": 23522,
  "old_user_id": "27375b47-0e11-426a-bf4f-abea5df096c7",
  "old_user_name": "Bill Inspector",
  "new_user_id": "3ca31345-d9ab-408f-8997-b7c4624dd41d",
  "new_user_name": "Sam Inspector",
  "changed_by": "164b361a-3d49-4fcf-8c9c-e26372d7e3bc",
  "changed_by_name": "Roger Office",
  "utc": "2027-02-12T18:00:00+00:00",
  "local": "2027-02-12T11:00:00-07:00"
}