Skip to content

AI API

Butterbase exposes an OpenAI-compatible API for chat completions, embeddings, and model listing. There are two ways to call it:

  • App-scoped — calls go through /v1/{app_id}/..., are billed to the app’s owner, and inherit the app’s AI configuration (default model, allowed models).
  • Gateway mode — calls go through /v1/... (no app_id), authenticated with a platform JWT or a personal API key. Use this when you want a generic OpenAI-compatible gateway and don’t need an app.
MethodPathPurpose
POST/v1/{app_id}/chat/completionsChat completion (OpenAI-compatible)
POST/v1/{app_id}/embeddingsGenerate embeddings
GET/v1/{app_id}/ai/configGet AI configuration
PUT/v1/{app_id}/ai/configUpdate AI configuration
GET/v1/{app_id}/ai/usageGet AI usage statistics
MethodPathPurposeAuth
POST/v1/chat/completionsChat completionRequired
POST/v1/embeddingsGenerate embeddingsRequired
GET/v1/modelsList available models (OpenAI-shape)Required
GET/v1/public/modelsPublic model catalog with pricingNone

Drop-in compatible with the OpenAI SDK: point baseURL at https://api.butterbase.ai/v1 and use a personal API key as the bearer token. The request and response shapes are identical to the app-scoped variants — only the path differs.

POST /v1/chat/completions
Authorization: Bearer bb_sk_...
{
"model": "anthropic/claude-3.5-sonnet",
"messages": [
{ "role": "user", "content": "Hello!" }
],
"max_tokens": 500,
"temperature": 0.7,
"stream": false
}

Set "stream": true for server-sent events.

POST /v1/embeddings
Authorization: Bearer bb_sk_...
{
"model": "openai/text-embedding-3-small",
"input": "What is Butterbase?",
"encoding_format": "float"
}
GET /v1/models
Authorization: Bearer bb_sk_...

Response:

{
"object": "list",
"data": [
{ "id": "anthropic/claude-3.5-sonnet", "object": "model", "display_name": "Claude 3.5 Sonnet" },
{ "id": "openai/gpt-4o", "object": "model", "display_name": "GPT-4o" }
]
}

A separate unauthenticated endpoint returns the full catalog with pricing and context window — useful for documentation pages, model pickers, and tooling that needs to enumerate models before the user has signed in.

GET /v1/public/models

No authorization header required.

Response:

{
"models": [
{
"id": "anthropic/claude-sonnet-4.6",
"name": "Claude Sonnet 4.6",
"inputPricePerMTokens": 3.6,
"outputPricePerMTokens": 18.0,
"contextWindow": 200000
},
{
"id": "openai/gpt-4o",
"name": "GPT-4o",
"inputPricePerMTokens": 3.0,
"outputPricePerMTokens": 12.0,
"contextWindow": 128000
}
]
}

Prices are per 1 million tokens and reflect what your account is charged when you call the model. contextWindow may be null for models that don’t report it.

Authenticate with either your platform JWT (for session-based clients like the dashboard) or a personal API key. Personal keys must have the ai:gateway scope to access these endpoints. See Personal API keys below.

Errors are returned in OpenAI-compatible shape:

{ "error": { "message": "...", "type": "...", "code": "..." } }
Statuserror.typeerror.codeWhen
401authentication_errormissing_credentialsNo Authorization header.
401authentication_errorinvalid_api_keyToken is unknown, revoked, or expired.
403permission_errorinsufficient_scopeAPI key is missing the ai:gateway scope.
402billing_errorinsufficient_creditsAccount balance is too low for the requested call.
404invalid_request_errormodel_not_foundRequested model id isn’t available.
400invalid_request_errorinvalid_requestRequest body failed validation.
5xxapi_error(varies)Temporary upstream issue. Retry with backoff.
POST /v1/{app_id}/chat/completions
Authorization: Bearer {token}
{
"model": "anthropic/claude-3.5-sonnet",
"messages": [
{ "role": "system", "content": "You are a helpful assistant." },
{ "role": "user", "content": "Hello!" }
],
"max_tokens": 500,
"temperature": 0.7,
"stream": false
}

Standard OpenAI-compatible response format. Set "stream": true for server-sent events.

