Everdrone AED Broker

HTTPS · JSON OAuth2 client credentials H3 resolution 9 Polling only UTC ISO-8601

Overview

This service answers one question: has an AED been delivered near this alarm? It does that while knowing as little as possible about the alarm.

How it works, in plain terms

  1. Someone has a cardiac arrest. SOS Alarm sends out both a Heartrunner and an Everdrone drone.
  2. Heartrunners tells the broker there is an alarm in a rough area — never the address.
  3. Everdrone tells the broker it has dropped an AED in an area.
  4. The broker matches the two by area, so the Heartrunner can see there is an AED nearby to grab.

The broker is just the meeting point between the two sides. It keeps only what it needs to make that match, and forgets it within hours.

What it holds — and doesn't

  • No exact position, address, SOS case number, or Everdrone ids. Location is only a coarse H3 grid cell.
  • Everdrone only sends, never receives. Both sides poll the broker; there are no callbacks, and Heartrunners can never reach Everdrone's systems.
  • Short-lived. A case expires after three hours, and expired data is deleted within a day.
  • Request bodies are never logged — only method, path, status, timing and a request id.

Base URL

The pattern is https://hr-<env>.everdrone.com/. Production is live now:

EnvironmentBase URL
productionhttps://hr-prod.everdrone.com/
staginghttps://hr-stage.everdrone.com/

The same host serves both Heartrunners and Everdrone. Every response includes an X-Request-ID header; send your own and it is echoed back, which makes any support question quicker to trace.

Compatibility contract

Responses only ever gain keys:

  • New keys can show up in any response at any time.
  • Existing keys are never removed or renamed without a coordinated release.
  • Ignore keys you don't recognise.
  • The broker also ignores unknown keys in a request, so you can start sending a new field before it understands it.

Likely next: richer delivery data from Everdrone, such as where and when a drop is planned.

Grid ids (H3)

A grid_id is an H3 cell index at resolution 9, written as 15 lowercase hex characters.

PropertyValue
Resolution9
Average edge length≈ 174 m
Average cell area≈ 0.105 km²
Example891fb466257ffff

A resolution-9 cell covers many buildings, so the broker can't point at an address. It's still small enough that two unrelated alarms rarely land in the same cell within one case's lifetime.

Both sides must use the same resolution. A cell at any other resolution is rejected with 422 — otherwise deliveries would just never match.

Case lifecycle

A case is in exactly one of three states:

StatusMeaning
pendingFiled and still active. No delivery recorded for its cell.
deliveredEverdrone dropped an AED in the cell. Terminal.
expiredThe case TTL (3 h) elapsed with no delivery. Terminal.

Either side may act first

Everdrone is often alerted before Heartrunners, so a delivery can arrive before the case exists. Deliveries are matched on the grid cell, not the case id, which makes the order irrelevant:

  • Case first. Heartrunners files the case, Everdrone confirms later, and the case turns delivered right away.
  • Delivery first. Everdrone confirms with no case yet; the broker holds it. When Heartrunners files a case for that cell, it comes back delivered on the first response.

So neither side needs retry logic, and no confirmation is lost. A held delivery lasts three hours.

Authentication

OAuth2-style client credentials over HTTPS. Exchange your client_id and client_secret for a short-lived JWT, then send it as Authorization: Bearer <token>.

ClientScopes
Heartrunnerscase:create case:read
Everdronecase:list delivery:confirm

Access tokens are HS256 JWTs valid for 15 minutes, with claims iss, aud, sub (your client id), scope, iat and exp. Clients need not inspect them — treat the token as opaque.

Renewing

Two equally supported options:

  • Call POST /auth/token again with your client credentials.
  • Call POST /auth/refresh with the refresh token you were issued.
Refresh tokens rotate. Each one works exactly once and returns a replacement — store the new value. Presenting an already-used token is treated as a leak: every outstanding token for that client is revoked and you must re-authenticate with your client credentials.

Auth endpoints

POST /auth/token no auth

Exchange client credentials for an access token.

Request body

FieldTypeDescription
client_idstringrequiredIssued out of band.
client_secretstringrequiredIssued out of band. Never logged.

Response 200

{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "case:create case:read",
  "refresh_token": "kNq7v1v0...",
  "refresh_expires_in": 604800
}

refresh_token and refresh_expires_in are omitted when refresh tokens are disabled for the environment.

Errors

401 invalid credentials · 429 rate limited

POST /auth/refresh no auth

Trade a refresh token for a new access token and a new refresh token.

Request body

FieldTypeDescription
refresh_tokenstringrequiredThe value from your previous token response.

Response 200

Identical in shape to POST /auth/token. The old refresh token is now dead — replace your stored copy.

Errors

401 unknown, expired, revoked or already-used token · 404 refresh disabled · 429 rate limited

Heartrunners endpoints

POST /cases case:create

File a case for a masked alarm location. Idempotent on hr_case_id — re-posting returns the current state rather than creating a duplicate, so this doubles as a safe retry.

