Guides
The practical details behind the endpoints: making retries safe, what you can send, how errors are shaped, and how to trust a webhook.
Networks fail ambiguously. You send a file, the connection drops before our 202 gets back, and you can't tell whether we received it. The safe move is to retry — but a naive retry would duplicate the elder's data.
Send an Idempotency-Key header (a UUID you generate, one per distinct file) on POST /v1/files. If we've already seen that key, we return the same receipt instead of processing again, so the file lands exactly once no matter how many times you retry.
curl https://api.elderella.com/v1/files \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 7c9e6a1f-3b2d-4a5e-8f10-2b3c4d5e6f70" \
-F elder=happy.sun.meadow@care.elderella.com \
-F file=@./lab-result.pdf
idempotency_key_reuse — a retry has to be identical to count as a retry. (We compare a content hash taken when the file was first accepted, not the raw bytes.)purposeThe API takes one file per POST /v1/files. Both limits are validated before anything is stored.
| Kind | Types |
|---|---|
| Documents | PDF, plain text (.txt) |
| Images | JPEG, PNG, HEIC |
| Audio | WAV, MP3, M4A |
An unsupported type is a 415 unsupported_media_type. New types may be added within v1 — that's an additive change, so it won't break you.
Maximum 25 MB per file. Larger is a 413 file_too_large. If your payloads run bigger, tell us — the limit is a launch default, not a permanent ceiling.
purpose hintpurpose is an optional free-text hint about what a file is — lab_result, discharge_summary, pharmacy fill. It helps us route the file without having to read it first. It is never validated: an unrecognized value is a hint we don't act on yet, not an error, so you can always send one. These are suggested values, not a fixed list:
Suggested purpose | For |
|---|---|
lab_result | Lab and diagnostic reports |
discharge_summary | Hospital or facility discharge paperwork |
medication_list | A current medication list or fill record |
care_note | A free-text note about the elder's care |
Every /v1 error uses one envelope. Branch on code (stable, part of the versioned contract); treat message as human-readable text that may change.
{
"error": {
"type": "invalid_request_error",
"code": "file_too_large",
"message": "The file exceeds the 25 MB limit.",
"param": "file"
}
}
| Status | Code | Means |
|---|---|---|
| 400 | invalid_request | A field is missing or malformed, or the address shape is wrong. |
| 401 | unauthorized | The bearer token is missing, malformed, expired, or revoked. |
| 403 | insufficient_scope | A valid token that lacks the scope this route needs. |
| 403 | no_active_connection | The single no-oracle send error — unresolvable address, unconsented elder, and revoked connection all return this, identically. |
| 404 | not_found | The id doesn't name a resource you can see (identical whether unknown or owned by another partner). |
| 409 | idempotency_key_reuse | An Idempotency-Key was reused with a different payload. |
| 413 | file_too_large | The file is over 25 MB. |
| 415 | unsupported_media_type | The file's type isn't in the v1 accepted set. |
| 429 | rate_limited | You exceeded the endpoint's rate limit. Honour the Retry-After header. |
POST /oauth/token returns the RFC 6749 form — { "error": "invalid_client" } — with values invalid_request, unsupported_grant_type, invalid_client, or invalid_scope. Every credential failure collapses to invalid_client. See authentication.
Don't try to tell no-oracle cases apart. The identical 403 no_active_connection and the identical 404 not_found are contract guarantees. Handle each as its single meaning — "no live connection here," "no resource you can see" — and don't infer more.
Every webhook delivery carries an Elderella-Signature header. Always verify it before trusting the payload — it proves the event came from us and lets you reject replays.
Elderella-Signature: t=1753716000,v1=5f2a...c9
The signature is an HMAC-SHA256 over the string "{t}.{raw_body}" — the timestamp, a dot, then the exact raw request body — keyed with the per-partner signing secret we issue alongside your credentials. To verify:
t and v1 from the header.HMAC_SHA256(secret, "{t}." + raw_body) over the raw body, before any JSON parsing.v1 with a constant-time equality check.t is too old (e.g. more than 5 minutes) to stop replays.import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, sig = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > tolerance:
return False # too old — reject as a possible replay
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
const crypto = require("crypto");
function verify(rawBody, header, secret, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const { t, v1 } = parts;
if (Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;
const signed = `${t}.` + rawBody; // rawBody as received
const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
id: delivery is at-least-once, so the same event can legitimately arrive twice.