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. Multiplev1=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:
| Attempt | Delay since previous | Cumulative time |
|---|---|---|
| 1 | — | 0 |
| 2 | 30 seconds | 30s |
| 3 | 2 minutes | 2.5m |
| 4 | 10 minutes | 12.5m |
| 5 | 1 hour | ~1.2h |
| 6 | 6 hours | ~7.2h |
| 7 | 24 hours | ~31.2h |
| DLQ | After attempt 7 fails | Held 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.
