InspectForge Partner API
Create inspections from your CRM, assign and schedule them, and follow each one through to a delivered client report.
| Base URL | https://api.inspectforge.com |
|---|---|
| Version | v1 — all paths are prefixed /api/v1 |
| Auth | Organisation API key, sent as a bearer token |
| Format | application/json for every request and response |
| Contact | support@inspectforge.com |
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 available | What that means for you |
|---|---|
| Findings, line items, comments | You cannot read the body of a report. You get the report link. |
| Photos and media | Not exposed. |
| Updating or deleting an inspection | Creates only. Corrections happen in the InspectForge app. |
| Agreements and e-signatures | Not exposed. |
| Invoices and payments | Payment webhooks exist; there is no payment API. |
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.
- Sign in at app.inspectforge.com
- Go to Settings → Integrations & API
- Choose New Key and name it after the system that will use it — e.g. “Zoho CRM — production”
- Select the scopes it needs (see Scopes)
- 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
- The organisation must be on a plan that includes the Partner API. Starter does not.
- Only Owner and Admin roles may create, rotate or revoke keys — enforced on the server, not merely hidden in the interface.
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:
Authorization: Bearer ifk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxX-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.
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 -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.
| Scope | Grants |
|---|---|
directory:read | GET templates, inspectors, agents |
clients:read | GET /clients |
clients:write | POST /clients, and find-or-create of a client inside an inspection |
properties:write | POST /properties, and find-or-create of a property inside an inspection |
inspections:read | Read, list and report endpoints |
inspections:write | POST /inspections |
webhooks:manage | All webhook subscription endpoints |
partner | Umbrella — everything above |
zapier | The legacy /api/zapier/* surface only. Grants no /api/v1 access. |
* | Everything, both surfaces |
Three things that explain most unexpected 403s:
zapierand the/api/v1scopes are separate. A key scoped onlyzapieris refused on every/api/v1data endpoint. If you use both surfaces, ask forzapier partner./api/v1/pingrequires no scope, by design — it is how you inspect a credential.- A missing scope is always
403 insufficient_scope, never a 404.
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:
"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 send | Interpreted as | Booked |
|---|---|---|
2026-09-10T08:30:00+02:00 | Local SAST | 08:30 |
2026-09-10T06:30:00Z | Same instant, in UTC | 08:30 |
2026-09-10T01:30:00-05:00 | Same instant, US Eastern | 08: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.
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.
?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.
| Call | Response |
|---|---|
First request with an externalRef | 201 Created — a new inspection |
Same externalRef again | 200 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.
{
"error": "invalid_request",
"message": "scheduledAt: '2026-09-10T08:30:00' is not ISO-8601 with a UTC offset."
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed or contradictory input. |
| 401 | unauthorized | Missing, invalid, revoked or expired key. |
| 402 | plan_limit_exceeded | A plan quota is exhausted. |
| 402 | trial_expired | The trial has ended. |
| 402 | subscription_inactive | The subscription lapsed. |
| 403 | insufficient_scope | Valid key, but this scope is not on it. |
| 403 | plan_upgrade_required | The plan does not include the Partner API. |
| 404 | not_found | No such record in your organisation. |
| 409 | conflict | The write collided with an existing record. |
| 429 | — | Rate limited. Honour Retry-After. |
| 500 | internal_error | Our fault. Retry; if it persists, send us the timestamp. |
Two behaviours are deliberate and worth knowing:
- A record belonging to another organisation returns 404, not 403. We do not confirm the existence of records you cannot see.
- A mistyped path returns 404 JSON, never an HTML page. If you receive HTML from this API, you are not talking to
/api/.
Rate limits #
| Bucket | Sustained | Burst |
|---|---|---|
| All Partner API requests | 60 / minute | 120 |
Writes — POST, DELETE | 10 / minute | 20 |
| Failed authentication | 10 / minute per source | 20 |
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.
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.
Returns the identity and permissions of the calling key. Your first call, and your first diagnostic.
{
"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"
}Your organisation's active inspection templates. Cache these — they change rarely, and a lookup per deal is wasted work.
[
{ "id": "adac3780-a3bb-4925-8105-8377f4234a97", "name": "Standard Home Inspection", "email": null },
{ "id": "d9ec50ce-2872-415a-b963-2e9edae08cc8", "name": "Basic Home Inspection", "email": null }
]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.
[
{ "id": "006bc41c-38c2-4ef3-bdc1-e3bfae1494f8", "name": "David Nester", "email": "david@example.com" }
]Referring real-estate agents. Same shape as /inspectors.
Search clients by name or email.
| Query | Notes |
|---|---|
search | Name or email. Omit to get the most recent clients. |
limit | Defaults to 25. |
Find-or-create by email. 201 when created, 200 when an existing client matched.
{
"firstName": "Johan",
"lastName": "Meyer",
"email": "johan.meyer@example.co.za",
"phone": "+27821234567",
"notes": "From Zoho deal 4471902"
}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.
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.
{
"address": "27 Kloof Road",
"unit": "Unit 12",
"complexName": "Kloof Manor",
"suburb": "Bantry Bay",
"city": "Cape Town",
"state": "Western Cape",
"zipCode": "8005",
"country": "South Africa"
}{
"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"
}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.
| Field | Required | Notes |
|---|---|---|
templateId | Yes | Must be active and belong to your organisation |
property | — | { "id": … } or address fields |
client | — | { "id": … } or contact fields; resolved by email |
assignedInspectorIdassignedInspectorEmail | — | Either one; email is usually simpler |
scheduledAt | — | ISO-8601 with offset. See Dates & time zones |
durationMinutes | — | 1–1440. Requires scheduledAt |
agentId | — | Referring agent |
inspectionFee | — | Decimal, in your organisation's currency |
notes | — | Free text, visible internally |
externalRef | Strongly advised | Your ID. See Idempotency |
{
"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"
}{
"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"
}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.
The full inspection object, exactly as shown above.
Paged list, newest first. This is your reconciliation path — poll updatedSince on a schedule and you do not depend on webhook delivery at all.
| Query | Notes |
|---|---|
status | Scheduled, InProgress, Completed, Delivered |
updatedSince | ISO-8601 with offset. A true instant |
externalRef | Exact match — “does this deal already have an inspection?” |
page | Default 1 |
pageSize | Default 25, maximum 100 |
{
"data": [ /* inspections */ ],
"page": 1, "pageSize": 25,
"totalCount": 137, "totalPages": 6
}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.
{
"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"
}Your organisation's active subscriptions.
targetUrl must be HTTPS and publicly resolvable. Private, loopback and link-local addresses are rejected, and the check is repeated at connection time.
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" }'
{
"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.
Issues a fresh signing secret and returns it once. Rotating a subscription that had no secret starts signing it from the next delivery.
{
"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.
Removes a subscription. Subscriptions belong to the organisation, not to a key — rotating or revoking a key leaves them running.
Webhooks #
Events
| Event | Fires when |
|---|---|
inspection.status.scheduled | An inspection is scheduled |
inspection.status.in_progress | An inspector starts on site |
inspection.status.completed | Fieldwork is finished |
inspection.status.delivered | The report is published — the one most integrations want |
appointment.created · .confirmed · .cancelled | Appointment lifecycle |
booking.received · booking.service_added | Online booking widget |
attachment.uploaded | A file is attached |
payment.received · .overdue · .failed | Payment lifecycle |
agreement.signed · .unsigned_reminder | Agreement lifecycle |
client.created | A client is created |
agent.created · .referral · .birthday · .no_referral | Agent 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.
{
"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 response | What happens next |
|---|---|
2xx | Delivered. No further attempts. |
4xx other than 408 and 429 | Permanent — 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, 429 | Retried on the schedule above. |
| Timeout, connection refused, DNS or TLS failure | Retried on the schedule above. |
3xx | Retried. 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.
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 #
| Header | Value |
|---|---|
X-InspectForge-Event | The event type, e.g. inspection.status.delivered. Always present. |
X-InspectForge-Delivery | GUID. Same across retries, distinct per subscription. Always present. |
X-InspectForge-Signature | t=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
- 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.
- Split the header value on
,; readt=andv1=. - Reject the delivery if
tis more than 300 seconds from your own clock. That is the replay window. - Compute
HMAC-SHA256(secret, t + "." + rawBody), hex, lowercase. - Compare with
v1— constant-time, if your platform offers it. - Then parse the body, dedupe on
X-InspectForge-Delivery, and return200promptly. Do the slow work afterwards; a slow200is a retry you did not need.
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.
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
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.
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.
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 #
| Check | Why it matters |
|---|---|
| Organisation time zone is set | Nothing else on this page matters as much. Settings → Organization. |
| Key stored in a Connection or secret store | Never in script text, never in source control. |
externalRef sent on every create | Workflow rules re-fire. This is what stops duplicates. |
| Signing secret stored, every callback verified | Captured at subscribe time — it is shown once. An unverified callback is an unauthenticated one. |
Callbacks deduped on X-InspectForge-Delivery | Retries carry the same id. This is what makes at-least-once safe. |
| Reconciliation job scheduled | Not just the webhook — delivery is at-least-once, and after about half an hour we stop retrying. |
| Failed calls log the response body | Our messages name the field at fault. Swallowing them wastes your time. |
| Tested with a real address from your market | Including unit, complex and suburb where they apply. |
Versioning & change policy #
- Additive changes — new endpoints, new optional request fields, new response fields — ship within
v1without notice. Your client must ignore unknown response fields. - Breaking changes ship as
/api/v2.v1is supported for at least 90 days afterv2is announced, and you will be told directly. /api/zapier/*is not a public surface. It exists for our Zapier app and may change without notice. Do not build against it.
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.