Rules and webhooks

A rule is a standing question. Instead of asking "are they home yet?" every thirty seconds, you register the question once and we POST to you when the answer changes.

Creating a rule

POST /v1/rules/place
Authorization: Bearer cka_…
Content-Type: application/json

{
  "type": "enter",
  "place_id": "9f2c…",
  "webhook_url": "https://api.example.com/hooks/contextkit"
}
{
  "id": "3d81…",
  "type": "enter",
  "target": { "place_id": "9f2c…", "label": "Home" },
  "dwell_minutes": null,
  "webhook_url": "https://api.example.com/hooks/contextkit",
  "disabled_at": null,
  "created_at": "2026-08-24T09:00:00.000Z",
  "secret": "6b1f…"
}

secret is returned exactly once. Store it now; you need it to verify every delivery, and we can't show it again.

Use /v1/rules/zone for geometry you supply yourself:

{
  "type": "exit",
  "lat": 37.7749,
  "lon": -122.4194,
  "radius_m": 500,
  "label": "Warehouse 5",
  "webhook_url": "https://api.example.com/hooks/contextkit"
}

Same constraints as verify-zone: radius_m is 100–10 000 m, and label is required and shown to the user every time the rule fires.

GET either path to list your rules; DELETE /v1/rules/place/{id} to remove one. Ten active rules per connection.

Types

typeFires whenNeeds
enterthey cross from outside to inside
exitinside to outside
dwellthey've been inside continuously for N minutesdwell_minutes (5–720)

dwell fires once per stay, not once per fix. Leaving and returning starts a new stay.

Receiving a delivery

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

type is place. or zone. plus the rule type. There are no coordinates in the payload — an event says someone entered "Home", never where Home is. You can't rebuild a track from your own notifications, which is exactly the point.

Respond 2xx quickly. Anything else counts as a failure.

Verifying the signature

Every request carries:

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

v1 is HMAC-SHA256(secret, "{t}.{raw body}"). Verify against the raw bytes — re-serializing the parsed JSON will change them and the signature will fail.

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

// Seconds. This only has to absorb YOUR clock's drift from ours — every
// delivery attempt, including retries, is signed fresh. Raise it toward 300
// only if legitimate deliveries are being rejected; don't start there.
const TOLERANCE_S = 60;

export function verify(rawBody, header, secret) {
  const t = /t=(\d+)/.exec(header)?.[1];
  const v1 = /v1=([0-9a-f]+)/.exec(header)?.[1];
  if (!t || !v1) return false;

  // Bounds how long a captured payload stays replayable.
  if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_S) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(v1, "hex");
  return expected.length === given.length && timingSafeEqual(expected, given);
}

Compare in constant time — timingSafeEqual, never ===.

Replay: the window is the weaker half

Two defences, and they aren't equal.

Deduplicate on event_id. This is the real one. A receiver that ignores an event_id it has already processed makes replay a no-op at any window length, and you need it anyway because deliveries are at-least-once.

Then bound the window. The timestamp is signed alongside the body so that a payload captured from your logs, a proxy, or a mis-scoped error report stops working shortly afterwards. Keep it as tight as your clock allows.

Why this matters more than it looks: if a delivery triggers something physical — unlocking a door, disarming an alarm — a replayed place.enter is a real attack, and "they arrived four minutes ago" is still plausible enough to act on. Sixty seconds is a much smaller window to work with; deduplication closes it entirely.

If you're only writing a log line or sending a notification, the window alone is fine.

Retries and disabling

An attempt that doesn't return 2xx is retried at 0 s, 30 s, and 5 min, then given up on. Twenty consecutive failures disables the rule (disabled_at is set) and it stops firing until you delete and recreate it.

A success resets the counter, so an endpoint that recovers isn't carried toward disablement by old trouble.

Deliveries are at-least-once and can be dropped. Retries live in-process on a single replica, so a deploy on our side discards anything still pending. Your handler must be idempotent on event_id — for reliability, and because that's also what defeats replay — and webhooks shouldn't be a system of record: reconcile with visits if you need completeness.

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

Before you drive something physical

Read this if a rule will unlock a door, disarm an alarm, or anything else with a consequence in the world.

An event tells you what happened, not what is true now. By design, an event can fire from a fix up to 15 minutes old, and retries can add ~5 minutes more. So place.enter can reach you around twenty minutes after someone actually walked in — by which time they may have come inside, locked up behind them, and gone to bed. Unlocking then leaves the door open while they believe it's locked.

Two things to do, and you want both.

Cap the age we'll deliver

{
  "type": "enter",
  "place_id": "9f2c…",
  "max_event_age_s": 90,
  "webhook_url": "https://api.example.com/hooks/contextkit"
}

We then refuse to deliver that event at all once it's older than 90 seconds, including on retries — a retry must never be what sneaks a stale event through. The user sees the withheld event in their access log, so a silently dropped notification is still accountable.

Accepts 30–3600 seconds. Omit it and events deliver regardless of age, which is the right default for logging and notifications.

Confirm before you act

The cap bounds staleness; it doesn't eliminate it. For anything irreversible, treat the webhook as a trigger to go and check, then ask:

GET /v1/answers/places/{place_id}/presence?max_age_s=60

Act only on state: "yes". If it's no, they've already left and the event was stale. If it's unknown, you genuinely don't know — and for a lock, not knowing must mean not unlocking.

This is what the two halves of the API are for: the rule tells you when to look, the question tells you what's true. A webhook alone is never enough to justify a physical action.

Latency, honestly

Events fire as points arrive from the user's phone, which means minutes, not seconds. iOS batches location uploads to save battery, and we can't tell you about a crossing before we hear about it.

If you need a decision now — unlocking a door, approving a transaction — ask a question at that moment instead of waiting to be told. Rules are for things that can happen a few minutes late: a notification, a log entry, turning on the heating.

Requirements and lifetime

webhook_url must be public HTTPS. Localhost, private IPs, IP literals and internal hostnames are rejected — use a tunnel for local development. We don't follow redirects on delivery, so give us the final URL.

The URL's path can be a secret if you like; we only ever log the host.

Rules belong to the connection that created them. When the user disconnects you, or the grant expires, or they delete the rule from their own Connections page, it stops firing. Users can see every standing rule you hold, described in plain language: "Tell them when you arrive at Home." Write your labels accordingly.