On this page
How it works
- You subscribe an HTTPS URL to one event, several, or all of them — through the API or in HailMate’s Settings.
- Something happens in HailMate — in the app, on a phone in the field, or through the API. It doesn’t matter which: the event is raised by the database, so every path fires it.
- HailMate
POSTs the record to your URL, signed with your subscription’s secret, and waits up to 10 seconds for a2xx. - If your endpoint doesn’t answer, HailMate retries — up to ten more times over about 14½ hours — and shows every try in Settings.
Subscribing
POST /webhooks with the event and your URL. Any key can subscribe, read-only keys included.
curl -X POST https://app.hailmate.ai/api/v1/webhooks \
-H "Authorization: Bearer $HAILMATE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"event": "job.stage_changed",
"target_url": "https://yourapp.example.com/hooks/hailmate",
"filters": {
"stage": "claim_approved"
}
}'import { randomUUID } from 'node:crypto';
const response = await fetch('https://app.hailmate.ai/api/v1/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HAILMATE_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
event: 'job.stage_changed',
target_url: 'https://yourapp.example.com/hooks/hailmate',
filters: {
stage: 'claim_approved',
},
}),
});
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/webhooks",
headers={
"Authorization": f"Bearer {os.environ['HAILMATE_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"event": "job.stage_changed",
"target_url": "https://yourapp.example.com/hooks/hailmate",
"filters": {
"stage": "claim_approved",
},
},
)
response.raise_for_status()
data = response.json(){
"id": "0f8c8c1b-dddd-4000-8000-000000000022",
"object": "webhook",
"event": "job.stage_changed",
"target_url": "https://yourapp.example.com/hooks/hailmate",
"description": null,
"filters": {
"stage": "claim_approved"
},
"created_at": "2026-09-16T14:02:00.000Z",
"disabled_at": null,
"secret": "whsec_3a2b1c5f…"
}Keep the secret
secret is returned once. It is how you check a delivery really came from HailMate. Owners and admins can read it again, and rotate it, in Settings.Several events, or all of them
Send events to point several events at one URL. They share one secret, and the answer is a list with the secret beside it. Send "event": "*" for every event — including ones we add later.
{
"events": [
"estimate.signed",
"invoice.paid",
"payment.received"
],
"target_url": "https://yourapp.example.com/hooks/hailmate"
}{
"event": "*",
"target_url": "https://yourapp.example.com/hooks/hailmate"
}The rules
- The URL must be
https, publicly reachable, and not a private or link-local address. - Subscribing the same event, URL and filters twice returns the existing subscription.
- A key sees only the subscriptions it created;
GET /webhookslists them. - You can also add a webhook without writing code, in Settings → Integrations → API & Webhooks.
Filters
filters narrows a subscription so it fires only when it should. {"stage": "Claim Approved"} on job.stage_changed fires only when a job enters Claim Approved — not on every stage change.
- Filters work on a single-event subscription. A subscription to several events, or to every event, can’t be filtered.
- Each key takes one value. Several keys must all match.
- A stage filter takes a key or a label; the answer shows the key it was matched to.
GET /eventslists what every event can be filtered on.
Filter keys
stageA stage key or its label ("Claim Approved"). Onjob.stage_changedit fires only when a job enters that stage.previous_stageA stage key or label the job just left.job_typeOne of:insuranceretailpipeline_idA pipeline id fromGET /pipelines.assigned_toA teammate’s user id fromGET /users.typeA contact type:homeownercommercialadjustercontractorsubcontractorinstall_crewinspectormortgage_companyotherappointment_typeOne of:inspectionadjuster_meetingbuild_dayis_appointmenttruefor appointments only.payment_typeOne of:acvdeductiblesupplementdepreciationretailotherpayer_typeOne of:insurancehomeownermortgage_companyotherpayment_methodOne of:checkcashcardachfinancingothercategoryOne of:photodocumentscopeestimatecontractotherknock_resultThe pin’s knock result:interestednot_interestedcontactedno_answerdoor_hangerfollow_upappointment_scheduleddont_knockrenterno_damagecash_quotelostprevious_knock_resultThe knock result it had before:interestednot_interestedcontactedno_answerdoor_hangerfollow_upappointment_scheduleddont_knockrenterno_damagecash_quotelostpin_typeOne of:knockinspectionjob
The 30 events
Every delivery carries the record at its top level — the same fields GET of that record returns — so the object linked beside each event is exactly what arrives.
Jobs
job.createdJobA job was created
Filter onstagejob_typepipeline_idjob.updatedJobA job changed (
changed_fieldssays what) — Several quick changes to one job arrive as one delivery carrying every changed field.Filter onjob_typejob.stage_changedJobA job moved stage (
previous_stagesays from where)Filter onstageprevious_stagejob_typepipeline_idjob.assignedJobA job got a new primary assignee
Filter onassigned_tojob.deletedDeletedSnapshotA job was deleted
Contacts
contact.createdContactA contact was added
Filter ontypecontact.updatedContactA contact changed
Filter ontypecontact.deletedDeletedSnapshotA contact was deleted
Filter ontype
Tasks
task.createdTaskA task or appointment was created
Filter onassigned_toappointment_typeis_appointmenttask.updatedTaskA task changed
Filter onassigned_toappointment_typeis_appointmenttask.completedTaskA task was marked done
Filter onassigned_toappointment_typeis_appointmenttask.deletedDeletedSnapshotA task was deleted
Filter onassigned_toappointment_typeis_appointment
Appointments
appointment.createdTaskAn appointment was scheduled
Filter onassigned_toappointment_type
Estimates
Invoices
Payments
Notes
note.createdNoteA note was added to a job
Photos & files
file.createdFileA photo or document was added to a job
Filter oncategory
Canvassing
Storm lists
storm_list.readyStormListA storm list finished building
What a delivery looks like
A POST with a flat JSON body: the record’s own fields at the top level, exactly as GET returns them, plus the event_* fields. Here is a job moving to Claim Approved:
{
"event_type": "job.stage_changed",
"event_id": "2cb27b8c-9e41-4c3a-8a52-5b1f0d6e7a90",
"event_at": "2026-09-16T14:02:00.000Z",
"event_workspace_id": "5f0c2a8e-1b3d-4c7a-9e21-7a3b8d4c6e10",
"previous_stage": "adjuster_scheduled",
"previous_stage_label": "Adjuster Scheduled",
"id": "f2b1a0c4-1111-4000-8000-0000000000aa",
"object": "job",
"job_number": "JOB-00142",
"name": "1804 Cedar Ridge Dr",
"job_type": "insurance",
"stage": "claim_approved",
"stage_label": "Claim Approved",
"stage_is_completed": false,
"stage_is_lost": false,
"stage_entered_at": "2026-09-16T14:02:00.000Z",
"pipeline_id": "1cf7d87b-7d99-4ba0-b277-0e88ac127e75",
"pipeline_name": "Insurance",
"address": "1804 Cedar Ridge Dr",
"city": "Plano",
"state": "TX",
"postal_code": "75024",
"county": "Collin",
"homeowner_id": "b7c2d3e4-4444-4000-8000-0000000000dd",
"homeowner": {
"id": "b7c2d3e4-4444-4000-8000-0000000000dd",
"name": "Dana Reed",
"email": "dana@example.com",
"phone": "(972) 555-0148"
},
"adjuster_id": null,
"adjuster": null,
"secondary_contact_ids": [],
"secondary_adjuster_ids": [],
"assigned_to_id": "5d0e1c2b-7777-4000-8000-0000000000ab",
"assigned_to_name": "Sam Carter",
"assignee_ids": [
"5d0e1c2b-7777-4000-8000-0000000000ab"
],
"assignee_names": [
"Sam Carter"
],
"insurance_company": "Example Mutual",
"claim_number": "CLM-88213",
"policy_number": null,
"date_of_loss": "2026-09-12",
"damage_types": [
"hail"
],
"tags": [],
"lead_source": "Website",
"priority": "normal",
"mortgage_company": null,
"financing_method": null,
"rcv_amount": 24380.5,
"acv_amount": 19240,
"deductible": 2500,
"supplements_amount": null,
"estimated_amount": null,
"final_amount": null,
"total_job_value": 24380.5,
"amount_received": 0,
"balance_due": 24380.5,
"contract_date": null,
"installation_date": null,
"archived": false,
"completed_at": null,
"lost_at": null,
"lost_reason": null,
"created_at": "2026-09-16T14:02:00.000Z",
"updated_at": "2026-09-16T14:02:00.000Z",
"url": "https://app.hailmate.ai/job/f2b1a0c4-1111-4000-8000-0000000000aa",
"portal_url": "https://app.hailmate.ai/project/0f0f0f0f-3333-4000-8000-0000000000cc"
}The envelope
event_typestringevent_idstringuuidAlso the X-HailMate-Delivery header.
event_atstringdate-timeWhen the change happened.
event_workspace_idstringuuidevent_testbooleanPresent and true on a test send from Settings.
previous_stagestring | nulljob.stage_changedonly.previous_stage_labelstring | nulljob.stage_changedonly.changed_fieldsarray of string*.updatedonly — the public field names that changed.previous_assigned_to_idstring | nulljob.assignedonly.previous_knock_resultstring | nullpin.result_changedonly.
Updates name what changed
A *.updated delivery carries changed_fields. Several quick edits to one record — a rep fixing the RCV and reassigning the job in quick succession — arrive as one delivery listing every changed field, holding the record as it is when we send it.
{
"event_type": "job.updated",
"event_id": "2cb27b8c-9e41-4c3a-8a52-5b1f0d6e7a90",
"event_at": "2026-09-16T14:02:00.000Z",
"event_workspace_id": "5f0c2a8e-1b3d-4c7a-9e21-7a3b8d4c6e10",
"changed_fields": [
"rcv_amount",
"assigned_to_id",
"assigned_to_name"
],
"id": "f2b1a0c4-1111-4000-8000-0000000000aa",
"object": "job",
"job_number": "JOB-00142",
"name": "1804 Cedar Ridge Dr",
"stage": "inspection_scheduled",
"stage_label": "Inspection Scheduled",
"rcv_amount": 24380.5,
"assigned_to_id": "5d0e1c2b-7777-4000-8000-0000000000ab",
"assigned_to_name": "Sam Carter",
"updated_at": "2026-09-16T14:02:00.000Z",
"url": "https://app.hailmate.ai/job/f2b1a0c4-1111-4000-8000-0000000000aa"
}Deletes carry what identifies the record
A *.deleted delivery can’t carry the record — it is gone — so it carries its id, "deleted": true and the fields that identify your copy: a job’s number, name and address; a contact’s name, email and phone; a task’s title and job. (A deleted job or contact can still be restored in HailMate for 30 days.)
{
"event_type": "job.deleted",
"event_id": "2cb27b8c-9e41-4c3a-8a52-5b1f0d6e7a90",
"event_at": "2026-09-16T14:02:00.000Z",
"event_workspace_id": "5f0c2a8e-1b3d-4c7a-9e21-7a3b8d4c6e10",
"id": "f2b1a0c4-1111-4000-8000-0000000000aa",
"object": "job",
"deleted": true,
"job_number": "JOB-00142",
"name": "1804 Cedar Ridge Dr",
"address": "1804 Cedar Ridge Dr"
}Dedupe, and ordering
- Dedupe on
event_id. A retry or a resend carries the same one (also sent asX-HailMate-Delivery). - A retried delivery can arrive after a newer one. Before overwriting your copy, compare
event_at— or the record’supdated_at— with what you already have. - Need more than the delivery carries? Fetch it: the record’s
idis at the top level, and the API takes it from there.
Delivery headers
| Header | Example | What it is |
|---|---|---|
X-HailMate-Signature | t=1757000000,v1=5257a8… | When we signed it, and one v1 HMAC per active secret. Verify this. |
X-HailMate-Event | job.stage_changed | The event, also in the body as event_type. |
X-HailMate-Delivery | 2cb27b8c-… | The event id, also event_id. Dedupe on it. |
X-HailMate-Webhook-Id | 0f8c8c1b-… | The subscription it was sent for. |
X-HailMate-Attempt | 1 | Which try this is: 1 for the first, up to 11 after ten retries. |
Content-Type | application/json | Always JSON. |
User-Agent | HailMate-Webhooks/2.0 (+https://hailmate.ai/docs/api/webhooks) | If your firewall allows by user agent. |
Verifying signatures
Anyone can post to a public URL. The signature proves a delivery came from HailMate and wasn’t changed on the way. Every delivery carries:
X-HailMate-Signature: t=1757000000,v1=5257a869e7ec…t is when we signed it (Unix seconds). Each v1 is a hex HMAC-SHA256 of "<t>.<raw body>" keyed with your secret. To verify:
- Read the raw request body, byte for byte. Not a re-serialised version of the parsed JSON — key order and spacing change, and the signature no longer matches.
- Compute
HMAC-SHA256(secret, t + "." + raw body)as hex. - Accept the delivery if it matches any
v1. While you rotate a secret, the new and the old one both sign for 24 hours, so twov1values arrive. - Reject a
tmore than five minutes from now: it is a replay. Compare in constant time.
import crypto from 'node:crypto';
import express from 'express';
function verifyHailMateSignature(rawBody, header, secret, toleranceSeconds = 300) {
const pairs = String(header || '')
.split(',')
.map((piece) => piece.trim().split('='));
const timestamp = pairs.find(([key]) => key === 't')?.[1];
const signatures = pairs.filter(([key]) => key === 'v1').map(([, value]) => value);
if (!timestamp || !signatures.length) return false;
// Refuse anything signed more than five minutes ago: a replayed delivery.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody) // the RAW bytes, exactly as they arrived
.digest('hex');
// While you rotate a secret, two v1 values arrive. Accept either.
return signatures.some(
(signature) =>
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)),
);
}
const app = express();
// express.raw keeps the body as bytes. express.json() would parse it first,
// and a re-serialised body no longer matches the signature.
app.post('/hooks/hailmate', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyHailMateSignature(
req.body,
req.get('X-HailMate-Signature'),
process.env.HAILMATE_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).send('Invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// Dedupe on event.event_id, then hand the work to a queue and answer fast.
console.log(event.event_type, event.id);
res.sendStatus(200);
});
app.listen(3000);import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
def verify_hailmate_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
pairs = [piece.strip().split("=", 1) for piece in (header or "").split(",")]
pairs = [pair for pair in pairs if len(pair) == 2]
timestamp = next((value for key, value in pairs if key == "t"), None)
signatures = [value for key, value in pairs if key == "v1"]
if not timestamp or not signatures:
return False
# Refuse anything signed more than five minutes ago: a replayed delivery.
try:
if abs(time.time() - int(timestamp)) > tolerance:
return False
except ValueError:
return False
signed = timestamp.encode() + b"." + raw_body # the RAW bytes, exactly as they arrived
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# While you rotate a secret, two v1 values arrive. Accept either.
return any(hmac.compare_digest(expected, signature) for signature in signatures)
app = Flask(__name__)
@app.post("/hooks/hailmate")
def hailmate_webhook():
raw_body = request.get_data() # before anything parses it
header = request.headers.get("X-HailMate-Signature", "")
if not verify_hailmate_signature(raw_body, header, os.environ["HAILMATE_WEBHOOK_SECRET"]):
abort(400)
event = request.get_json()
# Dedupe on event["event_id"], then hand the work to a queue and answer fast.
print(event["event_type"], event["id"])
return "", 200<?php
function verify_hailmate_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $piece) {
$pair = explode('=', trim($piece), 2);
if (count($pair) !== 2) {
continue;
}
if ($pair[0] === 't') {
$timestamp = $pair[1];
} elseif ($pair[0] === 'v1') {
$signatures[] = $pair[1];
}
}
if ($timestamp === null || !ctype_digit($timestamp) || count($signatures) === 0) {
return false;
}
// Refuse anything signed more than five minutes ago: a replayed delivery.
if (abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
// The RAW body, exactly as it arrived.
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
// While you rotate a secret, two v1 values arrive. Accept either.
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_HAILMATE_SIGNATURE'] ?? '';
if (!verify_hailmate_signature($rawBody, $header, (string) getenv('HAILMATE_WEBHOOK_SECRET'))) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($rawBody, true);
// Dedupe on $event['event_id'], then hand the work to a queue and answer fast.
http_response_code(200);Signature failing?
express.raw, Flask’s request.get_data(), PHP’s php://input). Next, check you are using this subscription’s secret: several events at one URL share one, but two subscriptions don’t.Responding
- Answer
2xxwithin 10 seconds. Put the work on a queue and answer first; a slow answer is a failed delivery, and it will be sent again. - The body of your answer is ignored. Only the status matters.
- Redirects are not followed — point the subscription at the final URL.
- Answer
410 Goneto unsubscribe. It is how Zapier tells us a Zap was deleted.
Retries and switching off
| Your endpoint answers | What HailMate does |
|---|---|
2xx | Done. The delivery is settled. |
410 Gone | Settles the delivery and deletes the subscription. |
5xx, 408, 429, a timeout, a redirect or a network error | Tries again — waiting a minute before the second attempt and doubling the wait each time, capped at six hours — up to ten retries, the last about 14½ hours after the first try. |
Any other 4xx | Stops that delivery: retrying an identical body against an endpoint that refused it is only noise. It stays in the delivery log with a Resend button. |
After 50 failures in a row the subscription is switched off, and the workspace’s owners and admins are told. Turning it back on is one tap in Settings. Any success resets the count.
Managing webhooks in HailMate
Owners and admins manage everything under Settings → Integrations → API & Webhooks:
- Add a webhook without writing code — pick the event, filters and URL.
- The delivery log: every delivery, every attempt, and what your endpoint answered.
- Resend any delivery. It carries the same
event_id, so your dedupe still works. - Send test posts a delivery to your URL, signed, with
"event_test": true— the way to check your endpoint and your signature code before anything real happens. - Rotate the secret. The old one keeps signing beside the new one for 24 hours, so you can deploy the new secret without dropping a delivery.
- Switch a subscription back on after it was turned off for failing.
Sample payloads
Map fields before anything has happened: GET /webhooks/samples/{event} returns up to three real recent records from your workspace, wrapped exactly as a delivery of that event wraps them.
curl https://app.hailmate.ai/api/v1/webhooks/samples/job.created \
-H "Authorization: Bearer $HAILMATE_API_KEY"const response = await fetch('https://app.hailmate.ai/api/v1/webhooks/samples/job.created', {
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/webhooks/samples/job.created",
headers={
"Authorization": f"Bearer {os.environ['HAILMATE_API_KEY']}",
},
)
response.raise_for_status()
data = response.json()Unsubscribing
DELETE /webhooks/{id}, answer a delivery with 410 Gone, or delete it in Settings. Revoking an API key also switches off every webhook that key created.
Keep reading