Partner Reference

InspectForge Partner API

Create inspections from your CRM, assign and schedule them, and follow each one through to a delivered client report.

Base URLhttps://api.inspectforge.com
Versionv1 — all paths are prefixed /api/v1
AuthOrganisation API key, sent as a bearer token
Formatapplication/json for every request and response
Contactsupport@inspectforge.com
Public reference

Everything here is published deliberately, including the limits. If something is wrong, unclear or missing, tell us — a reference that lies is worse than no reference.

Overview #

v1 is deliberately narrow. It does one job thoroughly — turning a closed deal into a scheduled inspection, and returning the report link when the work is done.

What you can do

Look things up

Your templates, inspectors and referring agents.

Resolve people and places

Find or create a client by email, or a property from a street address.

Create and schedule

Create an inspection, assign an inspector, set the appointment.

Follow it through

Read status, list and reconcile, and collect the client-facing report link.

What v1 does not do

Please design around these rather than assuming they are arriving shortly.

Not availableWhat that means for you
Findings, line items, commentsYou cannot read the body of a report. You get the report link.
Photos and mediaNot exposed.
Updating or deleting an inspectionCreates only. Corrections happen in the InspectForge app.
Agreements and e-signaturesNot exposed.
Invoices and paymentsPayment webhooks exist; there is no payment API.
Webhook delivery is signed and at-least-once

Every delivery carries an HMAC-SHA256 signature you can verify, and a failed one is retried three times over roughly half an hour. What you do not get is ordering, exactly-once, or delivery to an endpoint that stays down — so keep a reconciliation poll on GET /api/v1/inspections?updatedSince= whatever else you build.

Getting a key #

Keys are issued per organisation, by an Owner or Admin, inside the InspectForge app.

  1. Sign in at app.inspectforge.com
  2. Go to Settings → Integrations & API
  3. Choose New Key and name it after the system that will use it — e.g. “Zoho CRM — production”
  4. Select the scopes it needs (see Scopes)
  5. Copy the key immediately. It is shown once and stored only as a hash. If it is lost, rotate it — it cannot be recovered.

Requirements

Rotating a key

Use Rotate rather than deleting and recreating. Rotation issues the new key immediately and leaves the old one working for 72 hours, so you can deploy the new value without downtime.

Webhook subscriptions are not affected by rotation or revocation — they belong to the organisation, not to the key.

If a key leaks, revoke it in the same screen. Revocation takes effect on the next request.

Authentication #

Send the key as a bearer token on every request:

HTTP
Authorization: Bearer ifk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

X-Api-Key: ifk_… is also accepted if your platform cannot set an Authorization header. Keys always begin with ifk_. There is no OAuth flow, no token exchange, and no expiry other than the 72-hour window on a rotated-out key.

Do not send X-Organization-Id

A key already identifies exactly one organisation. The header is ignored for key callers and cannot be used to reach another tenant.

Check your setup

/ping is the one endpoint that needs no scope. It tells you which organisation a key belongs to and what it is allowed to do — start here whenever something returns 403.

cURL
curl -s https://api.inspectforge.com/api/v1/ping \
  -H "Authorization: Bearer $INSPECTFORGE_KEY"

Scopes #

A key carries a space-separated scope list. Least privilege is worth the small effort: a key that only creates inspections cannot read your client book.

