Delivery and batching
Request format
Each delivery is an HTTPS POST to your endpoint with Content-Type: application/json; charset=utf-8, plus any custom headers and the X-XY-Signature header you have configured. See Securing your endpoint.
Each attempt has a 10 second timeout. Anything other than a 2xx response — including a timeout or a connection failure — is treated as a failed delivery and retried.
The batch envelope
The request body is a JSON object wrapping the events, not a bare array. Field names are PascalCase.
{
"Id": "3f1b8f0e-2c44-4a1d-9f52-6b3f0a2d1c77",
"GroupId": "0b26f8f3-5d1c-4c6a-9d3e-1a2b3c4d5e6f",
"Type": "Occupancy",
"CustomerId": "2b7f9e10-4c3a-4b8e-9a11-7d2e5f6c8b90",
"Created": "2026-06-02T04:17:40Z",
"Sent": "2026-06-02T04:17:41Z",
"Data": [
{
"FloorSpaceId": "ac52e43d-7c21-4464-ab21-d0ace07a94b1",
"OccupancyStatus": "CurrentlyOccupied",
"Headcount": 2,
"CollectedDate": "2026-06-02T04:17:39.1066221Z",
"OccupancyStatusChangeDate": "2026-06-02T04:17:39.1066221Z",
"PreviousOccupancyStatus": "RecentlyOccupied"
}
]
}
| Field | Type | Notes |
|---|---|---|
Id | string (GUID) | Batch identifier. See Idempotency for when it is stable across retries. |
GroupId | string | The location the batch was grouped by. Only present when GroupByLocation is enabled; the field is omitted otherwise. |
Type | string | Occupancy, FloorSightings, or SpaceSensorReading. Note these differ from the GraphQL webhookType enum values. |
CustomerId | string (GUID) | Your organisation. Useful when several XY Sense organisations post to one receiver. |
Created | string (ISO8601) | UTC time the batch started filling. |
Sent | string (ISO8601) | UTC time this attempt was sent. Changes on every retry. Compare with Created to measure delivery delay. |
Data | array | The events. See Event payloads. |
Null fields are omitted rather than sent as null, so treat any field documented as optional as absent rather than null.
Ordering and acknowledgement
- Events are queued per webhook and delivered in order.
- A
2xxresponse acknowledges the whole batch. There is no partial acknowledgement. - Because ordering is preserved, a batch your receiver keeps rejecting blocks everything behind it. Return
2xxonce you have durably accepted a batch and handle parse or business errors out of band, rather than rejecting the delivery.
Batching
- A batch is sent when either
maxBatchSize(100–1000 events) ormaxBatchDelayMs(1 second–5 minutes) is reached. - A single batch can contain multiple events for the same entity — for example two headcount changes for one space within the same window. Process
Datain order rather than keeping the first or last match per entity.
Idempotency
Delivery is at-least-once. A batch that times out or fails after your receiver has already committed it will be sent again, so processing must be idempotent.
Id identifies the batch, but its stability across retries depends on configuration:
- Default (
GroupByLocationoff):Idis generated once per batch and is stable across all retry attempts. You can dedupe on it. GroupByLocationon: a freshIdis generated for each retry attempt, so it will not catch a redelivered batch.
For a scheme that works in both cases, dedupe on the event itself: the entity identifier plus its timestamp (FloorSpaceId + CollectedDate, FloorId + CollectedDate, or SpaceSensorId + CapabilityName + CollectedAt) is unique per event.
Retries and failure handling
- Failed attempts are retried with exponential backoff (2 seconds, 4, 8, …), capped at 10 minutes between attempts.
- Retries continue for up to 24 hours.
- If an endpoint is still failing after 24 hours, the webhook is stopped and set to
enabled: false. Its queued events are not delivered. Re-enable it withupdateWebhookonce the endpoint is healthy, then backfill the gap with the corresponding GraphQL history query, subject to its retention window.
Recovering a backlogged receiver
If your receiver has fallen a long way behind, use resetWebhookConsumer to skip to the latest queued event, then backfill the gap through GraphQL polling queries. See Resolve event data with GraphQL.
Compression
Set compressionType on the webhook to Gzip or Deflate to have request bodies compressed. Your receiver must decompress the body, and must sign against the decompressed bytes when validating signatures. Compression is off by default.
Configuration options
addWebhook and updateWebhook accept configOptions as key/value pairs:
| Key | Default | Applies to | Effect |
|---|---|---|---|
ExcludeHeadcountChanges | false | OccupancyChanges | Drop events where only the headcount changed. Cuts volume by roughly 90%. |
GroupByLocation | false | Occupancy, space sensors | Send one batch per location and populate GroupId. Note the Id caveat. |
OnlyComfortReadings | false | SpaceSensorReadings | Deliver only comfort readings. |
EnableBatching | true | FloorSightings only | Set to false to receive one event per request. Ignored for occupancy and space-sensor webhooks, which are always batched. |
Example: "configOptions": [{ "key": "ExcludeHeadcountChanges", "value": "true" }]
Service limits
| Limit | Value |
|---|---|
| Batch size | 100 to 1000 events |
| Batch delay | 1 second to 5 minutes |
| Per-attempt timeout | 10 seconds |
| Retry backoff cap | 10 minutes |
| Total retry window | 24 hours, after which the webhook disables |
| Queue depth per webhook | 1,000,000 events |
Managing webhooks
Everything available 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, compression, or config. Setenabled: falseto pause delivery.deleteWebhook: remove a webhook.resetWebhookConsumer: skip to the latest queued event.resetWebhookMetrics: clear delivery metrics.