POST /v1/{app_id}/embeddings
Authorization: Bearer {token}
{
"model": "openai/text-embedding-3-small",
"input": "What is Butterbase?",
"encoding_format": "float"
}
ParameterDescription
modelEmbedding model ID (required)
inputString or array of strings (required)
encoding_format"float" (default) or "base64"
ModelIDDimensions
Text Embedding 3 Smallopenai/text-embedding-3-small1536
Text Embedding 3 Largeopenai/text-embedding-3-large3072
Text Embedding Ada 002openai/text-embedding-ada-0021536

Video generation is asynchronous. You submit a job, poll for status, then download the bytes when it’s done. A single video typically takes 30 seconds to several minutes depending on model and length.

MethodPathPurpose
POST/v1/{app_id}/videos/completionsSubmit a generation job
GET/v1/{app_id}/videos/completions/{job_id}Poll job status (also downloads when terminal)
GET/v1/{app_id}/videos/completions/{job_id}/content?index=NStream the rendered MP4

The video models in your gateway appear in GET /v1/{app_id}/ai/models alongside chat and embedding models. Look for the ones whose IDs begin with provider prefixes for video families (e.g. bytedance/seedance-…, kwaivgi/kling-…, pixverse/…, google/veo-…). If you POST a video model to /chat/completions, Butterbase returns 400 USE_VIDEO_ENDPOINT with the correct URL in the message.

curl:

Terminal window
curl -X POST "https://api.butterbase.ai/v1/$APP_ID/videos/completions" \
-H "Authorization: Bearer $BUTTERBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "bytedance/seedance-2.0-fast",
"prompt": "A golden retriever running through sunflowers at sunset, cinematic",
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9"
}'

TypeScript (fetch):

