Developer

Webhooks

Receive server-side notifications for avatar session events, including connection state changes and conversation transcripts

Overview

A webhook is a URL on your own server that Trulience calls automatically whenever something notable happens on an avatar session — a user connecting or disconnecting, or a conversation completing — so your systems can react in real time instead of polling for updates.

This page covers avatar webhooks, which you set up yourself from your dashboard. If your integration connects to Trulience as a registered OAuth partner application instead, see OAuth partner webhooks below — that flow is provisioned by the Trulience team on your behalf, but your receiving server still needs to know what to expect.

Configuration

  1. Log in to your Trulience account
  2. Click Developer in the navigation bar
  3. Go to the Webhook tab
  4. Click Add webhook, give it a name, and enter the URL on your server that should receive notifications. It must be a public, reachable address — HTTPS is strongly recommended, since payloads can include session or conversation content
  5. Optionally add custom HTTP headers (e.g. an API key your own server expects) — these are sent with every request
  6. Click Add webhook to save. You’ll be shown a signing secret (whsec_...) in a one-time reveal dialog — copy and store this securely now. It cannot be retrieved again afterward; you can generate a new one anytime via Rotate secret if you lose it or suspect it’s been exposed
  7. Assign the webhook to an avatar from that avatar’s own settings: edit the avatar, go to the Advanced tab, and choose this webhook from the Webhook dropdown. A webhook only receives events for avatars it’s explicitly assigned to
  8. Open the webhook again from the Webhook tab and switch to its Delivery tab, then click Test endpoint to confirm your server is reachable and responding correctly before relying on it for real traffic

You can create multiple webhooks on your account.

Webhook payload structure

Every webhook is an HTTP POST with a JSON body. The event-specific data is nested under details:

POST /your/webhook/endpoint HTTP/1.1
Host: yourapp.example.com
Content-Type: application/json
webhook-id: 8f3e2a1b-4c5d-6e7f-8a9b-0c1d2e3f4a5b
webhook-timestamp: 1776245402
webhook-signature: v1,MEUCIQD3k9F...base64...

{
  "message_type": "Event",
  "name": "connection:state-changed",
  "details": {
    // event-specific payload
  }
}
FieldTypeDescription
message_typestringAlways Event for session events
namestringThe event name (e.g. connection:state-changed, session:conversation)
detailsobjectEvent-specific payload

The details payload generally includes session_id, avatar_id, user_id, user_name, and timestamp (Unix milliseconds) alongside the event-specific fields.

Headers sent with every request (when a signing secret is configured — see Verifying webhook signatures):

HeaderMeaning
webhook-idA stable, unique ID for this event — the same value on every retry attempt. Use it to de-duplicate (see Handling retries and duplicate deliveries)
webhook-timestampUnix time (seconds) the request was signed
webhook-signaturev1,<base64 HMAC-SHA256> — see below for how to verify it

Any custom headers you configured are included too. If your webhook was created before signing secrets existed and has never been rotated, these three headers may be omitted and the request is otherwise identical.

Connection events

Event name: connection:state-changed. Delivered to both the client and your webhook. The state field takes one of the three values below. Durations are in seconds.

connecting

The user is attempting to connect.

{
  "state": "connecting",
  "max_session_duration": 300,
  "timestamp": 1771246561123,
  "session_id": "74-1234-xxxx-e3de",
  "avatar_id": "647968xxxx6624",
  "user_id": "4724715218xxxx8565",
  "user_name": "User",
  "is_reconnect_attempt": false
}

connecting can occasionally arrive twice for the same session_id — once when authentication succeeds, and once more when the client independently reports its own connecting state. This is expected, not a bug; dedupe on session_id if you only want one. is_reconnect_attempt is currently always false (reserved for future use) — there is no separate reconnecting state, and a reconnect still arrives as an ordinary connectingconnected/disconnected sequence.

connected

