Browse the docs

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

http
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:

  1. Split the header on ,; read t and every v1.
  2. Reject if |now − t| exceeds your tolerance (Latido's own verifier uses 300 s).
  3. Compute HMAC-SHA256(secret, "<t>.<raw body>") as lowercase hex.
  4. Compare with each v1 in 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)

ts
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_payload

Express needs the raw body:

ts
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

js
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

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 (PATCH with enabled: 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 use createdAt / the record's updatedAt to keep the newest state.