const res = await fetch(`${BUTTERBASE_API_URL}/v1/${APP_ID}/videos/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'bytedance/seedance-2.0-fast',
prompt: 'A golden retriever running through sunflowers at sunset, cinematic',
duration: 4,
resolution: '720p',
aspect_ratio: '16:9',
}),
});
const job = await res.json();
// { job_id, status: 'pending', polling_url }

Python (requests):

import os, requests
res = requests.post(
f"{os.environ['BUTTERBASE_API_URL']}/v1/{APP_ID}/videos/completions",
headers={"Authorization": f"Bearer {os.environ['BUTTERBASE_API_KEY']}"},
json={
"model": "bytedance/seedance-2.0-fast",
"prompt": "A golden retriever running through sunflowers at sunset, cinematic",
"duration": 4,
"resolution": "720p",
"aspect_ratio": "16:9",
},
)
job = res.json()
# {"job_id": "...", "status": "pending", "polling_url": "..."}

Response (202 Accepted):

{
"job_id": "5cd4be3e-c65e-4524-97cf-4595a76e2096",
"status": "pending",
"polling_url": "https://api.butterbase.ai/v1/{app_id}/videos/completions/5cd4be3e-c65e-4524-97cf-4595a76e2096"
}

Request fields:

FieldRequiredDescription
modelyesVideo model ID (see “Choosing a model” above).
promptyesText description of the video to generate.
durationnoLength in seconds. Model-specific (commonly 4, 6, 8).
resolutionnoe.g. 720p, 1080p. Model-specific.
aspect_rationoe.g. 16:9, 9:16, 1:1. Model-specific.
generate_audionoBoolean. Some models can render audio alongside video.
seednoInteger for deterministic generation (not all providers honor this).
input_imagesnoArray of HTTPS image URLs for image-to-video / first-frame guidance.

Poll the URL from polling_url every 30 seconds until status is terminal (completed, failed, cancelled, or expired).

curl:

Terminal window
curl "https://api.butterbase.ai/v1/$APP_ID/videos/completions/$JOB_ID" \
-H "Authorization: Bearer $BUTTERBASE_API_KEY"

TypeScript:

async function poll(jobUrl: string, apiKey: string) {
while (true) {
const res = await fetch(jobUrl, { headers: { Authorization: `Bearer ${apiKey}` } });
const job = await res.json();
if (['completed', 'failed', 'cancelled', 'expired'].includes(job.status)) return job;
await new Promise(r => setTimeout(r, 30_000));
}
}

Python:

import time, requests
def poll(job_url, api_key):
while True:
job = requests.get(job_url, headers={"Authorization": f"Bearer {api_key}"}).json()
if job["status"] in {"completed", "failed", "cancelled", "expired"}:
return job
time.sleep(30)

Response (when completed):

{
"job_id": "5cd4be3e-c65e-4524-97cf-4595a76e2096",
"status": "completed",
"model": "bytedance/seedance-2.0-fast",
"polling_url": "https://api.butterbase.ai/v1/{app_id}/videos/completions/5cd4be3e-...",
"content_urls": [
"https://api.butterbase.ai/v1/{app_id}/videos/completions/5cd4be3e-.../content?index=0"
],
"error": null,
"created_at": "2026-05-24T09:59:10.738Z",
"charged_credits_usd": 0.72576,
"settled_at": "2026-05-24T10:00:56.769Z"
}
  • content_urls is an array because some models render multiple variants. Use ?index=N to pick one.
  • charged_credits_usd is populated once the job settles (first terminal poll). It’s null while pending or in progress.
  • For status: "failed", error carries the upstream message.

The URLs in content_urls are absolute and require the same Bearer API key. They stream video/mp4 bytes.

curl:

Terminal window
curl -L "$CONTENT_URL" \
-H "Authorization: Bearer $BUTTERBASE_API_KEY" \
--output video.mp4

TypeScript:

const mp4 = await fetch(job.content_urls[0], { headers: { Authorization: `Bearer ${apiKey}` } });
const buf = Buffer.from(await mp4.arrayBuffer());
fs.writeFileSync('video.mp4', buf);

Python:

import requests
with requests.get(job["content_urls"][0],
headers={"Authorization": f"Bearer {api_key}"}, stream=True) as r:
with open("video.mp4", "wb") as f:
for chunk in r.iter_content(8192):
f.write(chunk)
StatusCodeMeaning
400USE_VIDEO_ENDPOINTYou sent a video model to /chat/completions; use /videos/completions instead.
400INVALID_INDEX?index= was not a non-negative integer.
402INSUFFICIENT_CREDITSNot enough credits to reserve the job. Response includes required_usd, available_usd, and your auto-refill state.
403FORBIDDENYou’re not the submitter of this job.
404MODEL_NOT_FOUNDUnknown model ID.
404JOB_NOT_FOUNDUnknown job ID, or the job belongs to a different app.
409JOB_NOT_COMPLETEDYou requested /content but the job hasn’t reached completed.
502MODEL_UNAVAILABLEUpstream temporarily unavailable. Retry.

Each job is persisted. You can poll from any client / process — there’s no in-memory state. Lost the polling_url? It’s https://api.butterbase.ai/v1/{app_id}/videos/completions/{job_id}.

If you stop polling before the job completes, the credit reservation is automatically released after a few minutes and any upstream charge that occurred is on us. Just don’t expect to retrieve the video later — re-submit.

Image generation follows the same async submit → poll → download shape as video. Most models complete in 5–30 seconds. Unlike /chat/completions, the image endpoint accepts image-native parameters (size, aspect_ratio, seed, n, reference images, masks) which the chat surface can’t express.

MethodPathPurpose
POST/v1/{app_id}/images/completionsSubmit a generation job
GET/v1/{app_id}/images/completions/{job_id}Poll job status
GET/v1/{app_id}/images/completions/{job_id}/content?index=NStream the rendered PNG/JPEG

Image models appear in GET /v1/{app_id}/ai/models alongside chat and video. Available at launch:

FamilyModel IDs
OpenAIopenai/gpt-image-2, openai/gpt-image-1, openai/gpt-image-1-mini
Google (Nano Banana)google/gemini-3-pro-image-preview, google/gemini-3.1-flash-image-preview, google/gemini-2.5-flash-image
ByteDance Seedreambytedance/seedream-5-pro, bytedance/seedream-5-lite, bytedance/seedream-4-5
Alibaba Wanalibaba/wan-2.7-image-pro, alibaba/wan-2.7-image, alibaba/wan-2.6-t2i, alibaba/wan-2.6-image
PrunaAIprunaai/p-image, prunaai/p-image-edit

Each model exposes a different subset of parameters. Submitting an unsupported param returns 400 UNSUPPORTED_PARAM with the full whitelist of what that model does accept — no silent drops.

curl:

Terminal window
curl -X POST "https://api.butterbase.ai/v1/$APP_ID/images/completions" \
-H "Authorization: Bearer $BUTTERBASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "openai/gpt-image-2",
"prompt": "A serene mountain lake at sunset, photorealistic",
"size": "1024x1024",
"n": 1
}'

TypeScript (fetch):

const res = await fetch(`${BUTTERBASE_API_URL}/v1/${APP_ID}/images/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'google/gemini-3-pro-image-preview',
prompt: 'A serene mountain lake at sunset, photorealistic',
aspect_ratio: '16:9',
}),
});
const job = await res.json();
// { job_id, status: 'pending', polling_url }