ScopeGrants
directory:readGET templates, inspectors, agents
clients:readGET /clients
clients:writePOST /clients, and find-or-create of a client inside an inspection
properties:writePOST /properties, and find-or-create of a property inside an inspection
inspections:readRead, list and report endpoints
inspections:writePOST /inspections
webhooks:manageAll webhook subscription endpoints
partnerUmbrella — everything above
zapierThe legacy /api/zapier/* surface only. Grants no /api/v1 access.
*Everything, both surfaces

Three things that explain most unexpected 403s:

Dates & time zones #

This section prevents the most expensive class of mistake in this API — one that produces a schedule which looks completely correct and is wrong by hours.

Sending a time

scheduledAt must be ISO-8601 with an explicit UTC offset. A value without one is rejected:

Accepted & rejected
"2026-09-10T08:30:00+02:00"   // accepted
"2026-09-10T06:30:00Z"        // accepted
"2026-09-10T08:30:00"         // 400 invalid_request — no offset

We reject the bare form rather than guessing. Any guess is wrong by hours for anyone outside UTC, and the resulting report still looks perfectly correct — the kind of defect discovered by an inspector arriving at the wrong time, weeks later.

How your offset is used

The instant you send is converted into your organisation's configured time zone, and the resulting local wall-clock time is what gets booked. For an organisation set to Africa/Johannesburg, all three of these book 08:30:

You sendInterpreted asBooked
2026-09-10T08:30:00+02:00Local SAST08:30
2026-09-10T06:30:00ZSame instant, in UTC08:30
2026-09-10T01:30:00-05:00Same instant, US Eastern08:30

They are the same moment, so they produce the same appointment. Send whichever form your platform produces naturally — normalising to UTC is perfectly fine.

Set your organisation's time zone before your first live booking

If your organisation has no time zone configured, we fall back to treating your offset as the local time. 06:30Z would then book 06:30, not 08:30 — two hours early for a UTC+2 business, every single time.

Set it under Settings → Organization. Use an IANA zone id — Africa/Johannesburg, America/New_York, Europe/London — not a Windows name. Ask us if you are unsure which applies. This one setting is the difference between “correct whatever the CRM sends” and “correct only when the CRM happens to send local time”.

Reading a time back

scheduledAt in responses is a local wall clock with no offset"2026-09-10T08:30:00". That is genuinely what we store: the calendar, the mobile app and the report header all render this value directly, and the original zone is not persisted. Adding an offset to the response would mean inventing one.

Read it as “the time on the wall where the inspection happens”. scheduledDate and startTime are the same value, pre-split for convenience.

One exception — updatedSince

?updatedSince= is a true instant and is interpreted as one. It also requires an offset. Use the updatedAt from a previous page, or a UTC timestamp.

Idempotency #

Send your own identifier as externalRef on every inspection you create — your CRM's deal or job ID.

CallResponse
First request with an externalRef201 Created — a new inspection
Same externalRef again200 OK — the existing inspection

CRM workflow rules re-fire — on retries, on record edits, on manual re-runs. Without externalRef you will create duplicate inspections in your first week. With it, replays cost nothing.

externalRef is unique per organisation, is returned on every inspection, and is searchable via GET /api/v1/inspections?externalRef=. It is the cleanest way to answer “does this deal already have an inspection?” without storing our IDs anywhere.

Errors #

Every error is JSON, with a machine-readable error code and a human-readable message that names the field at fault.

JSON
{
  "error": "invalid_request",
  "message": "scheduledAt: '2026-09-10T08:30:00' is not ISO-8601 with a UTC offset."
}
StatusCodeMeaning
400invalid_requestMalformed or contradictory input.
401unauthorizedMissing, invalid, revoked or expired key.
402plan_limit_exceededA plan quota is exhausted.
402trial_expiredThe trial has ended.
402subscription_inactiveThe subscription lapsed.
403insufficient_scopeValid key, but this scope is not on it.
403plan_upgrade_requiredThe plan does not include the Partner API.
404not_foundNo such record in your organisation.
409conflictThe write collided with an existing record.
429Rate limited. Honour Retry-After.
500internal_errorOur fault. Retry; if it persists, send us the timestamp.

Two behaviours are deliberate and worth knowing:

Rate limits #

BucketSustainedBurst
All Partner API requests60 / minute120
Writes — POST, DELETE10 / minute20
Failed authentication10 / minute per source20

Limits are per API key and use a token bucket: you may spend the burst immediately, then refill at the sustained rate. The write limit replaces the general limit on write endpoints rather than adding to it. On 429, honour Retry-After.

Planning a bulk import?

Ten writes a minute is comfortable for deal-driven traffic, where each closed deal is a single write. It will not move a few thousand historical records. Talk to us first — we would rather raise your ceiling than have you retry into a wall.

Endpoint reference #

All paths are relative to https://api.inspectforge.com.

GET/api/v1/pingno scope

Returns the identity and permissions of the calling key. Your first call, and your first diagnostic.

Response 200
{
  "organizationId": "392fe1d3-f193-4017-8bfa-e23561789c90",
  "organizationName": "Acme Inspections",
  "apiKeyId": "6c45a83e-e119-490f-8247-1c72ca0ba33f",
  "scopes": ["zapier", "partner"],
  "apiVersion": "v1",
  "serverTimeUtc": "2026-08-21T13:16:46.512683+00:00"
}
GET/api/v1/templatesdirectory:read

Your organisation's active inspection templates. Cache these — they change rarely, and a lookup per deal is wasted work.

Response 200
[
  { "id": "adac3780-a3bb-4925-8105-8377f4234a97", "name": "Standard Home Inspection", "email": null },
  { "id": "d9ec50ce-2872-415a-b963-2e9edae08cc8", "name": "Basic Home Inspection",    "email": null }
]
GET/api/v1/inspectorsdirectory:read

Users who can be assigned an inspection. You may assign by assignedInspectorId or by assignedInspectorEmail — the email form avoids storing our IDs in your CRM.

Response 200
[
  { "id": "006bc41c-38c2-4ef3-bdc1-e3bfae1494f8", "name": "David Nester", "email": "david@example.com" }
]
GET/api/v1/agentsdirectory:read

Referring real-estate agents. Same shape as /inspectors.

GET/api/v1/clientsclients:read

Search clients by name or email.

QueryNotes
searchName or email. Omit to get the most recent clients.
limitDefaults to 25.
POST/api/v1/clientsclients:write

Find-or-create by email. 201 when created, 200 when an existing client matched.

Request
{
  "firstName": "Johan",
  "lastName":  "Meyer",
  "email":     "johan.meyer@example.co.za",
  "phone":     "+27821234567",
  "notes":     "From Zoho deal 4471902"
}
POST/api/v1/propertiesproperties:write

Find-or-create from an address. An existing property is matched before a new one is created, so a re-fired workflow does not accumulate duplicate addresses. Only address is required.

Addresses outside North America

unit, complexName, suburb and country exist because a unit inside a named complex, in a suburb, is the ordinary case in much of the world. Send them — they are preserved and rendered into fullAddress.

Request
{
  "address":     "27 Kloof Road",
  "unit":        "Unit 12",
  "complexName": "Kloof Manor",
  "suburb":      "Bantry Bay",
  "city":        "Cape Town",
  "state":       "Western Cape",
  "zipCode":     "8005",
  "country":     "South Africa"
}
Response 201
{
  "id": "64fa6854-2935-423f-bbe0-dc8039c3f5ef",
  "unit": "Unit 12", "complexName": "Kloof Manor",
  "address": "27 Kloof Road", "suburb": "Bantry Bay",
  "city": "Cape Town", "state": "Western Cape",
  "zipCode": "8005", "country": "South Africa",
  "fullAddress": "Unit 12, Kloof Manor, 27 Kloof Road, Bantry Bay, Cape Town, Western Cape 8005",
  "clientId": null,
  "createdAt": "2026-08-21T13:35:12.441"
}
POST/api/v1/inspectionsinspections:write

The call your integration is really built around. Property and client may each be given by id or by value (find-or-create), so your CRM never needs to store our IDs.

FieldRequiredNotes
templateIdYesMust be active and belong to your organisation
property{ "id": … } or address fields
client{ "id": … } or contact fields; resolved by email
assignedInspectorId
assignedInspectorEmail
Either one; email is usually simpler
scheduledAtISO-8601 with offset. See Dates & time zones
durationMinutes1–1440. Requires scheduledAt
agentIdReferring agent
inspectionFeeDecimal, in your organisation's currency
notesFree text, visible internally
externalRefStrongly advisedYour ID. See Idempotency
Request
{
  "templateId": "adac3780-a3bb-4925-8105-8377f4234a97",
  "property": {
    "address": "27 Kloof Road", "unit": "Unit 12", "complexName": "Kloof Manor",
    "suburb": "Bantry Bay", "city": "Cape Town", "state": "Western Cape",
    "zipCode": "8005", "country": "South Africa"
  },
  "client": {
    "firstName": "Johan", "lastName": "Meyer",
    "email": "johan.meyer@example.co.za", "phone": "+27821234567"
  },
  "assignedInspectorEmail": "david@example.com",
  "scheduledAt": "2026-09-10T08:30:00+02:00",
  "durationMinutes": 180,
  "inspectionFee": 4500.00,
  "notes": "Booked from Zoho deal 4471902",
  "externalRef": "zoho-deal-4471902"
}
Response 201 — or 200 on an externalRef replay
{
  "id": "057021eb-ed7a-4e47-a10c-06274aacfe6b",
  "externalRef": "zoho-deal-4471902",
  "status": "Scheduled",
  "reportNumber": "INS-2026-0091",
  "templateName": "Standard Home Inspection",
  "scheduledAt": "2026-09-10T08:30:00",
  "scheduledDate": "2026-09-10", "startTime": "08:30", "endTime": "11:30",
  "propertyId": "64fa6854-…", "propertyAddress": "27 Kloof Road",
  "clientId": "ddbc8e03-…", "clientName": "Johan Meyer",
  "assignedInspectorName": "David Nester",
  "inspectionFee": 4500.00,
  "publicReportUrl": "https://app.inspectforge.com/public/inspections/414ac3f0…",
  "pdfUrl": null,
  "deliveredAt": null,
  "createdAt": "2026-08-21T13:36:36.944"
}
publicReportUrl is stable from creation

It is issued when the inspection is created and does not change, so you may store it on the deal immediately. It simply becomes useful once the report is delivered.

GET/api/v1/inspections/{id}inspections:read

The full inspection object, exactly as shown above.

GET/api/v1/inspectionsinspections:read

Paged list, newest first. This is your reconciliation path — poll updatedSince on a schedule and you do not depend on webhook delivery at all.

QueryNotes
statusScheduled, InProgress, Completed, Delivered
updatedSinceISO-8601 with offset. A true instant
externalRefExact match — “does this deal already have an inspection?”
pageDefault 1
pageSizeDefault 25, maximum 100
Response 200
{
  "data": [ /* inspections */ ],
  "page": 1, "pageSize": 25,
  "totalCount": 137, "totalPages": 6
}
GET/api/v1/inspections/{id}/reportinspections:read

