Skip to main content
Canvas interactions record what a user does on a Canvas card: tap, submit, skip, or dismiss. Each interaction is recorded with a POST, delivered to your callback_url as a canvas.interaction webhook, and queryable with the history GET. All endpoints live under https://tavusapi.com.

When Magic Canvas is active

No per-conversation configuration is required. A PAL gets Canvas actions when both are true:
  • The PAL has the Magic Canvas skill attached: PUT /v2/pals/{pal_id}/skills/magic_canvas, covered in Configuring your PAL.
  • The conversation has a rendering surface. Audio-only conversations, chat conversations, and conversations that join an external meeting via meeting_url (Zoom, Teams, Google Meet) get no Canvas actions.
When both are true, the PAL gets one action per enabled component, the control actions canvas_clear and update_component, and system-prompt guidance on when to show each component. This applies to every conversation start path, including hosted deployments (POST /v2/deployments/{deployment_id}/start).

Record an Interaction

POST /v2/conversations/{conversation_id}/canvas/interactions The Tavus-hosted embed and @tavus/cvi-ui post interactions for you; call this endpoint directly only if you build your own renderer.
curl -X POST https://tavusapi.com/v2/conversations/{conversation_id}/canvas/interactions \
  -H "Content-Type: application/json" \
  -d '{
    "interaction_id": "ci_call_8f2d41_submit_5e0b7c2a",
    "tool_call_id": "call_8f2d41",
    "component": "canvas.question",
    "component_version": "v1",
    "type": "submit",
    "value": { "selected_option_ids": ["opt_2"], "skipped": false },
    "metadata": { "client": "kiosk-web" }
  }'
A successful request returns 200:
{ "success": true }
An identical retry returns the same 200; see Idempotency and delivery guarantees.

Authentication

No API key or token is required while the conversation is active. Once the conversation ends, every POST is rejected with a 400.

Rate limiting

The interaction POST is rate-limited at 120 requests per 60-second window per (client IP, conversation_id). The limit is checked before schema validation or any database work. When you exceed the limit, the API returns HTTP 429 with a Retry-After header (seconds until the current window resets) and a body of { "error": "Too many requests" }. Back off and retry after that interval. Custom renderers that post heartbeat interactions count toward this limit. Space heartbeats accordingly; the Tavus-hosted embed and @tavus/cvi-ui stay within the budget for normal sessions.
Never put your Tavus API key in a browser. The interaction POST doesn’t need it, and nothing client-side ever should.

Request Body

FieldTypeRequiredDescription
interaction_idstring1–128 chars. Your idempotency key: unique per logical interaction, reused verbatim on retries. The Tavus client generates ci_{tool_call_id}_{type}_{uuid}.
tool_call_idstring1–128 chars. The id of the Canvas invocation that showed the card. Ties the interaction to a specific card instance.
componentstringOne of the component ids below.
component_versionstringThe component’s contract version. v1 for all current components.
typestringOne of submit, skip, dismiss, clear, error, heartbeat. The component must also allow it (see below).
valueobjectThe interaction payload. At most 16 KB serialized. Shape depends on component and type.
metadataobjectYour own annotations, at most 4 KB serialized. Defaults to {}. Stored and delivered as-is; never validated against the component contract.
Unknown top-level fields are rejected with a 400. Component ids: canvas.question, canvas.input, canvas.calendar, canvas.scheduling_embed, canvas.text, canvas.chart, canvas.alert.
This endpoint accepts only the component ids above.

Interaction Types

typeSent whenAllowed on
submitThe user completed the card: picked an answer, typed a value, chose a slot, booked a meeting.question, input, calendar, scheduling_embed
skipThe user explicitly skipped the card without answering.question, input, calendar, scheduling_embed
dismissThe user closed the card.all components
clearThe card was cleared from the canvas.all components
errorThe card hit a render or runtime error.all components
heartbeatA liveness ping from a long-lived card.all components
Display-only components (text, chart, alert) allow only the four lifecycle types; sending submit to one returns a 400.

value Rules per Component

For submit and skip, value is validated against the component’s contract. The lifecycle types (dismiss, clear, error, heartbeat) pass value through without component-specific validation.
Componentskip rulessubmit rules
canvas.questionskipped: true and no answer payload.skipped: false with at least one selected option or a non-empty custom_text. Allowed keys are exactly selected_option_ids (list of strings, required), skipped (boolean, required), custom_text (max 4,000 chars), and option_texts (maps option ids to display strings of max 2,000 chars; every key must also appear in selected_option_ids). Unknown keys are rejected.
canvas.input{ "skipped": true }Requires input_type (one of text, email, number, tel) and value. For number the value must be numeric; otherwise it must be a string.
canvas.calendar{ "skipped": true }Requires exactly one of: selected_date (string), selected_slot ({ "id", "start", "end" }), selected_slots (non-empty list of slots), or selected_range ({ "start", "end" }).
canvas.scheduling_embedNot validated: only submit is checked against the contract; other types pass value through.Allowed keys are exactly provider, scheduled, event_uri, invitee_uri. provider must be "calendly" and scheduled must be true. The URI fields are optional but must be https Calendly URLs.
Example value payloads:
submit payloads by component
// canvas.question
{ "selected_option_ids": ["opt_2"], "skipped": false, "option_texts": { "opt_2": "2–10 people" } }

// canvas.input
{ "input_type": "email", "value": "ada@example.com" }

// canvas.calendar
{ "selected_slot": { "id": "slot_tue_10", "start": "2026-06-16T10:00:00Z", "end": "2026-06-16T10:30:00Z" } }

