Build on Conecto

A clean REST API over everything the platform does: write bots that don't just answer but act — on your database, your billing, your product — send images, GIFs, video and cards into the chat, and build your own integrations that the AI agent calls mid-conversation, whatever software they wrap. If the widget can do it, the API can drive it.

Live in production · Signed webhooks · 300 requests/min

The basics

Stuck? Ask an AI about this section:ClaudeChatGPTGrok

Base URL https://conecto.chat/api/v1. Create a credential in Settings → Developers — you get a client ID and a secret (shown once, stored hashed). Authenticate with HTTP Basic — client ID as username, secret as password — or Authorization: Bearer <client_id>:<secret>. A credential can cover the whole workspace or be scoped to a single widget.

curl https://conecto.chat/api/v1/me/ -u "ck_your_client_id:cs_your_secret"

Writing Python? The official SDK (pip install conecto) wraps all of this, and does the parts that are easy to get subtly wrong for you: webhook signature verification, delivery deduplication, idempotent writes, retries, and block validation that fires before a request leaves your process. Everything below still applies — it is the same API underneath.

Rate limit 300 requests/min per credential (429 + Retry-After beyond it). Errors are always {"error": {"code", "message"}}. List endpoints paginate with limit and before_id, returning next_before_id. Write endpoints accept an Idempotency-Key header (any UUID): retry the same key and you get the message that was already created, with a 200 instead of a 201, rather than a second copy in front of the visitor.

One endpoint describes all the others

GET /schema/ returns the whole surface as JSON — every endpoint, event, block type, reserved action, error code and numeric limit. It is served by the same code that enforces those limits, so it cannot drift from the running server the way a documentation page can. Generate your client from it, or check at boot that the feature you need is deployed.

curl https://conecto.chat/api/v1/schema/ -u "ck_...:cs_..." | jq '.limits, .events[].name'

Every response carries X-Conecto-Api-Version. Pin it in your client and log a warning when it changes — that header is how you find out a field moved before your users do.

Rich content: images, GIFs, video, cards

Stuck? Ask an AI about this section:ClaudeChatGPTGrok

Any message you send can carry blocks — an ordered list of typed content rendered under the bubble in the widget, and in the agent's inbox exactly as the visitor saw it. Up to 10 per message, and every field is validated and re-projected server-side, so you never hand us markup and we never hand the browser a string.

curl -X POST https://conecto.chat/api/v1/conversations/42/messages/ \
  -u "ck_...:cs_..." -H "Content-Type: application/json" \
  -d '{
    "body": "Here is how to reset it — takes about 20 seconds:",
    "blocks": [
      {"type": "image", "url": "https://cdn.you.com/reset.gif", "alt": "Reset flow"},
      {"type": "buttons", "items": [
        {"label": "That worked"},
        {"label": "Full guide", "url": "https://docs.you.com/reset"}
      ]}
    ]
  }'

The block types

imageurl, alt, caption, link — animated GIFs are just images
videourl to an .mp4/.webm/.mov, plus poster, autoplay, loop, muted
embedprovider youtube · vimeo · loom · wistia · spotify, and the normal share url
audiourl to an .mp3/.wav/.m4a, optional title
fileurl, filename, size — rendered as a download row
cardsitems[] of title · subtitle · text · image · url · price · badge · buttons; layout carousel or list
listrows[] of label · value · subtitle · image · url — order summaries, shipment steps
buttonsitems[]: {label, value} replies as the visitor, {label, url} opens a link
text · dividerextra paragraphs and a horizontal rule

URLs must be https. A block that can't be built is a 400 naming the block and the reason, never a silent drop — an image that vanishes without explanation costs an afternoon to track down.

Sending a GIF you don't have a home for

POST /media/ takes a multipart file (≤10 MB) and hands back a URL you can drop straight into a block. Use the returned url — it is a path, resolved against the widget's own origin, so the same message works in development and in production.

