FixGrid posts a signed JSON body to your endpoint when something happens that another system would want to know about. Events are queued when they happen. The first attempt runs on an hourly sweep. Retries are 1 · 2 · 4 · 8 · 24 hours after that.
The events
ticket.createdA maintenance ticket was opened, by staff, by a resident, by an inspection that failed a row, or via POST /tickets.live
ticket.status_changedA ticket moved. change carries the old and new value, so you never diff to find out what moved.live
turn.status_changedA unit with a turn changed status. data is the turn — the same object GET /turns/{turn_id} returns; change carries unit_status with the unit's old and new status.live
vendor.holdA vendor was placed on hold, usually because insurance lapsed. Stop dispatching them.live
turn.schedule_changedA turn's projected ready date moved. data is the same turn object; change carries projected_ready_on with the old and new date, either of which may be null.live
ticket.staleA ticket goes stale — a resident-filed ticket has gone quiet past the company's threshold, and fires again while the silence continues. data is the ticket, the same object GET /tickets/{ticket_id} returns; change is null.live
property.notice.publishedA property notice is published from the broadcast composer. Card-only: data is a small inline dict, not an object, and there is no notice to fetch on this API; change is null.live
turn.task.staleA make-ready task goes stale — still open the morning after its scheduled date, once per task. Card-only: data is a small inline dict with no /api/v1 read entity behind it; change is null.live
pm.staleA PM task goes stale — still open the morning after its due date, once per task. Card-only: data is a small inline dict with no /api/v1 read entity behind it; change is null.live
inspection.staleAn inspection goes stale — still open the morning after its scheduled date, once per inspection. data is the inspection, the same object GET /inspections/{inspection_id} returns; change is null.live
Three keys carry a small inline dict, not an object
property.notice.published sends subject · property · unit · url · created_at · when. turn.task.stale sends task_type · property · unit · url. pm.stale sends task_name · property · unit · due_date · url. On all three, property and unit are plain strings — the property's name, and a unit or area label, a count such as 3 units on a notice, or property-wide — never the nested {id, name, external_id} objects the other events carry, and there is no schema_version, entity_type or entity_id inside data. There is nothing to fetch afterwards: no notice, make-ready task or PM task is readable on /api/v1. url is the absolute link that opens the record for a person. The other seven keys carry the full object.
The envelope
Every event, whatever fired it, arrives in the same seven keys in the same order.
json · ticket.status_changed
{
"schema_version": 1,
"event_id": "evt_9dT4rKpQ2m",
"event": "ticket.status_changed",
"occurred_at": "2026-09-05T14:22:07Z",
"company_id": 27,
"data": { /* the same ticket object GET /tickets/{id} returns */ },
"change": { "field": "status", "from": "Open", "to": "In Progress" }
}
KeyTypeNotes
event_idstringUse it to deduplicate. A retry carries the same event_id as the attempt that failed, so “have I already processed this?” is one lookup.
eventstringThe key from the table above. Ignore keys you do not recognise rather than erroring — new events are additive and are not a breaking change.
occurred_atstringISO 8601 UTC — when it happened, not when it was delivered. These can differ by up to an hour.
dataobjectThe full object, identical in shape to the matching GET — one parser for both paths. On the three card-only keys (property.notice.published, turn.task.stale, pm.stale) it is the small inline dict described above instead.
changeobject|nullfield · from · to on the events that describe a transition; null on the ones that describe a creation, and on all five stale / published events.
The turn payloads
Receive turn.status_changed or turn.schedule_changed, read data.entity_id, then GET /turns/{that id} — the same object. entity_type is turn.
turn.schedule_changed carries the same data. Its change is { "field": "projected_ready_on", "from": "2026-09-19", "to": "2026-09-24" }.
Verifying the signature
Every delivery carries a FixGrid-Signature header. It holds the timestamp we signed at and an HMAC-SHA256 of <timestamp>.<body> under your subscription's signing secret.
http
FixGrid-Signature: t=1789012927,v1=6c1f...a93b
Sign the raw bytes, not your parsed body
The HMAC is computed over the exact bytes we sent. If your framework parses JSON and you re-serialise it to verify, key order and whitespace change and every signature fails. Capture the raw request body before anything touches it. This is the one mistake that costs an afternoon.
Python
python
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if"="in p)
t, v1 = parts.get("t"), parts.get("v1")
ifnot t ornot v1:
returnFalseifabs(time.time() - int(t)) > tolerance: # 5 minutesreturnFalse
expected = hmac.new(
secret.encode(), t.encode() + b"." + body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(v1, expected)
Node
javascript
const crypto = require('crypto');
function verify(secret, header, body, tolerance = 300) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('='))
);
const { t, v1 } = parts;
if (!t || !v1) returnfalse;
if (Math.abs(Date.now() / 1000 - Number(t)) > tolerance) returnfalse;
const expected = crypto
.createHmac('sha256', secret)
.update(t + '.')
.update(body) // body is a Buffer, not a string
.digest('hex');
return v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
Compare in constant time, and reject anything older than five minutes — the timestamp is what stops a captured body being replayed at you later.
Delivery, retries, and giving up
First attempt
Hourly sweep
Queued when it happens. First send is on the hourly rider, not within seconds.
Retries
1 · 2 · 4 · 8 · 24 h
Five attempts after the first, then the delivery is parked.
Kept
30 days
Delivery records, including parked ones, then swept.
Any 2xx is success. Answer fast and do your work afterwards — a slow 200 is treated as a timeout.
A 3xx is not followed. Give us the final URL, not a redirect to it.
Once a delivery is parked, it is not retried again. Your customer's administrator can see parked deliveries and retry them from the Integrations page.
Repeated failures put the whole subscription in a dead-lettered state on that page, which is how a person finds out before you do.
Your endpoint
https on port 443, to a publicly resolvable address. Not http, not another port, not a private or loopback address.
A valid certificate. We do not skip verification.
Expect duplicates and handle them with event_id. At-least-once is the guarantee; exactly-once is not.
Expect events out of order across different objects. Within one object, trust occurred_at.
Rotating the signing secret is a hard cut
There is no overlap window and no second valid secret. The moment a new secret is minted, the old one stops working and every delivery from then on is signed with the new value. Deploy the new secret to your endpoint first, then rotate — and if the rotation goes wrong, the administrator's Send test is the fastest way to prove the paste worked.
Subscribing
There are two ways in. Your customer's administrator can open Integrations → Developers, add your endpoint, pick the events and hand you the signing secret — or you can do it yourself with these three routes.
POST/api/v1/webhookswebhook_only or writelive
GET/api/v1/webhooksany active keylive
DELETE/api/v1/webhooks/{sub_id}webhook_only or writelive
The webhook_only scope exists for exactly these three and opens no object route. A write key satisfies it, the same way it satisfies read.
Creating one
A JSON body, and an Idempotency-Key header that is required — a create without one is refused.
KeyTypeNotes
destination_urlstringRequired, and held to the same rule as your endpoint above. A refusal is 422 invalid_destination_url.
eventsarrayEvent keys from the table at the top of this page. Effectively required: an empty list — or omitting the key entirely — is 422 events_empty, and an unrecognised key is 422 events_unknown_key. Stored sorted and deduplicated.
Idempotency-KeyheaderRequired, 64 characters or fewer. Missing is 400 idempotency_key_required; too long is 400 invalid_request.
The secret is returned exactly once, on the original request only
It is appended as the last key of that first 201 and never appears again — not on the list, not on a replay. Store it before you do anything else. Sending the same Idempotency-Key again returns the same 201 body with X-Idempotent-Replay: 1 and no secret key at all — not an empty string, not null, absent. If you lose it, delete the subscription and create it again, or have the administrator rotate it from the Integrations page.
What the Idempotency-Key is fingerprinted on
The destination plus the sorted, deduplicated event set — so the same destination with the same events in a different order is the same logical request, and a retry after a timeout is safe. Reusing that key with a different destination_url or a different event set is 409 idempotency_mismatch; sending it again while the first is still in flight is 409 idempotency_in_progress, which is the one 409 worth retrying — with the same key.
health is the subscription's own derived word — healthy · retrying · dead_lettered · paused. It describes the subscription, not a delivery: a delivery's status is a different fact and is not on this response.
Listing and deleting
GET /api/v1/webhooks answers in the standard envelope — {data, next_cursor, limit}, the same shape as every other list on this host — and takes any active key, read, write or webhook-only. No item ever carries a secret key.
DELETE /api/v1/webhooks/{sub_id} answers 204 with an empty body. An id that does not exist — or one belonging to another company — answers 404 not_found with “No webhook subscription with that id is visible to this key.” Never a 403, for exactly the reason a ticket is a 404: a 403 would confirm the record exists.
Pause, resume and rotate are not in the API
Three routes are not the whole lifecycle. Pausing a subscription, resuming it, and rotating its signing secret exist only on the Integrations page, where your customer's administrator does them — so do not build a flow that assumes an API call for any of the three. If that changes it appears on the changelog the day it works.