Asking questions

Webhook events

Every event ContextKit will POST to you, where it goes, how to verify it, and — just as importantly — what none of them contain.

Endpoints

Webhooks go to endpoints you register on your app, not to URLs named per rule or per user. Register one once, and every event for every user who connects your app is delivered to it, from their first event on. There is nothing to set up per connection.

In the developer portal, open your app and go to Webhooks:

  1. Add an endpoint. Give its URL, the events it should receive, and optionally a description. An app may have up to five endpoints.
  2. Store the signing secret. It is shown once, when the endpoint is created, and we cannot show it again. Keep it on your server, next to your client secret.
  3. Send a test. We deliver a signed ping to the endpoint straight away and show you the status it answered.

Choosing no events means all of them, including types added to this page later. Pick them explicitly if your handler cannot safely ignore a type it does not know.

Every event for any connection of your app goes to each enabled endpoint that subscribes to its type. Two endpoints that both take rule.fired both receive every rule delivery, so give different jobs different event lists.

The same actions are available over the portal API, with a developer session:

RouteDoes
GET /v1/developer/apps/:app_id/webhooksList endpoints (never their secrets)
POST /v1/developer/apps/:app_id/webhooksAdd one; the response carries secret, once
PATCH /v1/developer/apps/:app_id/webhooks/:endpoint_idChange its URL, events or description, or enabled: true to re-enable it
DELETE /v1/developer/apps/:app_id/webhooks/:endpoint_idRemove it
POST /v1/developer/apps/:app_id/webhooks/:endpoint_id/rotate-secretNew secret, once; the old one works 24 hours
POST /v1/developer/apps/:app_id/webhooks/:endpoint_id/testDeliver a ping now; returns {delivered, status, detail}

Rotating the secret

Rotate when a secret may have leaked, or on a schedule. The new secret is shown once; the old one keeps working for 24 hours, and during that overlap every delivery is signed with both (see Verifying a delivery). Deploy the new secret within the day, and the switch loses nothing.

Why endpoints are registered

An access token acts for one user and can be copied: into a log, a backup, an error report. If a rule could name any URL, whoever held a leaked token could create a rule pointing at their own server and receive that user's arrivals and departures.

So events are delivered only to URLs registered on your app, and registering one needs a signed-in developer, not a token. A rule may still name a webhook_url, but it must exactly match one of your registered endpoints, or creating the rule fails with 400 webhook_url_not_registered. A leaked access token can still ask what that token allows — which is why tokens are short-lived and revocable — but it cannot redirect a user's events anywhere you did not register.

The catalogue

Endpoint eventPayload typeSent whenNeeds
rule.firedplace.enter, place.exit, place.dwell, zone.enter, zone.exit, zone.dwellA rule fireslocation.rules.place / location.rules.zone
places.changedplaces.changedThe set of places shared with you changedlocation.places.watch
sensitive.expiringsensitive.expiringThe sensitive tier ends in 14 days, and again at 7a sensitive scope
sensitive.lapsedsensitive.lapsedThe sensitive tier ended; renewal grace begana sensitive scope
sensitive.removedsensitive.removedThe grace ended; sensitive scopes were removeda sensitive scope
—pingYou sent a test from the portal—

Every payload delivered to a registered endpoint names the app and the endpoint (app_id, endpoint_id), so one receiver can serve several apps or endpoints. Legacy subscription and per-rule deliveries do not carry them (see Migrating). Payloads for a user also carry grant_id, the connection they are about.

What no event contains

No coordinates, ever. A place.enter says someone arrived at "Home"; it never says where Home is. You cannot rebuild a track from your own notifications, which is the point — the push channel is exactly as private as the pull one.

places.changed goes further and names no place at all. It tells you the set moved and gives you the new version. You then re-read GET /v1/answers/places, which returns only what you are allowed to see. A webhook body travels somewhere the user cannot inspect, so it carries the nudge and not the content.

Rule events