Python (requests):

import os, requests
res = requests.post(
f"{os.environ['BUTTERBASE_API_URL']}/v1/{APP_ID}/images/completions",
headers={"Authorization": f"Bearer {os.environ['BUTTERBASE_API_KEY']}"},
json={
"model": "alibaba/wan-2.7-image-pro",
"prompt": "A cat wearing a spacesuit",
"size": "2K",
"seed": 42,
},
)
job = res.json()
# {"job_id": "...", "status": "pending", "polling_url": "..."}

Response (202 Accepted):

{
"job_id": "0776b4a1-aef9-4fbc-8ac6-18255aa18614",
"status": "pending",
"polling_url": "https://api.butterbase.ai/v1/{app_id}/images/completions/0776b4a1-aef9-4fbc-8ac6-18255aa18614"
}

Request fields:

FieldRequiredDescription
modelyesImage model ID (see table above).
promptyesText description of the image to generate.
sizenoe.g. 1024x1024, 1536x1024, or symbolic like 1K / 2K / 4K. Model-specific.
aspect_rationoe.g. 16:9, 1:1. Gemini and Pruna families only.
nnoNumber of images to generate. GPT Image 2 / 1 / 1-mini only.
seednoInteger for deterministic generation. Wan, Pruna, GPT Image 2, and a few OpenRouter models.
input_imagesnoArray of HTTPS image URLs for image-to-image or reference guidance (up to 14 refs on most models).
masknoHTTPS URL of an alpha mask for edit mode. GPT Image 2 only.
negative_promptnoText description of what to avoid. Wan 2.6 only.
providernoObject of model-specific params (e.g. Wan’s bbox_list / color_palette, GPT’s quality / background, Seedream’s optimize_prompt_options). Validated against the model’s provider-param whitelist.

Image URL aliases: For convenience, top-level keys image, image_url, image_uri, reference_image, input_image, and starting_image are folded into input_images[] before validation. Only mask remains a distinct top-level field.

Poll the URL from polling_url every 2–5 seconds until status is terminal (completed, failed, cancelled, or expired).

curl:

Terminal window
curl "https://api.butterbase.ai/v1/$APP_ID/images/completions/$JOB_ID" \
-H "Authorization: Bearer $BUTTERBASE_API_KEY"

TypeScript:

async function poll(jobUrl: string, apiKey: string) {
while (true) {
const res = await fetch(jobUrl, { headers: { Authorization: `Bearer ${apiKey}` } });
const job = await res.json();
if (['completed', 'failed', 'cancelled', 'expired'].includes(job.status)) return job;
await new Promise(r => setTimeout(r, 2_000));
}
}

Python:

import time, requests
def poll(job_url, api_key):
while True:
job = requests.get(job_url, headers={"Authorization": f"Bearer {api_key}"}).json()
if job["status"] in {"completed", "failed", "cancelled", "expired"}:
return job
time.sleep(2)

Response (when completed):

{
"job_id": "0776b4a1-aef9-4fbc-8ac6-18255aa18614",
"status": "completed",
"model": "openai/gpt-image-2",
"polling_url": "https://api.butterbase.ai/v1/{app_id}/images/completions/0776b4a1-...",
"content_urls": [
"https://api.butterbase.ai/v1/{app_id}/images/completions/0776b4a1-.../content?index=0"
],
"error": null,
"created_at": "2026-07-15T02:57:44.271Z",
"charged_credits_usd": 0.010168,
"settled_at": "2026-07-15T02:58:04.512Z"
}
  • content_urls is an array — with n > 1 on GPT Image, one entry per generated image.
  • charged_credits_usd is populated once the job settles (first terminal poll). It’s null while pending or in progress.
  • For status: "failed", error carries the upstream message.