URL=$(curl -s -X POST https://conecto.chat/api/v1/media/ -u "ck_...:cs_..." \
       -F "file=@celebrate.gif" | jq -r .media.url)

curl -X POST https://conecto.chat/api/v1/conversations/42/messages/ \
  -u "ck_...:cs_..." -H "Content-Type: application/json" \
  -d "{\"body\": \"All set!\", \"blocks\": [{\"type\": \"image\", \"url\": \"$URL\"}]}"

Shipping a GIF as a muted, looping video block is usually the better trade: a tenth of the bytes, and the visitor cannot tell the difference.

A card carousel

{"blocks": [{
  "type": "cards",
  "items": [
    {"title": "Trail Runner 2", "subtitle": "Road · Neutral",
     "price": "89.00 USD", "badge": "Back in stock",
     "image": "https://cdn.you.com/tr2.jpg",
     "url": "https://shop.you.com/p/tr2",
     "buttons": [{"label": "Add to cart", "url": "https://shop.you.com/cart/add/tr2"}]}
  ]
}]}

Build your own integration

Stuck? Ask an AI about this section:ClaudeChatGPTGrok

Shopify, Stripe and BigCommerce are integrations we wrote. This is the one you write. Describe your service — a base URL and a list of actions — install it on a widget, and the AI agent calls it mid-conversation. Downstream nothing can tell it apart from a native integration, which is the entire point: if your store, your billing or your CRM runs on something we have never heard of, you no longer have to wait for us to support it.

1 · Register it

curl -X POST https://conecto.chat/api/v1/integrations/ \
  -u "ck_...:cs_..." -H "Content-Type: application/json" \
  -d '{
    "slug": "acme-store",
    "name": "Acme Store",
    "base_url": "https://api.acme.example/conecto",
    "auth_type": "bearer",
    "credential": "sk_live_...",
    "actions": [
      {"name": "catalog.search_products", "path": "/search",
       "description": "Search the Acme catalog by keyword.",
       "parameters": [{"name": "query", "type": "string", "required": true},
                      {"name": "limit",  "type": "integer"}]},
      {"name": "orders.get_status", "path": "/orders/status", "risk": "verified_read",
       "description": "Look up one of the visitor's orders by number.",
       "parameters": [{"name": "order_number", "type": "string", "required": true}]},
      {"name": "warranty.register", "path": "/warranty", "risk": "write",
       "description": "Register a warranty for a product the visitor owns.",
       "parameters": [{"name": "serial", "type": "string", "required": true}]}
    ]
  }'

The response includes a signing_secret. You need it to verify our calls — it is readable on every later GET too, and {"rotate_signing_secret": true} rolls it.

2 · Install it on a widget

curl -X POST https://conecto.chat/api/v1/integrations/acme-store/install/ \
  -u "ck_...:cs_..." -H "Content-Type: application/json" \
  -d '{"widget_ids": [7], "actions": ["catalog.search_products", "orders.get_status"]}'

actions is an allowlist — omit it to enable everything you declared, or pass [] to keep the integration installed but idle. Nothing is exposed until you install it, and installing is per widget.

3 · Answer the call

We POST a signed JSON envelope to base_url + path. Verify the signature the same way you verify a webhook — one routine covers both directions:

// POST https://api.acme.example/conecto/search
{
  "action": "catalog.search_products",
  "integration": "acme-store",
  "arguments": { "query": "trail shoes", "limit": 3 },
  "source": "ai_agent",
  "idempotency_key": "8f14e45fceea167a...",
  "workspace": { "id": 12 },
  "widget": { "id": 7, "key": "w_..." },
  "conversation": { "id": 4821 },
  "visitor": { "session": "…", "email": "maya@acme.io",
               "verified": true, "verified_email": "maya@acme.io", "name": "Maya" }
}

// Headers: X-Conecto-Signature: sha256=<HMAC-SHA256(signing_secret, raw body)>
//          X-Conecto-Timestamp, X-Conecto-Action, X-Conecto-Integration,
//          X-Conecto-Idempotency-Key