{
  "event_id": "b2f0…",
  "app_id": "a19d…",
  "endpoint_id": "e5b2…",
  "rule_id": "3d81…",
  "grant_id": "7c4a…",
  "type": "place.enter",
  "occurred_at": "2026-08-24T09:15:00.000Z",
  "target": { "place_id": "9f2c…", "label": "Home" }
}

Registered with POST /v1/rules/place or /v1/rules/zone. Full behaviour, including when a rule deliberately will not fire, is on Rules.

places.changed

{
  "event_id": "5a1c…",
  "app_id": "a19d…",
  "endpoint_id": "e5b2…",
  "grant_id": "7c4a…",
  "type": "places.changed",
  "occurred_at": "2026-09-06T18:04:00.000Z",
  "places_version": 7
}

Sent when the user shares a place with you, or stops sharing one. Both directions matter: an id you were not told about is a place you never see, and an id you keep asking about after it was withdrawn just returns 404.

Re-read GET /v1/answers/places when you receive it, and store the version that response returns rather than the one in the event — by the time you fetch, the user may have changed something again, and the list's own version is the one that describes it.

Sensitive-tier events

A connection's sensitive scopes share one expiry of at most 90 days, separate from the rest of the connection — see Two expiries. ContextKit does not remind the user that it is ending. These three events tell you, so that a reminder, if you send one, comes from your own product.

They name scopes and dates, never places or coordinates. scopes is the sensitive scopes the event is about.

sensitive.expiring

{
  "event_id": "c41e…",
  "app_id": "a19d…",
  "endpoint_id": "e5b2…",
  "grant_id": "7c4a…",
  "type": "sensitive.expiring",
  "occurred_at": "2026-09-29T09:00:00.000Z",
  "sensitive_expires_at": "2026-10-13T09:00:00.000Z",
  "days_left": 14,
  "scopes": ["location.lookup"]
}

Sent once 14 days before sensitive_expires_at and once 7 days before (days_left is 14 or 7). A threshold that had already passed when the user consented is skipped: a 7-day grant gets no 14-day event, and a 1-day grant gets neither.

The Acceptable Use Policy allows one non-blocking, dismissible notice per event, stating the end date and how to renew — nothing that blocks the user, rewards renewing, or suggests a feature is lost for good.

sensitive.lapsed

{
  "event_id": "d902…",
  "app_id": "a19d…",
  "endpoint_id": "e5b2…",
  "grant_id": "7c4a…",
  "type": "sensitive.lapsed",
  "occurred_at": "2026-10-13T09:00:00.000Z",
  "sensitive_expires_at": "2026-10-13T09:00:00.000Z",
  "renewal_grace_ends_at": "2026-10-27T09:00:00.000Z",
  "scopes": ["location.lookup"]
}

The date has passed. Calls needing these scopes now answer 403 scope_expired; everything else on the connection keeps working. Until renewal_grace_ends_at, renewal is one confirmation on the consent screen. Stop scheduling sensitive work for this user rather than letting it fail.

sensitive.removed

{
  "event_id": "0b7d…",
  "app_id": "a19d…",
  "endpoint_id": "e5b2…",
  "grant_id": "7c4a…",
  "type": "sensitive.removed",
  "occurred_at": "2026-10-27T09:00:00.000Z",
  "scopes": ["location.lookup"]
}

The 14-day grace ended without a renewal and the scopes were removed from the grant. They now answer 403 missing_scope, and getting them back is a full consent.

How they fire

We check every 15 minutes, so an event can arrive up to that long after the moment it describes. Each event fires once per expiry date. A renewal sets a new date and starts the cycle again from it, so a connection renewed in time never sends sensitive.lapsed.

Handle them idempotently by event_id, like every other event, and treat GET /v1/me as the source of truth if one goes missing.

ping

{
  "event_id": "0f3c…",
  "type": "ping",
  "occurred_at": "2026-09-13T21:04:05.000Z",
  "app_id": "a19d…",
  "endpoint_id": "e5b2…"
}