The user has finished connecting. On-screen timer and billing can begin. max_session_duration is the maximum time allocated to this session before the user is disconnected with reason max_duration_reached.

{
  "state": "connected",
  "max_session_duration": 300,
  "timestamp": 1771246561123,
  "session_id": "74-1234-xxxx-e3de",
  "avatar_id": "647968xxxx6624",
  "user_id": "4724715218xxxx8565",
  "user_name": "User"
}

disconnected

Emitted when the user disconnects. Subtract duration from max_session_duration to obtain the unused seconds.

{
  "state": "disconnected",
  "max_session_duration": 300,
  "reason": "hangup",
  "will_attempt_reconnect": false,
  "duration": 60,
  "timestamp": 1771246561123,
  "session_id": "74-1234-xxxx-e3de",
  "avatar_id": "647968xxxx6624",
  "user_id": "4724715218xxxx8565",
  "user_name": "User"
}
reasonDescription
timeoutRequests took too long to arrive, possibly due to internet loss
hangupUser ended the call via the on-screen UI
max_duration_reachedCall duration exceeded max_session_duration
auth_failedAuthentication failed, possibly due to an invalid JWT token

Treat reason as informational context rather than a fixed enum to branch critical logic on — client-supplied values in particular aren’t guaranteed to stay identical across client versions. session_id/user_id/user_name may be absent if authentication failed before a session was ever assigned one.

Session events

session:conversation (transcript)

Webhook-only. Emitted once at call end if the avatar has Transcript Download enabled. Contains the full transcript and a short summary.

{
  "session_id": "74-1234-xxxx-e3de",
  "timestamp": 1771246561123,
  "avatar_id": "647968xxxx6624",
  "avatar_name": "Alex",
  "user_id": 4724715218xxxx8565,
  "user_name": "User",
  "conversation_id": "conv-550e8400-e29b-41d4-a716",
  "conversation_start_time": 1713168000000,
  "conversation_end_time": 1713168300000,
  "transcript": [
    { "speaker": "User", "message": "Hello" },
    { "speaker": "Alex", "message": "Hi, how are you?" }
  ],
  "summary": "User said hello, and Alex responded by asking the user how they were."
}

conversation_start_time/conversation_end_time are Unix milliseconds and may be null if not available. user_id may also be null for an anonymous session. The exact shape of each transcript entry can vary slightly by account configuration — pull a real payload from your delivery log (see Delivery log, replay, and rotating your secret) to confirm the exact fields before writing a parser against it.

Enabling transcript delivery:

  1. Log in to the Trulience Dashboard
  2. Edit the avatar
  3. Go to the Advanced tab
  4. Set Enable Transcript Download to one of the “yes”-based options
  5. Save

Delivery is best-effort, not guaranteed after every disconnect. It depends on (a) the config above being set, (b) the transcript being generated and submitted successfully after the call ends, and (c) the same delivery limits as any other webhook (see If your endpoint fails).

Agent events

agent:error

Delivered to both the client and your webhook.

Scope is narrower than the name suggests: today, this event only fires for a failed conversation-history operation (e.g. reading, writing, or clearing conversation context via the iframe bridge — see Conversation Context). Genuine LLM, STT, or TTS provider failures do not currently reach this event or any webhook — don’t rely on it for that.

{
  "error": "error message here",
  "timestamp": 1771246561123,
  "session_id": "74-1234-xxxx-e3de",
  "avatar_id": "647968xxxx6624",
  "user_id": "4724715218xxxx8565",
  "user_name": "User"
}

Platform events

platform:state-changed

Delivered to both the client and your webhook. Emitted when the platform switches due to setMediaStream() calls — not fired if the platform isn’t actually changing.

{
  "platform": "trulience/external",
  "timestamp": 1771246561123,
  "session_id": "74-1234-xxxx-e3de",
  "avatar_id": "647968xxxx6624",
  "user_id": "4724715218xxxx8565",
  "user_name": "User"
}

