When to use the API directly
The widget handles everything for a standard website embed. Use the REST API directly when you need to:
- Start voice sessions from your own backend (inject user context, keep API keys server-side)
- Programmatically create or update agent characters
- Connect your APIs as agent tools (HTTP, MCP)
- Retrieve form submissions after calls
- Build a native mobile app or custom front-end without the widget
Authentication
Get a JWT by logging in:
curl -X POST https://api.oshara.ai/api/auth/login/ \
-H "Content-Type: application/json" \
-d '{"email": "you@yoursite.com", "password": "yourpassword"}'{ "access": "eyJhbGci...", "refresh": "eyJhbGci..." }Include the access token in every request:
Authorization: Bearer eyJhbGci...Tokens are valid for 10 days. Refresh before expiry:
curl -X POST https://api.oshara.ai/api/auth/refresh/ \
-d '{"refresh": "eyJhbGci..."}'Pattern 1 — Start a session from your backend
This is the most important integration pattern. Rather than letting the widget call Oshara directly, your backend fetches the token and passes it through. This way:
- Your users never need their own Oshara credentials
- You can inject user-specific context (name, email, account tier, etc.)
- You control session limits and logging
Browser → POST /your-api/voice-token → your backend → POST /api/agents/agent-session/ → Oshara
│
Browser ← { token, livekit_url } ◄──────────────────────────────────────────────────────────┘Your backend endpoint
// Node.js / Express
app.post("/your-api/voice-token", requireAuth, async (req, res) => {
const user = req.user;
const session = await fetch("https://api.oshara.ai/api/agents/agent-session/", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Origin": "https://yoursite.com"
},
body: JSON.stringify({
agent: "support-bot",
language: req.body.language || "en",
metadata: {
user_id: user.id,
user_name: user.fullName,
user_email: user.email,
account_tier: user.plan, // "free" | "pro" | "enterprise"
org_id: user.orgId
}
})
}).then(r => r.json());
// Return only what the browser needs
res.json({
token: session.token,
livekit_url: session.livekit_url,
session_id: session.session_id
});
});Python equivalent
import httpx
from fastapi import APIRouter, Depends
router = APIRouter()
@router.post("/voice-token")
async def get_voice_token(user=Depends(get_current_user)):
async with httpx.AsyncClient() as client:
r = await client.post(
"https://api.oshara.ai/api/agents/agent-session/",
headers={"Origin": "https://yoursite.com"},
json={
"agent": "support-bot",
"metadata": {
"user_id": str(user.id),
"user_name": user.full_name,
"user_email": user.email,
}
}
)
data = r.json()
return {"token": data["token"], "livekit_url": data["livekit_url"]}Pattern 2 — Connect your API as an agent tool
Give the agent the ability to look up data or take actions in your system.
Step 1 — Create an endpoint the agent can call
Your endpoint receives the LLM’s tool arguments as a JSON POST body and returns JSON.
// Your API endpoint
app.post("/api/oshara/ticket-lookup", verifyOsharaToken, async (req, res) => {
const { ticket_id } = req.body;
const ticket = await db.tickets.findById(ticket_id);
if (!ticket) return res.status(404).json({ error: "Ticket not found" });
res.json({
id: ticket.id,
status: ticket.status,
subject: ticket.subject,
priority: ticket.priority,
updated: ticket.updatedAt
});
});Secure your endpoint with a static token — pass it in the tool’s config.headers. The agent worker sends it on every call.
Step 2 — Register the tool on your character
curl -X PATCH https://api.oshara.ai/api/ai-characters/support-bot/ \
-H "Authorization: Bearer <your-token>" \
-H "Content-Type: application/json" \
-d '{
"widget_appearance": { ... },
"tools": [
{
"name": "ticket_lookup",
"kind": "http",
"description": "Look up a support ticket by its ID. Use when the user mentions a ticket number like TICKET-123.",
"input_schema": {
"type": "object",
"properties": {
"ticket_id": { "type": "string", "description": "Ticket ID, e.g. TICKET-123" }
},
"required": ["ticket_id"]
},
"config": {
"url": "https://yoursite.com/api/oshara/ticket-lookup",
"method": "POST",
"headers": { "X-Oshara-Secret": "your-shared-secret" }
}
}
]
}'The agent can now look up tickets during a call. If a user says “what’s the status of ticket TICKET-456?”, the agent calls the tool, gets the response, and answers the user — all in real time.
Multiple tools
"tools": [
{
"name": "ticket_lookup",
"kind": "http",
"description": "...",
"config": { "url": "https://yoursite.com/api/oshara/ticket-lookup", "method": "POST" }
},
{
"name": "create_ticket",
"kind": "http",
"description": "Create a new support ticket. Use when the user reports an issue and wants to log it.",
"input_schema": {
"type": "object",
"properties": {
"subject": { "type": "string" },
"priority": { "type": "string", "enum": ["low", "normal", "high"] },
"details": { "type": "string" }
},
"required": ["subject", "details"]
},
"config": { "url": "https://yoursite.com/api/oshara/tickets", "method": "POST" }
},
{
"name": "search_docs",
"kind": "kb",
"description": "Search our knowledge base for policy and product information.",
"input_schema": {
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
},
"config": {}
}
]Pattern 3 — Retrieve form responses after calls
If you used submit_url: null (managed storage), fetch submissions from your backend:
// Nightly sync job
const responses = await fetch(
"https://api.oshara.ai/api/agents/support-bot/form-responses/?form_id=book-demo",
{ headers: { Authorization: `Bearer ${OSHARA_TOKEN}` } }
).then(r => r.json());
for (const submission of responses) {
await crm.createLead({
name: submission.values.name,
email: submission.values.email,
source: "voice-widget",
session: submission.session_id
});
}Filter by session to get all forms from a specific call:
GET /api/agents/support-bot/form-responses/?session_id=sess_abc123Pattern 4 — Programmatically manage characters
Useful if you’re building a SaaS platform on top of Oshara and need to create an agent per customer.
// Create a character for a new customer
async function provisionAgent(customer) {
const character = await fetch("https://api.oshara.ai/api/ai-characters/", {
method: "POST",
headers: {
Authorization: `Bearer ${OSHARA_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
slug: `${customer.id}-bot`,
name: `${customer.companyName} Assistant`,
system_prompt: `You are a support agent for ${customer.companyName}. ${customer.agentInstructions}`,
greeting: `Hi! I'm ${customer.companyName}'s assistant. How can I help?`,
language: "en",
allowed_origins: [`https://${customer.domain}`],
llm_model: "gpt-4o-mini",
widget_appearance: {
name: customer.companyName,
theme: { primary_color: customer.brandColor }
}
})
}).then(r => r.json());
return character.slug; // e.g. "cust-42-bot"
}Pattern 5 — Fetch form schema and render it in your own UI
Use this when you want to render Oshara forms in your own app (a React page, a mobile screen, a custom panel) instead of relying on the built-in widget UI.
Step 1 — Fetch form definitions from the appearance endpoint
GET /api/agents/support-bot/appearance/The response includes a forms array with the complete schema for every form defined on the character:
{
"name": "Support Bot",
"theme": { ... },
"forms": [
{
"id": "book-demo",
"title": "Book a demo",
"subtitle": "Tell us about your use case",
"submit_url": "https://yoursite.com/api/demo-requests",
"submit_label": "Confirm booking",
"success_message": "We'll be in touch within one business day!",
"fields": [
{ "name": "name", "label": "Your name", "type": "text", "required": true },
{ "name": "email", "label": "Work email", "type": "email", "required": true },
{ "name": "company", "label": "Company", "type": "text", "required": false },
{
"name": "use_case",
"label": "Use case",
"type": "select",
"required": true,
"options": ["Customer support", "Sales assistant", "Other"]
},
{ "name": "date", "label": "Preferred date","type": "date", "required": true }
]
},
{
"id": "contact",
"title": "Send us a message",
"submit_url": null,
"fields": [
{ "name": "name", "label": "Name", "type": "text", "required": true },
{ "name": "message", "label": "Message", "type": "textarea", "required": true, "rows": 4 }
]
}
]
}Each form in the array is self-describing — you have the id, every field’s name, type, label, required flag, and options (for select/radio). You have everything needed to render it with no hardcoding.
Step 2 — Fetch and extract forms (JavaScript)
async function getAgentForms(slug) {
const res = await fetch(`https://api.oshara.ai/api/agents/${slug}/appearance/`, {
headers: { Origin: "https://yoursite.com" }
});
const appearance = await res.json();
return appearance.forms ?? []; // array of FormDefinition objects
}
// Find a specific form by id
const forms = await getAgentForms("support-bot");
const demoForm = forms.find(f => f.id === "book-demo");Step 3 — Render fields dynamically
Map each field’s type to your UI component:
// React example
function FormField({ field, value, onChange }) {
switch (field.type) {
case "text":
case "email":
case "tel":
case "number":
return (
<input
type={field.type}
name={field.name}
value={value}
placeholder={field.placeholder ?? ""}
required={field.required}
onChange={e => onChange(field.name, e.target.value)}
/>
);
case "textarea":
return (
<textarea
name={field.name}
rows={field.rows ?? 3}
value={value}
required={field.required}
onChange={e => onChange(field.name, e.target.value)}
/>
);
case "select":
return (
<select
name={field.name}
value={value}
required={field.required}
onChange={e => onChange(field.name, e.target.value)}
>
<option value="">Select…</option>
{field.options.map(opt => {
const val = typeof opt === "string" ? opt : opt.value;
const label = typeof opt === "string" ? opt : (opt.label ?? opt.value);
return <option key={val} value={val}>{label}</option>;
})}
</select>
);
case "radio":
return (
<div>
{field.options.map(opt => {
const val = typeof opt === "string" ? opt : opt.value;
return (
<label key={val}>
<input
type="radio"
name={field.name}
value={val}
checked={value === val}
onChange={() => onChange(field.name, val)}
/>
{val}
</label>
);
})}
</div>
);
case "date":
case "time":
return (
<input
type={field.type}
name={field.name}
value={value}
required={field.required}
onChange={e => onChange(field.name, e.target.value)}
/>
);
case "checkbox":
return (
<input
type="checkbox"
name={field.name}
checked={value === "true"}
onChange={e => onChange(field.name, e.target.checked ? "true" : "false")}
/>
);
case "display":
return <p>{field.label}</p>; // read-only text, no input
default:
return (
<input
type="text"
name={field.name}
value={value}
onChange={e => onChange(field.name, e.target.value)}
/>
);
}
}Rendering a multi-step form
Multi-step forms have a steps array instead of a top-level fields array. Each step has its own fields:
{
"id": "onboarding",
"title": "Get started",
"steps": [
{ "id": "details", "title": "Your details", "fields": [...] },
{ "id": "use-case", "title": "Your use case", "fields": [...] },
{ "id": "schedule", "title": "Book a time", "fields": [...] }
]
}function MultiStepForm({ form }) {
const [stepIndex, setStepIndex] = React.useState(0);
const [values, setValues] = React.useState({});
// Determine whether to use steps or flat fields
const steps = form.steps
? form.steps
: [{ id: "default", fields: form.fields }];
const currentStep = steps[stepIndex];
const isLastStep = stepIndex === steps.length - 1;
const handleChange = (name, value) => setValues(v => ({ ...v, [name]: value }));
const handleNext = () => setStepIndex(i => i + 1);
const handleBack = () => setStepIndex(i => i - 1);
const handleSubmit = () => submitForm(form, values);
return (
<div>
<h2>{form.title}</h2>
{/* Step progress indicator */}
{form.steps && (
<p>Step {stepIndex + 1} of {steps.length}: {currentStep.title}</p>
)}
{/* Render current step's fields */}
{currentStep.fields.map(field => (
<div key={field.name ?? field.label}>
{field.type !== "display" && <label>{field.label}</label>}
<FormField
field={field}
value={values[field.name] ?? field.default_value ?? ""}
onChange={handleChange}
/>
{field.help_text && <small>{field.help_text}</small>}
</div>
))}
{/* Navigation */}
<div>
{stepIndex > 0 && <button onClick={handleBack}>Back</button>}
{isLastStep
? <button onClick={handleSubmit}>{form.submit_label ?? "Submit"}</button>
: <button onClick={handleNext}>Next</button>
}
</div>
</div>
);
}Step 4 — Submit the form
Collect all values across steps into one flat object and POST it to submit_url.
async function submitForm(form, values) {
// submit_url: null means POST to Oshara managed storage
const url = form.submit_url
?? `https://api.oshara.ai/api/agents/support-bot/form-responses/`;
const body = form.submit_url
? values // your endpoint: flat values object
: { form_id: form.id, values }; // Oshara managed: wrap with form_id
const res = await fetch(url, {
method: form.submit_method ?? "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error("Submission failed");
return form.success_message ?? "Submitted successfully.";
}Step 5 — Field validation before submit
Use the schema fields to validate before sending:
function validate(fields, values) {
const errors = {};
for (const field of fields) {
if (field.type === "display") continue;
const val = (values[field.name] ?? "").toString().trim();
if (field.required && !val) {
errors[field.name] = `${field.label} is required`;
continue;
}
if (field.type === "email" && val && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) {
errors[field.name] = "Enter a valid email address";
}
if (field.pattern && val && !new RegExp(field.pattern).test(val)) {
errors[field.name] = `${field.label} format is invalid`;
}
if (field.min !== undefined && Number(val) < field.min) {
errors[field.name] = `Minimum value is ${field.min}`;
}
if (field.max !== undefined && Number(val) > field.max) {
errors[field.name] = `Maximum value is ${field.max}`;
}
}
return errors; // empty = valid
}Field schema reference (what the API returns per field)
| Field | Type | Description |
|---|---|---|
name | string | Key used in the submitted values object. undefined for display fields. |
label | string | Display label for the field. |
type | string | Input type — text, email, tel, textarea, select, number, date, time, checkbox, radio, display. |
required | boolean | Whether the field must be non-empty before submission. |
placeholder | string | Placeholder text for text inputs. |
options | string[] or {value, label?}[] | Choices for select and radio. |
rows | number | Row count for textarea. |
default_value | string | Pre-filled value. Use as initial state. |
help_text | string | Sub-label hint shown below the field. |
pattern | string | Regex pattern for client-side validation. |
min | number | Min value (number) or min character length (text). |
max | number | Max value or max character length. |
width | "full" | "half" | Hint for layout: "half" fields go side-by-side in a two-column grid. |
Environment variables (recommended setup)
Keep your Oshara token out of source code:
# .env
OSHARA_API_URL=https://api.oshara.ai
OSHARA_TOKEN=eyJhbGci...
OSHARA_AGENT_SLUG=support-botconst OSHARA_HEADERS = {
Authorization: `Bearer ${process.env.OSHARA_TOKEN}`,
"Content-Type": "application/json"
};Common errors
| Error | Cause | Fix |
|---|---|---|
403 Forbidden on session start | Domain not in Allowed Origins | Add your domain in the dashboard |
404 Not Found on session start | Wrong agent slug | Check the slug in the dashboard |
401 Unauthorized | Expired or missing JWT | Refresh your token |
| Tool call returns error | Your endpoint returned non-2xx | Check your endpoint logs; ensure it returns 2xx |
| Form submission not received | submit_url misconfigured | Check the URL is absolute and returns 2xx |