A narrow projection answering “is it ready, and where is it?”. publicReportUrl is the client-facing report — safe to email or place on a CRM record. pdfUrl is null until a report has been generated.

Response 200
{
  "inspectionId": "057021eb-…",
  "status": "Delivered",
  "reportNumber": "INS-2026-0091",
  "publicReportUrl": "https://app.inspectforge.com/public/inspections/414ac3f0…",
  "pdfUrl": "https://app.inspectforge.com/public/inspections/414ac3f0…/pdf",
  "deliveredAt": "2026-09-10T15:22:41",
  "completedAt": "2026-09-10T14:05:09"
}
GET/api/v1/webhookswebhooks:manage

Your organisation's active subscriptions.

POST/api/v1/webhookswebhooks:manage

targetUrl must be HTTPS and publicly resolvable. Private, loopback and link-local addresses are rejected, and the check is repeated at connection time.

cURL
curl -s -X POST https://api.inspectforge.com/api/v1/webhooks \
  -H "Authorization: Bearer $INSPECTFORGE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "eventType": "inspection.status.delivered",
        "targetUrl": "https://your-crm.example.com/hooks/inspectforge",
        "label":     "Zoho — report delivered"
      }'
201 Created
{
  "id": "9f1c53a0-2f4e-4c1b-9d77-4b0c1f5a6e21",
  "eventType": "inspection.status.delivered",
  "targetUrl": "https://your-crm.example.com/hooks/inspectforge",
  "label": "Zoho — report delivered",
  "isActive": true,
  "triggerCount": 0,
  "lastTriggeredAt": null,
  "lastError": null,
  "createdAt": "2026-08-21T13:36:36.944",
  "signingSecret": "whsec_hQ2n7Rk9xVbT4pLcZ1sYfW6uJ0eA3gMdN8oPqI5rXyU"
}

