{
  "openapi": "3.1.0",
  "info": {
    "title": "Konverto API",
    "version": "1.3.0",
    "description": "Access to the customers, leads, conversations, bookings and totals your API key can see, plus lead writes and outgoing webhooks for all nine lead events.\n\nAn API key resolves to a set of customers. A customer key sees exactly one: its own. A partner key sees every active customer under the partnership. Every endpoint is filtered by that set, so there is no way to reach data outside it.\n\nReading needs nothing but a key. Creating a lead, changing one, or registering a webhook needs a key with the `write` scope. Keys are issued read-only and have to be upgraded deliberately.\n\nThe API is free. Rate limits exist to protect the service, not to sell you a bigger plan.\n\nThis is a server to server API. There are no CORS headers, deliberately: a secret key does not belong in browser JavaScript.\n\n## Errors\n\nEvery non-2xx response is a JSON object with a single `error` key holding a stable machine-readable `code` and a human-readable `message` (see the `Error` schema). Branch on `code`, never on `message`. `429` carries a `Retry-After` header.\n\n## Versioning and deprecation\n\nThe version lives in the path (`/v1`). Within a version we only add: new optional fields, new endpoints, new webhook events. Anything that would break an existing client goes into a new version. If a version is ever retired, it keeps working for at least six months after the retirement is announced on https://konverto.ai/developers/ and, during that period, its responses carry `Deprecation` and `Sunset` headers with the date."
  },
  "servers": [
    {
      "url": "https://pkmeytthmqthhyoiicnv.supabase.co/functions/v1/api-v1/v1"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Customers"
    },
    {
      "name": "Leads"
    },
    {
      "name": "Bookings"
    },
    {
      "name": "Stats"
    },
    {
      "name": "Webhooks"
    }
  ],
  "paths": {
    "/ping": {
      "get": {
        "operationId": "ping",
        "tags": [
          "Customers"
        ],
        "summary": "Verify a key and see how many customers it reaches",
        "description": "Returns the count, never the ids. Proving a key works should not double as a way to enumerate customers.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "owner_type": {
                      "type": "string",
                      "enum": [
                        "customer",
                        "partner"
                      ]
                    },
                    "accessible_customers": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/customers": {
      "get": {
        "operationId": "listCustomers",
        "tags": [
          "Customers"
        ],
        "summary": "List the customers this key can see",
        "parameters": [
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/cursor"
          },
          {
            "$ref": "#/components/parameters/createdSince"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Customer"
                      }
                    },
                    "next_cursor": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "description": "Returns every customer the key resolves to. A customer key gets exactly one entry (its own company); a partner key gets every active customer under the partnership. Use the ids here to scope the other endpoints."
      }
    },
    "/leads": {
      "get": {
        "operationId": "listLeads",
        "tags": [
          "Leads"
        ],
        "summary": "List inbound leads",
        "parameters": [
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/cursor"
          },
          {
            "$ref": "#/components/parameters/createdSince"
          },
          {
            "$ref": "#/components/parameters/updatedSince"
          },
          {
            "$ref": "#/components/parameters/customerId"
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter by lead status."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Lead"
                      }
                    },
                    "next_cursor": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "description": "Lists inbound leads across the customers the key can see, newest first. Filter by customer, status or time window with the query parameters; page through large sets with the paging parameters."
      },
      "post": {
        "operationId": "createLead",
        "tags": [
          "Leads"
        ],
        "summary": "Create a lead",
        "description": "Requires the `write` scope.\n\nA created lead is billed to the customer as an inquiry, so an `Idempotency-Key` header is required: a client that never saw our answer can retry with the same key and pay once.\n\nWe deduplicate on email or phone within the same customer. A repeat of someone we already have is added to the existing lead and answered with 200 instead of 201, and nothing is billed a second time. That is why at least one of `email` and `phone` is required.\n\n`source` is always set to `api` and cannot be chosen, `status` starts at `Kontaktet`, and unknown fields are rejected rather than ignored.",
        "parameters": [
          {
            "$ref": "#/components/parameters/idempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LeadCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "A new lead was created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Lead"
                }
              }
            }
          },
          "200": {
            "description": "The inquiry was added to a lead we already had. Nothing was billed again. A replayed Idempotency-Key returns the original answer with an `Idempotent-Replay: true` header.",
            "headers": {
              "Idempotent-Replay": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "true"
                  ]
                },
                "description": "Present when this is the stored answer to an earlier identical request."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Lead"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/leads/{id}": {
      "get": {
        "operationId": "getLead",
        "tags": [
          "Leads"
        ],
        "summary": "Fetch a single lead",
        "description": "A lead outside your scope returns the same 404 as a lead that does not exist. A key cannot probe for which ids are real.",
        "parameters": [
          {
            "$ref": "#/components/parameters/leadId"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Lead"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "patch": {
        "operationId": "updateLeadStatus",
        "tags": [
          "Leads"
        ],
        "summary": "Change the status of a lead",
        "description": "Requires the `write` scope. `status` is the only field v1 lets you change, and no Idempotency-Key is needed: setting the same status twice lands where setting it once does, and nothing is billed.\n\nThe values are the Danish ones the product uses, the same strings GET returns. Translating them here would mean PATCH took different words than GET gave back.",
        "parameters": [
          {
            "$ref": "#/components/parameters/leadId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LeadUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated lead.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Lead"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/leads/{id}/messages": {
      "get": {
        "operationId": "listLeadMessages",
        "tags": [
          "Leads"
        ],
        "summary": "The conversation with one lead",
        "description": "Emails and SMS in one timeline, newest first.",
        "parameters": [
          {
            "$ref": "#/components/parameters/leadId"
          },
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/cursor"
          },
          {
            "$ref": "#/components/parameters/createdSince"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Message"
                      }
                    },
                    "next_cursor": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/bookings": {
      "get": {
        "operationId": "listBookings",
        "tags": [
          "Bookings"
        ],
        "summary": "List bookings",
        "description": "Konverto books in three ways (online, phone assistant, planned on-site route). All three are returned in one shape; `source` tells you which.",
        "parameters": [
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/cursor"
          },
          {
            "$ref": "#/components/parameters/createdSince"
          },
          {
            "$ref": "#/components/parameters/updatedSince"
          },
          {
            "$ref": "#/components/parameters/customerId"
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "scheduled",
                "cancelled",
                "completed"
              ]
            }
          },
          {
            "name": "source",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "online",
                "phone",
                "route"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Booking"
                      }
                    },
                    "next_cursor": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/stats": {
      "get": {
        "operationId": "getStats",
        "tags": [
          "Stats"
        ],
        "summary": "Totals for a time window",
        "description": "One request instead of five list calls when all you want is the numbers. Everything is counted across the customers your key can see, and every block is always present with zeroes rather than missing, so you do not have to write code for two shapes.\n\nThe window is half open: `from` is included, `to` is not. Leave both out and you get the last 30 days. `amount_minor` is the smallest unit of the customer's own currency (øre for Danish customers): there is no currency field on a lead, so you need to know what your customer bills in.\n\nReading only. No `write` scope needed.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Start of the window, included. Defaults to 30 days ago."
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "End of the window, excluded. Defaults to now. `from` must be before `to`, and they can be at most 366 days apart, so a whole year fits in one call."
          },
          {
            "$ref": "#/components/parameters/customerId"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Stats"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/webhooks": {
      "get": {
        "operationId": "listWebhooks",
        "tags": [
          "Webhooks"
        ],
        "summary": "List your webhook subscriptions",
        "description": "Includes the delivery status of each subscription, so you can see whether we are actually reaching you without having to ask us.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Webhook"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "operationId": "createWebhook",
        "tags": [
          "Webhooks"
        ],
        "summary": "Register an endpoint we should POST to",
        "description": "Requires the `write` scope: pointing our outgoing traffic somewhere new is setting data in motion, and a read-only key must not be able to do that.\n\nA subscription belongs to the same owner as the key that created it, so one partner subscription covers every customer that key can see. At most five per owner.\n\nThe response carries the signing `secret` in cleartext. That is the only time it exists anywhere readable: we store it encrypted and cannot hand it back. Lose it and you delete the subscription and create a new one.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created. Store the secret now.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookWithSecret"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/webhooks/{id}": {
      "delete": {
        "operationId": "deleteWebhook",
        "tags": [
          "Webhooks"
        ],
        "summary": "Delete a subscription",
        "description": "Requires the `write` scope. A subscription owned by someone else returns the same 404 as one that does not exist, so an id cannot be guessed at.",
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookId"
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "deleted": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/InvalidRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    }
  },
  "webhooks": {
    "lead.created": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "A lead was created",
        "description": "What we send to the url you registered, and the place to read how every delivery works. The envelope is the same for all nine events.\n\n`data` is byte for byte the shape `GET /v1/leads/{id}` returns, so parsing code can be reused unchanged. `lead_id` repeats `data.id` on the envelope so a router does not have to unpack the body to know what the delivery is about.\n\n`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. Use `occurred_at` for ordering, `sent_at` for freshness.\n\n`source` describes what triggered the event and is only present when the trigger is something other than the lead itself. Its absence is the signal: lead.created, lead.meeting_booked, lead.purchased, lead.archived and lead.status_changed never carry it. `source.excerpt` is at most 500 characters, cut on a word boundary; fetch the full text with `GET /v1/leads/{id}/messages` when you need it.\n\nVerify the signature before you trust the body: HMAC-SHA256 over `<X-Webhook-Timestamp>.<raw body>` using your subscription secret, hex encoded, compared against the part after `v1=`. Sign the bytes you received, not a re-serialised object.\n\nAnswer 2xx. Anything else is retried with growing backoff, and a subscription that keeps failing is disabled. We do not follow redirects: a 30x would send signed personal data somewhere the subscription does not point.",
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookIdHeader"
          },
          {
            "$ref": "#/components/parameters/webhookEventHeader"
          },
          {
            "$ref": "#/components/parameters/webhookTimestampHeader"
          },
          {
            "$ref": "#/components/parameters/webhookSignatureHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookDelivery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx counts as delivered."
          }
        }
      }
    },
    "lead.meeting_booked": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "The lead booked a meeting",
        "description": "The time is in `data.meeting_datetime` and the moment it was booked in `data.meeting_booked_at`. No `source`: everything about the meeting is on the lead.\n\nEnvelope, signature and retries work exactly as described under lead.created.",
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookIdHeader"
          },
          {
            "$ref": "#/components/parameters/webhookEventHeader"
          },
          {
            "$ref": "#/components/parameters/webhookTimestampHeader"
          },
          {
            "$ref": "#/components/parameters/webhookSignatureHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookDelivery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx counts as delivered."
          }
        }
      }
    },
    "lead.purchased": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "The lead bought something",
        "description": "Sent when the lead is marked as having purchased. No `source`.\n\nEnvelope, signature and retries work exactly as described under lead.created.",
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookIdHeader"
          },
          {
            "$ref": "#/components/parameters/webhookEventHeader"
          },
          {
            "$ref": "#/components/parameters/webhookTimestampHeader"
          },
          {
            "$ref": "#/components/parameters/webhookSignatureHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookDelivery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx counts as delivered."
          }
        }
      }
    },
    "lead.archived": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "The lead was archived",
        "description": "The reason, when there is one, is in `data.archived_reason`. No `source`.\n\nEnvelope, signature and retries work exactly as described under lead.created.",
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookIdHeader"
          },
          {
            "$ref": "#/components/parameters/webhookEventHeader"
          },
          {
            "$ref": "#/components/parameters/webhookTimestampHeader"
          },
          {
            "$ref": "#/components/parameters/webhookSignatureHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookDelivery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx counts as delivered."
          }
        }
      }
    },
    "lead.status_changed": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "The lead's status changed",
        "description": "The new value is in `data.status`. The status names are the customer's own words, so treat them as text, not as an enum. No `source`.\n\nEnvelope, signature and retries work exactly as described under lead.created.",
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookIdHeader"
          },
          {
            "$ref": "#/components/parameters/webhookEventHeader"
          },
          {
            "$ref": "#/components/parameters/webhookTimestampHeader"
          },
          {
            "$ref": "#/components/parameters/webhookSignatureHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookDelivery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx counts as delivered."
          }
        }
      }
    },
    "lead.message": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "A message was sent or received",
        "description": "Fires for email, SMS, Facebook and Instagram DMs and WhatsApp, in both directions. `source.channel` tells you which, `source.direction` tells you whether it came in or went out, and `source.excerpt` is the first 500 characters of what was written. `source.from` is only filled in for email; the other channels do not carry a sender address we can pass on.\n\nEnvelope, signature and retries work exactly as described under lead.created.",
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookIdHeader"
          },
          {
            "$ref": "#/components/parameters/webhookEventHeader"
          },
          {
            "$ref": "#/components/parameters/webhookTimestampHeader"
          },
          {
            "$ref": "#/components/parameters/webhookSignatureHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookDelivery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx counts as delivered."
          }
        }
      }
    },
    "lead.call": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "A call was handled",
        "description": "`source.duration_seconds` is the length of the call, `source.outcome` what came of it, and `source.excerpt` a short summary. The full transcript is deliberately never sent: a whole conversation about a private person does not belong in a notification. Fetch it from the customer's own account if you need it.\n\nEnvelope, signature and retries work exactly as described under lead.created.",
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookIdHeader"
          },
          {
            "$ref": "#/components/parameters/webhookEventHeader"
          },
          {
            "$ref": "#/components/parameters/webhookTimestampHeader"
          },
          {
            "$ref": "#/components/parameters/webhookSignatureHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookDelivery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx counts as delivered."
          }
        }
      }
    },
    "lead.graded": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "The conversation was graded",
        "description": "`source.score` is 0 to 100 and `source.excerpt` is the one-sentence verdict. A grading says something about how the conversation went, not about the person.\n\nEnvelope, signature and retries work exactly as described under lead.created.",
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookIdHeader"
          },
          {
            "$ref": "#/components/parameters/webhookEventHeader"
          },
          {
            "$ref": "#/components/parameters/webhookTimestampHeader"
          },
          {
            "$ref": "#/components/parameters/webhookSignatureHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookDelivery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx counts as delivered."
          }
        }
      }
    },
    "lead.handover": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "The conversation needs a human",
        "description": "The assistant handed the conversation over. `source.reason` says why, and it is repeated in `source.excerpt` so a generic handler that only reads excerpts still gets the point. This is the one event where someone is expected to do something.\n\nEnvelope, signature and retries work exactly as described under lead.created.",
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookIdHeader"
          },
          {
            "$ref": "#/components/parameters/webhookEventHeader"
          },
          {
            "$ref": "#/components/parameters/webhookTimestampHeader"
          },
          {
            "$ref": "#/components/parameters/webhookSignatureHeader"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookDelivery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Any 2xx counts as delivered."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Authorization: Bearer kv_live_your_secret_key"
      }
    },
    "parameters": {
      "limit": {
        "name": "limit",
        "in": "query",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 25
        },
        "description": "Out-of-range values are rejected with 400 rather than silently clamped: a silent 100 on a request for 1000 looks like an empty page two."
      },
      "cursor": {
        "name": "cursor",
        "in": "query",
        "schema": {
          "type": "string"
        },
        "description": "The next_cursor from the previous page. Opaque; do not parse it. A malformed cursor is a 400, never a silent restart from the beginning."
      },
      "createdSince": {
        "name": "created_since",
        "in": "query",
        "schema": {
          "type": "string",
          "format": "date-time"
        },
        "description": "Only records created at or after this moment."
      },
      "updatedSince": {
        "name": "updated_since",
        "in": "query",
        "schema": {
          "type": "string",
          "format": "date-time"
        },
        "description": "Only records changed at or after this moment. Use this for incremental sync: it catches status changes, not just new records. Not available on /customers, which has no updated_at."
      },
      "customerId": {
        "name": "customer_id",
        "in": "query",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "description": "Narrow to one customer. An id outside your scope returns 404, never another customer's data."
      },
      "leadId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "webhookId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "webhookIdHeader": {
        "name": "X-Webhook-Id",
        "in": "header",
        "schema": {
          "type": "string"
        },
        "description": "Delivery id, for example whd_10482. A retry carries the same id, so duplicates can be dropped."
      },
      "webhookEventHeader": {
        "name": "X-Webhook-Event",
        "in": "header",
        "schema": {
          "type": "string"
        },
        "description": "Event name, for example lead.created. Same value as `event` in the body."
      },
      "webhookTimestampHeader": {
        "name": "X-Webhook-Timestamp",
        "in": "header",
        "schema": {
          "type": "integer"
        },
        "description": "Unix seconds when we signed. Part of the signed content."
      },
      "webhookSignatureHeader": {
        "name": "X-Webhook-Signature",
        "in": "header",
        "schema": {
          "type": "string"
        },
        "description": "v1=<hex HMAC-SHA256>."
      },
      "idempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": true,
        "schema": {
          "type": "string",
          "maxLength": 255
        },
        "description": "Your own unique string per creation attempt. Retrying with the same key and the same body returns the original answer instead of creating and billing a second lead. The same key with a different body is a 400, not a silent overwrite."
      }
    },
    "responses": {
      "InvalidRequest": {
        "description": "Malformed parameter",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing, malformed, unknown or revoked API key",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Unknown endpoint, or a record outside this key's scope",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "The key is read-only. Writing needs a key with the `write` scope.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Conflict": {
        "description": "A state that can change, not a missing permission. `lead_rejected`: the customer's account cannot accept leads right now (paused, no subscription, no credits) and nothing was created or billed. `conflict`: another request with the same Idempotency-Key is still running, or you already have the maximum of five webhooks.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests. Retry-After tells you how long to wait.\n\nTwo different limits answer with 429. `rate_limit_exceeded` is the per minute ceiling. `daily_cap_reached` means this customer has hit the daily ceiling for leads created through the API; nothing was created or billed, and Retry-After points at midnight UTC.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Limit": {
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "InternalError": {
        "description": "Something failed on our side. The request may have been partly applied (for example a lead created but not read back), so retry POST /leads with the same Idempotency-Key, which returns the existing lead instead of creating a second one. If it keeps failing, write to support@konverto.dk with the path, the time and the response body.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "unauthorized",
                  "forbidden",
                  "invalid_request",
                  "rate_limit_exceeded",
                  "daily_cap_reached",
                  "lead_rejected",
                  "conflict",
                  "not_found",
                  "method_not_allowed",
                  "internal_error"
                ],
                "description": "Stable string. Branch on this, not on the message."
              },
              "message": {
                "type": "string"
              }
            }
          }
        }
      },
      "Customer": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "company_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "contact_person": {
            "type": [
              "string",
              "null"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ]
          },
          "industry": {
            "type": [
              "string",
              "null"
            ]
          },
          "website": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "services": {
            "type": [
              "string",
              "null"
            ]
          },
          "active": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "onboarding_completed": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "booking_enabled": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "phone_booking_enabled": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "timezone": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "Lead": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "customer_id": {
            "type": "string",
            "format": "uuid"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ]
          },
          "address": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": [
              "string",
              "null"
            ]
          },
          "source": {
            "type": [
              "string",
              "null"
            ]
          },
          "channel": {
            "type": [
              "string",
              "null"
            ]
          },
          "campaign_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "ad": {
            "type": [
              "string",
              "null"
            ]
          },
          "other": {
            "type": [
              "string",
              "null"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "page_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "archived": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "archived_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "archived_reason": {
            "type": [
              "string",
              "null"
            ]
          },
          "is_test": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Test leads are returned with this flag rather than filtered out. A silent omission looks like a missing lead."
          },
          "meeting_status": {
            "type": [
              "string",
              "null"
            ]
          },
          "meeting_booked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "meeting_datetime": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "LeadCreate": {
        "type": "object",
        "description": "Either email or phone is required: deduplication matches on one of them, and without either every call would create a new lead and a new charge.\n\nUnknown fields are rejected. A client sending `telephone` instead of `phone` should be told at once, not discover it a month later when no lead has a number.",
        "properties": {
          "customer_id": {
            "type": "string",
            "format": "uuid",
            "description": "Which customer the lead belongs to. A customer key has exactly one and can leave this out. A partner key spans several and must say which."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 320
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 40,
            "description": "At least eight digits in total. Spaces and a country code are fine; we normalise it."
          },
          "message": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 5000,
            "description": "What the person wrote to the customer."
          },
          "ad": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200,
            "description": "Where the lead came from. Attribution belongs here, because `source` is locked to `api`."
          },
          "page_url": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000
          }
        },
        "anyOf": [
          {
            "required": [
              "email"
            ]
          },
          {
            "required": [
              "phone"
            ]
          }
        ]
      },
      "LeadUpdate": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "Kontaktet",
              "aktiv",
              "kold",
              "inaktiv"
            ]
          }
        }
      },
      "Webhook": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "enabled": {
            "type": "boolean"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "disabled_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "disabled_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why we stopped sending. A subscription that fails repeatedly is disabled rather than retried forever."
          },
          "last_success_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "last_error": {
            "type": [
              "string",
              "null"
            ]
          },
          "last_error_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "consecutive_failures": {
            "type": "integer"
          }
        }
      },
      "WebhookWithSecret": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Webhook"
          },
          {
            "type": "object",
            "properties": {
              "secret": {
                "type": "string",
                "description": "The signing secret, shown once and never again."
              }
            }
          }
        ]
      },
      "WebhookCreate": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "maxLength": 2048,
            "description": "Must be https and point at a public host. The body carries a private person's name, email, phone and address, so it does not travel in cleartext, and a url aimed at a private or loopback address is refused."
          },
          "events": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WebhookEvent"
            },
            "default": [
              "lead.created",
              "lead.meeting_booked",
              "lead.purchased",
              "lead.archived",
              "lead.message",
              "lead.call",
              "lead.status_changed",
              "lead.graded",
              "lead.handover"
            ],
            "description": "Leaving this out subscribes to everything. Not choosing is not the same as choosing lead.created: if you have not said what you want, you want to know what happens to your leads. Name an unknown event and the whole request is refused rather than half of it silently accepted."
          }
        }
      },
      "WebhookEvent": {
        "type": "string",
        "enum": [
          "lead.created",
          "lead.meeting_booked",
          "lead.purchased",
          "lead.archived",
          "lead.message",
          "lead.call",
          "lead.status_changed",
          "lead.graded",
          "lead.handover"
        ]
      },
      "WebhookDelivery": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Delivery id, for example whd_10482. The same on a retry."
          },
          "event": {
            "$ref": "#/components/schemas/WebhookEvent"
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the event happened. Order by this."
          },
          "sent_at": {
            "type": "string",
            "format": "date-time",
            "description": "When this attempt was sent. Later than occurred_at after a retry."
          },
          "lead_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "The lead the event is about. Same value as data.id, on the envelope so a router does not have to unpack the body."
          },
          "data": {
            "$ref": "#/components/schemas/Lead"
          },
          "source": {
            "$ref": "#/components/schemas/WebhookSource"
          }
        }
      },
      "WebhookSource": {
        "type": "object",
        "description": "What triggered the event, when it was something other than the lead itself. Present on lead.message, lead.call, lead.graded and lead.handover, and left out entirely on the rest, so the field's presence is the signal. Deliberately thin: no internal ids beyond the source's own, no raw mail headers, no thread or customer ids.",
        "properties": {
          "id": {
            "type": "string",
            "description": "The source row's own id. Stable across retries, so it can be used for deduplication next to the delivery id."
          },
          "kind": {
            "type": "string",
            "enum": [
              "email_threads",
              "sms_threads",
              "dm_messages",
              "whatsapp_messages",
              "call_transcriptions",
              "conversation_gradings",
              "dm_threads",
              "whatsapp_threads"
            ],
            "description": "Which kind of record it was. More precise than channel, and useful when you want to tell a Messenger DM from a WhatsApp message without parsing the excerpt."
          },
          "channel": {
            "type": "string",
            "enum": [
              "email",
              "sms",
              "dm",
              "whatsapp",
              "call",
              "grading"
            ]
          },
          "direction": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "inbound",
              "outbound",
              null
            ],
            "description": "Null where direction means nothing, that is on gradings and handovers."
          },
          "from": {
            "type": [
              "string",
              "null"
            ],
            "description": "The sender where we have one. Only email carries an address we can pass on; the other channels are null."
          },
          "excerpt": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500,
            "description": "At most 500 characters, cut on a word boundary and ending in ... when there was more. A webhook is a notification, not a copy of the conversation: fetch the rest with GET /v1/leads/{id}/messages."
          },
          "duration_seconds": {
            "type": "integer",
            "description": "lead.call only."
          },
          "outcome": {
            "type": "string",
            "description": "lead.call only. The customer's own outcome word, so treat it as text."
          },
          "score": {
            "type": "number",
            "description": "lead.graded only. 0 to 100."
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "lead.handover only. Why the assistant handed over. Repeated in excerpt."
          }
        }
      },
      "Stats": {
        "type": "object",
        "description": "Every block is always present, and every number is always a number. A key that exists one day and is missing the next forces you to write code for both shapes.",
        "properties": {
          "from": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the window that was measured, included."
          },
          "to": {
            "type": "string",
            "format": "date-time",
            "description": "End of the window that was measured, excluded."
          },
          "leads": {
            "type": "object",
            "properties": {
              "total": {
                "type": "integer"
              },
              "archived": {
                "type": "integer"
              },
              "by_status": {
                "type": "object",
                "additionalProperties": {
                  "type": "integer"
                },
                "description": "Keyed by the customer's own status words, so do not expect a fixed set."
              }
            }
          },
          "meetings": {
            "type": "object",
            "properties": {
              "booked": {
                "type": "integer"
              },
              "cancelled": {
                "type": "integer"
              }
            }
          },
          "bookings": {
            "type": "object",
            "properties": {
              "total": {
                "type": "integer"
              },
              "by_source": {
                "type": "object",
                "properties": {
                  "online": {
                    "type": "integer"
                  },
                  "phone": {
                    "type": "integer"
                  },
                  "route": {
                    "type": "integer"
                  }
                }
              },
              "by_status": {
                "type": "object",
                "additionalProperties": {
                  "type": "integer"
                }
              }
            }
          },
          "messages": {
            "type": "object",
            "properties": {
              "total": {
                "type": "integer"
              },
              "inbound": {
                "type": "integer"
              },
              "outbound": {
                "type": "integer"
              },
              "by_channel": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "integer"
                  },
                  "sms": {
                    "type": "integer"
                  }
                }
              }
            }
          },
          "sales": {
            "type": "object",
            "properties": {
              "purchased": {
                "type": "integer"
              },
              "amount_minor": {
                "type": "integer",
                "description": "Smallest unit of the customer's own currency, øre for Danish customers. There is no currency field on a lead, so you need to know what your customer bills in."
              }
            }
          }
        }
      },
      "Message": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "customer_id": {
            "type": "string",
            "format": "uuid"
          },
          "lead_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "channel": {
            "type": "string",
            "enum": [
              "email",
              "sms"
            ]
          },
          "direction": {
            "type": "string",
            "enum": [
              "inbound",
              "outbound"
            ]
          },
          "subject": {
            "type": [
              "string",
              "null"
            ],
            "description": "Always null for SMS."
          },
          "body": {
            "type": [
              "string",
              "null"
            ]
          },
          "from_address": {
            "type": [
              "string",
              "null"
            ]
          },
          "to_address": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Booking": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "customer_id": {
            "type": "string",
            "format": "uuid"
          },
          "lead_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Null for online bookings, which are not tied to a lead record."
          },
          "source": {
            "type": "string",
            "enum": [
              "online",
              "phone",
              "route"
            ]
          },
          "lead_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "lead_email": {
            "type": [
              "string",
              "null"
            ]
          },
          "lead_phone": {
            "type": [
              "string",
              "null"
            ]
          },
          "starts_at": {
            "type": "string",
            "format": "date-time",
            "description": "Absolute timestamp, already resolved from the customer's timezone."
          },
          "ends_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "For a route booking this is when the arrival window closes, not start plus duration."
          },
          "duration_minutes": {
            "type": [
              "integer",
              "null"
            ]
          },
          "status": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "scheduled",
              "cancelled",
              "completed",
              null
            ]
          },
          "booking_format": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      }
    }
  }
}