Request body

FieldTypeDescription
hr_case_idstringrequiredYour own identifier, ≤ 128 chars from A–Z a–z 0–9 . _ : -. A per-mission UUID is the expected form.
grid_idstringrequiredH3 resolution-9 cell of the alarm location.
{
  "hr_case_id": "6f1d5f2c-6c2c-4f4a-9a1f-2b0f1a9c7e31",
  "grid_id": "891fb466257ffff"
}

Response 200

{
  "hr_case_id": "6f1d5f2c-6c2c-4f4a-9a1f-2b0f1a9c7e31",
  "grid_id": "891fb466257ffff",
  "status": "pending",
  "created_at": "2026-08-26T09:14:02.118431+00:00",
  "delivered_at": null,
  "expires_at": "2026-08-26T12:14:02.118431+00:00"
}

If Everdrone already reported a drop in this cell, the very first response comes back with "status": "delivered" and a populated delivered_at.

Errors

401 · 403 wrong scope · 409 hr_case_id already exists with a different grid_id · 422 malformed id or wrong H3 resolution · 429

GET /cases/{hr_case_id} case:read

Read the current status of one of your cases. This is the endpoint to poll.

Response 200

{
  "hr_case_id": "6f1d5f2c-6c2c-4f4a-9a1f-2b0f1a9c7e31",
  "grid_id": "891fb466257ffff",
  "status": "delivered",
  "created_at": "2026-08-26T09:14:02.118431+00:00",
  "delivered_at": "2026-08-26T09:19:47.402118+00:00",
  "expires_at": "2026-08-26T12:14:02.118431+00:00"
}

Poll as briskly as you like within the rate limit — the read is a single indexed lookup. Stop once status is delivered or expired; both are terminal.

Errors

401 · 403 · 404 no such case (or already purged) · 429

Everdrone endpoints

GET /ed/cases/pending case:list

All active, not-yet-expired, not-yet-delivered cases, oldest first.

Response 200

[
  {
    "hr_case_id": "6f1d5f2c-6c2c-4f4a-9a1f-2b0f1a9c7e31",
    "grid_id": "891fb466257ffff",
    "created_at": "2026-08-26T09:14:02.118431+00:00",
    "expires_at": "2026-08-26T12:14:02.118431+00:00"
  }
]

An empty array means nothing is outstanding. There is no pagination; the list is bounded by the 3-hour case TTL.

Errors

401 · 403 · 429

POST /ed/deliveries delivery:confirm

Confirm that an AED was delivered to a grid cell. The cell is the match key, so you may confirm before Heartrunners has filed the case — the broker holds the confirmation and settles the case when it arrives.

Idempotent per cell: repeating a confirmation keeps the first delivered_at.

Request body

FieldTypeDescription
grid_idstringrequiredH3 resolution-9 cell the AED was delivered to.
delivered_atstringoptionalUTC ISO-8601. Defaults to receipt time. Must not be in the future.
hr_case_idstringoptionalConsistency check only. If the case exists, its grid_id must match. Omit it when no case exists yet.
{
  "grid_id": "891fb466257ffff",
  "delivered_at": "2026-08-26T09:19:47+00:00"
}

Response 200

{
  "grid_id": "891fb466257ffff",
  "delivered_at": "2026-08-26T09:19:47+00:00",
  "expires_at": "2026-08-26T12:19:47.882014+00:00",
  "matched_cases": [
    { "hr_case_id": "6f1d5f2c-6c2c-4f4a-9a1f-2b0f1a9c7e31", "status": "delivered" }
  ]
}

An empty matched_cases is a success, not a failure: the delivery is recorded and waiting. expires_at is how long it stays claimable.

Errors

401 · 403 · 409 grid_id does not match the referenced hr_case_id · 422 wrong H3 resolution, malformed or future delivered_at · 429

Health & readiness

GET /healthz no auth

Liveness. Always 200 if the process is up.

{ "status": "ok", "environment": "production", "h3_resolution": 9 }
GET /readyz no auth

Readiness — checks the database. 200 {"status":"ready"} or 503 {"status":"unavailable"}.

The machine-readable schema for everything above is at /openapi.json, suitable for generating a client.

Errors

Every error is JSON with a single detail string. Do not parse it — branch on the status code.

{ "detail": "Insufficient scope" }
StatusMeaningWhat to do
401Missing, invalid or expired tokenGet a new access token, then retry once.
403Token lacks the required scopeConfiguration error — do not retry.
404No such caseTerminal for that id. It may also have been purged after retention.
409Conflicts with stored stateClient bug — do not retry unchanged.
422Body failed validationClient bug — do not retry unchanged.
429Rate limitedHonour the Retry-After header.
503Database unreachableRetry with backoff.

Rate limits

A sliding 60-second window per client_id, default 600 requests/minute (10/s). Exceeding it returns 429 with a Retry-After header in seconds.

The limit is deliberately generous because both sides poll. If your polling cadence needs more headroom, ask — it is a configuration value, not an architectural one.