signingSecret is shown once and never again. It is not in GET /api/v1/webhooks, it is in no later response, and we cannot read it back to you. Store it wherever you keep your API key. Every delivery to this subscription is signed with it — see Verifying the signature.

POST/api/v1/webhooks/{id}/rotate-secretwebhooks:manage

Issues a fresh signing secret and returns it once. Rotating a subscription that had no secret starts signing it from the next delivery.

200 OK
{
  "id": "9f1c53a0-2f4e-4c1b-9d77-4b0c1f5a6e21",
  "signingSecret": "whsec_Yv2Kd8Tj1LmQ6bZcH4aX-nR0sPwE9uF7gV3iO5yD2kM",
  "rotatedAt": "2026-08-21T14:02:11.507"
}

There is no grace period. The old secret stops signing the moment this returns, so if your receiver verifies strictly, deploy the new secret before you rotate. Accepting two secrets at once would mean keeping the superseded one on file, which is exactly what rotating after a leak is meant to end.

DELETE/api/v1/webhooks/{id}webhooks:manage

Removes a subscription. Subscriptions belong to the organisation, not to a key — rotating or revoking a key leaves them running.

Webhooks #

Events

EventFires when
inspection.status.scheduledAn inspection is scheduled
inspection.status.in_progressAn inspector starts on site
inspection.status.completedFieldwork is finished
inspection.status.deliveredThe report is published — the one most integrations want
appointment.created · .confirmed · .cancelledAppointment lifecycle
booking.received · booking.service_addedOnline booking widget
attachment.uploadedA file is attached
payment.received · .overdue · .failedPayment lifecycle
agreement.signed · .unsigned_reminderAgreement lifecycle
client.createdA client is created
agent.created · .referral · .birthday · .no_referralAgent lifecycle