Reply with one of five shapes:

{"ok": true, "result": {...}}                  // success — the agent reads it
{"ok": true, "result": {...}, "blocks": [...]}  // …and attach rich content to the reply
{"ok": true, "not_found": true}                 // nothing matched (a normal outcome)
{"verify_required": true}                       // "I need a verified visitor for this"
{"ok": false, "error": "Already cancelled."}    // a failure the agent may relay

A plain JSON object with none of those keys is treated as the result itself, so you can point an action at an endpoint you already have. Budget: 8s timeout, 128 KB response. Everything you return reaches the model as data inside a JSON envelope, never as instructions, and secret-looking keys are stripped on the way in.

4 · Try it before a visitor does

curl -X POST https://conecto.chat/api/v1/integrations/acme-store/actions/catalog.search_products/run/ \
  -u "ck_...:cs_..." -H "Content-Type: application/json" \
  -d '{"arguments": {"query": "trail shoes"}}'

This runs the real call path — signature, credential, timeout, parsing — and shows you the envelope the agent will get. Pass conversation_id to run it in the context of a live chat, which is the only way a verified action can succeed.

Risk levels, and the one thing you can't turn off

public_readcatalog, availability, docs — no identity needed
verified_readone person's data. Refused until the visitor's email is verified
public_writea mutation that needs no identity — newsletter signup, lead capture
writea mutation on one person's account. Verified, and never silently retried

For the last two, the verified address is supplied by us, in visitor.verified_email — never taken from the model's arguments. Verification comes from an emailed code, or from your own site vouching for a logged-in user. No widget setting can exempt an action from it, because "whose order is this" is not a question a toggle should answer.

Reserved actions: your store, wired in like a native one

A few action names mean something to the platform itself. Declare catalog.search_products returning {"products": [{title, url, image, price_from, currency, available}]} and its results become product cards under the AI's replies and fill the widget's Home showcase — the same treatment a Shopify connection gets, from whatever your catalog actually runs on. orders.get_status, orders.get_tracking, orders.list_recent and account.lookup are reserved the same way, and are always verified reads. GET /integrations/{slug}/actions/ returns the full catalog with the shape each one expects.

Custom integrations are part of the AI Agent plans, and are limited to 20 per workspace with 40 actions each. Outbound calls are HTTPS-only, resolved and pinned to a public IP before connecting, with redirects refused — so an integration URL can never be pointed at something private.

Quickstart: a custom bot in three steps

Stuck? Ask an AI about this section:ClaudeChatGPTGrok

A bot is a webhook and a reply. Subscribe to message.created, answer through the messages endpoint, and the widget renders it live — quick-reply buttons, email capture, the native ticket form, typing indicators. Turn the built-in AI off on the widget and your bot owns the conversation.

1 — Subscribe your server (Settings → Developers → Webhooks, or via API):

curl -X POST https://conecto.chat/api/v1/webhooks/ \
  -u "ck_...:cs_..." -H "Content-Type: application/json" \
  -d '{"url": "https://bots.yourapp.com/conecto",
       "events": ["message.created", "conversation.created"]}'

2 — Verify and read the event. Every delivery is signed: X-Conecto-Signature: sha256=<HMAC-SHA256(secret, raw body)>.

// Node/Express
app.post('/conecto', express.raw({ type: '*/*' }), (req, res) => {
  const sig = 'sha256=' + crypto.createHmac('sha256', WEBHOOK_SECRET)
    .update(req.body).digest('hex')
  if (sig !== req.get('X-Conecto-Signature')) return res.sendStatus(401)

  const { event, data } = JSON.parse(req.body)
  if (event === 'message.created' && data.message.sender === 'visitor') {
    handle(data)                       // your bot logic — reply async, respond 200 fast
  }
  res.sendStatus(200)
})

3 — Reply with features.

curl -X POST https://conecto.chat/api/v1/conversations/42/messages/ \
  -u "ck_...:cs_..." -H "Content-Type: application/json" \
  -d '{"body": "I can refund order #1284 right away — confirm?",
       "buttons": ["Yes, refund it", "Talk to a human"]}'