Sent only when you press Send test in the portal or call the test route. It is signed like every other delivery, so a 2xx to a ping proves the URL, your signature check and your secret all line up. It concerns no user and carries no grant_id.

Verifying a delivery

Every request carries:

X-ContextKit-Signature: t=1787649300,v1=5d41402abc4b2a76b9719d911017c592…

v1 is HMAC-SHA256(secret, "{t}.{raw body}"), hex encoded. During the 24 hours after a rotation the header carries one v1 per valid secret:

X-ContextKit-Signature: t=1787649300,v1=9e107d9d372bb6826bd81d3542a419d6…,v1=5d41402abc4b2a76b9719d911017c592…

A delivery is genuine if any v1 matches the secret you hold. Don't assume the first one is yours.

Verify against the raw bytes of the body — re-serializing the parsed JSON changes them and the signature fails.

import { createHmac, timingSafeEqual } from "node:crypto";

// Seconds. This only absorbs clock drift between us — every attempt,
// including retries, is signed fresh. Raise toward 300 only if real
// deliveries are being rejected.
const TOLERANCE_S = 60;

// rawBody: the Buffer exactly as received (e.g. express.raw()), never parsed JSON.
export function verify(rawBody, header, secret) {
  // A missing header, or an array from a proxy, is not a signature.
  if (typeof header !== "string") return false;
  const t = /(?:^|,)\s*t=(\d+)\s*(?=,|$)/.exec(header)?.[1];
  const candidates = [...header.matchAll(/(?:^|,)\s*v1=([0-9a-f]{64})\s*(?=,|$)/gi)].map(
    (m) => m[1],
  );
  // One v1 per unexpired secret: normally one, two during a rotation, a few if
  // you rotate again within 24 hours. Far more than that is a stuffed header.
  if (!t || candidates.length === 0 || candidates.length > 8) return false;

  if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_S) return false;

  // Hash `${t}.` followed by the body's own bytes. Interpolating a Buffer into a
  // template string would hash its string form instead and never match.
  const expected = createHmac("sha256", secret)
    .update(Buffer.concat([Buffer.from(`${t}.`, "utf8"), Buffer.from(rawBody)]))
    .digest();
  return candidates.some((v1) => timingSafeEqual(expected, Buffer.from(v1, "hex")));
}

Compare in constant time. Never ===. The Node SDK's verifyWebhook does all of this, rotation included.

Stopping replays

Two defences, and they aren't equal.

Deduplicate on event_id. This is the important one. Ignoring an event_id you've already handled makes a replay harmless at any window length, and you need it anyway because a delivery can arrive more than once.

Then keep the window tight. The timestamp is signed with the body, so a payload captured from your logs or a proxy stops working shortly afterwards.

This matters most if a delivery triggers something physical. A replayed place.enter is a real attack, and "they arrived four minutes ago" is plausible enough to act on.

Retries and disabling

Respond 2xx within 5 seconds; anything else counts as a failure. A failed delivery is retried 30 s, 5 min, 30 min, 2 h, 6 h and 15 h after the previous attempt — seven attempts, the last about a day after the first — and then given up. Retries are stored with the event, so a deploy on our side delays a delivery by seconds rather than dropping it.

An event that exhausts its retries counts once against the endpoint. Twenty such events in a row disable it: disabled_at is set, it shows as disabled in the portal, and it receives nothing until you fix the receiver and enable it again there. An automatic success resets the counter. Events still waiting when an endpoint is disabled are not sent.

Endpoint health counts automatic delivery only. A replay you ask for never counts against the endpoint and never resets its counter, in either direction — so replaying a night's backlog against a receiver that is still broken cannot disable it while you are repairing it, and a replay that succeeds cannot clear a record live traffic has earned.

Deliveries are at-least-once, not guaranteed. The same event can arrive more than once — on a retry after a timeout we could not see succeed, or on every endpoint subscribed to it — always with the same event_id, so make your handler idempotent on it. An event can also be lost: a receiver down for more than a day, or an endpoint disabled while events waited. Don't use webhooks as a system of record — reconcile with visits and GET /v1/me if you need completeness.