webhook.test

Sent only when you click Test endpoint in your dashboard — never a real event. Useful to confirm your endpoint is reachable and signature-verifiable before going live.

{
  "message_type": "Event",
  "name": "webhook.test",
  "details": { "triggered_by": "test-endpoint" }
}

Test deliveries are logged separately and never count toward the auto-disable threshold described in If your endpoint fails.

Extended events (opt-in)

Beyond the events above, a larger set of client-side events (chat messages, avatar animation state, mic/speaker/network status, on-screen notifications, media-platform switches) can also be relayed to your webhook — but only for event names enabled on your account, since none of them are on by default. This isn’t a self-service dashboard toggle today — contact Trulience support to have specific event names enabled. Once enabled, delivery uses the same webhook, retry, and signing behavior as every other event described on this page.

Event nameFires when
agent:messageA user or avatar chat message is sent
avatar:state-changedThe avatar’s animation/activity state changes (idle, listening, talking, loading, loaded, unloaded, thinking)
avatar:load-progressAvatar asset load percentage changes (01)
media:mic-state-changedUser mutes/unmutes their mic locally
media:speaker-state-changedUser mutes/unmutes their speaker locally
media:mic-permission-state-changedBrowser mic-permission prompt is answered (prompt/granted/denied)
media:mic-readyThe avatar becomes ready to listen to the user’s speech
network:state-changedThe end user’s browser reports a local connectivity change
notificationThe client shows one of its own on-screen alerts (session-limit warning, connection failure, etc.) — treat fields as informational, not a fixed schema

Verifying webhook signatures

Every request is signed using an open, widely-used convention (“Standard Webhooks”): the signature is an HMAC-SHA256 over the exact string {webhook-id}.{webhook-timestamp}.{raw request body}, keyed with your signing secret.

Verify against the raw, unparsed request body bytes — if you parse the JSON and re-serialize it before checking, whitespace or field-ordering differences will make the signature check fail even for a legitimate request.

Python:

import hmac
import hashlib
import base64

def verify_webhook_signature(secret: str, webhook_id: str, webhook_timestamp: str,
                              raw_body: bytes, signature_header: str) -> bool:
    key_material = secret[len("whsec_"):] if secret.startswith("whsec_") else secret
    key = base64.b64decode(key_material)

    signed_content = f"{webhook_id}.{webhook_timestamp}.{raw_body.decode('utf-8')}"
    digest = hmac.new(key, signed_content.encode("utf-8"), hashlib.sha256).digest()
    expected_header = "v1," + base64.b64encode(digest).decode("utf-8")

    return hmac.compare_digest(expected_header, signature_header)

Node.js:

const crypto = require('crypto');