Button taps come back to your webhook as normal visitor messages containing the button text — your bot's state machine lives entirely on your side. Other reply powers: blocks sends images, GIFs, video and cards (see Rich content), {"ask_email": true} renders the email-capture form, {"ticket_form": true} attaches the native open-a-ticket form, products renders tappable product cards, {"internal": true} leaves an agent-only note, /typing/ shows “…is typing”, /handoff/ summons a human, and PATCH closes the conversation when you're done.

Bot cookbook

Stuck? Ask an AI about this section:ClaudeChatGPTGrok

Recipes people actually ship. Each is a webhook handler plus a handful of API calls.

1 · The action bot — changes things in your software

Because the webhook hits your server, your bot can do anything your backend can: write to your database, call your billing system, update a record in your accounting software. Combined with vouched identity you know exactly who is asking — so “file this $250 receipt under Marketing” is safe to execute:

async function handle({ conversation, visitor, message }) {
  const convo = conversation.id

  // "file 250 under Marketing" — your parsing, your rules
  const cmd = parseAccountingCommand(message.body)
  if (!cmd) return reply(convo, "Tell me e.g. 'file 250 under Marketing'.")

  // Only act for users YOUR backend has vouched for (logged in on your site)
  if (!visitor.verified)
    return reply(convo, "Please sign in first, then ask me again.", ["Log in"])

  await ledger.addEntry({            // <- YOUR accounting system
    account: cmd.account,
    amount:  cmd.amount,
    user:    visitor.verified_email, // identity Conecto guarantees
  })
  await reply(convo,
    `Done — $${cmd.amount} filed under ${cmd.account}. Anything else?`,
    ["Show this month's entries", "Undo that"])
}

The same pattern covers “add a seat to my plan”, “rename my project”, “book me a slot Thursday” — the bot is a thin conversational layer over your own API. Prefer the built-in AI instead of writing a bot? Register the same endpoints as an integration and the Conecto AI calls them mid-conversation, behind the same identity verification — no state machine to maintain. (If you already speak MCP, a remote MCP tool server works too: Dashboard → AI Agents → Integrations.)

2 · Order-status bot

if (/where.*order|track/i.test(message.body)) {
  await typing(convo, 'OrderBot', true)
  const order = await shop.lastOrder(visitor.email)     // your store
  await reply(convo, order
    ? `Order #${order.id} is ${order.state} — arriving ${order.eta}.`
    : "I couldn't find an order for this email.",
    order ? [`Track #${order.id}`] : undefined)
}

3 · Ticket-deflection bot

Search your help center first; only open a ticket when nothing matches:

const { articles } = await api('GET', '/articles/?q=' + q + '&published=true')
if (articles.length)
  await reply(convo, `This might help: “${articles[0].title}”. Did that solve it?`,
              ["Solved it", "Open a ticket"])
else
  await api('POST', `/conversations/${convo}/messages/`,
            { body: "Let's get this to the team.", ticket_form: true })

4 · Lead-qualification bot

// no email yet? capture it natively, then enrich + route
if (!visitor.email)
  return api('POST', `/conversations/${convo}/messages/`,
             { body: "Happy to help — what's your work email?", ask_email: true })

await api('POST', '/contacts/', { email: visitor.email,
  custom_fields: { lead_source: 'chat', intent: classify(message.body) } })
await api('POST', `/conversations/${convo}/messages/`,
          { body: "Routing you to sales…", internal: true })
await api('POST', `/conversations/${convo}/assign/`, { user_id: SALES_USER_ID })
await api('POST', `/conversations/${convo}/handoff/`)

5 · Proactive lifecycle messages

Push to a visitor session without waiting for them to write — shipping updates, trial nudges, cart recovery:

curl -X POST https://conecto.chat/api/v1/widgets/7/visitors/$SESSION/message/ \
  -u "ck_...:cs_..." -H "Content-Type: application/json" \
  -d '{"body": "Your order shipped — want live tracking?",
       "buttons": ["Track my order"]}'

Cart recovery works the same way, with products putting the item itself back in front of them as a tappable card:

-d '{"body": "Still thinking it over? Your cart is saved:",
     "products": [{"title": "Trail Runner 2", "price_from": "89.00",
                   "currency": "USD", "image": "https://cdn.you.com/tr2.jpg",
                   "url": "https://shop.you.com/cart"}]}'

6 · A store we've never heard of, wired into the AI

No webhook, no state machine: declare the reserved catalog action, point it at your search endpoint, and the AI recommends from your catalog with real product cards.

// POST /conecto/search on your server
app.post('/conecto/search', verifyConectoSignature, async (req, res) => {
  const { query, limit = 4 } = req.body.arguments
  const hits = await catalog.search(query, { limit })     // <- YOUR catalog

  res.json({ ok: hits.length > 0, not_found: hits.length === 0,
    products: hits.map(p => ({
      title: p.name, url: p.permalink, image: p.thumbnail,
      price_from: p.price.toFixed(2), currency: p.currency,
      available: p.stock > 0,
    })) })
})

That's the whole integration. The cards, the Home showcase, the "call this again before you mention a product" discipline and the rule against the model pasting raw URLs all come along with the reserved name.

7 · CSAT follow-up

Subscribe to conversation.rated; thank promoters, rescue detractors:

if (event === 'conversation.rated') {
  if (data.rating.score <= 2)
    await api('POST', '/tickets/', { email: data.visitor.email,
      message: `Low CSAT (${data.rating.score}/5): ${data.rating.comment}`,
      priority: 'high' })
  else
    await push(data.widget.id, data.visitor.session,
               "Glad we could help! Here's 10% off your next order: THANKS10")
}

Acting on your own systems, safely

Stuck? Ask an AI about this section:ClaudeChatGPTGrok

Three rules make action bots production-grade:

1 — Identity first. Only mutate data for sessions where visitor.verified is true — your backend vouched for them via /identify/. The vouch is time-boxed and session-scoped; revoke it with /unverify/ on logout.

