On this page
Quickstart
Three steps from nothing to a job on your board.
Make a key
In HailMate open Settings → Integrations → API & Webhooks and press New key. You need to be an owner or an admin of the workspace. Copy the key somewhere safe — it starts with
hm_live_and is shown once.Check it
GET /pingis the cheapest authenticated call. It answers with the workspace the key belongs to and whether the key can write.curl https://app.hailmate.ai/api/v1/ping \ -H "Authorization: Bearer $HAILMATE_API_KEY"const response = await fetch('https://app.hailmate.ai/api/v1/ping', { headers: { Authorization: `Bearer ${process.env.HAILMATE_API_KEY}`, }, }); const data = await response.json(); if (!response.ok) throw new Error(`${data.error.code}: ${data.error.message}`);import os import requests response = requests.get( "https://app.hailmate.ai/api/v1/ping", headers={ "Authorization": f"Bearer {os.environ['HAILMATE_API_KEY']}", }, ) response.raise_for_status() data = response.json()Response · 200{ "ok": true, "object": "workspace", "workspace_id": "5f0c2a8e-1b3d-4c7a-9e21-7a3b8d4c6e10", "workspace": "Ridgeline Roofing", "api_version": "v1", "key": { "name": "Zapier", "access": "write" } }Create a job
Only
nameandaddressare required.stagetakes a stage key or the label your board shows; leave it out and the job lands in the first column of your default pipeline. TheIdempotency-Keymakes the request safe to retry.curl -X POST https://app.hailmate.ai/api/v1/jobs \ -H "Authorization: Bearer $HAILMATE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "1804 Cedar Ridge Dr", "address": "1804 Cedar Ridge Dr", "city": "Plano", "state": "TX", "postal_code": "75024", "job_type": "insurance", "stage": "Inspection Scheduled", "homeowner_id": "b7c2d3e4-4444-4000-8000-0000000000dd", "lead_source": "Website", "insurance_company": "Example Mutual", "claim_number": "CLM-88213", "notes": "Homeowner reports hail on the back slope." }'import { randomUUID } from 'node:crypto'; const response = await fetch('https://app.hailmate.ai/api/v1/jobs', { method: 'POST', headers: { Authorization: `Bearer ${process.env.HAILMATE_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID(), }, body: JSON.stringify({ name: '1804 Cedar Ridge Dr', address: '1804 Cedar Ridge Dr', city: 'Plano', state: 'TX', postal_code: '75024', job_type: 'insurance', stage: 'Inspection Scheduled', homeowner_id: 'b7c2d3e4-4444-4000-8000-0000000000dd', lead_source: 'Website', insurance_company: 'Example Mutual', claim_number: 'CLM-88213', notes: 'Homeowner reports hail on the back slope.', }), }); const data = await response.json(); if (!response.ok) throw new Error(`${data.error.code}: ${data.error.message}`);import os import uuid import requests response = requests.post( "https://app.hailmate.ai/api/v1/jobs", headers={ "Authorization": f"Bearer {os.environ['HAILMATE_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "name": "1804 Cedar Ridge Dr", "address": "1804 Cedar Ridge Dr", "city": "Plano", "state": "TX", "postal_code": "75024", "job_type": "insurance", "stage": "Inspection Scheduled", "homeowner_id": "b7c2d3e4-4444-4000-8000-0000000000dd", "lead_source": "Website", "insurance_company": "Example Mutual", "claim_number": "CLM-88213", "notes": "Homeowner reports hail on the back slope.", }, ) response.raise_for_status() data = response.json()The answer is the new job, and it is on your pipeline board straight away. Next: subscribe to a webhook so HailMate tells you when it moves.
Authentication
Every request carries the key, either as a bearer token or in X-API-Key. Both work everywhere; use whichever your tool makes easier.
Authorization: Bearer hm_live_…
# or
X-API-Key: hm_live_…- A key belongs to one workspace and can only ever see that workspace’s records. If you run several workspaces on one plan, each needs its own key.
- The key is shown once. HailMate stores only a scrambled version, so nobody — us included — can read it back. Lost it? Revoke it and make another.
- Revoking a key takes effect at once, and switches off the webhooks that key created.
- There is no sandbox. To try things out without touching real jobs, use a separate workspace.
Full access and read-only keys
| Access | Can | Use it for |
|---|---|---|
| Full access | Read everything, create, change and delete records, and subscribe to webhooks. | Your own integrations, Zapier, Make and n8n. |
| Read only | Read everything and subscribe to webhooks. Changes nothing — a write answers 403 forbidden. | A reporting tool, a dashboard, an accountant. |
GET /ping tells you which one you are holding: key.access is write or read.
Log in with HailMate (OAuth 2.0)
An app that connects many HailMate companies — Zapier is the first — does not ask anybody for a key. It sends the person to HailMate, they pick a company and press Allow, and the app is connected. It is the standard OAuth 2.0 authorization-code flow:
- Send the person to
https://app.hailmate.ai/oauth/authorizewithresponse_type=code,client_id,redirect_uriandstate(addscope=readfor read-only). Only an owner or admin can press Allow. - They come back with
?code=…&state=…. Within ten minutes, trade the code atPOST /oauth/tokenfor an access token (hm_oat_…), and send it asAuthorization: Bearerexactly like a key. - An access token lasts an hour; trade the refresh token (
hm_ort_…) for a new one. The refresh token does not change. - The connection shows in Settings → API & Webhooks as a Connected app, and disconnecting it there stops it at once. Apps are registered by us — write to contact@hailmate.ai.
Requests and responses
Every path is under https://app.hailmate.ai/api/v1. Send and receive JSON (Content-Type: application/json); the one exception is a file upload, which may also be multipart/form-data.
- Every record carries
id(a UUID) andobject(job,contact,task…). Most carryurl, which opens that record in HailMate. - Timestamps such as
created_atare ISO 8601 in UTC. Dates such asdate_of_lossareYYYY-MM-DD. A task’sdue_dateis the exception — see wall-clock times. - A value HailMate doesn’t have is
nullrather than an empty string, and an empty list such astagsis[]. Money is a plain number of dollars. PATCHsends only what changes. A field sent asnullis cleared; a field left out is untouched.- Deleting a job or a contact puts it in Settings → Recently Deleted for 30 days, restorable with everything on it. A deleted task is gone.
Pagination and syncing
Every list is newest first and returns the same envelope. Pass next_cursor back as ?cursor= until it is null. limit is 1 to 200 (default 50).
{
"object": "list",
"data": [
{
"id": "f2b1a0c4-1111-4000-8000-0000000000aa",
"object": "job",
"job_number": "JOB-00142",
"stage": "claim_approved"
}
],
"has_more": true,
"next_cursor": "eyJjIjoiMjAyNi0wOS0xNlQxNDowMjowMC4wMDBaIiwiaSI6ImYyYjFhMGM0In0"
}- Don’t build a cursor yourself. It is opaque and ours to change; one we did not issue is a
400. - Every list takes
updated_since,created_afterandcreated_before(an ISO date or date-time). Store when you last synced and send it asupdated_sinceto pull only what moved. - The
/searchendpoints are not paged: they return the best matches, up tolimit.
async function* allJobs(filters = {}) {
let cursor = null;
do {
const query = new URLSearchParams({ limit: '200', ...filters });
if (cursor) query.set('cursor', cursor);
const response = await fetch(`https://app.hailmate.ai/api/v1/jobs?${query}`, {
headers: { Authorization: `Bearer ${process.env.HAILMATE_API_KEY}` },
});
const page = await response.json();
if (!response.ok) throw new Error(`${page.error.code}: ${page.error.message}`);
yield* page.data;
cursor = page.next_cursor; // null on the last page
} while (cursor);
}
// Everything that changed since your last sync.
for await (const job of allJobs({ updated_since: '2026-09-01T00:00:00Z' })) {
console.log(job.job_number, job.stage_label, job.balance_due);
}import os
import requests
def all_jobs(**filters):
params = {"limit": 200, **filters}
while True:
response = requests.get(
"https://app.hailmate.ai/api/v1/jobs",
headers={"Authorization": f"Bearer {os.environ['HAILMATE_API_KEY']}"},
params=params,
)
response.raise_for_status()
page = response.json()
yield from page["data"]
if not page["next_cursor"]: # None on the last page
break
params["cursor"] = page["next_cursor"]
# Everything that changed since your last sync.
for job in all_jobs(updated_since="2026-09-01T00:00:00Z"):
print(job["job_number"], job["stage_label"], job["balance_due"])Rather be told than ask?
updated_since is fine, but a webhook reaches you within a second or two of the change and costs none of your rate limit.Idempotency
Networks drop answers. Send Idempotency-Key: <any unique string> on a POST, PATCH or DELETE and a retry with the same key replays the first answer instead of doing the work twice — no second job, no second payment.
- Keys are remembered for 24 hours. A UUID is ideal; so is the id of the thing that caused the request, such as a form submission.
- A replayed answer carries
Idempotent-Replayed: true. - Reusing a key for a different request is a
409 conflict. So is a retry that arrives while the first request is still running — wait a moment and try again.
Rate limits
120 requests a minute per key. Hail history lookups are also limited to 20 a minute per key, inside that. Every response says where you stand:
HTTP/1.1 200 OK
X-Request-Id: req_4c1n8y2m0q7p3x5z9a1b
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 42X-RateLimit-Reset is the number of seconds until the window resets. Past the limit the answer is 429 rate_limited with a Retry-After header in seconds — wait that long, then retry.
async function hailmate(path, options = {}, attempt = 1) {
const response = await fetch(`https://app.hailmate.ai/api/v1${path}`, {
...options,
headers: { Authorization: `Bearer ${process.env.HAILMATE_API_KEY}`, ...options.headers },
});
if (response.status === 429 && attempt < 5) {
const wait = Number(response.headers.get('Retry-After') ?? 1);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
return hailmate(path, options, attempt + 1);
}
return response;
}Errors
Every error has the same shape, whatever went wrong. Branch on code — message is written for a person and may be reworded. field names the request field that was wrong, when there was one.
{
"error": {
"code": "invalid_request",
"message": "No stage called \"Aproved\" on this workspace's boards. Use a key or a label from GET /v1/stages.",
"field": "stage",
"request_id": "req_4c1n8y2m0q7p3x5z9a1b",
"doc_url": "https://hailmate.ai/docs/api#errors"
}
}| Code | HTTP | What it means |
|---|---|---|
invalid_request | 400 | Something in the request was wrong. field names what. |
unauthorized | 401 | The key is missing, wrong or revoked. |
plan_required | 402 | The workspace has no active subscription. |
forbidden | 403 | A read-only key tried to change something. |
not_found | 404 | No such record in this workspace. |
method_not_allowed | 405 | Wrong verb for that path. |
conflict | 409 | An Idempotency-Key was reused for a different request, or the first request with it is still running. |
payload_too_large | 413 | Files are capped at 25 MB. |
rate_limited | 429 | Too many requests. Wait Retry-After seconds, then retry. |
server_error | 500 | Our fault. Retry shortly, and quote the request_id if it keeps happening. |
A 5xx, a 429 or a dropped connection is worth retrying — with an Idempotency-Key on writes. A 4xx other than 429 will fail the same way again until the request changes.
Request IDs and the request log
Every response carries X-Request-Id, and every error body repeats it as request_id. Log it — it is the fastest way for us to find one request among millions.
Owners and admins can see every call a key made — its path, status and the error if there was one — under Settings → Integrations → API & Webhooks → Requests, for 14 days.
How HailMate data works
A few things that save an integration from its most common mistakes.
Stages have a key and a label
Every workspace names its own board. GET /stages gives each stage’s key (what you filter on) and label (what a person reads). Every job carries both, plus stage_is_completed and stage_is_lost. Anywhere you send a stage you may use either, so {"stage": "Claim Approved"} works. Moving a job with PATCH /jobs/{id} runs your stage automations and fires job.stage_changed, exactly as dragging the card does.
Money matches the Money tab
Every job carries total_job_value, amount_received and balance_due — the same figures its Money tab shows. An estimate’s total is the package the homeowner chose (else the base package), computed the way the PDF computes it; package_totals lists every package. A refund is its own payment with a negative amount, so summing amount gives the net. A payment applies to an invoice only when you name one with invoice_id — HailMate never guesses which invoice a cheque pays.
Phone numbers match however they were typed
GET /contacts/search?phone= treats +16155550148, 615-555-0148 and (615) 555-0148 as the same number. Send whatever format you have. US numbers you send are stored as (615) 555-0148, the way your team types them.
Appointment times are wall-clock
An appointment is a task with appointment_type set to inspection, adjuster_meeting or build_day. Its due_date is the local time your crew will read, with no offset — 2026-10-02T15:00:00. If you send one with an offset (2026-10-02T15:00:00-05:00, which is what Zapier sends), the offset is dropped and 3:00 PM is kept. HailMate never shifts an appointment by a time zone.
An empty search finds nothing
/jobs/search and /contacts/search with no criteria return an empty list — never “the newest records”, which is how a blank search step ends up writing to the wrong job.
What the API deliberately cannot do
- Set
sms_consent. No integration can establish that a homeowner agreed to be texted. - Create an estimate or an invoice. Both are documents with pricing and a signing or payment flow, built in HailMate. You can read both, and be told when they are sent, viewed, signed or paid.
- Build a storm list. That spends data credits and happens in HailMate; the API reads the result, and
storm_list.readytells you when it is done.
Versioning
The version is in the path: /v1. Within it we only ever add — new endpoints, new fields, new events, new values. We don’t rename or remove a field, or change what one means, inside a version.
- Ignore fields you don’t recognise rather than failing on them.
- Treat enums as open: handle a value you haven’t seen, such as a new knock result, gracefully.
- Watch the changelog for what’s new.
OpenAPI document
The whole API — every path, parameter, object and webhook — is described in one OpenAPI 3.1 document. The reference on this site is rendered from it. Import it into Postman, Insomnia or a code generator.
Getting help
Email contact@hailmate.ai or use the Help button in the app, and quote the request_id of the call that went wrong.