Subscribe to * for everything, though most integrations want only inspection.status.delivered.

Payload

Delivered as POST, application/json, flat. externalReference is your own ID, so you can route the callback without storing ours. triggeredAt is when the event happened, in UTC — not when the attempt you are reading was made, which matters once retries are in play.

POST to your endpoint
{
  "eventType": "inspection.status.delivered",
  "triggeredAt": "2026-08-21T13:36:39.0031234Z",
  "entityId": "057021eb-ed7a-4e47-a10c-06274aacfe6b",
  "entityType": "Inspection",
  "organizationName": "Acme Inspections",
  "reportNumber": "INS-2026-0091",
  "externalReference": "zoho-deal-4471902",
  "inspectionStatus": "Delivered",
  "clientName": "Johan Meyer",
  "clientEmail": "johan.meyer@example.co.za",
  "propertyAddress": "Unit 12, Kloof Manor, 27 Kloof Road, Bantry Bay, Cape Town",
  "inspectorName": "David Nester",
  "templateName": "Standard Home Inspection",
  "reportUrl": "https://app.inspectforge.com/public/inspections/414ac3f0…",
  "pdfUrl": "https://app.inspectforge.com/public/inspections/414ac3f0…/pdf"
}

Delivery guarantees

Signed, at-least-once, unordered. Three words; here is what each one costs you.

Signed

Every delivery to a subscription created through POST /api/v1/webhooks carries X-InspectForge-Signature. Signing is a property of the subscription, not of the endpoint — subscriptions created by our Zapier app hold no secret and are delivered unsigned, exactly as they always have been. See Verifying the signature.

At-least-once

A delivery is attempted up to four times: once when the event happens, then retried at about +1 minute, +5 minutes and +30 minutes. Each attempt has 10 seconds to return a response.

