API reference
Webhooks
Latido POSTs an event to your endpoints whenever something happens to a connection or a
record. Endpoints are managed in the dashboard (project → Webhooks; session-authenticated
/dashboard/projects/{projectId}/webhook-endpoints), one per URL, each bound to one mode.
| Setting | Notes |
|---|---|
mode |
test or live: the endpoint receives the events of members of that mode only. |
url |
http(s). Live endpoints must be https and public (no localhost or private addresses). |
eventTypes |
Types to receive (≤ 50); omitted or empty = every type. |
description |
Free text. |
| secret | whsec_<32 base62>, shown once at creation. Rotate it with Rotate secret (POST …/{endpointId}/rotate-secret): the new secret applies to the next attempt; there is no overlap window. |
The number of endpoints is a plan limit (webhookEndpoints).
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Latido-Webhooks/1.0
Latido-Signature: t=1793000000,v1=5f2b…(64 hex)
Latido-Event-Id: evt_V1StGXR8_Z5jdHi6B
Latido-Event-Type: workout.created
{"id":"evt_V1StGXR8_Z5jdHi6B","type":"workout.created","projectId":"proj_…","mode":"test","createdAt":"2026-10-26T07:00:00.000Z","data":{…}}The body is the event envelope (webhookEventEnvelopeSchema):
| Field | Type | Notes |
|---|---|---|
id |
string | evt_…. The same on every retry and resend: deduplicate on it. |
type |
WebhookEventType |
See below. Also in Latido-Event-Type. |
projectId |
string | |
mode |
test | live |
|
createdAt |
ISO 8601 | When the event happened (not when this attempt was sent). |
data |
object | Depends on the type. |
Events
| Type | When | data |
|---|---|---|
connection.created |
A member connected a provider (hosted flow, or the first mobile upload). | connection payload |
connection.updated |
An existing connection was re-authorized. | connection payload |
connection.revoked |
Revoked by you (revokedBy: "developer") or by the member at the provider (revokedBy: "member"). |
connection payload + revokedBy |
connection.error |
The provider refused the tokens (connection expired: send the member through a new session), or a backfill failed partly. |
connection payload + error (message) |
backfill.completed |
The first sync after connecting finished. | connection payload + from, to (ISO), records (count per data type), providerApp |
workout.created / .updated / .deleted |
A workout appeared, changed or was deleted at the provider. | record payload |
daily_summary.updated |
A day's totals were created or changed (days keep changing until they end, so there is no .created). |
record payload |
sleep_session.created / .updated |
record payload | |
body_measurement.created |
record payload | |
heart_rate_sample.created |
record payload | |
glucose_sample.created |
record payload | |
planned_workout.pushed / .failed |
The asynchronous push of a planned workout finished. | memberId, connectionId, provider, plannedWorkout (with status, providerWorkoutId or error) |
webhook.test |
Send test event in the dashboard, to that endpoint only, whatever it subscribes to. | message, endpointId |
Corrections to body measurements and samples raise no event (there is no .updated type
for them): the read API has the new value.
Connection payload: memberId, externalUserId, connectionId, provider, status,
connection (the full Connection object, as GET /v1/connections/{id} returns it).
Record payload: memberId, externalUserId, connectionId, provider, dataType,
recordId, providerRecordId, and record (the normalized record, without raw) — except
on *.deleted, which carries no record.
externalUserId lets you route the event to your user without a lookup. The envelope is
validated by the SDK; data is typed as Record<string, unknown> in the contract, so check
the fields you use (or re-read the record by recordId).
Verifying the signature
Latido-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256> where the MAC is computed with the
endpoint secret over the exact bytes "<t>.<raw body>" (Stripe-style). To verify:
- Split the header on
,; readtand everyv1. - Reject if
|now − t|exceeds your tolerance (Latido's own verifier uses 300 s). - Compute
HMAC-SHA256(secret, "<t>.<raw body>")as lowercase hex. - Compare with each
v1in constant time; accept if one matches.
Use the raw body: parsing and re-serializing JSON changes the bytes. Each attempt is
signed afresh, so a retry carries a new t and v1.
TypeScript (SDK)
import { Latido, LatidoWebhookVerificationError } from "@latido/sdk";
const event = await Latido.webhooks.verify(rawBody, signatureHeader, secret, {
toleranceSeconds: 300, // default
});
// event: LatidoWebhookEvent — throws LatidoWebhookVerificationError with
// reason: missing_header | malformed_header | timestamp_out_of_tolerance
// | signature_mismatch | invalid_payloadExpress needs the raw body:
app.post("/latido/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
try {
const event = await Latido.webhooks.verify(req.body, req.get("Latido-Signature"), secret);
await handle(event); // idempotently, keyed on event.id
res.sendStatus(204);
} catch (error) {
if (error instanceof LatidoWebhookVerificationError) return res.status(400).send(error.reason);
throw error;
}
});For your own tests, await Latido.webhooks.sign(body, secret, timestamp?) returns a valid
header.
Node, without the SDK
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyLatido(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.groupBy(header.split(","), (p) => p.trim().split("=")[0]);
const t = Number(parts.t?.[0]?.split("=")[1]);
const signatures = (parts.v1 ?? []).map((p) => p.trim().slice(3));
if (!Number.isInteger(t) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return signatures.some((hex) => {
const given = Buffer.from(hex, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}(Object.groupBy needs Node ≥ 21; split by hand on older versions.)
Python
import hashlib, hmac, time
def verify_latido(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
t, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
t = int(value)
elif key == "v1" and value:
signatures.append(value)
if t is None or not signatures or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
# Flask: verify_latido(request.get_data(), request.headers["Latido-Signature"], SECRET)
# Django: verify_latido(request.body, request.headers["Latido-Signature"], SECRET)Responses, retries and auto-disable
- Answer 2xx within 10 seconds. Anything else — another status, a timeout, a connection error — is a failed attempt. Do the work asynchronously if it takes longer.
- Retries: 8 attempts in all. After a failure Latido waits 1 min, 5 min, 30 min, 1 h,
3 h, 6 h and 12 h (about 23 hours in total), then marks the delivery
failed. - Auto-disable: an endpoint whose deliveries have kept failing for 3 days is disabled
and its pending deliveries abandoned. Re-enable it in the dashboard (
PATCHwithenabled: true, which also clears the failure streak). - Delivery log: each endpoint's deliveries (status, attempts, response status and the
first 2 KB of the response body, duration) are in the dashboard; any delivery can be
resent (same event, same
id). The project's event log keeps every event, delivered or not. - Order is not guaranteed and an event can arrive more than once (a resend, a retry after
a timeout you actually processed). Make handlers idempotent on
Latido-Event-Id, and usecreatedAt/ the record'supdatedAtto keep the newest state.