2 — Confirm destructive steps. Send the action as a question with buttons (“Refund order #1284?” · Yes / No) — the tap comes back as the button's text, your idempotent handler executes, and the transcript documents consent.

3 — Hand off when unsure. POST /conversations/{id}/handoff/ flags the thread human-needed (your routing rules apply), and {"internal": true} notes give the teammate full context your bot gathered.

Endpoint reference

Stuck? Ask an AI about this section:ClaudeChatGPTGrok

Conversations & messages

GET/conversations/

Newest first. Filters: status (open · pending · closed), widget_id, session; paginate with limit/before_id.

GET/conversations/{id}/

Conversation + visitor-facing transcript (≤500 messages, since_id for increments).

POST/conversations/{id}/messages/

body (≤4000), buttons (≤6 × 60 chars), blocks (≤10 — see Rich content), ask_email, ticket_form (native ticket form), internal (agent-only note), products (≤4 cards rendered as a mini carousel under the bubble: {title, url, price_from, currency, image} — title required, everything else optional). Honors Idempotency-Key. 409 when closed.

POST/conversations/{id}/typing/

{"name": "OrderBot", "on": true} — auto-expires ~8s.

POST/conversations/{id}/assign/

{"user_id": 12} (or null to unassign) — teammates come from /members/.

POST/conversations/{id}/handoff/

Flag human-needed; surfaces in the inbox like an AI handoff, routing rules included.

PATCH/conversations/{id}/

{"status": "closed" | "open"}. Closing fires conversation.closed.

POST/widgets/{widget_id}/visitors/{session}/message/

Proactive push: reuses the session's live conversation or opens one; body + buttons + blocks + products. Honors Idempotency-Key.

Integrations — your own software as a first-class integration

GET/integrations/ · POST

List, or register one: slug, name, base_url (https), optional description/icon_url/homepage_url, auth_type (none · bearer · api_key · basic) + credential, and actions inline. Returns the signing_secret you verify our calls with.

GET/integrations/{slug}/ · PATCH · DELETE

PATCH takes the same fields; actions upserts by name and replace_actions: true makes your list authoritative (deploy-from-source). rotate_signing_secret rolls the secret; active: false switches it off everywhere at once.

GET/integrations/{slug}/actions/ · POST · DELETE /{name}/

Each action: name (dotted, e.g. orders.lookup), path, method, risk, description (what the AI reads), parameters ({name, type, required, description, enum}), ai_enabled. GET also returns the reserved-action catalog.

POST/integrations/{slug}/install/ · /uninstall/

widget_ids (all visible widgets when omitted), actions allowlist, enabled.

POST/integrations/{slug}/actions/{name}/run/

Invoke it now through the real call path. arguments, optional conversation_id for visitor context. Returns {status, result, blocks}.

Media

POST/media/

Multipart file (≤10 MB) → {media: {url, absolute_url, filename, content_type, size, is_image}}. Put url in an image/video/file block.

Tickets

GET/tickets/

Filters: status (open · pending · resolved), email; paginated.

POST/tickets/

email, message, optional name, category_id, priority (low · normal · high · urgent), submission_id (UUID idempotency key). The requester gets the standard acknowledgement email.

GET/tickets/{id}/ · PATCH

Detail with the comment thread; PATCH status, priority, assignee_user_id.

POST/tickets/{id}/reply/

body — emails the requester; internal: true for a private note.

GET/ticket-categories/

The workspace's categories for ticket creation.

Contacts — sync your user database

POST/contacts/

Upsert by email (201 created / 200 updated): profile fields + custom_fields (≤30 keys, merged).

GET/contacts/ · GET/PATCH/DELETE /contacts/{id}/

Search with email (exact) or q; standard pagination.

Visitors — identity & personalization

POST/widgets/{widget_id}/visitors/{session}/identify/

email (required), name, data (custom fields), verified + verify_hours (≤72, default 12) — see Identity. Sessions can be pre-provisioned.

POST/widgets/{widget_id}/visitors/{session}/unverify/

Revoke the vouch — call on logout.

GET/widgets/{widget_id}/visitors/{session}/

Current identity: email, name, verification state.

Knowledge base

GET/articles/

q full-text search, published=true filter — perfect for bot answer lookups.

POST/articles/ · GET/PATCH/DELETE /articles/{id}/

Sync docs programmatically. HTML content is allowlist-sanitized server-side.

Configuration & meta

GET/widgets/{widget_id}/ · PATCH

Read/update widget config — greeting, colors, home action buttons, quick questions, office hours… the same validated fields the dashboard customizer edits.

GET/members/

Teammates (user_id, name, role) for assignment.

GET/stats/

Live counts: open/pending conversations, open tickets, contacts, published articles.

GET/me/

Your workspace, credential scope, and widgets (ids + embed keys).

GET/schema/

The machine-readable description of everything above: endpoints, events, block types, reserved actions, error codes and limits. Generate your client from it.

Identity: skip re-verification for logged-in users

Stuck? Ask an AI about this section:ClaudeChatGPTGrok

The widget stores its session id in the browser. Read it client-side, send it to your backend with your own session cookie, then vouch server-to-server:

# your backend, right after your own auth check
curl -X POST https://conecto.chat/api/v1/widgets/7/visitors/$CONECTO_SESSION/identify/ \
  -u "ck_...:cs_..." -H "Content-Type: application/json" \
  -d '{"email": "maya@acme.io", "name": "Maya",
       "verified": true, "verify_hours": 24,
       "data": {"plan": "pro", "customer_since": "2024"}}'