Deliveries through a legacy rule URL or a subscription keep the old schedule (0 s, 30 s, 5 min, held in memory, so a deploy can drop them).

Each attempt is signed fresh, so a retry five minutes later still passes a tight freshness check.

The delivery log

Every event sent to your endpoints is listed on your app's Webhooks tab in the portal: when it was sent, which event, which endpoint, how it ended, and what your receiver actually answered — the status, or the error text if it never did. Filter by endpoint, event or outcome; each filter narrows the query on the server, so the list stays the same size however busy the app is.

A row ends in one of four states:

StateMeaning
TryingStill on the retry schedule. The row says which attempt it is on and when the next one is due.
DeliveredYour endpoint answered 2xx.
FailedEvery attempt was used, or the endpoint was disabled before we could send.
Too old to sendThe event passed its rule's max_event_age_s before it could be delivered.

Delivered events are kept 7 days; failures are kept 30. Failures outlive receipts because they are what the log is for, and the questions asked of it are slow ones — has this endpoint been flaky all month, or only since Tuesday?

Sending an event again

A settled row has a Redeliver button. It queues a fresh attempt carrying the same event_id, so a receiver that did get the first copy discards the replay — exactly what your idempotency on event_id is already for. The replay is signed fresh, like any attempt, and shows in the log as a new row marked as a replay, with the original marked as replayed.

Redelivery is refused in three cases, each with the reason on screen:

  • The event is past its freshness cap. max_event_age_s is your rule's own declaration that a cold event is worse than no event, and the portal will not break it on your behalf.
  • The endpoint is disabled. Enable it first, once the receiver is fixed.
  • The row is still being attempted. Use Retry now instead.

Retry now

A row still on the schedule has a Retry now button, which brings its next attempt forward to immediately. Use it when you have just fixed the receiver and don't want to wait out the gap — by the fifth attempt that gap is six hours.

It moves the due time and nothing else: no extra attempt is spent, no second copy is queued, and the row is still delivered at most once per attempt.

What the user sees

Nothing changes in the user's own access log when you replay an event. Their log records what happened at the time, once per event, and a replay does not add a second entry for one thing that happened once.

When events stop for a user

Delivery is checked against live authorization every time, not against what was true when the endpoint was registered. Nothing is sent about a user if:

  • the user disconnected your app, or an admin revoked it
  • the user removed the scope the event needs (location.places.watch for places.changed, the rules scopes for rule.fired), or an admin narrowed the app to exclude it — the sensitive-tier events are the exception, since their whole point is that those scopes are ending
  • the user deleted the rule that would have fired

Requirements

Endpoint URLs must be https, at most 2,048 characters, and publicly resolvable. localhost, .local, .internal, IP literals and single-label hosts are refused at registration. We don't follow redirects, so register the final URL — use a tunnel for local development.

The path can be a secret. We only log the host.

Migrating from per-rule secrets and subscriptions

Before app endpoints, each rule carried its own webhook_url and returned its own secret, and connection events needed a POST /v1/subscriptions per connection. Both still work for now; neither is where new code should go.

Rules created before the change keep delivering to the URL they were created with, signed with their own secret, until you delete them. New rules return no secret. To move a user over: register an endpoint subscribed to rule.fired, create the replacement rule with no webhook_url, then delete the old rule. During the switch, verify each delivery against the secret for where it came from — the endpoint secret, or the old rule's.

/v1/subscriptions is deprecated. Connection events (places.changed and the sensitive.* events) now reach your endpoints for every connection automatically, so there is nothing to register per user. The routes keep working this release. Once your endpoint is receiving connection events, delete each subscription with DELETE /v1/subscriptions/:id, so your handler is not fed the same change down two channels.

Deliveries that still come through a subscription or a legacy rule URL carry no app_id or endpoint_id (subscription deliveries carry subscription_id instead), which is an easy way to see what is left to migrate.

Previous
Rules and webhooks