// canvas.scheduling_embed
{ "provider": "calendly", "scheduled": true, "event_uri": "https://api.calendly.com/scheduled_events/AAAA" }

Errors

Schema and contract validation failures return an { "error", "fields" } envelope. Conversation-state errors (400) and conflicts (409) return a { "message": "<string>" } envelope.
StatusBodyMeaning
400{ "error": "Invalid canvas interaction payload.", "fields": ["<field>", "_schema", ...] }The body failed schema or contract validation. fields lists the offending top-level field names only (max 10, each truncated to 64 chars); cross-field failures surface under the single key _schema. No marshmallow detail strings are returned.
400{ "message": "Invalid conversation_id" }No conversation exists with this id. Note this is a 400, not a 404.
400{ "message": "Canvas interactions can only be recorded for active conversations." }The conversation has ended (or hasn’t started).
409{ "message": "Interaction does not match the issued canvas instance for this tool_call_id." }The component or component_version doesn’t match the card Tavus issued for this tool_call_id.
409{ "message": "interaction_id was already recorded with a different payload." }This interaction_id was already used with different contents. Retries must be byte-identical; new interactions need a new id.
429{ "error": "Too many requests" }Rate limit exceeded (120 POSTs per 60-second window per client IP and conversation). Response includes a Retry-After header. Back off and retry.
The following checks run internally and all surface through the { "error", "fields" } envelope above; their internal messages (component, component_version, interaction-type, and size rules) are not returned to the client:
  • An unsupported component id.
  • A component_version that doesn’t match the component’s current version (v1).
  • An interaction type the component doesn’t allow (for example, submit on canvas.text).
  • value over 16 KB or metadata over 4 KB serialized.
  • A value that breaks the component’s submit or skip rules; see the per-component rules above.
The size caps apply to the serialized JSON of value and metadata individually, not to the raw request body.

The canvas.interaction Webhook

Every recorded interaction is delivered to your conversation webhook as a canvas.interaction event, on the same callback pipeline as all other conversation events. Set callback_url when creating the conversation and route on event_type === "canvas.interaction"; properties carries the interaction exactly as recorded.
{
  "message_type": "canvas",
  "event_type": "canvas.interaction",
  "conversation_id": "c123456",
  "timestamp": "2026-06-09T21:14:03Z",
  "properties": {
    "conversation_id": "c123456",
    "interaction_id": "ci_call_8f2d41_submit_5e0b7c2a",
    "tool_call_id": "call_8f2d41",
    "component": "canvas.question",
    "component_version": "v1",
    "type": "submit",
    "value": { "selected_option_ids": ["opt_2"], "skipped": false },
    "metadata": { "client": "kiosk-web" },
    "created_at": "2026-06-09T21:14:03.518923"
  }
}
properties fields:
FieldTypeRequiredDescription
conversation_idstringThe conversation the interaction belongs to.
interaction_idstringThe client’s idempotency key for this interaction.
tool_call_idstringThe Canvas invocation that showed the card.
componentstringComponent id, e.g. canvas.question.
component_versionstringComponent contract version, e.g. v1.
typestringOne of submit, skip, dismiss, clear, error, heartbeat.
valueobjectThe interaction payload, exactly as validated and stored.
metadataobjectThe client’s annotations, passed through unvalidated.
created_atstring or nullWhen the interaction was recorded. A naive ISO-8601 timestamp with microseconds and no timezone suffix (no trailing Z). The value is UTC; parse it accordingly. The top-level timestamp on the webhook envelope, by contrast, carries a Z.
skip and dismiss events are delivered as well as submit; count them if you track completion rates.

Fetch History

GET /v2/conversations/{conversation_id}/canvas/interactions Requires your API key; you must own the conversation. Call it from your backend, not the browser.
curl https://tavusapi.com/v2/conversations/{conversation_id}/canvas/interactions \
  -H "x-api-key: <your-api-key>"
{
  "data": [
    {
      "conversation_id": "c123456",
      "interaction_id": "ci_call_8f2d41_submit_5e0b7c2a",
      "tool_call_id": "call_8f2d41",
      "component": "canvas.question",
      "component_version": "v1",
      "type": "submit",
      "value": { "selected_option_ids": ["opt_2"], "skipped": false },
      "metadata": { "client": "kiosk-web" },
      "created_at": "2026-06-09T21:14:03.518923"
    }
  ]
}
  • Items use the same fields as the webhook’s properties object.
  • Ordered oldest first (created_at, then insertion order as a tiebreaker).
  • Readable during and after the conversation: writes stop when the call ends, reads don’t. Use it for post-call processing and to reconcile against your webhook log.

Idempotency and Delivery Guarantees

  • The POST is idempotent on (conversation_id, interaction_id). An identical retry returns 200, stores nothing new, and fires no second webhook. Safe to retry on timeouts.
  • “Identical” means tool_call_id, component, component_version, type, and value all match. metadata is excluded; the first recorded metadata is kept and delivered.
  • A reused interaction_id with a different payload is a 409, never a silent overwrite.
  • The webhook fires once, when the interaction is first recorded. Replays, retries, and concurrent duplicates never re-fire it.
  • The interaction store is the source of truth. In rare failure cases an event may not reach your endpoint even though the interaction was stored. If completeness matters, reconcile with the history GET after the call.
  • Self-generated interaction_ids must be unique per logical interaction and reused on every retry of that interaction. The Tavus client uses ci_{tool_call_id}_{type}_{uuid}.