Quincer AI Docs Home Support Start free

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:

Create developer API key
×
Key name
e.g. CRM Sync, Analytics Export
Scopes
Conversations
conversations:read List and read conversations + messages
conversations:takeover Claim a conversation as an external agent
Leads
leads:read List and read leads
Expiry date — optional
mm / dd / yyyy
CancelCreate key

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
i

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", …}:

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.

i

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.

MethodPathScopePurpose
GET/api/v1/conversationsconversations:readList conversations. Filters: widget_id, status (single or comma-separated), channel, engaged (true|all), updated_since, and identity searchphone, 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/:idconversations:readFetch full transcript + metadata.
POST/api/v1/conversations/:id/takeoverconversations:takeoverClaim a conversation — AI stops responding.
POST/api/v1/conversations/:id/replyconversations:replyPost a human-agent message (auto-translated to visitor's language).
POST/api/v1/conversations/:id/handbackconversations:handbackRelease the takeover — AI resumes.
POST/api/v1/conversations/:id/handoffconversations:writeSwitch the active persona for this conversation. Body: { "persona_id": "..." | null } (null clears the override).
POST/api/v1/conversations/:id/reply-templateconversations:writeSend 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-windowconversations:readWhatsApp 24-h customer-service window state. WhatsApp channel only.

Leads

MethodPathScopePurpose
GET/api/v1/leadsleads:readList 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/leadsleads:writeCreate 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/:idleads:readFull lead record (BANT, tags, assigned task history).
POST/api/v1/leads/:idleads:writeUpdate fields: stage, temperature, tags, score, assignment, contact info. PATCH on the same path is also accepted (legacy alias).
DELETE/api/v1/leads/:idleads:deletePermanently delete the lead and its associated rows.
GET/api/v1/leads/:id/emailsleads:readList drafted & sent follow-up emails for this lead.
POST/api/v1/leads/:id/emails/generateleads:writeGenerate an AI follow-up draft on demand. Reuses the latest draft if one already exists.
PATCH/api/v1/leads/:id/emails/:email_idleads:writeInline-edit a draft's subject/body. Draft status only.
POST/api/v1/leads/:id/emails/:email_idleads:writeSend a draft. Body: { "action": "approve" | "send" }.
POST/api/v1/leads/:id/emails/:email_id/redraftleads:writeAI rewrites the existing draft per natural-language instructions. Body: { "instructions": "..." }.

Tasks

MethodPathScopePurpose
GET/api/v1/taskstasks:readAgent task log. Filters: widget_id, lead_id, status, type, updated_since. Mounted at /api/v1/activities too as a legacy alias.
POST/api/v1/taskstasks:writeSchedule a new task. Requires widget_id, type, title.
GET/api/v1/tasks/:idtasks:readFull task record with execution + result data. Mounted at /api/v1/activities/:id too as a legacy alias.
POST/api/v1/tasks/:idtasks:writeUpdate status, priority, assignment, or schedule. PATCH also accepted.
DELETE/api/v1/tasks/:idtasks:deleteCancel/delete the task. Useful for clearing stuck tasks that no longer have a runner.

Knowledge base

MethodPathScopePurpose
GET/api/v1/knowledgeknowledge:readList 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/knowledgeknowledge:writeCreate 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/:idknowledge:readSingle item.
POST/api/v1/knowledge/:idknowledge:writeUpdate title/content/persona binding/location binding (location_id: null makes it brand-shared). PATCH also accepted.
DELETE/api/v1/knowledge/:idknowledge:deleteCascade-deletes chunks and embeddings.

Personas

MethodPathScopePurpose
GET/api/v1/personaspersonas:readList personas. Filters: widget_id, external_ref (look a persona up by your own id).
POST/api/v1/personaspersonas:writeCreate 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/:idpersonas:readSingle persona.
POST/api/v1/personas/:idpersonas:writeUpdate 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/avatarpersonas:writeUpload 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/:idpersonas:deleteDelete. Refuses if this is the widget’s last persona. Deletes its email inbox history too. Optional ?knowledge=share|deleteshare (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.

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
ParameterNotes
phoneConvenience alias. Pins the type, so a value that isn't a phone number returns 400 rather than being reinterpreted.
emailConvenience alias. Case- and whitespace-insensitive.
identity_valueThe generic form. Also accepts a social handle (@bobsmith) and an opaque platform id (a Messenger psid or Instagram igsid).
identity_typephone | 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.

MethodPathScopePurpose
GET/api/v1/locationslocations:readList locations, newest first. Filters: widget_id, external_ref, status. Cursor-paginated.
POST/api/v1/locationslocations:writeCreate — 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 (monsun); 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/bulklocations:writeBulk 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/:idlocations:readOne location, with its mapped numbers and knowledge count.
PATCH/api/v1/locations/:idlocations:writeUpdate any field (explicit null clears). status: "suspended" pauses the location fail-closed; "active" resumes.
DELETE/api/v1/locations/:idlocations:deleteDelete. 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.

MethodPathScopePurpose
GET/api/v1/phone-numbersphone:readList numbers, newest first. Filters: widget_id, location_id. Cursor-paginated.
POST/api/v1/phone-numbersphone:writeMap 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/:idphone:writeRe-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/:idphone:deleteUnmap the number (routing removed; the number itself is untouched at the carrier).

Sales playbook

MethodPathScopePurpose
GET/api/v1/playbookplaybook:readList entries. Filters: widget_id, category.
POST/api/v1/playbookplaybook:writeCreate. category must be one of value_prop, roi_data, objection_handler, competitor_comparison, pricing, case_study.
GET/api/v1/playbook/:idplaybook:readSingle entry.
POST/api/v1/playbook/:idplaybook:writeUpdate. PATCH also accepted.
DELETE/api/v1/playbook/:idplaybook:deleteDelete.
POST/api/v1/playbook/generateplaybook:writeUpload 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

MethodPathScopePurpose
GET/api/v1/modelswidgets:readList 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

MethodPathScopePurpose
GET/api/v1/widgetswidgets:readList widgets in the workspace.
POST/api/v1/widgetswidgets:writeCreate 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/:idwidgets:readFull widget config: branding, AI model, agent mode, voice, languages, attendees.
POST/api/v1/widgets/:idwidgets:writeUpdate any configurable field. Plan-gated knobs (proactive mode, multi-domain, voice, attachments) are enforced. PATCH also accepted.
DELETE/api/v1/widgets/:idwidgets:deleteDelete. Refuses if this is the workspace's only widget.
GET/api/v1/widgets/:id/keyswidgets:readList 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/keyswidgets:writeMint a new public embed key for the widget (rotation). Returns the key + embed snippet.
POST/api/v1/widgets/:id/import-storefrontknowledge:writeImport 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

MethodPathScopePurpose
GET/api/v1/integrationsintegrations:readList supported integrations and their connection state for this workspace (optionally a specific widget_id).
GET/api/v1/integrations/:providerintegrations:readConnection 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.

MethodPathScopePurpose
GET/api/v1/webhookswebhooks:readList subscriptions in this workspace.
POST/api/v1/webhookswebhooks:writeCreate. Body: { "url", "events": [] }. The signing secret is returned once on creation — store it; subsequent GETs do not include it.
GET/api/v1/webhooks/:idwebhooks:readSingle subscription.
POST/api/v1/webhooks/:idwebhooks:writeUpdate url, events, active state, or reset_fail_count. PATCH also accepted.
DELETE/api/v1/webhooks/:idwebhooks:deleteDelete 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

MethodPathScopePurpose
GET/api/v1/voice/sessionsvoice:readList voice sessions. Filters: widget_id, status. Cursor-paginated by started_at.
GET/api/v1/voice/sessions/:idvoice:readSingle voice session metadata.
GET/api/v1/voice/sessions/:id/transcriptvoice:readOrdered transcript lines (speaker: visitor | ai | agent).
POST/api/v1/voice/sessions/:id/endvoice:writeTerminate a live voice session and tear down the SFU room.
GET/api/v1/voice/settingsvoice:readRead org-level voice defaults (silence timeout, max call duration, provider, xAI model).
PUT/api/v1/voice/settingsvoice:writeUpdate org-level voice defaults. Takes effect on the next session. PATCH also accepted.
POST/api/v1/voice/callback/requestvoice:writeFile 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.

MethodPathScopePurpose
POST/api/v1/outbound/emailoutbound:emailSend (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/smsoutbound:smsSend 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/callsoutbound:callPlace 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/messagesoutbound:readList outbound messages across channels. Filters: channel, status, widget_id. Cursor-paginated.
GET/api/v1/outbound/messages/:idoutbound:readUnified status record for one send, incl. blocked_reason for compliance refusals.
GET/api/v1/suppressionssuppressions:readList opted-out contacts. Filters: channel, email, phone.
POST/api/v1/suppressionssuppressions:writeSuppress 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/:idsuppressions:deleteRemove 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).

Add webhook
×
Endpoint URL
https://example.com/webhooks/quincer
Events
Lead Created
Lead Stage Changed
Conversation Started
Visitor Message Received
Conversation Ended
Task Completed
CancelCreate webhook

The Add webhook dialog — a delivery URL plus the exact events to subscribe; the signing secret is shown once on create.

Events

EventFires when
conversation.message_receivedVisitor sends any message. Payload includes status (active or human_active) so subscribers know whether an external agent is driving.
conversation.takeoverA 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.handbackTakeover released — AI resumes. Same payload shape as conversation.takeover.
lead.createdA new lead record is captured.
lead.updatedAny lead field changes.
lead.stage_changedLead moves between pipeline stages. Payload includes both previous_stage (canonical) and previousStage (legacy alias).
task.createdAn agent task (follow-up, qualification, CRM sync, etc.) is scheduled.
task.completedA task finishes successfully.
email.sentA lead email goes out.
email.draft_createdAn 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:

Connect OpenClaw
×

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.

Agent display name
OpenClaw

Shown to visitors when OpenClaw replies in chat.

CancelConnect & Issue Token

The Connect OpenClaw dialog — names the agent, then mints a token bound to a dedicated OpenClaw team member.

  1. 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.
  2. Download SKILL.md and drop it in ~/.openclaw/workspace/skills/quincer/.
  3. Add the token to ~/.openclaw/openclaw.json under skills.entries.quincer.env.QUINCER_API_KEY.
  4. Restart the OpenClaw gateway. The quincer tool 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."