Your responseWhat happens next
2xxDelivered. No further attempts.
4xx other than 408 and 429Permanent — we stop. The same bytes will get the same answer in thirty minutes, so retrying would only burn your rate limit and ours.
5xx, 408, 429Retried on the schedule above.
Timeout, connection refused, DNS or TLS failureRetried on the schedule above.
3xxRetried. We do not follow redirects — subscribe the final URL.

Each delay is measured from the moment an attempt starts, not from when it fails, and due retries are swept up every 30 seconds — so treat the times as approximate. The fourth attempt lands roughly 36 minutes after the event, and then we stop. There is no endpoint you can call to replay a delivery. Deleting or deactivating a subscription drops its pending deliveries rather than retrying them.

Unordered

There is no per-subscription serialisation, and a retried event does not hold anything back — it simply arrives late. Two status changes on one inspection can reach you in either order. Never infer state from arrival order: use triggeredAt from the body, or re-read the inspection.

Dedupe on X-InspectForge-Delivery

That header is a GUID that is identical on every attempt of one delivery and distinct for every subscription. Store it; drop anything whose id you have already handled. At-least-once is only safe for you if you do this — a receiver that returns 200 a moment after our 10-second timeout gets the identical body again a minute later, and we will have recorded the first attempt as a failure.

Signatures are the braces. Keep the belt.

At-least-once is not exactly-once, and four attempts across half an hour is not forever. If your endpoint is down for an hour, those events are gone for good. A signature tells you a callback is genuine; it tells you nothing about the callback that never arrived.

  • Keep polling GET /api/v1/inspections?updatedSince= on a schedule — every 15 minutes is ample.
  • For anything irreversible — invoicing, emailing a client — call GET /api/v1/inspections/{id} and act on what the API says rather than on what the payload said.

Verifying the signature #

HeaderValue
X-InspectForge-EventThe event type, e.g. inspection.status.delivered. Always present.
X-InspectForge-DeliveryGUID. Same across retries, distinct per subscription. Always present.
X-InspectForge-Signaturet=1755648000,v1=<64 lowercase hex>. Present only when the subscription has a signing secret.

Deliveries are POST, Content-Type: application/json; charset=utf-8, User-Agent: InspectForge-Webhooks/1.0.

The signed string is the timestamp, a literal full stop, and the raw body — {t}.{raw request body} — HMAC-SHA256 with your signing secret, hex-encoded lowercase. The shape is Stripe's on purpose: whoever builds your integration has almost certainly verified this exact header before.

The recipe

  1. Capture the raw body as a string, before you parse it. Re-serialising parsed JSON changes whitespace and key order, and the digest will never match. This is the step that catches people out.
  2. Split the header value on ,; read t= and v1=.
  3. Reject the delivery if t is more than 300 seconds from your own clock. That is the replay window.
  4. Compute HMAC-SHA256(secret, t + "." + rawBody), hex, lowercase.
  5. Compare with v1 — constant-time, if your platform offers it.
  6. Then parse the body, dedupe on X-InspectForge-Delivery, and return 200 promptly. Do the slow work afterwards; a slow 200 is a retry you did not need.
Key with the secret exactly as issued

whsec_ prefix included, UTF-8 bytes. Do not strip the prefix and do not base64-decode it. What you pasted in is what you key with.

t is regenerated on every attempt, so a retry that has waited half an hour still arrives inside a 300-second window. The body is byte-identical across attempts — including triggeredAt. Same delivery: same body, same delivery id, new t, and therefore a new v1.

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

function verify(rawBody, sigHeader, secret) {
  const parts = {};
  for (const p of sigHeader.split(",")) {
    const i = p.indexOf("=");
    if (i > 0) parts[p.slice(0, i).trim()] = p.slice(i + 1).trim();
  }

  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;   // replay window

  const expected = crypto
    .createHmac("sha256", secret)          // secret INCLUDING the whsec_ prefix
    .update(`${t}.${rawBody}`, "utf8")     // raw body — never JSON.stringify(req.body)
    .digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(parts.v1 ?? "", "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

In Express, keep the raw body with express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } }). The Deluge equivalent is in the Zoho walkthrough.

