NOVA API documentation
Everything you need to drive NOVA programmatically — dispatch qualification calls, manage billing, receive signed lifecycle webhooks, and pipe portal leads straight into the agent.
On this page
Overview
The NOVA API is a JSON-over-HTTPS REST API. Authenticated endpoints
expect a JWT in the Authorization header:
Authorization: Bearer <your-jwt>
You get a JWT from POST /auth/register
or POST /auth/login. New accounts
start a 7-day trial with 10 call minutes included — no card required.
Demo mode — read this first. NOVA currently runs in browser demo mode: submitting calls dispatches the full agent pipeline (context generation, agent room, 4-phase conversation), but the agent connects to a browser-based test room instead of dialing real phone numbers. You can talk to your agent end-to-end from the browser via the demo token endpoint. For production calling, contact ceo@novalabs.ae.
Browser requests are CORS-restricted to
novalabs.ae origins; server-to-server requests are
unrestricted. All request and response bodies are
application/json unless noted otherwise.
Quickstart
Three requests from zero to a live agent conversation. You can do the same flow without code from the wizard at novalabs.ae — “Deploy Your First Agent”.
1. Create an account, get a token
curl -X POST https://api.novalabs.ae/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "you@example.com",
"password": "a-strong-password"
}'
The response includes your JWT and trial status:
{
"access_token": "eyJhbGciOi...",
"token_type": "bearer",
"user": {
"id": "8f3b2a4c-...",
"email": "you@example.com",
"trial_active": true,
"trial_minutes_remaining": 10.0,
"created_at": "2026-06-12T08:30:00Z"
}
}
2. Dispatch your first agent call
curl -X POST https://api.novalabs.ae/calls \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $NOVA_TOKEN" \
-d '{
"owner_email": "you@example.com",
"product": "CloudSync CRM",
"website_url": "https://your-site.example",
"context": "Mid-market CRM, AED 99/seat, main objection is migration effort",
"goal": "qualify_interest",
"consent": true,
"leads": [
{ "phone": "+971501234567", "name": "Sarah", "company": "Acme Corp" }
]
}'
NOVA builds a personalised call context per lead (scraping
website_url if provided) and dispatches one agent per
lead. The response gives you a call_id per lead:
{
"calls": [
{
"call_id": "c1a2...",
"context_id": "d4e5...",
"phone": "+971501234567",
"lead_name": "Sarah",
"status": "in_progress",
"expires_in_seconds": 600.0,
"message": "Call dispatched successfully"
}
],
"total": 1,
"dispatched": 1,
"failed": 0
}
3. Poll call status
curl https://api.novalabs.ae/calls/$CALL_ID
Call jobs are ephemeral — they expire 10 minutes after creation,
after which the status endpoint returns 404. Use
webhooks for durable lifecycle events.
Authentication
Native JWT auth — register, log in, and pass the returned
access_token as a Bearer header. Passwords must be at
least 6 characters; emails are normalised to lowercase.
/auth/register
no auth
Create an account and return a JWT. Starts a 7-day trial with 10
minutes. Optional fields: name,
referral_code.
| Status | Meaning |
|---|---|
201 | Account created — body is { access_token, token_type, user } |
409 | Email already registered |
422 | Password shorter than 6 characters / invalid email |
/auth/login
no auth
Body { "email", "password" }. Returns the same
{ access_token, token_type, user } shape as
register. 401 on bad credentials.
/auth/me
bearer
Current user profile: id, email,
email_verified, phone,
trial_active,
trial_minutes_remaining, created_at.
/auth/forgot-password
no auth
Body { "email" }. Sends a 6-digit reset code valid
for 10 minutes. Always returns 200 with a generic
message (no account enumeration). Rate limit: 3 requests per
email per hour.
/auth/reset-password
no auth
Body { "email", "code", "new_password" }. Maximum 5
verification attempts per code (429 after that);
400 on invalid/expired code; 422 on a
short password.
/auth/check-email
no auth
Body { "email" } → { "exists": bool }.
Used by the signup UI to route to log-in vs. register.
/me/features
bearer
Per-user feature-flag payload —
{ "voice_cloning": bool, "byo_twilio": bool }. The
dashboard reads this to decide whether to show the
gated feature surfaces.
Calls
/calls
bearer optional*
Submit a batch of 1–5 leads. NOVA generates a per-lead call
context and dispatches one agent conversation per lead.
*Anonymous submissions are currently accepted; when the
FEATURE_CALLS_AUTH_REQUIRED server flag is on, a
Bearer token is mandatory (401 without one).
Authenticated calls are metered against your plan
(402 when over limit).
Request body
| Field | Type | Notes |
|---|---|---|
owner_email | string, required | Where call result briefs are sent |
leads | array, required | 1–5 objects: phone (E.164, required, no duplicates), name, company, email, title |
product | string, required | What you’re selling |
website_url | string | Scraped for product context when provided |
context | string | Free-text product info, objections, pricing |
goal | enum, required | book_meeting · qualify_interest · collect_info · close_sale |
language | enum | en (default) or ar-AE |
booking_link | string | Required when goal=book_meeting |
payment_link | string | Required when goal=close_sale |
pricing_summary / urgency_hook / goal_criteria | string | Optional goal refinements |
consent | bool, required | Must be true — you confirm permission to contact these numbers |
Errors
| Status | Meaning |
|---|---|
400 | Missing consent · no leads · >5 leads · non-E.164 phone · duplicate phone · missing goal-specific link |
401 | Auth required (when the server auth gate is on) |
402 | Plan/trial minute limit would be exceeded |
429 | Rate limit — max 5 submissions per IP per 60 seconds |
Response is a BatchCallResponse — see the
quickstart for the exact shape. Per-lead
status is one of pending,
in_progress, completed,
failed.
/calls/{call_id}
no auth
Status for one dispatched call:
{ call_id, status, phone, sms_sent, error,
expires_in_seconds }. Returns 404 when the ID
is unknown or the job passed its 10-minute TTL.
/contexts/{context_id}
no auth
Debugging endpoint — returns the full generated context instance
that was handed to the agent (opening line, qualification
questions, objection handlers, fact sheet). Same 10-minute TTL
and 404 semantics as call jobs.
Browser demo token
/token
no auth
Generate a LiveKit participant token so you can join the agent’s room from a browser and have the full conversation over WebRTC — this is how “Join Call as Test Caller” works in demo mode.
curl -X POST https://api.novalabs.ae/token \
-H "Content-Type: application/json" \
-d '{ "room_name": "<room from your dispatch>", "participant_name": "Test Caller" }'
Response:
{ server_url, participant_token, room_name }. The
token grants audio publish + subscribe and expires after 10
minutes. 500 if LiveKit is not configured on the
server.
Billing & usage
Plans are usage-based on call minutes. Trial accounts get 10 minutes over 7 days. Card payments run through Stripe; UAE customers can pay in AED via Ziina.
/billing/plans
no auth
Available plans with price, included minutes, and overage rate: Starter ($29/mo · 100 min), Growth ($99/mo · 500 min), Scale ($299/mo · 2000 min).
/billing/usage
bearer
Current period usage:
{ plan, status, minutes_used, minutes_limit,
minutes_remaining, period_start, period_end, trial_ends_at }.
/billing/subscription
bearer
Active subscription details, or null when none
exists.
/billing/subscribe
bearer
Body { "plan": "starter" | "growth" | "scale",
"payment_method_id" }. Creates a Stripe subscription and
returns { subscription_id, status, client_secret } —
confirm the payment client-side with the
client_secret.
/billing/setup-intent
bearer
Stripe SetupIntent for saving a payment method without charging —
returns { client_secret, setup_intent_id }.
/billing/create-payment
bearer
Ziina payment intent (AED) for a plan. Body
{ "plan_id" }; returns
{ redirect_url, payment_intent_id } — redirect the
user to the hosted Ziina page. 503 when the payment
service is not configured; 400 on an unknown plan.
/billing/cancel
bearer
Cancel at period end. 404 when there is no active
subscription.
/billing/webhook
stripe only
Inbound Stripe webhook (subscription lifecycle + invoices).
Requires a valid Stripe-Signature header — not for
direct use.
Referrals
Every account gets a referral code. Pass it as
referral_code on /auth/register and you
earn a commission when the referred account converts to a paid
plan. All endpoints require a Bearer token.
| Endpoint | What it returns |
|---|---|
GET /referrals/code |
Your code, commission rate, and shareable link (creates one if missing) |
GET /referrals/stats |
Totals: referrals, pending signups, conversions, earnings, available balance (cents) |
GET /referrals/list |
Individual referrals with status + earnings (limit/offset paging) |
GET /referrals/earnings |
Earning records with commission breakdown |
POST /referrals/payout |
Request a payout — body { amount_cents, method }; minimum $50; 400 if it exceeds your available balance |
GET /referrals/payouts |
Your payout request history |
Call quality scores
/api/calls/{call_id}/quality
bearer optional
Per-call quality score produced asynchronously by an LLM-as-judge
over a 5-dimension rubric. Returns
{ call_id, scored_at, score }; score is
null while scoring is still pending. You also get
the score pushed via the call.scored
webhook event.
| Status | Meaning |
|---|---|
200 | Call exists (score may be null = pending) |
403 | Authenticated but you don’t own this call |
404 | Call not found |
Outbound webhooks — call lifecycle
Register an HTTPS URL and NOVA POSTs a signed JSON payload on each subscribed event. All management endpoints require a Bearer token.
Events
call.started— outbound call dispatchedcall.ended— call hung up (any reason)call.transcribed— full transcript persistedcall.scored— LLM-as-judge quality score persisted
Register a webhook
/v1/webhooks
bearer
{
"url": "https://hooks.example.com/nova",
"events": ["call.started", "call.ended", "call.transcribed", "call.scored"]
}
The 201 response includes a secret —
save it immediately. NOVA stores only its
SHA-256 hash; the raw value is never retrievable again.
{
"id": "8f3b2a4c-1234-...",
"url": "https://hooks.example.com/nova",
"events": ["call.started", "call.ended", "call.transcribed", "call.scored"],
"secret": "raw-token-shown-once-save-it-now",
"is_active": true,
"created_at": "2026-06-12T08:30:00Z"
}
Delivery payload
POST <your-url>
Content-Type: application/json
X-Nova-Event: call.ended
X-Nova-Signature: sha256=<hex_hmac>
User-Agent: NOVA-Webhooks/1.0
{
"event": "call.ended",
"delivered_at": "2026-06-12T08:30:00Z",
"data": {
"call_id": "uuid",
"customer_id": "uuid",
"started_at": "2026-06-12T08:27:00Z",
"ended_at": "2026-06-12T08:30:00Z",
"duration_seconds": 187,
"outcome": "qualified",
"goal": "qualify_interest",
"goal_completed": true,
"to_phone_e164": "+15555550100"
}
}
Verifying the signature
X-Nova-Signature is an HMAC-SHA256 over the raw
request body. The HMAC key is the
SHA-256 hex digest of your raw secret — hash your
stored token first, then HMAC. Compare with a constant-time
function.
Python
import hashlib
import hmac
def verify(raw_secret: str, body: bytes, header: str) -> bool:
key = hashlib.sha256(raw_secret.encode()).hexdigest()
expected = "sha256=" + hmac.new(
key.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header)
Node
const crypto = require("crypto");
function verify(rawSecret, body, header) {
const key = crypto.createHash("sha256")
.update(rawSecret).digest("hex");
const expected = "sha256=" + crypto
.createHmac("sha256", key)
.update(body).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(header)
);
}
Retries
Non-2xx responses and network errors are retried with backoff — waits of 1s, 5s, 30s between attempts, 4 attempts total, 10-second timeout per attempt. After the final failure the delivery is marked failed and not retried.
Management endpoints
| Endpoint | Behaviour |
|---|---|
GET /v1/webhooks |
List your webhooks (secrets never returned); ?include_inactive=true for soft-deleted ones |
DELETE /v1/webhooks/{id} |
Soft-delete (is_active=false) — 204, idempotent |
GET /v1/webhooks/{id}/deliveries |
Recent deliveries with status_code, attempt_count, error_message (limit 1–500, default 50) |
POST /v1/webhooks/{id}/test |
Sends a fake call.started payload end-to-end; 400 on inactive webhooks |
Constraints
- HTTPS only —
http://URLs are rejected - Private / internal addresses are rejected (localhost, RFC1918, link-local)
- Unknown event types are rejected with
400
Inbound portal webhooks — Property Finder & Bayut
NOVA can receive lead webhooks directly from UAE property portals and dispatch a qualification call within seconds of the inquiry. The receiver acknowledges in milliseconds and dispatches the call in the background.
Partnership-gated. Both receivers ship disabled
and return 503 until the integration contract and
shared HMAC secret are configured for your account. Contact
ceo@novalabs.ae to enable
portal ingestion.
/webhooks/property-finder
hmac signed
Signature header: X-PropertyFinder-Signature with
value sha256=<hex> — HMAC-SHA256 of the raw
request body using the shared secret.
/webhooks/bayut
hmac signed
Signature header: X-Bayut-Signature, same
sha256=<hex> HMAC convention.
Verification & replay protection
-
Signatures are verified with a constant-time comparison; anything
that doesn’t match — or doesn’t start with
sha256=— is rejected with401. -
Replay protection: deliveries are deduplicated
on the portal event ID. A replayed event ID gets an idempotent
200acknowledgement without re-dispatching a call. -
Malformed payloads return
400; a valid signed payload returns200with{ accepted, lead_id | event_id, elapsed_ms }.
Response codes
| Status | Meaning |
|---|---|
200 | Lead accepted (or duplicate — idempotent ack) |
400 | Payload failed schema validation |
401 | Missing or invalid HMAC signature |
503 | Receiver not enabled / secret not configured |
Gated features — available on request
Two surfaces ship behind per-account feature flags and return
404 until enabled for your user. Check
GET /me/features for your account’s flags, and contact
ceo@novalabs.ae to request
access.
Custom voice cloning — /voice/*
Clone your own voice for outbound agent calls. The flow is
consent-first: a signed consent record is required before any audio
is accepted, enrolment is limited to one attempt per 24 hours, and
audio uploads are capped at 16 MB. Enrolment is not available
from the EU/EEA/UK (451) pending data-protection
compliance. Revoking a profile deletes the cloned voice at the
provider.
| Endpoint | Purpose |
|---|---|
GET /voice/consent | Current consent version + text |
POST /voice/consent | Sign consent (typed name) — required before enrolment |
POST /voice/enroll | Upload enrolment audio (multipart) — returns 202 + profile ID |
GET /voice/status/{id} | Processing status: processing / ready / failed |
POST /voice/preview/{id} | Synthesize a short preview (≤300 chars) with the clone |
POST /voice/activate/{id} | Use this voice for your outbound calls |
GET /voice/profiles | List your voice profiles |
DELETE /voice/{id} | Revoke the profile + delete the provider-side clone |
Bring-your-own Twilio — /twilio/*
Place calls from your own Twilio numbers. Credentials are stored encrypted (only the last 4 of the Account SID is ever returned), and number ownership must be proven via SMS OTP before a number can go active. OTP sends are rate-limited (3 per 10 minutes, 10 per 24 hours per number).
| Endpoint | Purpose |
|---|---|
POST /twilio/credentials | Store/replace your encrypted API key triplet |
POST /twilio/credentials/validate | Live-check the credentials against Twilio |
GET /twilio/credentials | Status envelope (status + SID last4 only) |
DELETE /twilio/credentials | Revoke credentials + tear down imported numbers/trunks |
GET /twilio/numbers/available | List numbers on your Twilio account |
POST /twilio/numbers/import | Provision a SIP trunk for one of your numbers |
POST /twilio/numbers/{id}/send-otp | Send the ownership-verification SMS code |
POST /twilio/numbers/{id}/verify | Verify with the OTP — activates the number |
POST /twilio/numbers/{id}/set-default | Use this number as outbound caller ID |
GET /twilio/numbers | List your imported numbers |
DELETE /twilio/numbers/{id} | Revoke a number + its trunk |
MCP — drive NOVA from AI agents
NOVA ships a Model Context Protocol server so AI assistants (Claude, Cursor, and any MCP-capable agent) can drive the platform directly — register accounts, dispatch qualification calls, check call status, and read results as tools instead of raw HTTP.
Setup, the tool catalogue, and configuration examples live in the repo: github.com/Kazemkhani/nova-mcp. The MCP server is a thin layer over the same public API documented on this page — everything it can do, you can do with plain HTTPS.
Errors
Errors follow the FastAPI convention — a JSON body with a
detail field:
{ "detail": "Rate limit exceeded. Max 5 requests per 60 seconds." }
| Status | When |
|---|---|
400 | Validation failure (consent, phone format, missing goal fields, bad webhook URL…) |
401 | Missing/invalid credentials or webhook signature |
402 | Plan or trial minute limit exceeded |
403 | Authenticated but not authorised for this resource |
404 | Not found, expired (TTL), or feature not enabled for your account |
409 | Conflict (duplicate email, already-imported number, stale consent version) |
422 | Request body failed schema validation |
429 | Rate limited — check Retry-After where present |
451 | Feature unavailable in your region (voice cloning in EU/EEA/UK) |
502 | Upstream provider error (payment, telephony, voice provider) |
503 | Surface not configured / disabled (portal webhooks, payment service) |
Operational endpoints
/health
no auth
Shallow liveness:
{ status, timestamp, active_calls, ttl_seconds }.
/health/deep
no auth
Dependency probes (database + voice infrastructure), each capped
at 2 seconds. 200 when all probes pass,
503 with a per-check breakdown when any fail.
/finetune/status
no auth
Read-only snapshot of the nightly fine-tune pipeline — latest run status, baseline vs. candidate score, and the currently promoted model alias.