function verifyWebhookSignature(secret, webhookId, webhookTimestamp, rawBody, signatureHeader) {
  const keyMaterial = secret.startsWith('whsec_') ? secret.slice('whsec_'.length) : secret;
  const key = Buffer.from(keyMaterial, 'base64');

  const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`;
  const digest = crypto.createHmac('sha256', key).update(signedContent, 'utf8').digest('base64');
  const expectedHeader = `v1,${digest}`;

  return crypto.timingSafeEqual(Buffer.from(expectedHeader), Buffer.from(signatureHeader));
}

Recommended: also check for replay. Reject requests where webhook-timestamp is too far in the past (e.g. more than 5 minutes) — Trulience doesn’t enforce this for you, it’s a best practice to add on your receiving side. This is safe even for retries sent hours later: webhook-id stays the same across every attempt of a given event, but webhook-timestamp and webhook-signature are freshly recomputed at the moment each individual attempt is actually sent — so a retry delivered hours after the original event still arrives with a timestamp from a few seconds ago.

Checking the signature is optional — if you skip it, you still receive the exact same notifications, you just lose the ability to cryptographically confirm a request genuinely came from Trulience.

What your server should return

  • Respond with any 2xx status code to mark the delivery successful. Trulience allows up to 4 seconds to connect and 10 seconds total to read your response — anything else (a non-2xx status, a timeout, a connection error) is treated as a failure and retried per the schedule below.
  • Respond quickly and do your real processing asynchronously if it takes any real time — don’t make Trulience wait on slow downstream work in your handler, since a slow response risks hitting the timeout and triggering a harmless-but-avoidable retry.
  • Your response body isn’t interpreted — you don’t need to return JSON or any particular body, just the right status code promptly.

Handling retries and duplicate deliveries

  • Every delivery attempt for a given event carries the same webhook-id, across all its retries. Use it as an idempotency key — store the IDs you’ve already processed (even just recently) and skip a repeat if you see the same webhook-id again.
  • In rare edge cases (e.g. your server accepted the request but the success response was lost in transit), you could see the same event delivered more than once even without any failure on your end. Design your handler to be safe to run twice for the same event — this is normal, expected behavior for any reliable webhook system, not specific to Trulience.

If your endpoint fails — retries, pausing, and recovery

If your endpoint is unreachable or errors out:

  1. Trulience retries that specific event on a backoff schedule — immediate, then 30s, 2min, 10min, 30min, 2hr, 8hr, 24hr (8 attempts total, spread over roughly a day and a half) — before giving up on that one event.
  2. Independently, if your endpoint racks up 20 consecutive failed attempts (across all events, not just one), Trulience pauses delivery to it entirely so we stop hammering a broken endpoint. You’ll get an email at this point.
  3. While paused, new events are not delivered but are not discarded either — they’re held, waiting for you to fix things.
  4. Once you’ve fixed your endpoint:
    • Open the webhook’s Delivery tab and click Test endpoint — this must pass before you’re allowed to resume
    • Click Re-enable. This only works with a recent (within the last few minutes) passing test result against your current URL, secret, and headers — so if you change anything after testing, test again before re-enabling
    • Once re-enabled, everything that was held resumes delivery automatically
  5. You can also replay individual held or failed deliveries yourself from the delivery log without waiting for the full re-enable flow, once your endpoint can accept them again.

Delivery log, replay, and rotating your secret

Open a webhook from Developer → Webhook and switch to its Delivery tab. From there you can:

  • See a history of recent deliveries: event type, status, attempt count, and the HTTP response code/body your server returned
  • Click Test endpoint to send a real, signed webhook.test event to your URL on demand — useful before pointing your webhook at production infrastructure. It’s common to first point it at a request-inspection tool like webhook.site or ngrok (to expose a local dev server) so you can see exactly what a real payload and its headers look like before writing your handler
  • Replay an individual failed or held delivery
  • Rotate secret at any time (e.g. if you suspect it’s been exposed) — a new secret is shown once, the same way as at creation. Rotating immediately invalidates the re-enable “passing test” gate described above, so if your webhook happens to be paused when you rotate, test again before re-enabling

Retention: finished deliveries (successfully delivered, or exhausted after all retries failed) are kept in the log for 30 days, then permanently deleted. This applies only to the log entries themselves — anything still waiting to be delivered or currently held while paused is kept until it succeeds or you replay/discard it, regardless of age. If you need a durable record beyond 30 days (e.g. for compliance), export what you need before it ages out, or record deliveries on receipt in your own webhook handler.

Session duration (max_session_duration)

max_session_duration appears in connection payloads and caps how long a session can run before it is torn down with reason max_duration_reached. It is a per-avatar dashboard setting, not a per-request parameter:

  1. Log in to the Trulience Dashboard
  2. Edit the avatar
  3. Go to the Advanced tab → Usage LimitsMax Session Duration (seconds)
  4. Save

Set it to -1 for unlimited, which is the default (there is no fixed maximum). This is currently a dashboard-only setting — it cannot be set via /auth/generate-token or any API-key-authenticated endpoint, so it can’t be overridden per session at token-generation time.

Token lifetime vs. session lifetime: The expire_at value on /auth/generate-token only bounds the one-time JWT used to establish the session (60–3600s, default 120s). It does not cap the session itself. Once connected, the session runs until hangup, transport failure, or max_session_duration — regardless of when the original JWT would have expired.

Ending a session from your backend

There is a server-side API to terminate an in-flight session by session_id. See Disconnect a session in the API reference. This is in addition to the natural exit paths (client hangup, transport failure, max_session_duration).

OAuth partner webhooks

If your integration connects to Trulience as a registered partner application (OAuth), your webhook is configured by the Trulience team on your behalf — you don’t set this up yourself — but your receiving server still needs to know what to expect.

Two different secrets — don’t mix them up

When your Trulience contact registers your OAuth client, you’ll receive two separate secret values from them — each one shown only once at creation time and not retrievable again afterward, so store both securely as soon as you get them. They do completely different jobs, and neither can substitute for the other:

Client SecretWebhook Signing Secret
What it’s forAuthenticating your application to Trulience’s OAuth endpointsVerifying that a webhook notification genuinely came from Trulience
Where you use itPOST /oauth/token — sent as Authorization: Basic base64(client_id:client_secret) together with grant_type=authorization_code (or grant_type=refresh_token) when exchanging a code, or a refresh token, for an access tokenComputing/checking the webhook-signature header on every webhook request you receive — see Verifying webhook signatures
What it looks likeA UUID, e.g. b8075d9e-a1b3-4157-b280-885d693a675dwhsec_<base64>, e.g. whsec_7k14bhmlZc2OGUA5WNXgs1vgzho0sPiArS44QerSFUI=
If you lose itContact your Trulience representative — there’s no self-service reset for this valueAsk your Trulience contact to rotate your webhook signing secret

If your integration only ever receives webhooks and never calls Trulience’s OAuth token endpoint yourself, you may not end up using the Client Secret directly — but store it securely regardless, since whoever holds it can authenticate as your application against Trulience’s OAuth endpoints.

Differences from an avatar webhook:

  • Requests additionally carry an Authorization: Bearer <JWT> header, on top of the same webhook-signature HMAC header — you should verify both. The JWT is RS256-signed; fetch the current public key from Trulience’s published key set at https://www.trulience.com/.well-known/jwks.json (select the key matching the token’s kid header) and check:

    ClaimExpected value
    isshttps://www.trulience.com
    audYour client_id
    expNot expired — tokens are short-lived and minted fresh for each delivery, so don’t cache a pass/fail result across requests
  • Failure, retry, and pause behavior is identical to avatar webhooks (see If your endpoint fails), except the failure notification goes to the Trulience team internally rather than to a customer inbox, since an OAuth client isn’t owned by a single portal user with an email on file — you may hear about a sustained outage from your Trulience contact instead.

  • To get a signing secret generated or rotated for your integration, contact your Trulience representative — this is a deliberate, one-at-a-time action on our side, not self-service.

customer:logout

Fired when a customer using your integration logs out of their Trulience account.

{
  "message_type": "Event",
  "name": "customer:logout",
  "details": {
    "client_id": "your-oauth-client-id",
    "session_id": "b7e6c2a4-1f3d-4a9b-8e5c-6d2f1a0b3c9e",
    "timestamp": 1776245402345
  }
}

session:conversation

Same event and payload shape as the avatar-webhook version above, plus a client_id field identifying your OAuth client — OAuth partner integrations can receive this too, if enabled for your integration. It’s gated by the same Transcript Download must be enabled on the avatar requirement described above.

Note on webhook.test for OAuth partners: unlike avatar webhooks, there’s no self-service “Test endpoint” button for an OAuth client’s webhook — that test is triggered internally by the Trulience team, not by you. If you need one fired, ask your Trulience contact.