If your platform does not expose custom request headers to your webhook handler at all, you cannot verify — treat the callback as nothing more than a nudge to run the reconciliation poll early, and act on what the API returns.

Zoho CRM walkthrough #

Written in Deluge because Zoho is the first integration built on v1. The shape translates directly to any platform.

One-time setup

Create a Connection under Setup → Developer Space → Connections → Custom Service, holding the header Authorization: Bearer ifk_…, so the key never appears in script text. Cache your template and inspector IDs once — do not look them up per deal.

On deal closed — create the inspection

Deluge — workflow rule on Deal Stage = Closed Won
payload = Map();
payload.put("templateId", "adac3780-a3bb-4925-8105-8377f4234a97");
payload.put("externalRef", "zoho-deal-" + deal.get("id"));

// Property by value — Zoho holds an address, not our GUID
property = Map();
property.put("address", deal.get("Street"));
property.put("suburb",  deal.get("Suburb"));
property.put("city",    deal.get("City"));
property.put("state",   deal.get("Province"));
property.put("zipCode", deal.get("Zip_Code"));
property.put("country", "South Africa");
payload.put("property", property);

client = Map();
client.put("firstName", deal.get("Contact_First_Name"));
client.put("lastName",  deal.get("Contact_Last_Name"));
client.put("email",     deal.get("Contact_Email"));
payload.put("client", client);

payload.put("assignedInspectorEmail", deal.get("Assigned_Inspector_Email"));

// ISO-8601 WITH OFFSET. "+02:00" is SAST — use your own.
payload.put("scheduledAt",
  deal.get("Inspection_Date").toString("yyyy-MM-dd'T'HH:mm:ss") + "+02:00");
payload.put("durationMinutes", 180);

response = invokeurl
[
    url        : "https://api.inspectforge.com/api/v1/inspections"
    type       : POST
    parameters : payload.toString()
    headers    : {"Content-Type": "application/json"}
    connection : "inspectforge"
];

if(response.get("id") != null)
{
    update = Map();
    update.put("InspectForge_Inspection_Id", response.get("id"));
    update.put("InspectForge_Report_Number", response.get("reportNumber"));
    update.put("InspectForge_Report_Url",    response.get("publicReportUrl"));
    zoho.crm.updateRecord("Deals", deal.get("id"), update);
}
else
{
    info "InspectForge create failed: " + response.toString();
}

Because externalRef is the Zoho deal ID, a re-fired rule returns the existing inspection instead of creating a second one — the rule is safe to retry.

Receiving the delivered report

Publish a Deluge function as a REST API and subscribe it to inspection.status.delivered. Store the signingSecret from the subscribe response in an org variable first — it is never shown again.

Deluge — standalone function published as a REST API
request = crmAPIRequest.toMap();
rawBody = request.get("body");                  // RAW string. Do not re-serialise it.
headers = request.get("headers").toMap();

// Zoho may lower-case header names; check both spellings.
sigHeader = headers.get("X-InspectForge-Signature");
if(sigHeader == null) { sigHeader = headers.get("x-inspectforge-signature"); }

deliveryId = headers.get("X-InspectForge-Delivery");
if(deliveryId == null) { deliveryId = headers.get("x-inspectforge-delivery"); }

if(sigHeader == null) { return {"status": "rejected", "reason": "unsigned"}; }

// ── 1. Pull t and v1 out of "t=1755648000,v1=abc123…" ──
t  = "";
v1 = "";
for each part in sigHeader.toList(",")
{
    kv = part.trim();
    if(kv.startsWith("t="))  { t  = kv.removeFirstOccurence("t=");  }
    if(kv.startsWith("v1=")) { v1 = kv.removeFirstOccurence("v1="); }
}

// ── 2. Replay window: 300 seconds ──
// toLong() is epoch MILLISECONDS. Log skew once and confirm before you enforce this.
skew = zoho.currenttime.toLong() / 1000 - t.toLong();
if(skew < 0) { skew = skew * -1; }
if(skew > 300) { return {"status": "rejected", "reason": "stale timestamp"}; }