The URLs in content_urls are absolute and require the same Bearer API key. They stream PNG or JPEG bytes (see the Content-Type header — varies per model / request).

curl:

Terminal window
curl -L "$CONTENT_URL" \
-H "Authorization: Bearer $BUTTERBASE_API_KEY" \
--output image.png

TypeScript:

const img = await fetch(job.content_urls[0], { headers: { Authorization: `Bearer ${apiKey}` } });
const buf = Buffer.from(await img.arrayBuffer());
// Content-Type header tells you the format: image/png, image/jpeg, image/webp

Same taxonomy as video generation, plus one image-specific code:

StatusCodeMeaning
400UNSUPPORTED_PARAMRequest included a param the target model doesn’t accept. Response body lists supported_top_level and supported_provider for that model.
400WRONG_MODALITYModel isn’t an image model (probably a chat or video model).
402INSUFFICIENT_CREDITSApp is out of credits.
404JOB_NOT_FOUNDjobId doesn’t exist or belongs to another end-user.
409JOB_NOT_COMPLETEDRequested /content on a job that hasn’t reached terminal status.
502MODEL_UNAVAILABLEUpstream temporarily unavailable. Retry.

Content URLs are backed by the upstream provider’s storage, which typically expires ~30 days after generation. If you need long-term storage, download the image and mirror it to your own bucket (Butterbase Storage works well) before the window closes.

There is no per-app or per-key concurrency cap on the Butterbase side — fire off as many parallel POSTs as your credit balance covers, and poll each returned job_id independently. Upstream rate limits from the underlying model provider are the only ceiling; if you hit one, you’ll get a 429 with the specific upstream error surfaced.

PUT /v1/{app_id}/ai/config
{
"defaultModel": "anthropic/claude-3.5-sonnet",
"maxTokensPerRequest": 4096,
"allowedModels": ["anthropic/claude-3.5-sonnet"]
}
FieldDescription
defaultModelModel used when none specified
maxTokensPerRequestToken limit per request (1-100,000)
allowedModelsRestrict allowed models
GET /v1/{app_id}/ai/usage?startDate=2026-01-01&endDate=2026-01-31

Response:

{
"totalTokens": 150000,
"totalCost": 0.45,
"byModel": {
"anthropic/claude-3.5-sonnet": {
"tokens": 120000,
"cost": 0.40,
"requests": 25
}
}
}

To call the gateway endpoints from outside the dashboard (scripts, CLIs, the OpenAI SDK), mint a personal API key with the ai:gateway scope.

POST /api-keys
Authorization: Bearer {jwt}
{
"name": "my-cli",
"scopes": ["ai:gateway"]
}

The response contains the plaintext key once — store it immediately. Subsequent requests show only the prefix.

ScopeGrants
*Full access to all Butterbase APIs the user can use.
ai:gatewayAccess to POST /v1/chat/completions, POST /v1/embeddings, and GET /v1/models. Nothing else.

The dashboard at /api-keys lists and revokes keys; for now, scoping a key to ai:gateway is done by calling POST /api-keys directly.

ctx.ai.meetings lets your app spawn a bot that joins a Zoom, Google Meet, Microsoft Teams, or Webex call, records it, and transcribes the audio. Your app starts a bot via the REST API or SDK, then waits for webhook events to learn when the call ends and the artifacts (recording, transcript) are ready to download. Recordings and transcripts are billed against the app’s AI credit balance using the same ledger as chat completions.

MethodPathPurpose
POST/v1/ai/meetingsStart a bot
GET/v1/ai/meetings/{bot_id}Get bot status + artifact URLs
DELETE/v1/ai/meetings/{bot_id}Stop a bot
GET/v1/ai/meetingsList bots
GET/v1/ai/meetings/_estimateEstimate cost up front

Request:

POST /v1/ai/meetings
Authorization: Bearer bb_sk_...
{
"meetingUrl": "https://meet.google.com/abc-defg-hij",
"transcript": true,
"recording": "mp4",
"metadata": {
"dealId": "d_42"
}
}

Request fields:

FieldTypeRequiredDefaultDescription
meetingUrlstringyesFull URL of the meeting to join.
transcriptbooleannotrueWhether to generate a transcript.
recording"mp4" | "audio_only" | falseno"mp4"Recording format, or false to skip recording.
metadataRecord<string, string>no{}Arbitrary key–value pairs stored on the bot. Keys starting with bb_ are reserved and will be rejected with 400.

Response (201 Created):

{
"id": "bot_01j9...",
"status": "joining",
"startedAt": null,
"completedAt": null,
"durationSeconds": null,
"recordingUrl": null,
"transcriptUrl": null,
"metadata": {
"dealId": "d_42"
}
}

The MeetingBot shape is the same for all endpoints that return a bot.

Bots move through these states in order:

StatusMeaning
joiningBot is dialing into the meeting.
waiting_roomBot reached the meeting but is held in the waiting room.
in_callBot has been admitted to the call.
recordingBot is actively recording (and transcribing, if enabled).
endedThe call has ended; artifacts are being processed.
doneArtifacts are ready. recordingUrl and transcriptUrl (if applicable) are now populated.
fatalBot encountered an unrecoverable error. Check status for details and the bot.fatal webhook.
import { butterbase } from '@butterbase/sdk';
const bb = butterbase({ apiKey: '...', appId: '...' });
// Start a bot
const { data: bot, error } = await bb.ai.meetings.start({
meetingUrl: 'https://meet.google.com/abc-defg-hij',
transcript: true,
recording: 'mp4',
metadata: { dealId: 'd_42' },
});
// Later, after the webhook tells you the bot is done:
const { data: finished } = await bb.ai.meetings.get(bot!.id);
// finished.recordingUrl + finished.transcriptUrl are now populated

Additional SDK methods:

// Stop a bot early
await bb.ai.meetings.stop(bot!.id);
// List bots with optional filters
const { data: bots } = await bb.ai.meetings.list({ status: 'done', limit: 20, cursor: '...' });
// Estimate cost before dispatching
const { data: estimate } = await bb.ai.meetings.estimateCost({ durationMinutes: 60, transcript: true });
// estimate.usd — projected total charge

Recording: $0.50/hr + markup, prorated per second. Transcription: $0.15/hr + markup, also prorated per second. Both charges are applied to the app’s AI credit balance once the bot reaches done status.

Use GET /v1/ai/meetings/_estimate?durationMinutes=N&transcript=true to project the charge before dispatching a bot.

Apps receive forwarded events at the URL configured via manage_ai’s configure_meetings_webhook action. Butterbase re-signs each forwarded event before delivery.

Event types:

EventWhen it fires
bot.in_call_recordingBot has been admitted and recording has started.
bot.doneBot left the call; recording artifact is available.
bot.fatalBot failed; check bot status for details.
recording.doneRecording artifact is ready to download.
transcript.doneTranscript artifact is ready to download.
transcript.failedTranscription failed for this bot.

Payload shape:

POST <your-forward-url>
Content-Type: application/json
x-bb-event: bot.done
x-bb-signature: v1,<base64>
x-bb-key-id: <16-char identifier>
{
"event": "bot.done",
"data": { /* event-specific payload */ }
}

The x-bb-signature header is an HMAC-SHA256 of the raw request body bytes, signed with Butterbase’s webhook signing key. A valid signature confirms the event originated from Butterbase’s infrastructure. Full per-tenant HMAC verification (where each app has its own signing key) is on the roadmap. For now, verify the request arrived over TLS from a Butterbase-owned origin, and confirm that x-bb-key-id matches the first 16 characters of the secret returned on the most recent rotate_secret call. If it does not match, the event was signed before your latest key rotation — treat it as stale and drop it.

Do not trigger side-effects (database writes, downstream calls) on duplicate deliveries. Use the event payload’s bot.id combined with the event name as an idempotency key. The upstream delivery service may retry for up to 24 hours.

  • Single region. In v1, bots are spawned from the US East workspace. Latency-sensitive multi-region routing is on the roadmap.
  • No hard duration cap. Bots run until the call ends or the app’s AI credit balance is exhausted.
  • Artifact retention. Recording and transcript artifacts are retained for 7 days after the call ends. Download them before the retention window expires.