Webhooks

HailMate calls you

Subscribe a URL to an event and HailMate posts it a signed JSON body — usually within a second or two of the change. A job moving stage, an estimate signed, money received, a door knocked: 30 events in all, each one filterable, retried when your endpoint is down.

How it works

  1. You subscribe an HTTPS URL to one event, several, or all of them — through the API or in HailMate’s Settings.
  2. 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.
  3. HailMate POSTs the record to your URL, signed with your subscription’s secret, and waits up to 10 seconds for a 2xx.
  4. 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"
  }
}'
Response · 201
{
  "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.

Several events
{
  "events": [
    "estimate.signed",
    "invoice.paid",
    "payment.received"
  ],
  "target_url": "https://yourapp.example.com/hooks/hailmate"
}
Every event
{
  "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 /webhooks lists 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 /events lists what every event can be filtered on.

Filter keys

  • stageA stage key or its label ("Claim Approved"). On job.stage_changed it fires only when a job enters that stage.
  • previous_stageA stage key or label the job just left.
  • job_typeOne of:
    insuranceretail
  • pipeline_idA pipeline id from GET /pipelines.
  • assigned_toA teammate’s user id from GET /users.
  • typeA contact type:
    homeownercommercialadjustercontractorsubcontractorinstall_crewinspectormortgage_companyother
  • appointment_typeOne of:
    inspectionadjuster_meetingbuild_day
  • is_appointmenttrue for appointments only.
  • payment_typeOne of:
    acvdeductiblesupplementdepreciationretailother
  • payer_typeOne of:
    insurancehomeownermortgage_companyother
  • payment_methodOne of:
    checkcashcardachfinancingother
  • categoryOne of:
    photodocumentscopeestimatecontractother
  • knock_resultThe pin’s knock result:
    interestednot_interestedcontactedno_answerdoor_hangerfollow_upappointment_scheduleddont_knockrenterno_damagecash_quotelost
  • previous_knock_resultThe knock result it had before:
    interestednot_interestedcontactedno_answerdoor_hangerfollow_upappointment_scheduleddont_knockrenterno_damagecash_quotelost
  • pin_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.createdJob

    A job was created

    Filter onstagejob_typepipeline_id
  • job.updatedJob

    A job changed (changed_fields says what) — Several quick changes to one job arrive as one delivery carrying every changed field.

    Filter onjob_type
  • job.stage_changedJob

    A job moved stage (previous_stage says from where)

    Filter onstageprevious_stagejob_typepipeline_id
  • job.assignedJob

    A job got a new primary assignee

    Filter onassigned_to
  • job.deletedDeletedSnapshot

    A job was deleted

Contacts

  • contact.createdContact

    A contact was added

    Filter ontype
  • contact.updatedContact

    A contact changed

    Filter ontype
  • contact.deletedDeletedSnapshot

    A contact was deleted

    Filter ontype

Tasks

  • task.createdTask

    A task or appointment was created

    Filter onassigned_toappointment_typeis_appointment
  • task.updatedTask

    A task changed

    Filter onassigned_toappointment_typeis_appointment
  • task.completedTask

    A task was marked done

    Filter onassigned_toappointment_typeis_appointment
  • task.deletedDeletedSnapshot

    A task was deleted

    Filter onassigned_toappointment_typeis_appointment

Appointments

  • appointment.createdTask

    An appointment was scheduled

    Filter onassigned_toappointment_type

Estimates

  • estimate.createdEstimate

    An estimate was created

  • estimate.sentEstimate

    An estimate went out

  • estimate.viewedEstimate

    The homeowner opened an estimate for the first time

  • estimate.signedEstimate

    A homeowner signed an estimate

  • estimate.declinedEstimate

    A homeowner declined an estimate

Invoices

  • invoice.createdInvoice

    An invoice was created

  • invoice.sentInvoice

    An invoice was sent

  • invoice.paidInvoice

    An invoice was paid in full

  • invoice.overdueInvoice

    An invoice went past due

  • invoice.voidedInvoice

    An invoice was voided

Payments

  • payment.receivedPayment

    Money came in on a job

    Filter onpayment_typepayer_typepayment_method
  • payment.refundedPayment

    A payment was refunded (negative amount)

    Filter onpayment_typepayer_typepayment_method

Notes

  • note.createdNote

    A note was added to a job

Photos & files

  • file.createdFile

    A photo or document was added to a job

    Filter oncategory

Canvassing

  • pin.createdPin

    A rep dropped a pin on the canvassing map

    Filter onknock_resultpin_type
  • pin.result_changedPin

    A pin got a new knock result

    Filter onknock_resultprevious_knock_resultpin_type

Storm lists

  • storm_list.readyStormList

    A 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:

job.stage_changed
{
  "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_typestring
  • event_idstringuuid

    Also the X-HailMate-Delivery header.

  • event_atstringdate-time

    When the change happened.

  • event_workspace_idstringuuid
  • event_testboolean

    Present and true on a test send from Settings.

  • previous_stagestring | null

    job.stage_changed only.

  • previous_stage_labelstring | null

    job.stage_changed only.

  • changed_fieldsarray of string

    *.updated only — the public field names that changed.

  • previous_assigned_to_idstring | null

    job.assigned only.

  • previous_knock_resultstring | null

    pin.result_changed only.

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.

job.updated (trimmed)
{
  "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.)

job.deleted
{
  "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 as X-HailMate-Delivery).
  • A retried delivery can arrive after a newer one. Before overwriting your copy, compare event_at — or the record’s updated_at — with what you already have.
  • Need more than the delivery carries? Fetch it: the record’s id is at the top level, and the API takes it from there.

Delivery headers

HeaderExampleWhat it is
X-HailMate-Signaturet=1757000000,v1=5257a8…When we signed it, and one v1 HMAC per active secret. Verify this.
X-HailMate-Eventjob.stage_changedThe event, also in the body as event_type.
X-HailMate-Delivery2cb27b8c-…The event id, also event_id. Dedupe on it.
X-HailMate-Webhook-Id0f8c8c1b-…The subscription it was sent for.
X-HailMate-Attempt1Which try this is: 1 for the first, up to 11 after ten retries.
Content-Typeapplication/jsonAlways JSON.
User-AgentHailMate-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:

Header
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:

  1. 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.
  2. Compute HMAC-SHA256(secret, t + "." + raw body) as hex.
  3. 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 two v1 values arrive.
  4. Reject a t more 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);

Signature failing?

It is almost always the body. Frameworks parse JSON before your handler runs — use the raw body (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 2xx within 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 Gone to unsubscribe. It is how Zapier tells us a Zap was deleted.

Retries and switching off

Your endpoint answersWhat HailMate does
2xxDone. The delivery is settled.
410 GoneSettles the delivery and deletes the subscription.
5xx, 408, 429, a timeout, a redirect or a network errorTries 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 4xxStops 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"

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.

Webhook endpoints in the reference