// ── 3. HMAC-SHA256 over "{t}.{rawBody}", keyed with the secret INCLUDING whsec_ ──
secret   = zoho.crm.getOrgVariable("inspectforge_webhook_secret");
expected = zoho.encryption.hmacsha256(secret, t + "." + rawBody, false);   // false → hex

if(expected.toLowerCase() != v1.toLowerCase())
{
    return {"status": "rejected", "reason": "bad signature"};
}

// ── 4. Only now is the body worth trusting. Dedupe, then act. ──
if(zoho.crm.getOrgVariable("inspectforge_last_delivery") == deliveryId)
{
    return {"status": "ok", "note": "duplicate"};     // a retry of one already handled
}

payload     = rawBody.toMap();
externalRef = payload.get("externalReference");     // "zoho-deal-4471902"
reportUrl   = payload.get("reportUrl");

if(externalRef != null && reportUrl != null)
{
    dealId = externalRef.replaceAll("zoho-deal-", "");
    update = Map();
    update.put("InspectForge_Report_Url", reportUrl);
    update.put("InspectForge_Status",     payload.get("inspectionStatus"));
    zoho.crm.updateRecord("Deals", dealId, update);
}

zoho.crm.setOrgVariable("inspectforge_last_delivery", deliveryId);
return {"status": "ok"};

Two things to be honest about in that script. A single org variable only remembers the last delivery id, which is enough while one subscription fires occasionally and not enough once you subscribe to several events — promote the dedupe store to a custom module row or a cache entry with an hour's TTL, keyed on the GUID, as soon as you do. And Deluge has no constant-time string comparison; on a hex digest inside a Zoho function that is an acceptable risk, but use one wherever your platform provides it.

The reconciliation job — please do not skip this

Webhooks are at-least-once, not exactly-once, and after four attempts across about half an hour we stop trying. This is what makes a missed callback self-heal.

Deluge — scheduled every 15 minutes
cursor = zoho.crm.getOrgVariable("inspectforge_cursor");
if(cursor == null)
{
    cursor = zoho.currentdate.addDay(-1).toString("yyyy-MM-dd'T'HH:mm:ss") + "+02:00";
}

response = invokeurl
[
    url        : "https://api.inspectforge.com/api/v1/inspections?updatedSince="
                 + zoho.encryption.urlEncode(cursor) + "&pageSize=100"
    type       : GET
    connection : "inspectforge"
];

for each item in response.get("data")
{
    if(item.get("externalRef") != null && item.get("publicReportUrl") != null)
    {
        dealId = item.get("externalRef").replaceAll("zoho-deal-", "");
        update = Map();
        update.put("InspectForge_Status",     item.get("status"));
        update.put("InspectForge_Report_Url", item.get("publicReportUrl"));
        zoho.crm.updateRecord("Deals", dealId, update);
    }
}
zoho.crm.setOrgVariable("inspectforge_cursor",
    zoho.currenttime.toString("yyyy-MM-dd'T'HH:mm:ss") + "+02:00");

Go-live checklist #

CheckWhy it matters
Organisation time zone is setNothing else on this page matters as much. Settings → Organization.
Key stored in a Connection or secret storeNever in script text, never in source control.
externalRef sent on every createWorkflow rules re-fire. This is what stops duplicates.
Signing secret stored, every callback verifiedCaptured at subscribe time — it is shown once. An unverified callback is an unauthenticated one.
Callbacks deduped on X-InspectForge-DeliveryRetries carry the same id. This is what makes at-least-once safe.
Reconciliation job scheduledNot just the webhook — delivery is at-least-once, and after about half an hour we stop retrying.
Failed calls log the response bodyOur messages name the field at fault. Swallowing them wastes your time.
Tested with a real address from your marketIncluding unit, complex and suburb where they apply.

Versioning & change policy #

Known limitations, restated plainly: no findings or media, no update or delete, no agreements, and webhook delivery that is at-least-once and unordered rather than exactly-once — four attempts over about half an hour, then we stop, which is why the reconciliation poll is not optional. If any of those blocks your integration, tell us — that is how this roadmap gets prioritised.