Everdrone AED Broker
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
- Someone has a cardiac arrest. SOS Alarm sends out both a Heartrunner and an Everdrone drone.
- Heartrunners tells the broker there is an alarm in a rough area — never the address.
- Everdrone tells the broker it has dropped an AED in an area.
- 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:
| Environment | Base URL |
|---|---|
production | https://hr-prod.everdrone.com/ |
staging | https://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.
| Property | Value |
|---|---|
| Resolution | 9 |
| Average edge length | ≈ 174 m |
| Average cell area | ≈ 0.105 km² |
| Example | 891fb466257ffff |
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.
422 — otherwise deliveries would just never match.
Case lifecycle
A case is in exactly one of three states:
| Status | Meaning |
|---|---|
pending | Filed and still active. No delivery recorded for its cell. |
delivered | Everdrone dropped an AED in the cell. Terminal. |
expired | The 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
deliveredright away. - Delivery first. Everdrone confirms with no case yet; the broker holds it.
When Heartrunners files a case for that cell, it comes back
deliveredon 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>.
| Client | Scopes |
|---|---|
| Heartrunners | case:create case:read |
| Everdrone | case: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/tokenagain with your client credentials. - Call
POST /auth/refreshwith the refresh token you were issued.
Auth endpoints
Exchange client credentials for an access token.
Request body
| Field | Type | Description | |
|---|---|---|---|
client_id | string | required | Issued out of band. |
client_secret | string | required | Issued 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
Trade a refresh token for a new access token and a new refresh token.
Request body
| Field | Type | Description | |
|---|---|---|---|
refresh_token | string | required | The 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
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
| Field | Type | Description | |
|---|---|---|---|
hr_case_id | string | required | Your own identifier, ≤ 128 chars from A–Z a–z 0–9 . _ : -. A per-mission UUID is the expected form. |
grid_id | string | required | H3 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
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
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
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
| Field | Type | Description | |
|---|---|---|---|
grid_id | string | required | H3 resolution-9 cell the AED was delivered to. |
delivered_at | string | optional | UTC ISO-8601. Defaults to receipt time. Must not be in the future. |
hr_case_id | string | optional | Consistency 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
Liveness. Always 200 if the process is up.
{ "status": "ok", "environment": "production", "h3_resolution": 9 }
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" }
| Status | Meaning | What to do |
|---|---|---|
401 | Missing, invalid or expired token | Get a new access token, then retry once. |
403 | Token lacks the required scope | Configuration error — do not retry. |
404 | No such case | Terminal for that id. It may also have been purged after retention. |
409 | Conflicts with stored state | Client bug — do not retry unchanged. |
422 | Body failed validation | Client bug — do not retry unchanged. |
429 | Rate limited | Honour the Retry-After header. |
503 | Database unreachable | Retry 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.