Webhooks
Use webhooks for near-real-time delivery of change events.
For most integrations, webhooks should be the primary source of ongoing changes. Use GraphQL to bootstrap and refresh the building, floor, space, and sensor reference data needed to interpret each event. See Resolve webhook events with GraphQL.
Supported event types
OccupancyChangesFloorSightingsSpaceSensorReadings
Delivery model
- Events are queued per webhook, preserving order.
- A
2xxresponse acknowledges the batch. - Retries back off up to 10 minutes for up to 24 hours.
- Webhooks focus on recent changes and support up to 1,000,000 queued events.
Key requirements
- Endpoints must be reachable over HTTPS.
- Treat delivery as at-least-once and idempotently process events downstream.
- Expect batched payloads.
- Preserve the raw request body if you validate signatures.
Scope and volume
- A webhook receives events for your entire organisation: every building and floor you have access to. Webhooks are not filtered per building or floor, so filter downstream by
floorSpaceId/floorIdwhere a receiver only needs one site. - Plan for volume. A large portfolio can produce on the order of 1–2 million occupancy events per weekday (tens of events per second at peak). Sizing your receiver for this up front, and acknowledging batches quickly while processing asynchronously, keeps it comfortably ahead of the queue. Over 90% of occupancy events are headcount-only changes, so if you only need occupied/vacant state the
ExcludeHeadcountChangesconfig option below reduces volume dramatically.
Batching semantics
Payloads are JSON arrays of events. Read every element:
- A single batch can contain multiple events for the same space (e.g. two headcount changes in the same delivery window), so process the array in order rather than keeping the first match per space.
- Batch size and flush delay are configurable per webhook (
maxBatchSize: 100–1000 events,maxBatchDelayMs: 1 second–5 minutes). A batch is sent when either limit is reached. - Events are queued per webhook in order; a
2xxresponse acknowledges the whole batch.
Configuration options
addWebhook/updateWebhook accept configOptions as key/value pairs:
| Key | Default | Effect |
|---|---|---|
ExcludeHeadcountChanges | false | Occupancy webhooks only: drop events where only the headcount changed. |
GroupByLocation | false | Group batches by location. |
OnlyComfortReadings | false | Space-sensor webhooks only: deliver only comfort readings. |
EnableBatching | true | Disable to receive one event per request (not recommended at volume). |
Example: "configOptions": [{ "key": "ExcludeHeadcountChanges", "value": "true" }]
Manage webhooks over their lifecycle
Everything you can do in the Admin Console is also available via GraphQL (requires WritePublicApi on the key):
webhooks/webhook(id:): list and inspect registered webhooks, including delivery metrics.updateWebhook: change the endpoint, batching, headers, secret, or config. Setenabled: falseto pause delivery.deleteWebhook: remove a webhook.resetWebhookConsumer: skip to the latest queued event. Use this to recover a receiver that has fallen a long way behind: reset, then backfill the gap via GraphQL polling queries.resetWebhookMetrics: clear delivery metrics.
Service limits
- Batch size range:
100to1000 - Batch delay range:
1 secondto5 minutes - Retry behavior: failed deliveries are retried with backoff (up to 10 minutes between attempts) for up to
24 hours. Events that exhaust the retry window are dropped from the webhook queue, and can be recovered afterwards with the corresponding GraphQL history query, subject to its retention window. - Queue depth: up to
1,000,000events per webhook.
Example payloads
Occupancy webhook payload
[
{
"floorSpaceId": "ac52e43d-7c21-4464-ab21-d0ace07a94b1",
"headcount": 2,
"occupancyStatus": "CurrentlyOccupied",
"previousOccupancyStatus": "RecentlyOccupied",
"collectedDate": "2026-06-02T04:17:39.1066221Z",
"occupancyStatusChangeDate": "2026-06-02T04:17:39.1066221Z"
}
]
Floor sightings webhook payload
[
{
"floorId": "a2961d46-971f-4ce1-a66e-7ee7bdb2a204",
"headcount": 3,
"collectedDate": "2026-06-02T07:36:11.964156Z",
"sightings": [
{ "x": 1842, "y": 971 },
{ "x": 2510, "y": 1164 },
{ "x": 2732, "y": 1189 }
]
}
]
Space sensor readings webhook payload
[
{
"spaceSensorId": "8bb1ba0b-6ea8-4635-86ef-3d57a7de4df3",
"capabilityName": "Temperature",
"value": "22.4",
"collectedAt": "2026-06-02T07:36:11.964156Z"
},
{
"spaceSensorId": "8bb1ba0b-6ea8-4635-86ef-3d57a7de4df3",
"capabilityName": "Humidity",
"value": "48.2",
"collectedAt": "2026-06-02T07:36:11.964156Z"
}
]
Authenticate webhook requests
You can protect downstream receivers with either custom headers, request signatures, or both.
Custom headers
Configure one or more fixed headers such as an API token and validate them on receipt.
Signature model
If a signature secret is configured, each request includes X-XY-Signature containing a timestamp and HMAC SHA-256 signature over:
<timestamp>.<raw json payload>
Validate signed requests
The signature scheme, precisely:
tis the send time as Unix epoch seconds.- The signed content is the UTF-8 bytes of
{t}.followed by the raw, decompressed request body. Decompress first if you enabled compression, and use the body bytes as received rather than re-serialized JSON. sis the HMAC-SHA-256 of that content, keyed with the UTF-8 bytes of your signature secret, encoded as uppercase hex.- The secret must be 64–256 characters and cannot be retrieved after it is set, so store it when you create the webhook.
To validate:
- Extract
tandsfromX-XY-Signature(formatt=<epoch seconds>,s=<hex>). - Compute
HMAC_SHA256(secret, "<t>." + rawBody)and hex-encode it. - Compare against
susing a constant-time comparison (case-insensitively, or uppercase yours). - Reject stale timestamps. Five minutes is a reasonable tolerance for replay protection.
Worked example
import hashlib, hmac
secret = b"my-signature-secret-of-at-least-64-characters-abcdefghijklmnopqrs"
raw_body = b'[{"floorSpaceId":"ac52e43d-7c21-4464-ab21-d0ace07a94b1","headcount":2}]'
t = "1608766071"
signed_content = t.encode() + b"." + raw_body
expected = hmac.new(secret, signed_content, hashlib.sha256).hexdigest().upper()
# compare `expected` to the `s` value with hmac.compare_digest
Example request with signature header
POST https://<your endpoint>
Content-Type: application/json; charset=utf-8
X-XY-Signature: t=1608766071,s=13EEAE8BEDA256A9098554AECA718096504B143C1D51CAC2D1555F38210B09A2
<json payload>
Register your webhook
You can register webhooks via the Admin Console at https://app.xysense.io/settings/webhooks or programmatically through GraphQL.
Each webhook type requires the matching API feature (Occupancy API, Sightings API, or Space Sensor Readings API) to be enabled on at least one of your floors, and events are only delivered for floors where it is enabled. If you are unable to register your Webhook, your XY Sense representative can enable the feature. See Feature enablement.
GraphQL request
Open in API ExplorerExample query
mutation AddWebhook($headers: [InputPair!]) {
addWebhook(
name: "Occupancy stream"
webhookType: OccupancyChanges
endpoint: "https://example.com/hooks/xy"
email: "integrations@example.com"
additionalHeaders: $headers
signatureSecret: "<64-256 char secret>"
enabled: true
) {
id
name
endpoint
enabled
webhookType
}
}
Example variables
{
"headers": [
{
"key": "X-Partner-Token",
"value": "mytoken"
}
]
}
Example response
{
"data": {
"addWebhook": {
"name": "Occupancy stream",
"endpoint": "https://example.com/hooks/xy",
"additionalHeaders": [
{
"key": "X-Partner-Token",
"value": "mytoken"
}
],
"signatureSecret": "[redacted]",
"enabled": true
}
}
}
Test your receiver
If your floor is in dev mode, you can generate synthetic occupancy and floor-sighting traffic. The full request and response examples are in Webhook test events.