Konverto API
A REST API for connecting your own systems to Konverto. Built for agency partners and technical customers who want to read their leads, conversations and bookings programmatically. Server-to-server, authenticated with an API key.
Authentication
Every request is authenticated with a secret API key sent in the Authorization header as a bearer token. Keys are issued to you by Konverto and look like kv_live_…
Authorization: Bearer kv_live_your_secret_keyKeep your key secret. This is a server-to-server API with no CORS support, so never embed a key in a browser, mobile app or any client-side code. Treat it like a password. If a key is exposed, contact us and we will revoke it.
A key is scoped to exactly the data you are allowed to see: a customer key returns only that customer's data, a partner key returns data for the customers in your partner account. You never see anything outside that set.
Base URL
All endpoints are versioned under /v1.
https://pkmeytthmqthhyoiicnv.supabase.co/functions/v1/api-v1/v1A dedicated api.konverto.dk hostname is planned. Until then, use the URL above; it will keep working.
There is an OpenAPI 3.1 description at /openapi.json if you would rather generate a client than read a page.
Endpoints
/v1/pingLiveVerifies that your key works. Returns your account type and how many customers the key can access. Use it to test your integration before anything else.
curl https://pkmeytthmqthhyoiicnv.supabase.co/functions/v1/api-v1/v1/ping \
-H "Authorization: Bearer kv_live_your_secret_key"{
"ok": true,
"owner_type": "partner",
"accessible_customers": 12
}/v1/customersLiveThe customers your key can see. A customer key returns exactly one record, its own. A partner key returns every active customer under the partnership.
{
"data": [
{
"id": "8f2c...",
"company_name": "Murer & Søn ApS",
"contact_person": "Jens Hansen",
"email": "jens@murerogsoen.dk",
"phone": "+4520304050",
"industry": "Murer",
"website": "https://murerogsoen.dk",
"active": true,
"booking_enabled": true,
"created_at": "2026-03-11T09:14:02Z"
}
],
"next_cursor": null
}/v1/leadsLiveInbound leads across every customer your key can see. Each lead carries the customer_id it belongs to, so a partner key can route them without a second call.
| Parameter | Description |
|---|---|
| limit | 1 to 100. Defaults to 25. Out-of-range values are rejected rather than clamped. |
| cursor | Pass the next_cursor from the previous page. Treat it as opaque. |
| created_since | ISO-8601 timestamp. Only leads created at or after this moment. |
| updated_since | ISO-8601 timestamp. Only leads changed at or after this moment. This is the one to use for incremental sync: it catches status changes, not just new leads. Not available on /v1/customers. |
| status | Filter by lead status. |
| customer_id | Narrow to one customer. An id outside your scope returns 404. |
curl "https://pkmeytthmqthhyoiicnv.supabase.co/functions/v1/api-v1/v1/leads?limit=50&created_since=2026-07-01T00:00:00Z" \
-H "Authorization: Bearer kv_live_your_secret_key"{
"data": [
{
"id": "3a91...",
"customer_id": "8f2c...",
"name": "Mette Sørensen",
"email": "mette@example.dk",
"phone": "+4530405060",
"address": "Nørregade 4, 8000 Aarhus",
"status": "new",
"source": "website",
"channel": "form",
"is_test": false,
"meeting_status": null,
"created_at": "2026-07-04T08:22:11Z"
}
],
"next_cursor": "MjAyNi0wNy0wNFQwODoyMjoxMVp8M2E5MQ=="
}Pagination
Lists are ordered newest first and paginated with an opaque cursor. Keep requesting until next_cursor comes back as null, which means you have reached the end. Cursors stay correct even when new leads arrive while you are paging, which is why there is no page number.
What "newest" means depends on the query. Normally lists are ordered by created_at. When you pass updated_since they are ordered by updated_at instead, so a lead created last year but edited today shows up at the top rather than buried deep in your history.
Because of that, a cursor belongs to the query that produced it. Keep updated_since present or absent consistently across every page of one run. Switching midway returns 400 rather than quietly giving you the wrong page.
For incremental sync, store the newest updated_at you have seen and pass it as updated_since on your next run. That catches both new leads and changes to existing ones. Use created_since only when you genuinely want new leads and nothing else. If you would rather not poll at all, register a webhook and we will call you instead.
/v1/leads/{id}LiveA single lead. Returns the lead object directly, not wrapped in a list. A lead outside your scope returns 404, the same answer as a lead that does not exist, so a key cannot probe for which ids are real.
/v1/leads/{id}/messagesLiveThe full conversation with one lead: emails and SMS in one timeline, newest first, paginated like any other list. Each message carries a channel of email or sms and a direction of inbound or outbound. SMS has no subject, so that field is null.
{
"data": [
{
"id": "9c11...",
"customer_id": "8f2c...",
"lead_id": "3a91...",
"channel": "sms",
"direction": "inbound",
"subject": null,
"body": "Kan I komme på tirsdag?",
"from_address": "+4530405060",
"to_address": "+4520304050",
"created_at": "2026-07-04T09:01:44Z"
}
],
"next_cursor": null
}/v1/bookingsLiveBookings across every customer your key can see. Konverto books in three ways and this endpoint returns all of them in one shape. The source field tells you which: online (booked on the website), phone (booked by the phone assistant) or route (a planned on-site visit).
starts_at and ends_at are always absolute timestamps, already resolved from the customer's own timezone, so you never have to guess it. For a route booking they describe the arrival window, not a meeting length: a "we will be there between 9 and 11" is a two hour window, not a two hour job.
Filter with status, source, customer_id, updated_since and the usual limit/cursor.
{
"data": [
{
"id": "b7f0...",
"customer_id": "8f2c...",
"lead_id": "3a91...",
"source": "phone",
"lead_name": "Mette Sørensen",
"lead_email": "mette@example.dk",
"lead_phone": "+4530405060",
"starts_at": "2026-07-20T07:00:00Z",
"ends_at": "2026-07-20T07:30:00Z",
"duration_minutes": 30,
"status": "scheduled",
"booking_format": "phone",
"created_at": "2026-07-04T09:12:00Z",
"updated_at": "2026-07-04T09:12:00Z"
}
],
"next_cursor": null
}/v1/leadsLivePush a lead from your own system into Konverto. Requires a key with the write scope, which is not granted by default. Send name, email, phone, message, ad and page_url. At least one of email or phone is required, because that is how we recognise a person you have sent us before. If your key covers more than one customer, name the one you are creating for with customer_id.
An Idempotency-Key header is required. Choose any unique string per lead, keep it if you retry, and a repeated call returns the original response instead of creating a second lead. We keep the answer for 24 hours and mark the repeat with an Idempotent-Replay header.
We answer 201 when a new lead was created and 200 when we matched the email or phone to a lead the customer already had. In the second case the enquiry is added to that existing lead rather than duplicating the person. The body is a lead object, exactly as GET /v1/leads/{id} returns it.
A lead created this way is treated like any other lead the customer receives: it appears in their pipeline and notifies them. If the customer's account is paused or cannot take leads, we create nothing and answer 409.
curl -X POST https://pkmeytthmqthhyoiicnv.supabase.co/functions/v1/api-v1/v1/leads \
-H "Authorization: Bearer kv_live_..." \
-H "Idempotency-Key: your-unique-id-per-lead" \
-H "Content-Type: application/json" \
-d '{
"name": "Mette Sørensen",
"email": "mette@example.dk",
"phone": "+45 30 40 50 60",
"message": "Vil gerne have et tilbud på nyt tag",
"page_url": "https://kunde.dk/kontakt"
}'/v1/leads/{id}LiveUpdate a lead's status. Also requires the write scope. Status is the only writable field, and it takes the same Danish values the read endpoints return: Kontaktet, aktiv, kold or inaktiv. No Idempotency-Key is needed, since setting the same status twice changes nothing. Returns the updated lead.
/v1/webhooksLive/v1/webhooksLive/v1/webhooks/{id}LiveRegister an https endpoint and we will POST to it whenever something happens, instead of you polling for it. Creating and deleting a subscription requires the write scope; listing does not. A subscription belongs to the same owner as the key that created it, so one partner subscription covers every customer that key can see.
Nine events exist. Name the ones you want in events. Leaving the field out subscribes you to all nine: not choosing is not the same as choosing lead.created, and if you have not said what you want, you want to know what happens to your leads. An unknown name is refused outright, so a typo cannot silently cost you half a subscription.
| Event | Fires when |
|---|---|
| lead.created | A lead is created. Test leads too, flagged with data.is_test. |
| lead.meeting_booked | The meeting status turns to booked. Only the transition counts. |
| lead.purchased | The lead is marked as having purchased. Only the transition counts. |
| lead.archived | The lead is archived. Only the transition counts. |
| lead.status_changed | The status becomes a different value. It is free text, not an enum. |
| lead.message | A message on the lead is sent or received: email, SMS, DM or WhatsApp, in both directions. |
| lead.call | A call is stored against the lead. |
| lead.graded | The conversation is graded, and on a regrade only when the verdict or the outcome actually changed. |
| lead.handover | A DM or WhatsApp conversation asks for a human for the first time. Email and SMS have no such state and never fire it. |
A subscription created before this expansion keeps the events it already had, usually just lead.created. There is no backfill: saying yes to new leads was not saying yes to a stream of messages, calls and status changes. The events on an existing subscription cannot be changed either, so to widen one you delete it and create a new one, with a new secret.
curl -X POST "https://pkmeytthmqthhyoiicnv.supabase.co/functions/v1/api-v1/v1/webhooks" \
-H "Authorization: Bearer kv_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-system.example/hooks/konverto",
"events": ["lead.created", "lead.message", "lead.handover"]
}'The response contains a secret field. That is the only time you will ever see it. We store it encrypted and cannot read it back out to you, so if you lose it, delete the subscription and create a new one.
{
"id": "6f1c0f2e-0b9a-4d4a-9d20-2f8f6a1b7c33",
"url": "https://your-system.example/hooks/konverto",
"events": ["lead.created", "lead.message", "lead.handover"],
"enabled": true,
"created_at": "2026-07-22T09:14:03.221Z",
"disabled_at": null,
"disabled_reason": null,
"last_success_at": null,
"last_error": null,
"last_error_at": null,
"consecutive_failures": 0,
"secret": "whsec_9f0c...shown once"
}Receiving webhooks
Every delivery is a POST with a JSON body, and the envelope is the same for all nine events. The data object is byte for byte the same shape GET /v1/leads/{id} returns. Same field names, same values, nothing extra. If you already parse a lead from the read endpoints, you can reuse that code unchanged.
occurred_at is when the event happened, sent_at is when this particular attempt left us. They differ after a retry: if you were unreachable for two hours, sent_at is now and occurred_at is two hours ago. Order by occurred_at. lead_id repeats data.id on the envelope so a router does not have to unpack the body.
{
"id": "whd_10482",
"event": "lead.created",
"occurred_at": "2026-09-07T08:41:12.204Z",
"sent_at": "2026-09-07T08:41:13.902Z",
"lead_id": "1b0a3c44-9d20-4d4a-8b9a-2f8f6a1b7c33",
"data": {
"id": "1b0a3c44-9d20-4d4a-8b9a-2f8f6a1b7c33",
"customer_id": "7c2a91e4-5f83-42d1-9b06-1e4f8a2c3d55",
"created_at": "2026-09-07T08:41:12.204Z",
"updated_at": "2026-09-07T08:41:12.204Z",
"name": "Mette Sørensen",
"email": "mette@example.dk",
"phone": "4530405060",
"address": null,
"status": "Kontaktet",
"source": "api",
"channel": null,
"campaign_name": null,
"ad": null,
"other": null,
"notes": null,
"page_url": "https://kunde.dk/kontakt",
"archived": false,
"archived_at": null,
"archived_reason": null,
"is_test": false,
"meeting_status": null,
"meeting_booked_at": null,
"meeting_datetime": null
}
}Four events describe something other than the lead itself and carry a source block as well: lead.message, lead.call, lead.graded and lead.handover. The other five never do, and the field is left out entirely rather than set to null, so its presence is the signal.
{
"id": "whd_10501",
"event": "lead.message",
"occurred_at": "2026-09-07T09:14:03.101Z",
"sent_at": "2026-09-07T09:14:05.884Z",
"lead_id": "1b0a3c44-9d20-4d4a-8b9a-2f8f6a1b7c33",
"data": { "...": "the same lead object as above, as it looks right now" },
"source": {
"id": "3f5c1a90-77d4-4e2b-9c18-0a6b2d4e8f11",
"kind": "email_threads",
"channel": "email",
"direction": "inbound",
"from": "mette@example.dk",
"excerpt": "Thanks for the quick reply. We would like a quote for a new roof ..."
}
}Headers
| Header | Meaning |
|---|---|
| X-Webhook-Id | Delivery id. A retry of the same event carries the same id, so you can drop duplicates. |
| X-Webhook-Event | Event name, for example lead.created. |
| X-Webhook-Timestamp | Unix seconds when we signed. Part of the signed content. |
| X-Webhook-Signature | v1=<hex>, an HMAC-SHA256 over the timestamp and the raw body. |
The source block
| Field | Present on | Meaning |
|---|---|---|
| id | all four | The source row's own id. The same on a retry. |
| kind | all four | Which kind of record it was: email_threads, sms_threads, dm_messages, whatsapp_messages, call_transcriptions, conversation_gradings, dm_threads or whatsapp_threads. |
| channel | all four | One of email, sms, dm, whatsapp, call, grading. |
| direction | all four | inbound or outbound, and null where direction means nothing, that is on gradings and handovers. |
| from | all four | The sender where we have one. Only email carries an address we can pass on, the other channels are null. |
| excerpt | all four | At most 500 characters of what was written, cut on a word boundary and ending in three dots when there was more. Can be null. Fetch the full text with GET /v1/leads/{id}/messages. |
| duration_seconds | lead.call | Length of the call in seconds. |
| outcome | lead.call | What came of the call, in our own words. Treat it as text, not an enum. |
| score | lead.graded | The grade, 0 to 100. |
| reason | lead.handover | Why the assistant handed over. Repeated in excerpt, so a handler that only reads excerpts still gets the point. |
The last four fields are left out when the value is missing, not set to null. The block is a whitelist, not a filter: there are no tokens, no thread or customer ids, no raw mail headers and never a full call transcript, whatever else the row happens to hold.
Verifying the signature
Sign <timestamp>.<raw body> with your secret using HMAC-SHA256 and compare the hex digest to the header. Use the raw request body, not a re-serialised object: re-encoding changes whitespace and key order, and the signature will not match.
Reject anything where the timestamp is more than five minutes old. Without that check, someone who captured one valid delivery could replay it later and the signature would still verify. Compare digests in constant time.
import crypto from "node:crypto";
function verify(rawBody, headers, secret) {
const timestamp = headers["x-webhook-timestamp"];
const received = (headers["x-webhook-signature"] || "").replace(/^v1=/, "");
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!timestamp || age > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(received, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib, hmac, time
def verify(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Webhook-Timestamp", "")
received = headers.get("X-Webhook-Signature", "").removeprefix("v1=")
if not timestamp or abs(int(time.time()) - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, received)Retries
Answer with any 2xx as soon as you have the body safely stored. We wait at most ten seconds for your response, and we do not follow redirects: a 30x counts as a failure, because a signed body carrying personal data should only ever go to the address the subscription names.
A delivery is attempted eight times in all: the first attempt and then seven retries with doubling backoff, starting at one minute (1m, 2m, 4m, 8m, 16m, 32m, 64m), so roughly two hours from the first attempt to the last. After that the delivery is given up. Five given-up deliveries in a row disable the subscription, and GET /v1/webhooks will show enabled: false together with the last error we saw. Delete it and create a new one once your endpoint is healthy again.
Deliveries are at least once, not exactly once. If your endpoint answers slowly and we time out after you already stored the lead, you will see the same X-Webhook-Id again. Treat that id as the deduplication key. The payload is built at delivery time, so a lead that changed between the event and the delivery arrives in its current state.
Errors
Errors use standard HTTP status codes and a consistent JSON body:
{
"error": {
"code": "unauthorized",
"message": "Invalid or revoked API key."
}
}| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing, malformed or revoked key. |
| 400 | invalid_request | Bad limit, cursor or timestamp. |
| 403 | forbidden | Your key is read-only. Writing needs the write scope. |
| 404 | not_found | Unknown endpoint or a record outside your scope. |
| 409 | lead_rejected | The customer's account cannot take leads right now. Nothing was created. |
| 409 | conflict | A call with the same Idempotency-Key is still running. Retry shortly. |
| 405 | method_not_allowed | Wrong HTTP method for this endpoint. |
| 429 | rate_limit_exceeded | Too many requests. See the Retry-After header. |
| 429 | daily_cap_reached | This customer hit the daily limit for leads created through the API. |
| 500 | internal_error | Something went wrong on our side. |
Rate limits
Each key has a per-minute request limit. The current limit is returned on every response in the X-RateLimit-Limit header. When you exceed it you get a 429 with a Retry-After header telling you how many seconds to wait. Prefer updated_since over polling everything to stay well under the limit.
Getting a key
Self-service key management is on the way. For now, contact us at kontakt@konverto.dk and we will issue a key for your account.