Connect
API & webhooks
Quincer AI is API-first. Everything you can do in the dashboard — take over a live chat, update a lead, read the activity log — you can do via REST. This page is the map. For request/response previews against your real workspace, open Developer APIs inside the dashboard, or try endpoints live with your key in the API Explorer (runs in your browser, so it isn’t affected by non-browser request filtering).
Authentication
All API requests use a workspace developer key. Generate one under
Dashboard → Integrations → API keys, then Create key.
In the dialog below, Key name is a label for you (“e.g. CRM Sync, Analytics
Export”); the Scopes list is grouped by resource — tick a whole group
or individual scopes like leads:read or conversations:takeover, and the
key can only do what you check. An optional Expiry date auto-revokes it.
Create key stays disabled until at least one scope is selected. Pass the key as a
Bearer token:
The Create developer API key dialog — name it, tick scopes grouped by resource, set an optional expiry.
Authorization: Bearer cw_dev_<your-key>
Two kinds of credentials exist: widget keys (cw_live_) are
embedded in the page script and only identify a widget to the public chat endpoint.
Developer keys (cw_dev_) are scoped to an organization, carry
explicit scopes like leads:read or conversations:takeover, and
must stay server-side.
A developer key can also be limited to specific brands. Choose “All brands” (the default, and what every existing key uses) or tick the brands it may reach when you create or edit the key.
A brand limit applies even when you omit widget_id.
A list request without widget_id returns rows for the key's brands
only, rather than the whole workspace — so restricting a key never depends on
the caller remembering to filter.
Naming a brand outside the limit returns 404, not 403,
so a restricted key cannot discover which other brands exist. A restricted key also
cannot POST /v1/widgets. Call GET /v1/me to see the key's
brand_scope and the brands it can reach.
Deleting is a separate permission from updating
A :write scope never authorizes a DELETE. Destructive
operations have their own :delete scope — widgets:delete,
knowledge:delete, leads:delete, personas:delete,
locations:delete, playbook:delete,
directory:delete, tasks:delete, phone:delete,
suppressions:delete, webhooks:delete — so a key minted to
keep your CRM in sync cannot erase what it syncs, and a key minted to restyle a brand
cannot destroy it.
On brands, clearing a field costs the delete scope. On
PATCH /api/v1/widgets/:id, setting a value to null or an
array to [] destroys data exactly like a DELETE does, so it
requires widgets:delete and returns 403 without it.
Omitting a field leaves it unchanged; only an explicit null clears it. A
field that has no cleared state at all (brand, tagline, the
colours — all NOT NULL) returns 400 instead, because
no permission would make it valid.
Resetting a field to its default is not a clear and needs no extra scope:
ai_model, header_text_color, default_email_provider
and default_voice_language all document null as “use the
default”, so widgets:write is enough.
locked_domain is not in that list. It is the brand’s domain
authorization: empty means “unlocked, the next request wins the lock”, so
clearing it requires widgets:delete. For a text field that can be cleared, sending
"" counts as clearing it — a blank and a null leave the
record in the same state, so they cost the same permission. (On list and object
fields, use [], {} or null; ""
there is a type error.)
Personas follow the same rule. On
PATCH /api/v1/personas/:id, clearing a nullable field —
internal_instructions, a Slack or Telegram channel id,
url_patterns, external_ref, custom_skills,
quote_send_mode — requires personas:delete. Keys that
already held personas:write were granted it automatically, so nothing in
flight breaks. Uploading a replacement avatar destroys the stored image and needs it
too.
The remaining resources are rolling onto this rule and today still accept a clear
under their :write scope. Every DELETE endpoint, everywhere,
already requires its own :delete scope.
On that same endpoint, sending the string "null" returns
400 rather than storing it, as does a value of the wrong type
("30" where a number is expected) or a value outside an enum —
nothing is silently coerced or dropped, and a refused write names the field and the
reason. Other endpoints are still being moved onto this contract.
webhooks:manage still works and covers read, write and delete, so keys
created before the split keep working. New keys should tick
webhooks:read / webhooks:write / webhooks:delete
individually.
Base URL
https://chat.quincer.com
Embedding Quincer in a mobile app? Use
@quincer/react-native instead of calling these REST endpoints
directly. The SDK wraps /api/chat, the voice-relay, the
widget-key conversation endpoints, and the live agent-takeover SSE stream
— with native UI, native voice, and AsyncStorage persistence. See the
React Native SDK guide.
POST /api/chat — widget-key chat
The widget/SDK chat endpoint is authenticated by a
widget key (x-api-key: cw_live_…), not a
developer key, and is CORS-open for browser + mobile clients. It is the same
endpoint the JS widget and the React Native SDK use; most integrators embed
the widget rather than calling it directly, but the response contract is
documented here so mobile and custom clients can parse it consistently.
With stream: false the response is a single JSON body:
{ reply, personaName?, personaBubbleColor?, personaId?,
conversationId?, messageId?, escalationPending?, secondMessage? }.
With stream: true the response is a Server-Sent Events stream of
frames data: {type:"text"|"handoff"|"done", …}:
{type:"text", text}— a token chunk of the current reply.-
{type:"handoff", sourcePersonaName?, sourcePersonaId?, sourcePersonaBubbleColor?, personaName?, personaId?, personaBubbleColor?}— an immediate persona handoff: the current reply is finalized under the source persona and a new reply for the target persona begins in the followingtextframes. Clients should render this as a second, target-tinted bubble. -
{type:"done", personaName?, personaBubbleColor?, personaId?, conversationId?, messageId?, escalationPending?}— end of stream.
The non-streaming equivalent of the handoff frame is the additive
secondMessage: { reply, personaName?, personaBubbleColor?, personaId?
} field — the arriving persona's self-introduction,
rendered as its own bubble after reply. Clients that read only
reply (or only text frames) are unaffected.
Not the same as /api/v1/…/handoff. The
SSE handoff frame above is emitted by the LLM
mid-response when a persona hands off. The
POST
/api/v1/conversations/:id/handoff endpoint (developer
key, conversations:write) is an operator/API action that
switches the active persona for a conversation. Different mechanisms,
similar name.
POST /api/widget/visitor-info — visitor-supplied details
The submit target of the visitor-info
form: when a brand requires identity details, the web widget renders a box per
missing detail and posts them here, rather than the persona collecting them one
question at a time. Widget key in x-api-key, CORS *.
POST /api/widget/visitor-info
x-api-key: <widget key>
Content-Type: application/json
{
"conversationId": "cnv_123", // optional, must belong to this widget
"personaId": "per_456", // optional, decides the lead's CRM destination
"values": {
"name": "Dana Ruiz",
"email": "dana@acme.com",
"company": "Acme",
"phone": "+1 415 555 0134",
"jobTitle": "Operations Lead"
}
}
200 { "ok": true }
200 { "ok": true, "invalidEmail": true } // address rejected, nothing saved for it
Only the five known fields are read; anything else in values is ignored
rather than rejected, so a newer widget can talk to an older server. Values are
trimmed and capped at 200 characters. Submissions persist through the same path as
the agent’s own capture: one lead per conversation, identity mirrored onto the
conversation, your team notified, CRM synced.
A placeholder email is not a captured email. An address like
test@example.com is refused and reported back as
invalidEmail with nothing saved — storing it would leave the
requirement permanently unsatisfiable while looking satisfied. Re-prompt for that
one field.
Conversations
Use these to let an outside AI (OpenClaw, a custom agent, or your own backend) discover, take over, reply on, and hand back live conversations. The takeover/reply/handback trio is how the OpenClaw integration works under the hood.
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/conversations | conversations:read | List conversations. Filters: widget_id, status (single or comma-separated), channel, engaged (true|all), updated_since, and identity search — phone, email, or identity_value + optional identity_type (phone|email|handle|platform_id). See Finding someone by phone or email. Cursor-paginated. |
GET | /api/v1/conversations/:id | conversations:read | Fetch full transcript + metadata. |
POST | /api/v1/conversations/:id/takeover | conversations:takeover | Claim a conversation — AI stops responding. |
POST | /api/v1/conversations/:id/reply | conversations:reply | Post a human-agent message (auto-translated to visitor's language). |
POST | /api/v1/conversations/:id/handback | conversations:handback | Release the takeover — AI resumes. |
POST | /api/v1/conversations/:id/handoff | conversations:write | Switch the active persona for this conversation. Body: { "persona_id": "..." | null } (null clears the override). |
POST | /api/v1/conversations/:id/reply-template | conversations:write | Send a pre-approved WhatsApp template outside the 24-h window. Requires the conversation to be taken over first (same precondition as /reply). Body: { "template_id", "variables": [], "speaker_name"? }. |
GET | /api/v1/conversations/:id/whatsapp-window | conversations:read | WhatsApp 24-h customer-service window state. WhatsApp channel only. |
Leads
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/leads | leads:read | List leads. Filters: widget_id, stage, temperature, assigned_to, updated_since, plus the same identity search (phone, email, identity_value). Cursor-paginated — see Pagination below. |
POST | /api/v1/leads | leads:write | Create a lead outside the chat flow (e.g. CRM ingestion, marketing-form capture). Requires widget_id and at least one of email, name, phone. Optional location_id or location_ref (your external_ref) attributes the lead to a location — multi-location brands — and routes the lead notification to that location's own recipients; the lead object carries it as locationId. |
GET | /api/v1/leads/:id | leads:read | Full lead record (BANT, tags, assigned task history). |
POST | /api/v1/leads/:id | leads:write | Update fields: stage, temperature, tags, score, assignment, contact info. PATCH on the same path is also accepted (legacy alias). |
DELETE | /api/v1/leads/:id | leads:delete | Permanently delete the lead and its associated rows. |
GET | /api/v1/leads/:id/emails | leads:read | List drafted & sent follow-up emails for this lead. |
POST | /api/v1/leads/:id/emails/generate | leads:write | Generate an AI follow-up draft on demand. Reuses the latest draft if one already exists. |
PATCH | /api/v1/leads/:id/emails/:email_id | leads:write | Inline-edit a draft's subject/body. Draft status only. |
POST | /api/v1/leads/:id/emails/:email_id | leads:write | Send a draft. Body: { "action": "approve" | "send" }. |
POST | /api/v1/leads/:id/emails/:email_id/redraft | leads:write | AI rewrites the existing draft per natural-language instructions. Body: { "instructions": "..." }. |
Tasks
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/tasks | tasks:read | Agent task log. Filters: widget_id, lead_id, status, type, updated_since. Mounted at /api/v1/activities too as a legacy alias. |
POST | /api/v1/tasks | tasks:write | Schedule a new task. Requires widget_id, type, title. |
GET | /api/v1/tasks/:id | tasks:read | Full task record with execution + result data. Mounted at /api/v1/activities/:id too as a legacy alias. |
POST | /api/v1/tasks/:id | tasks:write | Update status, priority, assignment, or schedule. PATCH also accepted. |
DELETE | /api/v1/tasks/:id | tasks:delete | Cancel/delete the task. Useful for clearing stuck tasks that no longer have a runner. |
Knowledge base
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/knowledge | knowledge:read | List knowledge items. Filters: widget_id, persona_id, location_id ("none" = brand-shared only) or location_ref (requires widget_id), source_type, query (or q) to search title/content/URL. Cursor-paginated. |
POST | /api/v1/knowledge | knowledge:write | Create a knowledge item. Body: { "title", "content", "widget_id"?, "persona_id"?, "location_id"? | "location_ref"?, "personas"?: [] } — widget_id is optional only on a single-brand workspace; omit it with several brands and the OLDEST is used, with a warnings entry naming which. — a location_id-tagged item answers only for that location (plus brand-shared items); omit for brand-shared. Embedding is regenerated automatically. |
GET | /api/v1/knowledge/:id | knowledge:read | Single item. |
POST | /api/v1/knowledge/:id | knowledge:write | Update title/content/persona binding/location binding (location_id: null makes it brand-shared). PATCH also accepted. |
DELETE | /api/v1/knowledge/:id | knowledge:delete | Cascade-deletes chunks and embeddings. |
Personas
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/personas | personas:read | List personas. Filters: widget_id, external_ref (look a persona up by your own id). |
POST | /api/v1/personas | personas:write | Create a persona. Body: { "name", "systemPrompt", "widget_id"?, "bubble_color"?, "url_patterns"?, "is_default"?, "status"?, "external_ref"? }. widget_id is optional only on a single-brand workspace; omit it with several brands and the OLDEST is used, with a warnings entry naming which. A duplicate external_ref on the widget returns 409. |
GET | /api/v1/personas/:id | personas:read | Single persona. |
POST | /api/v1/personas/:id | personas:write | Update fields: name, prompt, urlPatterns, isDefault, voice, agentMode, status ("active"|"suspended"), external_ref (or null to clear), Slack/Telegram routing, Meta asset bindings, email-inbox settings. enabledToolIds is accepted and stored but is inert — it was a per-persona allow-list and is never read. To switch a tool off for ONE persona, send disabled_tool_ids: a deny-list of tool ids, where absent or empty means inherit everything the brand, plan and channel already allow. To switch a tool off for the whole brand, disable it on the integration. A write that sends enabledToolIds returns a warnings entry saying so. Also accepts avatarUrl — a preset path (one of the built-in /avatars/*.svg presets) to set a preset avatar, or null/"" to clear it. An arbitrary avatarUrl is rejected; custom images go through the avatar upload below. |
POST | /api/v1/personas/:id/avatar | personas:write | Upload a custom avatar image via multipart/form-data with a file part (PNG, JPG, WebP, or GIF). Replaces any prior uploaded/generated avatar and sets avatarKind: "upload". Returns { object, id, avatarUrl, avatarKind, src } where src is the resolved display URL. |
DELETE | /api/v1/personas/:id | personas:delete | Delete. Refuses if this is the widget’s last persona. Deletes its email inbox history too. Optional ?knowledge=share|delete — share (default) keeps the persona’s knowledge items and makes them available to every persona on the brand; delete removes them in the background and returns knowledge.queued. |
Each persona object carries two avatar fields: avatarUrl — the
stored value (a preset path, an upload/generated storage key, or null) —
and avatarKind — one of "preset", "upload",
"generated", or null when the persona has no avatar. To render an
avatar, use the resolved display URL: preset paths are served as-is, uploaded/generated
images resolve to /api/persona-avatars/{persona_id}. The public embed config
(/api/embed/config) and the chat frames already return a resolved, renderable
URL; the /api/v1/personas objects return the raw stored avatarUrl +
avatarKind.
Persona objects also include phoneNumbers (the inbound DIDs routed to the
persona — manage them via the Phone numbers endpoints),
status ("active" | "suspended" — suspend to pause a
location; its inbound calls stop being answered), and externalRef (your own stable
id for the persona, e.g. a location id, unique per widget). status and
externalRef are settable on create/update, and you can filter the list by
external_ref to make provisioning idempotent (find → create if absent,
update if present). Knowledge scoping: knowledge added to a specific persona
is answered only by that persona plus shared (unassigned) knowledge — other
personas never retrieve it. Legacy note: addressing franchise locations via
persona externalRef/status (one persona per location) is superseded
by the first-class Locations resource, which keeps personas shared
across locations — prefer it for multi-location brands.
Finding someone by phone, email or handle
Every channel now records who the visitor is, in one place, so a single query finds
every conversation involving one person — whether they reached you by web chat,
a phone call, SMS, WhatsApp, email, or a social DM. The same filters work on
/api/v1/leads, which answers the same question the other way round
(“who is this number?”).
# Every conversation involving a number, on any channel
GET /api/v1/conversations?phone=%2B14155550123
# The same, spelled generically
GET /api/v1/conversations?identity_value=%2B14155550123&identity_type=phone
# By email, or by a social handle
GET /api/v1/conversations?email=bob@example.com
GET /api/v1/conversations?identity_value=@bobsmith
# Who is this number?
GET /api/v1/leads?phone=%2B14155550123
| Parameter | Notes |
|---|---|
phone | Convenience alias. Pins the type, so a value that isn't a phone number returns 400 rather than being reinterpreted. |
email | Convenience alias. Case- and whitespace-insensitive. |
identity_value | The generic form. Also accepts a social handle (@bobsmith) and an opaque platform id (a Messenger psid or Instagram igsid). |
identity_type | phone | email | handle | platform_id. Inferred when omitted — pass it when the value is ambiguous, or to make a mismatch fail loudly instead of being guessed. |
Every call is channel=voice — an inbound or
outbound phone call and a call started from the web widget or mobile SDK all land
on that one channel. There is no channel=phone on conversations.
A phone call is searchable by the caller's number; a web or mobile call has no
number of its own, so it is findable only by an email or phone the visitor gave
you during the call.
“Handle” means a username, not a display name. It matches
where the platform gives us one — an Instagram @username, a
WhatsApp username. It does not match a person's name: Google reviews
carry the reviewer's display name (“Bob Smith”), Messenger and
WhatsApp carry a profile name, and none of those are identifiers — indexing
them would make a search for one common name return unrelated people. Search
those threads by name in the dashboard instead. A value containing a space is
rejected with a 400 rather than silently matching nothing.
Matching is on the whole value, not a fragment. There is no
“last four digits” search. Phone numbers are compared after normalising
away formatting, so +14155550123, 14155550123,
(415) 555-0123 and 415-555-0123 all find the same
conversation. Give numbers in full international form; a bare 10-digit number is
accepted for the US and Canada only.
An unusable value is a 400, never an unfiltered list.
If we cannot parse what you sent, you get an error explaining the format — we
will not quietly drop the filter and return every conversation in the workspace.
GET /api/v1/conversations/:id returns an identities array
([{ "type", "value" }]) listing every handle a thread is reachable by,
including the platform ids you need to line a thread up against your own webhook
payloads. Staff conversations (Slack/Teams) are never returned by this API and are
not reachable through identity search.
Locations
First-class locations for multi-location brands (franchises, chains). A location owns its
local knowledge (items tagged location_id answer only for that location,
alongside brand-shared items), its phone numbers, its lifecycle, and per-location overrides
(spoken greeting, front-desk handoff number, timezone/hours and context notes injected into the
AI's context) — while personas stay brand-level and shared across locations.
Identity is external_ref (YOUR system's location id, unique per widget):
POST with "upsert": true, or /bulk, and re-running your
full location sync converges idempotently. status: "suspended" fails
closed on every channel — calls to the location's numbers are rejected, its
texts get no reply, and its web sessions are refused (location_suspended).
Every plan includes one location, and creating a brand seeds it.
That seeded row carries no external_ref, so your first
upsert or /bulk sync adopts it rather than
creating alongside it — you get 200, not 201, because no
row was added. Multi-location is the plan-gated part: past your first, a
net-new location returns 403 naming the cap. An update-only re-sync is never
capped, however large.
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/locations | locations:read | List locations, newest first. Filters: widget_id, external_ref, status. Cursor-paginated. |
POST | /api/v1/locations | locations:write | Create — or create-or-update with "upsert": true (requires external_ref; 201 on create, 200 on update, omitted fields left unchanged). Body: { "widget_id", "name", "external_ref"?, "status"?, "address_line1"…"country"?, "timezone"?, "hours"?, "booking_hours"?, "phone_greeting"?, "handoff_phone_number"?, "context_notes"?, "website_url"?, "email"?, "notify_emails"?, "calendly_url"?, "url_patterns"?, "persona_id"?, "metadata"? }. website_url is the location's own site (bare domains normalized to https://); notify_emails (array, or one delimited string) receives that location's lead notifications; calendly_url wins over the persona's Calendly and the brand-level integration when the AI books a meeting at this location; url_patterns (array, or one comma-delimited string) routes web chats to this location by page URL — e.g. /locations/downtown/* or downtown.example.com, most specific wins, the embed's explicit ref overrides. booking_hours is when STAFF here take meetings — same shape as hours and deliberately a separate field, because opening hours describe the building; it is rejected with 400 if no range is parseable — including any single range that ends at or before it starts, so a late-night venue must send {"fri":[["22:00","23:59"]]} rather than {"fri":[["22:00","02:00"]]} — and leaving it unset means this location places no constraint on booking (it does not fall back to hours). Both hours and booking_hours are capped at 8,192 characters of JSON and 12 ranges per day. booking_hours stores only the day keys (mon…sun); any other key is dropped, since nothing reads it. hours keeps whatever you send, so a read-modify-write sync does not lose keys it wrote. Sending {} or null clears either field. booking_hours requires the location to have a valid timezone — they are wall-clock times, so a location without one is rejected with a 400 rather than storing a window nothing can enforce. All are returned on the location object. |
POST | /api/v1/locations/bulk | locations:write | Bulk sync up to 500 rows in one call (one write against your rate limit). Each row is an idempotent upsert keyed on external_ref; returns per-row results + a summary, and the plan-limit check runs on the batch up front (no partial writes at the cap). Status: 200 on full or partial success (read summary.errored and the per-row results); 422 when no row could be written, with the same body — so a client checking only the status is never told that nothing-happened was success. |
GET | /api/v1/locations/:id | locations:read | One location, with its mapped numbers and knowledge count. |
PATCH | /api/v1/locations/:id | locations:write | Update any field (explicit null clears). status: "suspended" pauses the location fail-closed; "active" resumes. |
DELETE | /api/v1/locations/:id | locations:delete | Delete. Returns 409 while numbers are still mapped (re-map/unmap first, or suspend instead). Knowledge scoped to the location (inventory, crawled pages) is deleted with it; conversations survive, un-tagged. |
Phone numbers
Inbound phone numbers (DIDs) mapped to a widget and, optionally, a persona and/or a location. An incoming call routes by the dialed number: the location is resolved first (a suspended location rejects the call), then the answering persona — the location's persona override, else the number's persona, else the widget default.
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/phone-numbers | phone:read | List numbers, newest first. Filters: widget_id, location_id. Cursor-paginated. |
POST | /api/v1/phone-numbers | phone:write | Map a number. Body: { "widget_id", "e164", "carrier" ("twilio"|"telnyx"), "persona_id"?, "location_id"? | "location_ref"?, "label"? } (location_ref addresses the location by YOUR external_ref). The number must already exist at your carrier and forward to Quincer. e164 is globally unique — an already-mapped number returns 409. |
PATCH | /api/v1/phone-numbers/:id | phone:write | Re-point / enable-disable / relabel. Body (all optional): { "persona_id", "location_id" | "location_ref", "enabled", "label", "carrier" } — persona_id: null routes to the widget default, location_id: null detaches the location; omitted fields are left unchanged. |
DELETE | /api/v1/phone-numbers/:id | phone:delete | Unmap the number (routing removed; the number itself is untouched at the carrier). |
Sales playbook
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/playbook | playbook:read | List entries. Filters: widget_id, category. |
POST | /api/v1/playbook | playbook:write | Create. category must be one of value_prop, roi_data, objection_handler, competitor_comparison, pricing, case_study. |
GET | /api/v1/playbook/:id | playbook:read | Single entry. |
POST | /api/v1/playbook/:id | playbook:write | Update. PATCH also accepted. |
DELETE | /api/v1/playbook/:id | playbook:delete | Delete. |
POST | /api/v1/playbook/generate | playbook:write | Upload a document (PDF / Markdown / text, max 10MB) via multipart/form-data to auto-generate entries (mode=generate) or import as-is (mode=import). Requires file, widgetId. |
Models
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/models | widgets:read | List the AI models this workspace can pin, from its own provider keys. Models reachable only through Quincer’s shared platform keys are excluded — those serve the platform default, which a widget uses by leaving aiModel empty. Returns an empty list when the workspace has no keys of its own. Cached 10 minutes. |
Widgets
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/widgets | widgets:read | List widgets in the workspace. |
POST | /api/v1/widgets | widgets:write | Create a new widget (one brand / deploy target). Requires brand; websiteUrl records the site it's deployed on. Seeds default personas. By default also mints a public embed key (cw_live_*) and returns apiKey + embed (scriptUrl + ready-to-paste snippet), so one call yields a deployable brand. Pass withApiKey: false to skip. |
GET | /api/v1/widgets/:id | widgets:read | Full widget config: branding, AI model, agent mode, voice, languages, attendees. |
POST | /api/v1/widgets/:id | widgets:write | Update any configurable field. Plan-gated knobs (proactive mode, multi-domain, voice, attachments) are enforced. PATCH also accepted. |
DELETE | /api/v1/widgets/:id | widgets:delete | Delete. Refuses if this is the workspace's only widget. |
GET | /api/v1/widgets/:id/keys | widgets:read | List the widget's active public embed keys, each with an embed snippet. Re-fetch a key returned at create time. |
POST | /api/v1/widgets/:id/keys | widgets:write | Mint a new public embed key for the widget (rotation). Returns the key + embed snippet. |
POST | /api/v1/widgets/:id/import-storefront | knowledge:write | Import a public Shopify storefront's catalog (its /products.json) into this widget's knowledge base — no app install needed. Body: { url? } (defaults to the widget's websiteUrl). Products are also available live to the generative-UI product panel. |
Integrations
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/integrations | integrations:read | List supported integrations and their connection state for this workspace (optionally a specific widget_id). |
GET | /api/v1/integrations/:provider | integrations:read | Connection details for one provider. Returns { "connected": false } if not connected. |
Connect/disconnect flows still require an interactive OAuth roundtrip and live under the dashboard at Dashboard → Integrations. This endpoint is read-only by design — programmatically minting OAuth grants from a dev key is a footgun.
Webhooks
Programmatic webhook subscription management. Previously dashboard-only; now a developer key
with webhooks:write can provision a subscription end-to-end.
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/webhooks | webhooks:read | List subscriptions in this workspace. |
POST | /api/v1/webhooks | webhooks:write | Create. Body: { "url", "events": [] }. The signing secret is returned once on creation — store it; subsequent GETs do not include it. |
GET | /api/v1/webhooks/:id | webhooks:read | Single subscription. |
POST | /api/v1/webhooks/:id | webhooks:write | Update url, events, active state, or reset_fail_count. PATCH also accepted. |
DELETE | /api/v1/webhooks/:id | webhooks:delete | Delete the subscription. |
Delivery. Each delivery POSTs { "id", "event", "timestamp", "data" }
to your URL. id is a stable delivery UUID (also sent as the
Quincer-Delivery-Id header) that is the same across retries — dedupe
on it for effectively-once processing. Verify Quincer-Signature
(sha256=<hmac> of the raw body, keyed by your signing secret). Failed
deliveries retry up to 3× with exponential backoff on transient failures
(5xx / 429 / network / timeout); a 4xx is a permanent reject and is not retried.
A subscription auto-disables after 10 consecutive failures.
Key events (full list + live “fires today” state under
Dashboard → Integrations → Webhooks):
conversation.message_received;
conversation.ended — channel, persona/location, visitor, and the transcript,
so you can reconstruct a whole conversation from one event (voice + text);
conversation.escalated — fires the moment a conversation is handed to a human,
with reason/target/source;
conversation.takeover/handback;
lead.*, task.*, and outbound.* (incl.
outbound.call.completed with the call outcome).
Voice
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/voice/sessions | voice:read | List voice sessions. Filters: widget_id, status. Cursor-paginated by started_at. |
GET | /api/v1/voice/sessions/:id | voice:read | Single voice session metadata. |
GET | /api/v1/voice/sessions/:id/transcript | voice:read | Ordered transcript lines (speaker: visitor | ai | agent). |
POST | /api/v1/voice/sessions/:id/end | voice:write | Terminate a live voice session and tear down the SFU room. |
GET | /api/v1/voice/settings | voice:read | Read org-level voice defaults (silence timeout, max call duration, provider, xAI model). |
PUT | /api/v1/voice/settings | voice:write | Update org-level voice defaults. Takes effect on the next session. PATCH also accepted. |
POST | /api/v1/voice/callback/request | voice:write | File a callback request against the voice queue. Body: { "session_id"?, "name"?, "phone"?, "email"?, "note"? } — at least one of phone or email is required. |
Voice handoff (agent claim) remains seat-licensed and is initiated from
Inbox → Live in the dashboard — it requires a User row with liveHandoffLicensed,
not a service key, so it has no /api/v1 equivalent.
Outbound (email, SMS & suppressions)
Send outbound email and SMS through the unified dispatcher and manage the org-wide suppression (opt-out) list. Requires the Scale plan or the Outbound Campaigns add-on. Every send runs the full compliance pipeline server-side: the suppression list, TCPA quiet hours (SMS sent only during the recipient’s local 8am–9pm window), per-contact frequency caps, the CAN-SPAM mailing address (email), and per-persona daily mailbox limits. Email goes out from your connected Gmail/Outlook mailbox — never a shared Quincer address — with an unsubscribe footer appended automatically.
| Method | Path | Scope | Purpose |
|---|---|---|---|
POST | /api/v1/outbound/email | outbound:email | Send (or queue for review) an outbound email. Body: widget_id, to.email, subject, body, optional persona_id, lead_id, consent_basis. Returns 202 with status: queued | pending_review (the widget’s email follow-up mode decides). |
POST | /api/v1/outbound/sms | outbound:sms | Send an outbound SMS. Body: widget_id, to.phone (E.164), text, required consent_basis (cold is refused — TCPA), optional from_number, to.timezone, defer_on_quiet_hours. |
POST | /api/v1/outbound/calls | outbound:call | Place an outbound AI phone call. Body: widget_id, to.phone, objective, optional talking_points, persona_id, voicemail.policy (hangup default | message), required consent_basis. Returns status: dialing; track via the messages endpoint or outbound.call.* webhooks. Twilio numbers only for now. |
GET | /api/v1/outbound/messages | outbound:read | List outbound messages across channels. Filters: channel, status, widget_id. Cursor-paginated. |
GET | /api/v1/outbound/messages/:id | outbound:read | Unified status record for one send, incl. blocked_reason for compliance refusals. |
GET | /api/v1/suppressions | suppressions:read | List opted-out contacts. Filters: channel, email, phone. |
POST | /api/v1/suppressions | suppressions:write | Suppress a contact org-wide (sync opt-outs from your CRM into Quincer). Body: channel (email|sms|voice|all) + one of email/phone. Idempotent. |
DELETE | /api/v1/suppressions/:id | suppressions:delete | Remove a suppression. Deleting a STOP-sourced entry without the recipient’s request is a compliance risk. |
Sends are idempotent when you pass an Idempotency-Key
header (or idempotency_key body field): replays return the original
result — including refusals. Errors use
{ "error": { "code", "message", "retry_at"? } } with codes such as
suppressed_recipient (409), quiet_hours (409 +
retry_at), frequency_capped (429),
no_mailbox_connected (409), no_consent_attested (403),
invalid_destination (422), outbound_disabled (503), and
provider_unavailable (503 — the mailbox provider or carrier
rate-limited us or was down; nothing was sent, so the same request may be retried).
Webhook events: outbound.email.sent/.failed,
outbound.sms.sent/.failed, outbound.call.started,
outbound.call.completed (payload carries the outcome).
All update operations use POST on the resource path
(POST /api/v1/leads/:id). PATCH on the same path stays
accepted for one major version so existing integrations don’t break, but
POST is the canonical method going forward.
Resource type discriminator
Every resource response carries a read-only object field naming the
resource type ("lead", "conversation", "task",
"message"). List responses carry object: "list" at the
envelope and per-item object fields inside data[]. Use it to
deserialize polymorphic payloads (e.g., webhooks, mixed-resource responses).
{
"object": "lead",
"id": "clx...",
"stage": "qualified",
"temperature": "warm"
}
Pagination
List endpoints return a data array plus a has_more boolean and
a url field naming the endpoint. Use cursor pagination via
starting_after=<resource_id> — the API returns the next page of
items created before that cursor. limit is bounded at 200 (default 50).
GET /api/v1/leads?limit=20&starting_after=clx_abc123
{
"object": "list",
"data": [ { "object": "lead", "id": "clx_xyz789", ... }, ... ],
"has_more": true,
"url": "/api/v1/leads",
// Legacy fields surfaced alongside the new envelope for one major
// version. New consumers should read `data` + `has_more`.
"leads": [ /* same items as data[] */ ],
"total": 142,
"limit": 20,
"offset": 0
}
Naming conventions
Envelope / meta keys are snake_case (object, data,
has_more, url). Resource field names are camelCase
(widgetId, visitorId, takenOverBy, activePersonaId,
createdAt, …) — read them exactly as they appear in the response
examples on this page. Query parameters accept snake_case
(widget_id, updated_since, starting_after), and the
camelCase equivalents (widgetId, updatedSince, assignedTo,
leadId) also continue to work as filter aliases.
Rate limits
Developer keys are rate-limited per key: 100 reads/min and 30 writes/min
by default. Higher ceilings are available — if your integration needs them, ask
support and we can raise them on your account without you changing anything.
Every authenticated response carries the current state in headers so well-behaved
clients can self-throttle, and Quincer-RateLimit-Limit always reports
your ceiling rather than the default:
Quincer-RateLimit-Limit: 100
Quincer-RateLimit-Remaining: 87
Quincer-RateLimit-Reset: 1714435260
Over the limit returns 429 Too Many Requests with both the standard
Retry-After header (seconds until reset) and the
Quincer-RateLimit-* headers. The error body indicates the remaining count.
HTTP/1.1 429 Too Many Requests
Retry-After: 42
Quincer-RateLimit-Limit: 100
Quincer-RateLimit-Remaining: 0
Quincer-RateLimit-Reset: 1714435260
{
"error": "Rate limit exceeded. 0 read requests remaining. Retry after 42 seconds."
}
Webhooks
Subscribe to events under Dashboard → Integrations → Webhooks, then
Add webhook. In the dialog below, Endpoint URL is where Quincer
POSTs each event; the Events grid lets you tick exactly the events you want —
Lead Created, Lead Stage Changed, Visitor Message Received,
Conversation Ended, Task Completed, and the rest. On Create webhook, the
signing secret is shown once — copy it before closing. Quincer AI signs every
payload with HMAC-SHA256 using that secret. Requests include
Quincer-Signature (sha256=...), Quincer-Event, and
Quincer-Timestamp. The legacy X-Quincer-* variants are also sent
for one major version (RFC 6648 deprecated the X- prefix — existing
subscribers reading X-Quincer-* keep working unchanged).
The Add webhook dialog — a delivery URL plus the exact events to subscribe; the signing secret is shown once on create.
Events
| Event | Fires when |
|---|---|
conversation.message_received | Visitor sends any message. Payload includes status (active or human_active) so subscribers know whether an external agent is driving. |
conversation.takeover | A human or external agent claimed a conversation (dashboard takeover, Slack/Telegram approve, or POST /api/v1/conversations/:id/takeover). AI stops responding until handback fires. Payload: conversationId, widgetId, actorKind (internal_user or external_agent), actorUserId, actorName, optional externalLabel, optional surface (dashboard | slack | telegram | api). |
conversation.handback | Takeover released — AI resumes. Same payload shape as conversation.takeover. |
lead.created | A new lead record is captured. |
lead.updated | Any lead field changes. |
lead.stage_changed | Lead moves between pipeline stages. Payload includes both previous_stage (canonical) and previousStage (legacy alias). |
task.created | An agent task (follow-up, qualification, CRM sync, etc.) is scheduled. |
task.completed | A task finishes successfully. |
email.sent | A lead email goes out. |
email.draft_created | An AI-drafted email is ready for review. Email resources are managed via the dashboard — the payload is fully self-contained for downstream notification use. |
Verifying signatures
const signature = req.headers["quincer-signature"];
// (or "x-quincer-signature" if you set up your webhook before April 2026)
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.QUINCER_WEBHOOK_SECRET)
.update(req.rawBody)
.digest("hex");
if (signature !== expected) {
return res.status(401).send("invalid signature");
}
OpenClaw integration
Quincer AI ships a first-class OpenClaw skill so any OpenClaw agent can take over Quincer AI conversations, query leads, and log activity. Setup takes about a minute. The Connect OpenClaw dialog below is step one — it explains that connecting creates a dedicated OpenClaw team member in your workspace and issues an API token bound to it, so takeovers and replies show up in your inbox under that member. The Agent display name field is what visitors see when OpenClaw replies in chat (defaults to “OpenClaw”); on Connect the token is shown once for you to copy:
Connecting creates a dedicated OpenClaw team member in your workspace and issues an API token bound to it. OpenClaw actions (takeovers, replies) show up in your inbox under that member.
Shown to visitors when OpenClaw replies in chat.
The Connect OpenClaw dialog — names the agent, then mints a token bound to a dedicated OpenClaw team member.
- In Quincer AI, open Dashboard → Integrations → OpenClaw and click Connect OpenClaw. This creates a dedicated "OpenClaw" team member in your workspace and issues an API token bound to it.
- Download SKILL.md and drop it in
~/.openclaw/workspace/skills/quincer/. - Add the token to
~/.openclaw/openclaw.jsonunderskills.entries.quincer.env.QUINCER_API_KEY. - Restart the OpenClaw gateway. The
quincertool is now available.
OpenClaw actions (takeovers, replies) appear in your Quincer AI inbox under the OpenClaw team member, with their own avatar and audit trail — not as a generic "external agent."