While the vouch lasts, Conecto treats the email as verified: the widget knows their name, chats attach to the right CRM contact, and flows that normally demand an email OTP — refund lookups, order changes, sensitive account data through the AI's tools — proceed without one. Your attestation is as strong as our code-by-email, because you actually authenticated them. Only vouch sessions your backend has verified, and call /unverify/ on logout.

Browser SDK: script the widget on your page

Stuck? Ask an AI about this section:ClaudeChatGPTGrok

The embed exposes a small page API — no extra script to load. Add data-launcher="none" to the install snippet to hide the default bubble, put data-conecto-open on any element to make it a launcher, and use window.ConectoWidget for everything scripted:

ConectoWidget.open();  ConectoWidget.close();  ConectoWidget.toggle();

// Lock the text bar until the visitor picks a topic, then ask for them:
ConectoWidget.composer(false, { placeholder: 'Pick a topic below to start' });
document.querySelector('#refunds-btn').onclick = () => {
  ConectoWidget.ask('I have a question about a refund', { send: true });
  ConectoWidget.composer(true);   // typing allowed from here on
};

// ask() without send just pre-fills the composer for the visitor to send.
ConectoWidget.ask('What are your opening hours?');

// React to the widget: 'open', 'close', and 'message' ({sender, text}).
const off = ConectoWidget.on('message', (m) => {
  if (m.sender === 'agent') showBadge();
});
off();   // every on() returns its unsubscribe

Two things to know. ask(..., {send: true}) goes through the same pipeline as a typed message, so consent and email-verification gates still apply — it opens their sheets instead of skipping them, and server-side rate limits are unchanged. And composer(false) is a UI convenience, not a security boundary: it runs in the visitor's browser, so anything that genuinely must not happen without consent or verification is enforced by the server, never by a disabled text box.

Webhooks

Stuck? Ask an AI about this section:ClaudeChatGPTGrok

Manage in the dashboard or via GET/POST /webhooks/ and DELETE /webhooks/{id}/ (https only, ≤10 per workspace, optional per-widget scope). Deliveries carry X-Conecto-Event, X-Conecto-Delivery, X-Conecto-Timestamp and the HMAC signature, and time out after 2.5s — respond 200 fast, process async. Events:

conversation.createdfirst visitor message of a new thread (incl. home-tab forms)
message.createdeach visitor message — the custom-bot trigger
conversation.closedclosed by an agent or the API
conversation.assignedassigned to, or unassigned from, a teammate
conversation.handoffflagged as needing a human
conversation.ratedvisitor CSAT rating (1–5 + comment)
ticket.createdany source: widget form, AI, bots, automations, the API
ticket.updatedstatus, priority or assignee changed
contact.createda new person entered the CRM — sync it to yours
visitor.identifieda session was identified through the API, with its vouch state

Delivery is at-least-once and unordered. Every payload carries an id (also in X-Conecto-Delivery): store it, skip ids you have already handled, and both replays and duplicates stop being your problem. When your bot answers a delivery, pass that same id as the Idempotency-Key and a redelivery can never make it speak twice.

app.post('/conecto', express.raw({ type: '*/*' }), async (req, res) => {
  const sig = 'sha256=' + crypto.createHmac('sha256', WEBHOOK_SECRET)
    .update(req.body).digest('hex')
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(req.get('X-Conecto-Signature') || '')))
    return res.sendStatus(401)

  const { id, event, data } = JSON.parse(req.body)
  res.sendStatus(200)                        // ack first, work after
  if (await seen(id)) return                 // at-least-once: dedupe on the delivery id

  if (event === 'message.created' && data.message.sender === 'visitor') {
    await fetch(`https://conecto.chat/api/v1/conversations/${data.conversation.id}/messages/`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Idempotency-Key': id, ...auth },
      body: JSON.stringify({ body: await answer(data) }),
    })
  }
})

Everything here is designed to sit under an SDK: stable envelopes, slug-addressed integrations, typed errors, cursor pagination, idempotent writes, one signature scheme in both directions, and GET /schema/ to generate from. Building something? Talk to us — we want people building on Conecto, and we prioritize what devs ask for.