Docs

Webhooks

Tendral fires three outreach events to your endpoint and consumes three clinician events from yours. All wrapped in the same envelope, all signed with Stripe-style multi-sig HMAC, all retried on 5xx with bounded backoff.

Envelope

Every webhook payload is wrapped in a versioned envelope so consumers can dedupe by event_id and branch on event_type. The data shape inside data varies by event type — see the catalog below.

{
  "object": "event",
  "livemode": true,
  "event_id": "evt_018f5a2e7c127c8da123cafed00dfeed",
  "event_type": "outreach.email_sent",
  "event_version": 1,
  "occurred_at": "2026-04-27T13:02:00Z",
  "emitted_at": "2026-04-27T13:02:01Z",
  "source": "tendral",
  "data": { ... event-specific payload ... },
  "metadata": { ... optional passthrough ... }
}

Signature format

Outbound webhooks carry the Stripe-style multi-sig header:

X-Signature: t=1714225320,v1=a3f1e9b8c7d6...,v1=4f8c1e2a9b3d...
  • t=<unix-ts> — server timestamp at signing.
  • v1=<hex> — HMAC-SHA256 of <ts>.<rawBody> keyed under one active webhook secret. Multiple v1= entries appear during secret rotation; a verifier with any matching secret accepts the request.

Reject if the timestamp drifts more than 5 minutes from your local clock; this is your replay-protection window. Compare signatures with a constant-time function (crypto.timingSafeEqual in Node, hmac.compare_digest in Python, etc.) — never ===.

Verification — code samples

Reference implementations. Each accepts a list of secrets so the same code handles rotation: pass [current] normally, [new, old] during the rotation grace window.

import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 5 * 60

/**
 * Verify a Tendral webhook signature.
 * Accepts an array of secrets to support zero-downtime rotation.
 */
export function verifyTendralSignature({
  header,         // value of X-Signature
  rawBody,        // raw request body BEFORE JSON.parse
  secrets,        // [currentSecret] OR [newSecret, oldSecret] during rotation
  nowSeconds = Math.floor(Date.now() / 1000),
}: {
  header: string
  rawBody: string
  secrets: string[]
  nowSeconds?: number
}): boolean {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.trim().split('=', 2)),
  )
  const ts = parseInt(parts.t, 10)
  if (!ts || Math.abs(nowSeconds - ts) > TOLERANCE_SECONDS) return false

  const candidate = header
    .split(',')
    .map((p) => p.trim())
    .filter((p) => p.startsWith('v1='))
    .map((p) => p.slice(3))

  const signingInput = `${ts}.${rawBody}`

  for (const secret of secrets) {
    const expected = createHmac('sha256', secret)
      .update(signingInput)
      .digest('hex')
    for (const sig of candidate) {
      if (
        sig.length === expected.length &&
        timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))
      ) {
        return true
      }
    }
  }
  return false
}

Retry schedule

Tendral retries any non-2xx response (or network failure) on this schedule, with at-least-once delivery guarantees:

AttemptDelay since previousCumulative time
10
230 seconds30s
32 minutes2.5m
410 minutes12.5m
51 hour~1.2h
66 hours~7.2h
724 hours~31.2h
DLQAfter attempt 7 failsHeld for manual replay

4xx responses are NOT retried (treated as permanent client failures). 5xx and network errors are retried per the schedule above.

Idempotency: the same event_id may be delivered multiple times during retries or via the reconciliation feed (GET /v1/partners/stitched/events?since=). Always dedupe on event_id, store-then-acknowledge as fast as possible, and process asynchronously.

Event catalog

outreach.recipients_published

Tendral → Partner (you receive)

Pre-arrival batch — fired before campaign send so the consumer can seat token → NPI mappings ahead of time. May be delivered as a single batch or split into chunks; correlate by tendral_campaign_id.

data shape

{
  "tendral_campaign_id": "cmp_018f5a2e7c127c8da123cafed00dfeef",
  "tendral_program_id": "pgm_018f5a2e7c127c8da123cafed00dffff",
  "stitched_program_slug": "alz-tx-decisions",
  "scheduled_send_window_start": "2026-04-27T13:00:00Z",
  "scheduled_send_window_end": "2026-04-28T13:00:00Z",
  "recipients": [
    {
      "token": "Ab3kQ9",
      "npi": "1234567890",
      "email": "jdoe@example.com",
      "tendral_recipient_id": "rcp_018f5a2e7c127c8da123cafed00dfeed"
    }
  ]
}

outreach.email_sent

Tendral → Partner (you receive)

Per-recipient delivery confirmation. Fire-and-forget; consumers may use this to mark mappings as "expected to click soon".

data shape

{
  "tendral_recipient_id": "rcp_018f5a2e7c127c8da123cafed00dfeed",
  "tendral_campaign_id": "cmp_018f5a2e7c127c8da123cafed00dfeef",
  "token": "Ab3kQ9"
}

outreach.email_bounced

Tendral → Partner (you receive)

Hard-bounce notification. Mark the token as undeliverable; account creation for this token will not happen.

data shape

{
  "tendral_recipient_id": "rcp_018f5a2e7c127c8da123cafed00dfeed",
  "tendral_campaign_id": "cmp_018f5a2e7c127c8da123cafed00dfeef",
  "token": "Ab3kQ9",
  "bounce_reason": "mailbox_full"
}

clinician.account_created

Partner → Tendral (you send)

Fires when any clinician creates an account on the partner platform — regardless of whether they came in via a Tendral token. attribution = "tendral_token" when the click was attributed; data.token is set in that case.

data shape

{
  "npi": "1234567890",
  "email": "jdoe@example.com",
  "stitched_program_slug": "alz-tx-decisions",
  "attribution": "tendral_token",
  "token": "Ab3kQ9"
}

clinician.learner_qualified

Partner → Tendral (you send)

Fires when a clinician crosses the program-defined learner threshold. Tendral consumes this to increment local quota counters and withdraw matching active leads.

data shape

{
  "npi": "1234567890",
  "stitched_program_slug": "alz-tx-decisions",
  "token": "Ab3kQ9"
}

clinician.completer_qualified

Partner → Tendral (you send)

Fires when a clinician completes the program. Same handler shape as learner_qualified.

data shape

{
  "npi": "1234567890",
  "stitched_program_slug": "alz-tx-decisions",
  "token": "Ab3kQ9"
}

Reconciliation feed

For belt-and-suspenders, Tendral exposes a pull endpoint that returns every event since a timestamp:

GET https://api.tendralhealth.com/v1/partners/stitched/events?since=2026-04-27T00:00:00Z
Authorization: Bearer tk_live_...

Recommended: poll every 15 minutes with since= set to the last successful checkpoint. Combined with HMAC-verified webhooks and event_id deduplication, no event is ever lost or double-applied.