{
  "openapi": "3.0.3",
  "info": {
    "title": "Lojiq Public API",
    "version": "1.2.0",
    "summary": "Leads in, outcomes out, and Lojiq's managed agents from your own software.",
    "description": "The Lojiq Public API is the programmable side of the Lojiq platform. With one API key you can:\n\n* **Push leads in** — one at a time or in bulk from CSV, with consent fields, deduplicated per organization.\n* **Get outcomes out** — subscribe to signed webhooks for lead, call, appointment, deal and billing events.\n* **Run voice agents** — create provider-neutral voice agents, start phone or browser (WebRTC) calls, read transcripts and recordings.\n* **Read and steer campaigns, calls, appointments and deals** from your own software.\n\nEvery endpoint is **organization-scoped**: the API key decides which organization's data you see. There is no\n`organization_id` parameter anywhere.\n\n**Two APIs, two hosts.** This is the public API at `https://api.lojiq.ai/v1`. The Lojiq web and mobile apps use a\nseparate private API (`https://app.lojiq.ai/api/v1`) that is not documented, not versioned for outside use, and not\nserved on `api.lojiq.ai`. Build only against `api.lojiq.ai`.\n\n**Conventions**\n\n* JSON in, JSON out (`Content-Type: application/json`), except the CSV export and the voice preview.\n* Lists return `{ \"data\": [...], \"limit\": n, \"offset\": n }`. There is no total count; page until `data` is shorter than `limit`.\n* Errors return one envelope, `{ \"error\": \"<code>\", \"message\": \"<optional human text>\", \"request_id\": \"<uuid>\" }`,\n  with a conventional HTTP status. `error` is a stable snake_case code; `request_id` equals the `X-Request-Id` header.\n* Every response carries `X-Request-Id` (401s and 429s included). Quote it when you contact support.\n* Writes take an allow-list of fields; any other key answers `400 unknown_field:<name>` rather than being written.\n* `POST /leads`, `POST /calls` and `POST /agents/{id}/calls` honour an `Idempotency-Key` header: a retried request\n  never creates a second lead or places a second paid call.\n* Timestamps are ISO-8601 UTC. Phone numbers are stored as E.164 (`+15551234567`); 10-digit US numbers get `+1`.\n",
    "termsOfService": "https://lojiq.ai/terms",
    "contact": {
      "name": "Lojiq developer support",
      "url": "https://docs.lojiq.ai",
      "email": "support@lojiq.ai"
    },
    "x-logo": {
      "url": "/assets/logo.svg",
      "altText": "Lojiq"
    }
  },
  "servers": [
    {
      "url": "https://api.lojiq.ai/v1",
      "description": "Production"
    }
  ],
  "x-tagGroups": [
    {
      "name": "Core",
      "tags": [
        "Meta",
        "Account",
        "Leads",
        "Contacts",
        "Campaigns"
      ]
    },
    {
      "name": "Voice",
      "tags": [
        "Voice agents",
        "Calls",
        "Voices"
      ]
    },
    {
      "name": "Outcomes",
      "tags": [
        "Appointments",
        "Deals",
        "Webhooks"
      ]
    },
    {
      "name": "Reference files",
      "tags": [
        "Reference files"
      ]
    }
  ],
  "tags": [
    {
      "name": "Meta",
      "description": "Version and health-style information about the API itself."
    },
    {
      "name": "Account",
      "description": "Your organization as the API sees it, including whether voice calls are currently allowed."
    },
    {
      "name": "Leads",
      "description": "Leads are the people Lojiq calls and texts. Create them one at a time (deduplicated by phone number inside your\norganization), import them in bulk from CSV, read them back, update their status, or export everything as CSV.\n\nConsent matters: `consent_source` and `consented_at` are stored with every lead and are the record you will\nneed if a lead ever asks why they were contacted. See the Leads guide.\n"
    },
    {
      "name": "Contacts",
      "description": "Contacts are business contacts (people at a company) rather than dial-list leads.\n**Not yet available** on production — see each operation.\n"
    },
    {
      "name": "Campaigns",
      "description": "Read your campaigns and start or pause them. Campaigns are built in the Lojiq app; the API steers them."
    },
    {
      "name": "Voice agents",
      "description": "A voice agent is a reusable call template: a system prompt, a voice and a few behavior knobs. Agents created\nthrough the API are fully managed here; agents created in the Lojiq app are listed read-only.\n"
    },
    {
      "name": "Calls",
      "description": "Read every call your organization made or received, pull transcripts and recordings, start ad-hoc calls with an\ninline prompt, and end live calls. Calls started through the API bill tokens exactly like calls started in the app.\n"
    },
    {
      "name": "Voices",
      "description": "The voice catalog your agents can use, with a preview clip per voice."
    },
    {
      "name": "Appointments",
      "description": "Appointments booked by your AI agents or your team. Read them and cancel them."
    },
    {
      "name": "Deals",
      "description": "Pipeline deals attached to leads. **Not yet available** on production — see each operation.\n"
    },
    {
      "name": "Webhooks",
      "description": "Subscribe an HTTPS endpoint to events. Every delivery is signed with HMAC-SHA256 (`X-Lojiq-Signature-256`),\nretried up to 8 times with exponential backoff, and carries a stable event id for idempotency. See the Webhooks guide.\n"
    },
    {
      "name": "Reference files",
      "description": "Unauthenticated files the API serves about itself — the OpenAPI spec, the getting-started guide and the browser voice SDK."
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    },
    {
      "ApiKeyHeader": []
    }
  ],
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "lojiq_live_<64 hex characters> (lojiq_test_… for a sandbox organization)",
        "description": "`Authorization: Bearer lojiq_live_...`. Keys are created by an organization Owner or Admin in the Lojiq app\n(Developer → API keys), shown once, and can be scoped, rate-limited, expired, rotated and revoked. Keys of a\nsandbox organization start with `lojiq_test_` and work the same way.\n"
      },
      "ApiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Lojiq-Api-Key",
        "description": "The same key, as a custom header, for clients that cannot set `Authorization`."
      }
    },
    "parameters": {
      "limit": {
        "name": "limit",
        "in": "query",
        "description": "Page size. Default 50, maximum 200 (larger values are clamped to 200).",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 200,
          "default": 50
        }
      },
      "offset": {
        "name": "offset",
        "in": "query",
        "description": "Number of rows to skip. Default 0. Page by increasing `offset` by `limit` until `data` comes back shorter than `limit`.",
        "schema": {
          "type": "integer",
          "minimum": 0,
          "default": 0
        }
      },
      "id": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Resource id (UUID).",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e"
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Optional, 1–255 printable ASCII characters (a UUID is ideal). The first request with a key runs normally and its\nanswer is remembered for 24 hours. The same key with the same body returns that answer again with\n`Idempotency-Replayed: true` — nothing runs twice. The same key with a different body answers\n`422 idempotency_key_reused`; the same key while the first request is still running answers\n`409 idempotency_in_progress` (with `Retry-After: 1`). A `5xx` answer is not remembered, so a retry with the same\nkey runs again. Scope: your API key + method + path + key.\n",
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 255
        },
        "example": "4e5f2a0c-7b1d-4c8e-9a2f-3b6d1e0c5a77"
      }
    },
    "headers": {
      "X-Request-Id": {
        "description": "Unique id of this request. Quote it to support.",
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "Idempotency-Replayed": {
        "description": "`true` when this answer was replayed from an earlier request with the same `Idempotency-Key`.",
        "schema": {
          "type": "boolean"
        }
      },
      "X-RateLimit-Limit": {
        "description": "Requests per minute allowed for this key.",
        "schema": {
          "type": "integer"
        }
      },
      "X-RateLimit-Remaining": {
        "description": "Requests left in the current minute.",
        "schema": {
          "type": "integer"
        }
      },
      "Retry-After": {
        "description": "Seconds to wait before retrying (only on `429`).",
        "schema": {
          "type": "integer"
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "No key, or a key that is unknown, revoked or expired. `error` is one of\n`missing_api_key`, `invalid_api_key`, `revoked_api_key`, `expired_api_key`.\n",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/X-Request-Id"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "invalid_api_key",
              "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
            }
          }
        }
      },
      "Forbidden": {
        "description": "The key does not carry the scope this operation needs. `required` names the missing scope.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "insufficient_scope",
              "message": "This key does not have the leads:write scope.",
              "required": "leads:write",
              "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
            }
          }
        }
      },
      "NotFound": {
        "description": "No such resource in your organization (ids from other organizations also answer 404, on reads and on updates).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "not_found",
              "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
            }
          }
        }
      },
      "FeatureUnavailable": {
        "description": "`feature_unavailable` — this endpoint is not available on your account yet.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "feature_unavailable",
              "message": "Contacts are not available on this account. Use /leads.",
              "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
            }
          }
        }
      },
      "IdempotencyConflict": {
        "description": "`idempotency_in_progress` — a request with this `Idempotency-Key` is still running. Retry after `Retry-After` seconds.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/Retry-After"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "idempotency_in_progress",
              "message": "A request with this Idempotency-Key is still being processed. Retry in a moment.",
              "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
            }
          }
        }
      },
      "IdempotencyReused": {
        "description": "`idempotency_key_reused` — this `Idempotency-Key` was already used with a different request body. Use a new key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "idempotency_key_reused",
              "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests for this key in the current minute. Wait `Retry-After` seconds.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/Retry-After"
          },
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/X-RateLimit-Limit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/X-RateLimit-Remaining"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "rate_limited",
              "message": "This key is limited to 60 requests per minute. Retry in 3s.",
              "retry_after_seconds": 3,
              "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
            }
          }
        }
      },
      "ServerError": {
        "description": "`internal_error` — something failed on Lojiq's side. The detail is in Lojiq's log under your `request_id`, never in the response. Retry with backoff and quote `X-Request-Id` to support.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "internal_error",
              "message": "Something went wrong on our side. Retry",
              "or contact support with the request_id.": null,
              "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Stable machine-readable code (snake_case). Some codes carry a suffix after a colon, e.g. `unknown_field:nickname`, `unknown_scope:foo`, `unknown_event_type:x.y`, `unknown_template_field:tone`. Match on the part before the colon."
          },
          "message": {
            "type": "string",
            "description": "Human-readable detail. Present on most validation errors; absent on the short codes. On a `400` about your own input it may carry the storage engine's wording (for example \"invalid input syntax for type uuid\")."
          },
          "request_id": {
            "type": "string",
            "format": "uuid",
            "description": "The same value as the `X-Request-Id` header. Quote it to support."
          },
          "required": {
            "type": "string",
            "description": "Only on `insufficient_scope` — the scope the operation needs."
          },
          "retry_after_seconds": {
            "type": "integer",
            "description": "Only on `rate_limited`."
          },
          "docs": {
            "type": "string",
            "format": "uri",
            "description": "Only on `404 not_found` for an unknown path — the documentation URL."
          }
        },
        "example": {
          "error": "invalid_phone_number",
          "message": "phone_number is required: 10-15 digits, E.164 preferred (+15551234567).",
          "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
        }
      },
      "Ok": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean",
            "enum": [
              true
            ]
          }
        },
        "example": {
          "ok": true
        }
      },
      "ApiMeta": {
        "type": "object",
        "properties": {
          "api": {
            "type": "string",
            "example": "lojiq-public-api"
          },
          "version": {
            "type": "string",
            "example": "v1"
          },
          "docs": {
            "type": "string",
            "format": "uri",
            "example": "https://docs.lojiq.ai"
          }
        }
      },
      "Page": {
        "type": "object",
        "properties": {
          "limit": {
            "type": "integer",
            "example": 50
          },
          "offset": {
            "type": "integer",
            "example": 0
          }
        }
      },
      "LeadSummary": {
        "type": "object",
        "description": "The fields returned by list endpoints.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "first_name": {
            "type": "string",
            "nullable": true
          },
          "last_name": {
            "type": "string",
            "nullable": true
          },
          "full_name": {
            "type": "string",
            "description": "Derived: `First Last`, or `Lead 4567` (last four digits) when no name was given."
          },
          "phone_number": {
            "type": "string",
            "description": "E.164 (stored canonical form)."
          },
          "email_address": {
            "type": "string",
            "format": "email",
            "nullable": true
          },
          "status": {
            "type": "string",
            "description": "Free-text lead status. `new`/`pending` until dialed; after that the dialer writes the outcome of the last\nattempt. Values seen in production include `pending`, `contacted`, `completed`, `no_answer`, `busy`,\n`failed`, `voicemail`, `do_not_call`, `canceled`. Treat as an open set.\n"
          },
          "campaign_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "consent_source": {
            "type": "string",
            "nullable": true
          },
          "consented_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        },
        "example": {
          "id": "4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e",
          "first_name": "Avery",
          "last_name": "Rivera",
          "full_name": "Avery Rivera",
          "phone_number": "+15551234567",
          "email_address": "avery@example.com",
          "status": "new",
          "campaign_id": null,
          "consent_source": "web_form",
          "consented_at": "2026-10-02T16:58:00Z",
          "created_at": "2026-10-02T17:03:11Z",
          "updated_at": null
        }
      },
      "Lead": {
        "allOf": [
          {
            "$ref": "#/components/schemas/LeadSummary"
          },
          {
            "type": "object",
            "description": "`GET /leads/{id}`, `POST /leads` and `PATCH /leads/{id}` return the **full stored record**, which includes the\ndocumented fields below plus internal columns Lojiq uses for dialing and texting (for example\n`last_call_outcome`, `global_dnc`, `is_suppressed`). Internal columns are not part of the versioned contract\nand may change without notice; read only the documented ones.\n",
            "properties": {
              "consent_source": {
                "type": "string",
                "description": "Where consent was collected. `public_api` when omitted on create; `csv_import` for CSV rows without a value."
              },
              "consented_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "credit_score": {
                "type": "integer",
                "nullable": true
              },
              "annual_income": {
                "type": "number",
                "nullable": true
              },
              "notes": {
                "type": "string",
                "nullable": true
              },
              "source_tag": {
                "type": "string",
                "description": "How the lead arrived: `api:standalone`, `api:campaign:<campaign_id>`, or an app/CSV tag."
              },
              "updated_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              }
            },
            "additionalProperties": true
          }
        ],
        "example": {
          "id": "4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e",
          "first_name": "Avery",
          "last_name": "Rivera",
          "full_name": "Avery Rivera",
          "phone_number": "+15551234567",
          "email_address": "avery@example.com",
          "status": "new",
          "campaign_id": null,
          "consent_source": "web_form",
          "consented_at": "2026-10-02T16:58:00Z",
          "credit_score": null,
          "annual_income": null,
          "notes": null,
          "source_tag": "api:standalone",
          "created_at": "2026-10-02T17:03:11Z",
          "updated_at": null
        }
      },
      "LeadCreate": {
        "type": "object",
        "required": [
          "phone_number"
        ],
        "description": "Only the fields listed are accepted; any other key answers `400 unknown_field:<name>`. `organization_id` is never accepted — it comes from your key.",
        "properties": {
          "phone_number": {
            "type": "string",
            "description": "Required; 10–15 digits in any format. Stored and deduplicated in canonical E.164: `5551234567`,\n`(555) 123-4567` and `+15551234567` are the same lead. Send E.164 anyway so what you store matches.\n"
          },
          "first_name": {
            "type": "string",
            "nullable": true,
            "maxLength": 120
          },
          "last_name": {
            "type": "string",
            "nullable": true,
            "maxLength": 120
          },
          "email_address": {
            "type": "string",
            "format": "email",
            "nullable": true,
            "description": "Stored lower-cased."
          },
          "status": {
            "type": "string",
            "default": "new",
            "maxLength": 64
          },
          "campaign_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "Attach the lead to a campaign you own (`404 campaign_not_found` otherwise). Omit to add it to the lead bank only."
          },
          "consent_source": {
            "type": "string",
            "default": "public_api",
            "maxLength": 64,
            "description": "Free text naming where consent was collected (`web_form`, `signed_application`, `inbound_call`...)."
          },
          "consented_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "When the person consented to be contacted (ISO 8601). Strongly recommended — see the Leads guide."
          },
          "credit_score": {
            "type": "integer",
            "minimum": 300,
            "maximum": 850,
            "nullable": true
          },
          "annual_income": {
            "type": "number",
            "exclusiveMinimum": 0,
            "nullable": true
          },
          "notes": {
            "type": "string",
            "nullable": true,
            "maxLength": 4000
          }
        },
        "example": {
          "phone_number": "+15551234567",
          "first_name": "Avery",
          "last_name": "Rivera",
          "email_address": "avery@example.com",
          "consent_source": "web_form",
          "consented_at": "2026-10-02T16:58:00Z"
        }
      },
      "LeadPatch": {
        "type": "object",
        "description": "Send only the fields you want to change (at least one — an empty body answers `400 nothing_to_update`). Only the\nfields listed are accepted; any other key answers `400 unknown_field:<name>`. `phone_number` identifies the\nlead and cannot be changed (`400 phone_number_immutable`). Changing `status` emits `lead.disposition_changed`;\nany other change emits `lead.updated`.\n",
        "properties": {
          "first_name": {
            "type": "string",
            "nullable": true,
            "maxLength": 120
          },
          "last_name": {
            "type": "string",
            "nullable": true,
            "maxLength": 120
          },
          "email_address": {
            "type": "string",
            "format": "email",
            "nullable": true
          },
          "status": {
            "type": "string",
            "maxLength": 64
          },
          "campaign_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true,
            "description": "Must be one of your campaigns (`404 campaign_not_found`), or `null` to detach."
          },
          "consent_source": {
            "type": "string",
            "maxLength": 64
          },
          "consented_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "credit_score": {
            "type": "integer",
            "minimum": 300,
            "maximum": 850,
            "nullable": true
          },
          "annual_income": {
            "type": "number",
            "exclusiveMinimum": 0,
            "nullable": true
          },
          "notes": {
            "type": "string",
            "nullable": true,
            "maxLength": 4000
          }
        },
        "example": {
          "status": "do_not_call"
        }
      },
      "BulkImportRequest": {
        "type": "object",
        "required": [
          "csv"
        ],
        "properties": {
          "csv": {
            "type": "string",
            "description": "The raw CSV text (UTF-8, header row first). Keep files under roughly 5,000 rows per request; the import runs synchronously."
          },
          "mapping": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "`{ \"<canonical column>\": \"<your header>\" }`. Use the `suggested_mapping` from `POST /leads/inspect-csv`.\nIf omitted, headers must already be the canonical names (`first_name`, `last_name`, `phone_number`,\n`email_address`, `status`, `credit_score`, `annual_income`, `consent_source`, `consented_at`); aliases such\nas `Phone` or `Email` are **not** auto-mapped here.\n"
          },
          "campaign_id": {
            "type": "string",
            "format": "uuid",
            "description": "Route every imported lead into this campaign. Omit to import into the lead bank only."
          }
        },
        "example": {
          "csv": "first_name,last_name,phone,email,opt_in_date\nAvery,Rivera,(555) 123-4567,avery@example.com,2026-09-30\n",
          "mapping": {
            "first_name": "first_name",
            "last_name": "last_name",
            "phone_number": "phone",
            "email_address": "email",
            "consented_at": "opt_in_date"
          },
          "campaign_id": "0d2f6b1e-4a7c-4e8d-9b1a-2c3d4e5f6a7b"
        }
      },
      "BulkImportSummary": {
        "type": "object",
        "properties": {
          "job_id": {
            "type": "string",
            "format": "uuid",
            "description": "Import job id (kept for support; there is no job endpoint yet)."
          },
          "total": {
            "type": "integer"
          },
          "inserted": {
            "type": "integer",
            "description": "New leads."
          },
          "updated": {
            "type": "integer",
            "description": "Rows that matched an existing lead by phone number and updated it."
          },
          "skipped": {
            "type": "integer",
            "description": "Always 0 today."
          },
          "failed": {
            "type": "integer",
            "description": "Rows rejected (missing/invalid phone, missing required column). Per-row errors are stored but not yet exposed."
          }
        },
        "example": {
          "job_id": "7b8c9d0e-1f2a-4b3c-8d4e-5f6a7b8c9d0e",
          "total": 120,
          "inserted": 117,
          "updated": 2,
          "skipped": 0,
          "failed": 1
        }
      },
      "CsvInspectRequest": {
        "type": "object",
        "required": [
          "csv"
        ],
        "properties": {
          "csv": {
            "type": "string",
            "description": "The raw CSV text. Only the header row and first rows are needed, but sending the whole file is fine."
          }
        },
        "example": {
          "csv": "First,Last,Phone,Email\nAvery,Rivera,5551234567,avery@example.com\n"
        }
      },
      "CsvInspection": {
        "type": "object",
        "properties": {
          "total_rows": {
            "type": "integer"
          },
          "headers": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "suggested_mapping": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Canonical column → your header, built from the alias list (`phone`, `mobile`, `cell` → `phone_number`; `email`, `e-mail` → `email_address`; `fico` → `credit_score`; `opt_in_date` → `consented_at` ...). Pass it straight to `POST /leads/bulk`."
          },
          "unmapped": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Your headers that matched nothing (they are ignored on import)."
          },
          "missing_required": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Required canonical columns with no match. `phone_number` is the only required column."
          },
          "sample_rows": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": {
                "type": "string"
              }
            },
            "description": "The first 5 rows, as parsed."
          }
        },
        "example": {
          "total_rows": 1,
          "headers": [
            "First",
            "Last",
            "Phone",
            "Email"
          ],
          "suggested_mapping": {
            "first_name": "First",
            "last_name": "Last",
            "phone_number": "Phone",
            "email_address": "Email"
          },
          "unmapped": [],
          "missing_required": [],
          "sample_rows": [
            {
              "First": "Avery",
              "Last": "Rivera",
              "Phone": "5551234567",
              "Email": "avery@example.com"
            }
          ]
        }
      },
      "Contact": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "first_name": {
            "type": "string",
            "nullable": true
          },
          "last_name": {
            "type": "string",
            "nullable": true
          },
          "phone_number": {
            "type": "string"
          },
          "email_address": {
            "type": "string",
            "format": "email",
            "nullable": true
          },
          "company": {
            "type": "string",
            "nullable": true
          },
          "role": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "example": {
          "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
          "first_name": "Jordan",
          "last_name": "Lee",
          "phone_number": "+15557654321",
          "email_address": "jordan@acme.example",
          "company": "Acme Dental",
          "role": "Office manager",
          "created_at": "2026-10-02T17:03:11Z"
        }
      },
      "ContactCreate": {
        "type": "object",
        "required": [
          "phone_number"
        ],
        "properties": {
          "phone_number": {
            "type": "string",
            "description": "E.164. Required."
          },
          "first_name": {
            "type": "string"
          },
          "last_name": {
            "type": "string"
          },
          "email_address": {
            "type": "string",
            "format": "email"
          },
          "company": {
            "type": "string"
          },
          "role": {
            "type": "string"
          }
        },
        "example": {
          "phone_number": "+15557654321",
          "first_name": "Jordan",
          "last_name": "Lee",
          "company": "Acme Dental",
          "role": "Office manager"
        }
      },
      "CampaignSummary": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "description": "`draft`, `active`, `paused`, `completed` or `cancelled`. Only `paused` ↔ `active` can be changed through the API."
          },
          "dialer_mode": {
            "type": "string",
            "nullable": true,
            "description": "How the campaign dials (for example `ai`, `predictive`, `power`)."
          },
          "assigned_ai_agent_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "launched_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "paused_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        },
        "example": {
          "id": "0d2f6b1e-4a7c-4e8d-9b1a-2c3d4e5f6a7b",
          "name": "October refinance outreach",
          "status": "active",
          "dialer_mode": "ai",
          "assigned_ai_agent_id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
          "launched_at": "2026-09-29T15:00:00Z",
          "paused_at": null,
          "created_at": "2026-09-28T14:00:00Z",
          "updated_at": "2026-10-02T17:03:11Z"
        }
      },
      "Campaign": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CampaignSummary"
          },
          {
            "type": "object",
            "description": "`GET /campaigns/{id}` returns the full stored campaign record, including configuration columns that are not part of the versioned contract.",
            "properties": {
              "updated_at": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              }
            },
            "additionalProperties": true
          }
        ],
        "example": {
          "id": "0d2f6b1e-4a7c-4e8d-9b1a-2c3d4e5f6a7b",
          "name": "October refinance outreach",
          "status": "active",
          "dialer_mode": "ai",
          "assigned_ai_agent_id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
          "launched_at": "2026-09-29T15:00:00Z",
          "paused_at": null,
          "created_at": "2026-09-28T14:00:00Z",
          "updated_at": "2026-10-02T17:03:11Z"
        }
      },
      "CampaignStatus": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "paused"
            ]
          },
          "changed": {
            "type": "boolean",
            "description": "`false` when the campaign was already in the requested state (nothing happened)."
          }
        },
        "example": {
          "id": "0d2f6b1e-4a7c-4e8d-9b1a-2c3d4e5f6a7b",
          "status": "active",
          "changed": true
        }
      },
      "CallSummary": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "call_sid": {
            "type": "string",
            "nullable": true,
            "description": "Carrier call id; `web_<id>` for browser calls."
          },
          "lead_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "campaign_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "to_number": {
            "type": "string",
            "nullable": true
          },
          "from_number": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "description": "`initiating` → `initiated` → `in-progress` → one of `completed`, `failed`, `busy`, `no-answer`, `canceled`, `voicemail`."
          },
          "call_outcome": {
            "type": "string",
            "nullable": true,
            "description": "Outcome written at the end of the call (`completed`, `voicemail`, `no_answer`, `failed`, `congestion`, `appointment_set`…). Open set."
          },
          "disposition": {
            "type": "string",
            "nullable": true,
            "description": "Disposition chosen by a rep or the AI, when one was recorded."
          },
          "duration": {
            "type": "integer",
            "nullable": true,
            "description": "Seconds."
          },
          "duration_seconds": {
            "type": "integer",
            "nullable": true,
            "description": "Seconds (same value as `duration` where both are set)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the call record was created (lists are sorted by this, newest first)."
          },
          "ended_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "recording_url": {
            "type": "string",
            "format": "uri",
            "nullable": true,
            "description": "Present when a recording exists. Prefer `GET /calls/{id}/recording`."
          }
        },
        "example": {
          "id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
          "call_sid": "a1b2c3d4e5f6",
          "lead_id": "4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e",
          "campaign_id": "0d2f6b1e-4a7c-4e8d-9b1a-2c3d4e5f6a7b",
          "to_number": "+15551234567",
          "from_number": "+15550001111",
          "status": "completed",
          "call_outcome": "appointment_set",
          "disposition": null,
          "duration": 184,
          "duration_seconds": 184,
          "created_at": "2026-10-02T17:03:11Z",
          "ended_at": "2026-10-02T17:06:15Z",
          "recording_url": null
        }
      },
      "Call": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CallSummary"
          },
          {
            "type": "object",
            "description": "`GET /calls/{id}` returns the full stored call record. Documented extras below; everything else is internal\nand may change.\n",
            "properties": {
              "summary": {
                "type": "string",
                "nullable": true,
                "description": "AI summary, when one was produced."
              },
              "transcript": {
                "type": "string",
                "nullable": true,
                "description": "Plain-text transcript, when one was produced. Prefer `GET /calls/{id}/transcript`."
              },
              "metadata": {
                "type": "object",
                "description": "For calls started through the API — `source: public_api`, `public_api_medium`, `ai_agent_id`, and your `api_metadata`.",
                "additionalProperties": true
              }
            },
            "additionalProperties": true
          }
        ],
        "example": {
          "id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
          "call_sid": "a1b2c3d4e5f6",
          "lead_id": "4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e",
          "campaign_id": "0d2f6b1e-4a7c-4e8d-9b1a-2c3d4e5f6a7b",
          "to_number": "+15551234567",
          "from_number": "+15550001111",
          "status": "completed",
          "call_outcome": "appointment_set",
          "disposition": null,
          "duration": 184,
          "duration_seconds": 184,
          "created_at": "2026-10-02T17:03:11Z",
          "ended_at": "2026-10-02T17:06:15Z",
          "recording_url": null,
          "summary": "Caller agreed to a consultation on Thursday at 2pm.",
          "transcript": "Agent: Hi, this is Riley from Acme Dental...\nUser: Sure, I have a minute.",
          "metadata": {
            "source": "public_api",
            "public_api_medium": "phone",
            "ai_agent_id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
            "api_metadata": {
              "source": "nightly-follow-up"
            }
          }
        }
      },
      "TranscriptRow": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "call_id": {
            "type": "string",
            "format": "uuid"
          },
          "role": {
            "type": "string",
            "enum": [
              "agent",
              "user"
            ],
            "description": "Who spoke."
          },
          "content": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Transcript": {
        "type": "object",
        "properties": {
          "transcript": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TranscriptRow"
            },
            "description": "Structured turns when available; otherwise an empty array and the text fields below."
          },
          "transcript_text": {
            "type": "string",
            "nullable": true,
            "description": "Plain-text transcript (most AI calls store this form)."
          },
          "summary": {
            "type": "string",
            "nullable": true
          }
        },
        "example": {
          "transcript": [],
          "transcript_text": "Agent: Hi, this is Riley from Acme Dental...\nUser: Sure, I have a minute.",
          "summary": "Caller agreed to a consultation on Thursday at 2pm."
        }
      },
      "Account": {
        "type": "object",
        "properties": {
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "description": "`active` when approved; anything else blocks calling."
          },
          "sandbox": {
            "type": "boolean",
            "description": "`true` for a sandbox organization (no real calls or texts)."
          },
          "key_mode": {
            "type": "string",
            "enum": [
              "live",
              "test"
            ],
            "description": "`test` when the key you used is a `lojiq_test_` key."
          },
          "voice_calls_allowed": {
            "type": "boolean",
            "description": "True when the organization is active, voice is not suspended for billing, and the token balance allows AI voice minutes."
          }
        },
        "example": {
          "organization_id": "7f6e5d4c-3b2a-4190-8877-665544332211",
          "name": "Acme Dental",
          "status": "active",
          "sandbox": false,
          "key_mode": "live",
          "voice_calls_allowed": true
        }
      },
      "CallTemplate": {
        "type": "object",
        "required": [
          "system_prompt"
        ],
        "description": "Only the fields listed are accepted; any other key is rejected with `unknown_template_field:<key>`.",
        "properties": {
          "system_prompt": {
            "type": "string",
            "maxLength": 20000,
            "description": "The agent's instructions. `{{variable}}` placeholders are filled per call from `template_context`."
          },
          "voice": {
            "type": "string",
            "description": "A voice id or name from `GET /voices`. Unknown voices are rejected (`400 unknown_voice`); there is no silent fallback."
          },
          "temperature": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "first_speaker": {
            "type": "string",
            "enum": [
              "agent",
              "user"
            ],
            "default": "agent",
            "description": "Who speaks first. Phone calls connect the AI after answering-machine detection, so the agent greets on join."
          },
          "recording_enabled": {
            "type": "boolean",
            "default": true
          },
          "greeting": {
            "type": "string",
            "maxLength": 1000,
            "description": "Exact opening line. Supports `{{variable}}` placeholders."
          },
          "max_duration_seconds": {
            "type": "integer",
            "minimum": 60,
            "maximum": 7200,
            "description": "Hard cap on call length. Browser calls default to 1800 (30 min); phone calls default to the platform cap."
          }
        },
        "example": {
          "system_prompt": "You are {{agent_name}}, a friendly scheduler for {{business_name}}. Book the caller a 30-minute consultation.",
          "voice": "Kyle",
          "temperature": 0.3,
          "recording_enabled": true,
          "greeting": "Hi, this is {{agent_name}} from {{business_name}} — do you have a quick minute?"
        }
      },
      "VoiceAgent": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "type": {
            "type": "string",
            "description": "`standard` for API-created agents; app agents may be `receptionist` or `custom`."
          },
          "created_via": {
            "type": "string",
            "enum": [
              "app",
              "public_api"
            ],
            "description": "Only `public_api` agents can be updated, deleted or assigned a number through this API."
          },
          "call_template": {
            "allOf": [
              {
                "$ref": "#/components/schemas/CallTemplate"
              },
              {
                "description": "For app-created agents this is a read-only view synthesized from the app configuration (`system_prompt` is null)."
              }
            ]
          },
          "phone_number": {
            "type": "string",
            "nullable": true,
            "description": "The inbound number this agent answers, if any."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "example": {
          "id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
          "name": "Appointment Setter",
          "type": "standard",
          "created_via": "public_api",
          "call_template": {
            "system_prompt": "You are {{agent_name}}, a friendly scheduler for {{business_name}}.",
            "voice": "Kyle",
            "voice_id": "0b1c2d3e-voice",
            "voice_name": "Kyle",
            "temperature": 0.3,
            "recording_enabled": true
          },
          "phone_number": null,
          "created_at": "2026-10-02T17:03:11Z",
          "updated_at": "2026-10-02T17:03:11Z"
        }
      },
      "VoiceAgentCreate": {
        "type": "object",
        "required": [
          "name",
          "call_template"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "call_template": {
            "$ref": "#/components/schemas/CallTemplate"
          }
        },
        "example": {
          "name": "Appointment Setter",
          "call_template": {
            "system_prompt": "You are {{agent_name}}, a friendly scheduler for {{business_name}}. Book the caller a 30-minute consultation.",
            "voice": "Kyle",
            "temperature": 0.3,
            "greeting": "Hi, this is {{agent_name}} from {{business_name}} — do you have a quick minute?"
          }
        }
      },
      "VoiceAgentPatch": {
        "type": "object",
        "description": "Provide `name` and/or `call_template`. A provided `call_template` **replaces the whole template** (no deep merge), so resend every field you want to keep.",
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "call_template": {
            "$ref": "#/components/schemas/CallTemplate"
          }
        },
        "example": {
          "call_template": {
            "system_prompt": "You are Riley, a scheduler for Acme Dental. Book a 30-minute consultation.",
            "voice": "Kyle"
          }
        }
      },
      "PhoneNumberAssign": {
        "type": "object",
        "required": [
          "phone_number"
        ],
        "properties": {
          "phone_number": {
            "type": "string",
            "description": "An E.164 number **your organization owns** (bought in the Lojiq app), or that number's id."
          }
        },
        "example": {
          "phone_number": "+15559876543"
        }
      },
      "VoiceAgentWithRouting": {
        "allOf": [
          {
            "$ref": "#/components/schemas/VoiceAgent"
          },
          {
            "type": "object",
            "properties": {
              "inbound_webhook_configured": {
                "type": "boolean",
                "description": "Whether the number's inbound voice webhook could be pointed at Lojiq automatically. `false` means check the number's routing in the app."
              }
            }
          }
        ]
      },
      "CreateCallRequest": {
        "type": "object",
        "required": [
          "medium"
        ],
        "properties": {
          "medium": {
            "type": "object",
            "description": "Exactly one of `web` or `phone`. `{\"web\": {}}` returns a `join_url` for a browser session; `{\"phone\": {\"to\": \"+1...\"}}` dials out.",
            "properties": {
              "web": {
                "type": "object",
                "description": "Empty object."
              },
              "phone": {
                "type": "object",
                "required": [
                  "to"
                ],
                "properties": {
                  "to": {
                    "type": "string",
                    "description": "Destination, E.164 preferred (10-digit US numbers are accepted and normalized)."
                  },
                  "from": {
                    "type": "string",
                    "description": "Optional caller id. Must be a number your organization owns; defaults to a local-presence match."
                  }
                }
              }
            }
          },
          "template_context": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Values for `{{variable}}` placeholders in the prompt and greeting. Up to 32 keys, 2,000 characters each; numbers and booleans are stringified."
          },
          "metadata": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Up to 16 keys, 256 characters each. Stored on the call under `metadata.api_metadata`."
          },
          "lead_id": {
            "type": "string",
            "format": "uuid",
            "description": "Attach the call to one of your leads."
          },
          "system_prompt": {
            "type": "string",
            "description": "Per-call override of the template prompt (required on `POST /calls`)."
          },
          "voice": {
            "type": "string",
            "description": "Per-call voice override (id or name)."
          },
          "temperature": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "first_speaker": {
            "type": "string",
            "enum": [
              "agent",
              "user"
            ]
          },
          "recording_enabled": {
            "type": "boolean"
          },
          "greeting": {
            "type": "string"
          },
          "max_duration_seconds": {
            "type": "integer",
            "minimum": 60,
            "maximum": 7200,
            "nullable": true
          }
        },
        "example": {
          "medium": {
            "phone": {
              "to": "+15551234567"
            }
          },
          "template_context": {
            "agent_name": "Riley",
            "business_name": "Acme Dental"
          },
          "lead_id": "4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e",
          "metadata": {
            "source": "nightly-follow-up"
          }
        }
      },
      "AdHocCallRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CreateCallRequest"
          },
          {
            "type": "object",
            "required": [
              "system_prompt"
            ]
          }
        ],
        "example": {
          "medium": {
            "web": {}
          },
          "system_prompt": "You are Riley, a scheduler for Acme Dental. Book a 30-minute consultation.",
          "voice": "Kyle",
          "max_duration_seconds": 900
        }
      },
      "VoiceCall": {
        "type": "object",
        "description": "The create-time shape. Reads via `GET /calls/{id}` return the full call record instead.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "description": "`in-progress` for browser calls (live immediately); `initiated` for phone calls (dialing)."
          },
          "medium": {
            "type": "string",
            "enum": [
              "web",
              "phone"
            ]
          },
          "join_url": {
            "type": "string",
            "nullable": true,
            "description": "Browser calls only. Hand it to your client through the Lojiq Voice SDK; treat it as a secret."
          },
          "agent_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "lead_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "to_number": {
            "type": "string",
            "nullable": true
          },
          "from_number": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "example": {
          "id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
          "status": "initiated",
          "medium": "phone",
          "join_url": null,
          "agent_id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
          "lead_id": "4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e",
          "to_number": "+15551234567",
          "from_number": "+15550001111",
          "created_at": "2026-10-02T17:03:11Z"
        }
      },
      "Voice": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "gender": {
            "type": "string",
            "nullable": true
          },
          "age": {
            "type": "integer",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "language": {
            "type": "string",
            "nullable": true
          },
          "personality_traits": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "nullable": true
          },
          "preview_url": {
            "type": "string",
            "format": "uri",
            "description": "Absolute URL of the preview clip (`GET /voices/{id}/preview`, needs your key)."
          }
        },
        "example": {
          "id": "0b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
          "name": "Kyle",
          "gender": "male",
          "age": 34,
          "description": "Warm, confident, mid-tempo.",
          "language": "en-US",
          "personality_traits": [
            "friendly",
            "professional"
          ],
          "preview_url": "https://api.lojiq.ai/v1/voices/0b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e/preview"
        }
      },
      "Appointment": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "lead_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "campaign_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "contact_name": {
            "type": "string",
            "nullable": true
          },
          "appointment_datetime": {
            "type": "string",
            "format": "date-time"
          },
          "duration_minutes": {
            "type": "integer",
            "nullable": true
          },
          "timezone": {
            "type": "string",
            "nullable": true,
            "description": "IANA zone the time was booked in (for example `America/Los_Angeles`)."
          },
          "status": {
            "type": "string",
            "enum": [
              "scheduled",
              "confirmed",
              "cancelled",
              "completed"
            ]
          },
          "fulfillment_mode": {
            "type": "string",
            "enum": [
              "ai",
              "human"
            ],
            "description": "Whether the AI scheduler or a person owns the appointment."
          },
          "source": {
            "type": "string",
            "nullable": true,
            "description": "What booked it (for example `ai_call`, `app`)."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "example": {
          "id": "8d7c6b5a-4f3e-4d2c-9b1a-0f9e8d7c6b5a",
          "lead_id": "4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e",
          "campaign_id": "0d2f6b1e-4a7c-4e8d-9b1a-2c3d4e5f6a7b",
          "contact_name": "Avery Rivera",
          "appointment_datetime": "2026-10-09T21:00:00Z",
          "duration_minutes": 30,
          "timezone": "America/Los_Angeles",
          "status": "scheduled",
          "fulfillment_mode": "ai",
          "source": "ai_call",
          "created_at": "2026-10-02T17:06:15Z"
        }
      },
      "AppointmentCancelRequest": {
        "type": "object",
        "properties": {
          "reason": {
            "type": "string",
            "maxLength": 500,
            "description": "Free text, stored on the appointment and included in the `appointment.cancelled` event. Defaults to `public_api`."
          }
        },
        "example": {
          "reason": "customer_requested"
        }
      },
      "AppointmentStatus": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "cancelled"
            ]
          }
        },
        "example": {
          "id": "8d7c6b5a-4f3e-4d2c-9b1a-0f9e8d7c6b5a",
          "status": "cancelled"
        }
      },
      "Deal": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "lead_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "status": {
            "type": "string"
          },
          "stage": {
            "type": "string"
          },
          "amount_cents": {
            "type": "integer",
            "nullable": true
          },
          "currency": {
            "type": "string",
            "nullable": true,
            "example": "USD"
          },
          "notes": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "example": {
          "id": "3c2b1a0f-9e8d-4c7b-8a6f-5e4d3c2b1a0f",
          "lead_id": "4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e",
          "status": "open",
          "stage": "application",
          "amount_cents": 425000,
          "currency": "USD",
          "notes": null,
          "created_at": "2026-10-01T12:00:00Z",
          "updated_at": "2026-10-02T17:06:15Z"
        }
      },
      "DealPatch": {
        "type": "object",
        "description": "Only `status`, `stage`, `amount_cents` and `notes` are accepted; other keys are ignored. Changing `status` or `stage` emits `deal.status_changed`.",
        "properties": {
          "status": {
            "type": "string"
          },
          "stage": {
            "type": "string"
          },
          "amount_cents": {
            "type": "integer"
          },
          "notes": {
            "type": "string"
          }
        },
        "example": {
          "stage": "underwriting"
        }
      },
      "EventCatalog": {
        "type": "object",
        "properties": {
          "events": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "type": {
                  "type": "string",
                  "example": "lead.created"
                },
                "description": {
                  "type": "string",
                  "example": "A new lead was created."
                }
              }
            }
          }
        }
      },
      "WebhookSubscription": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "nullable": true
          },
          "target_url": {
            "type": "string",
            "format": "uri"
          },
          "event_types": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "enabled": {
            "type": "boolean"
          },
          "last_success_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "last_failure_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "consecutive_failures": {
            "type": "integer"
          },
          "disabled_reason": {
            "type": "string",
            "nullable": true,
            "description": "Set when Lojiq auto-disabled the subscription after 25 consecutive failures. Re-enable with `PATCH` once your endpoint is healthy."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "example": {
          "id": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
          "name": "crm-sync",
          "target_url": "https://example.com/lojiq/webhook",
          "event_types": [
            "lead.created",
            "lead.disposition_changed",
            "call.completed"
          ],
          "enabled": true,
          "last_success_at": "2026-10-02T17:06:15Z",
          "last_failure_at": null,
          "consecutive_failures": 0,
          "disabled_reason": null,
          "created_at": "2026-10-02T17:03:11Z",
          "updated_at": "2026-10-02T17:03:11Z"
        }
      },
      "WebhookSubscriptionCreate": {
        "type": "object",
        "required": [
          "target_url",
          "event_types"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "A label for your own reference."
          },
          "target_url": {
            "type": "string",
            "format": "uri",
            "description": "Must be `https://`, publicly resolvable, with no credentials in the URL. Private, loopback, link-local and cloud-metadata addresses are rejected (also re-checked on every delivery)."
          },
          "event_types": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "minItems": 1,
            "description": "Event names from `GET /event-catalog`, or `[\"*\"]` for everything."
          }
        },
        "example": {
          "name": "crm-sync",
          "target_url": "https://example.com/lojiq/webhook",
          "event_types": [
            "lead.created",
            "lead.disposition_changed",
            "call.completed"
          ]
        }
      },
      "WebhookSubscriptionCreated": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "nullable": true
          },
          "target_url": {
            "type": "string",
            "format": "uri"
          },
          "event_types": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "enabled": {
            "type": "boolean"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "secret": {
            "type": "string",
            "description": "`whsec_` + 64 hex characters. **Returned once.** Store it; you need it to verify `X-Lojiq-Signature-256`."
          }
        },
        "example": {
          "id": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
          "name": "crm-sync",
          "target_url": "https://example.com/lojiq/webhook",
          "event_types": [
            "lead.created",
            "lead.disposition_changed",
            "call.completed"
          ],
          "enabled": true,
          "created_at": "2026-10-02T17:03:11Z",
          "secret": "whsec_9f2c1a7b3e5d4c6f8a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f"
        }
      },
      "WebhookSubscriptionPatch": {
        "type": "object",
        "description": "Send only the fields to change. Setting `enabled: true` also clears `disabled_reason` and the failure counter.",
        "properties": {
          "name": {
            "type": "string"
          },
          "target_url": {
            "type": "string",
            "format": "uri"
          },
          "event_types": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "minItems": 1
          },
          "enabled": {
            "type": "boolean"
          }
        },
        "example": {
          "enabled": true
        }
      },
      "WebhookSubscriptionUpdated": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "nullable": true
          },
          "target_url": {
            "type": "string",
            "format": "uri"
          },
          "event_types": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "enabled": {
            "type": "boolean"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "example": {
          "id": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
          "name": "crm-sync",
          "target_url": "https://example.com/lojiq/webhook",
          "event_types": [
            "lead.created",
            "lead.disposition_changed",
            "call.completed"
          ],
          "enabled": true,
          "updated_at": "2026-10-02T18:00:00Z"
        }
      },
      "WebhookEvent": {
        "type": "object",
        "description": "The JSON body Lojiq POSTs to your `target_url`.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Event id. The same id is reused on every retry — use it to deduplicate."
          },
          "type": {
            "type": "string",
            "example": "lead.created"
          },
          "organization_id": {
            "type": "string",
            "format": "uuid"
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time"
          },
          "data": {
            "type": "object",
            "description": "Event-specific payload. See the Webhooks guide for every event's fields.",
            "additionalProperties": true
          }
        },
        "example": {
          "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
          "type": "lead.created",
          "organization_id": "7f6e5d4c-3b2a-4190-8877-665544332211",
          "occurred_at": "2026-10-02T17:03:11Z",
          "data": {
            "lead_id": "4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e",
            "deduped": false
          }
        }
      }
    }
  },
  "paths": {
    "/": {
      "get": {
        "tags": [
          "Meta"
        ],
        "operationId": "getApiMeta",
        "summary": "API metadata",
        "description": "Returns the API name, version and the docs URL. Useful as a connectivity check after you configure a key.\nRequires a valid key but no particular scope.\n",
        "x-scope": "none",
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiMeta"
                },
                "example": {
                  "api": "lojiq-public-api",
                  "version": "v1",
                  "docs": "https://docs.lojiq.ai"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/event-catalog": {
      "get": {
        "tags": [
          "Meta"
        ],
        "operationId": "listEventCatalog",
        "summary": "List webhook event types",
        "description": "Every event type you can subscribe to, with a one-line description. Scope: `webhooks:manage`.\nThe Webhooks guide documents each event's `data` payload and which ones fire today.\n",
        "x-scope": "webhooks:manage",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EventCatalog"
                },
                "example": {
                  "events": [
                    {
                      "type": "lead.created",
                      "description": "A new lead was created."
                    },
                    {
                      "type": "lead.updated",
                      "description": "A lead was updated (any field except disposition)."
                    },
                    {
                      "type": "lead.disposition_changed",
                      "description": "A lead disposition / status changed."
                    },
                    {
                      "type": "call.started",
                      "description": "An outbound or inbound call started."
                    },
                    {
                      "type": "call.ended",
                      "description": "A call ended (any reason)."
                    },
                    {
                      "type": "call.transferred",
                      "description": "A call was warm-transferred."
                    },
                    {
                      "type": "call.completed",
                      "description": "A call completed normally."
                    },
                    {
                      "type": "call.transcript_ready",
                      "description": "A call transcript (and summary) finished processing and is available."
                    },
                    {
                      "type": "deal.status_changed",
                      "description": "A deal moved between pipeline stages."
                    },
                    {
                      "type": "appointment.booked",
                      "description": "An appointment was booked (AI or human)."
                    },
                    {
                      "type": "appointment.cancelled",
                      "description": "An appointment was cancelled."
                    },
                    {
                      "type": "appointment.rescheduled",
                      "description": "An appointment was rescheduled."
                    },
                    {
                      "type": "ai_engineer.onboarding_completed",
                      "description": "An AI Engineer finished onboarding."
                    },
                    {
                      "type": "billing.low_balance",
                      "description": "Account balance crossed the low-balance threshold."
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/account": {
      "get": {
        "tags": [
          "Account"
        ],
        "operationId": "getAccount",
        "summary": "Get your organization",
        "description": "Your organization's id, name and status, plus `voice_calls_allowed` — the single flag to check before starting\ncalls through the API. Scope: `account:read`.\n",
        "x-scope": "account:read",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Account"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/leads": {
      "get": {
        "tags": [
          "Leads"
        ],
        "operationId": "listLeads",
        "summary": "List leads",
        "description": "Newest first. Filter by campaign, status or phone number. Scope: `leads:read`.",
        "x-scope": "leads:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/offset"
          },
          {
            "name": "campaign_id",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Only leads attached to this campaign."
          },
          {
            "name": "phone_number",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "One lead by phone number, in any format (it is canonicalised to E.164 before matching). The quickest way to look a lead up before creating one."
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Only leads with exactly this status (`new`, `contacted`, `do_not_call`…)."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/LeadSummary"
                          }
                        }
                      }
                    },
                    {
                      "$ref": "#/components/schemas/Page"
                    }
                  ]
                },
                "example": {
                  "data": [
                    {
                      "id": "4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e",
                      "first_name": "Avery",
                      "last_name": "Rivera",
                      "full_name": "Avery Rivera",
                      "phone_number": "+15551234567",
                      "email_address": "avery@example.com",
                      "status": "contacted",
                      "campaign_id": "0d2f6b1e-4a7c-4e8d-9b1a-2c3d4e5f6a7b",
                      "consent_source": "web_form",
                      "consented_at": "2026-10-02T16:58:00Z",
                      "created_at": "2026-10-02T17:03:11Z",
                      "updated_at": "2026-10-02T19:40:02Z"
                    }
                  ],
                  "limit": 50,
                  "offset": 0
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "post": {
        "tags": [
          "Leads"
        ],
        "operationId": "createLead",
        "summary": "Create a lead",
        "description": "Creates a lead, or updates the existing lead with the same `phone_number` in your organization.\nScope: `leads:write`.\n\n**Deduplication.** The phone number is canonicalised to E.164 (`5551234567` → `+15551234567`) and compared with\nyour organization's leads. A match updates that lead with the fields you sent and answers **`200`**; no match\ninserts and answers **`201`**. On a match, fields you omit are kept — except `status` and `consent_source`, which\ntake their defaults (`new`, `public_api`) on every create call. To change one field of an existing lead without\ntouching the rest, use `PATCH /leads/{id}`.\n\n**Retries.** Send an `Idempotency-Key` and a retried request returns the first answer instead of running again.\nWithout one, retrying is still safe: the second call deduplicates and answers `200`.\n\n**Events.** `lead.created` on insert, `lead.updated` on dedupe, both with `{ lead_id, deduped, source: \"public_api\" }`.\n\n**Consent.** `consent_source` defaults to `public_api`. Send `consented_at` whenever you have it; AI and\nautodialed outreach needs prior express written consent in the US (see the Leads guide).\n",
        "x-scope": "leads:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LeadCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created. The full lead record.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/Idempotency-Replayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Lead"
                }
              }
            }
          },
          "200": {
            "description": "An existing lead with this phone number was updated instead. The full lead record.",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/Idempotency-Replayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Lead"
                }
              }
            }
          },
          "400": {
            "description": "`invalid_phone_number`, `unknown_field:<name>`, `invalid_request` (a field has the wrong type, length or range — `message` says which), `invalid_json`, or `invalid_idempotency_key`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "invalid_phone_number",
                  "message": "phone_number is required: 10-15 digits, E.164 preferred (+15551234567).",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "`campaign_not_found` — the `campaign_id` is not one of your campaigns.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "campaign_not_found",
                  "message": "No campaign with that id in your organization.",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyReused"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/leads/{id}": {
      "get": {
        "tags": [
          "Leads"
        ],
        "operationId": "getLead",
        "summary": "Get a lead",
        "description": "The full stored lead record. Scope: `leads:read`.",
        "x-scope": "leads:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Lead"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "patch": {
        "tags": [
          "Leads"
        ],
        "operationId": "updateLead",
        "summary": "Update a lead",
        "description": "Partial update. Scope: `leads:write`.\n\nChanging `status` emits `lead.disposition_changed`; any other change emits `lead.updated` — both with\n`{ lead_id, changes: [the field names you sent], source: \"public_api\" }`. Only the listed fields are accepted\n(`400 unknown_field:<name>`); `phone_number` cannot be changed (`400 phone_number_immutable`); an empty body is\n`400 nothing_to_update`. A lead that is not in your organization answers `404 not_found`.\n",
        "x-scope": "leads:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LeadPatch"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated lead record.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Lead"
                }
              }
            }
          },
          "400": {
            "description": "`unknown_field:<name>`, `phone_number_immutable`, `nothing_to_update`, `invalid_request` (wrong type, length or range) or `invalid_json`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "unknown_field:nickname",
                  "message": "Unknown field \"nickname\". Allowed: first_name, last_name, email_address, status, campaign_id, consent_source, consented_at, credit_score, annual_income, notes.",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "`not_found` (no such lead in your organization) or `campaign_not_found` (the `campaign_id` is not yours).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not_found",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/leads/bulk": {
      "post": {
        "tags": [
          "Leads"
        ],
        "operationId": "bulkImportLeads",
        "summary": "Bulk import leads from CSV",
        "description": "Imports a CSV in one request and answers `202` with a summary once every row has been processed\n(the import is synchronous; keep requests under roughly 5,000 rows and under 5,000,000 bytes of CSV —\nlarger bodies answer `413 payload_too_large`). Scope: `leads:write`.\n\n**Recommended flow:** `POST /leads/inspect-csv` first, then pass its `suggested_mapping` as `mapping`.\nWithout `mapping`, headers must already be the canonical column names.\n\nPer row: the phone number is normalized (10-digit US numbers get `+1`), deduplicated against your organization\nby phone, and inserted or updated. Rows without a usable phone number count as `failed`. `consent_source`\ndefaults to `csv_import` for rows without one. Each inserted row emits `lead.created`, each updated row\n`lead.updated`, with `{ lead_id, source: \"csv_import\", job_id }`.\n",
        "x-scope": "leads:write",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BulkImportRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Processed. Counts per outcome.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkImportSummary"
                }
              }
            }
          },
          "400": {
            "description": "`csv_required`, `unknown_field:<name>` (only `csv`, `mapping`, `campaign_id` are accepted), or `invalid_request` (`mapping` is not an object, or the CSV names an unknown column).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "csv_required",
                  "message": "Send the file contents as a string in \"csv\".",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "`campaign_not_found` — the `campaign_id` is not one of your campaigns.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "campaign_not_found",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "413": {
            "description": "`payload_too_large` — `csv` is over 5,000,000 bytes. Split the file.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "payload_too_large",
                  "message": "csv is limited to 5000000 bytes per request. Split the file.",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "`import_failed` — the import could not be started. Retry, or contact support with the `request_id`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "import_failed",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          }
        }
      }
    },
    "/leads/inspect-csv": {
      "post": {
        "tags": [
          "Leads"
        ],
        "operationId": "inspectLeadsCsv",
        "summary": "Preflight a CSV",
        "description": "Parses the CSV, auto-maps your headers onto Lojiq's lead columns using a built-in alias list, and reports what\nis unmapped or missing. Nothing is stored. Scope: `leads:write`.\n",
        "x-scope": "leads:write",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CsvInspectRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CsvInspection"
                }
              }
            }
          },
          "400": {
            "description": "`csv_required` (no `csv` string) or `invalid_csv` (the text could not be parsed).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "invalid_csv",
                  "message": "Unexpected quote at row 3.",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "413": {
            "description": "`payload_too_large` — `csv` is over 5,000,000 bytes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/leads.csv": {
      "get": {
        "tags": [
          "Leads"
        ],
        "operationId": "exportLeadsCsv",
        "summary": "Export leads as CSV",
        "description": "Streams every lead in your organization (or one campaign's) as a CSV attachment, oldest first.\nColumns: `first_name, last_name, phone_number, email_address, status, credit_score, annual_income,\nconsent_source, consented_at, created_at, lead_id`. Scope: `leads:read`.\n",
        "x-scope": "leads:read",
        "parameters": [
          {
            "name": "campaign_id",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Only leads attached to this campaign."
          }
        ],
        "responses": {
          "200": {
            "description": "`text/csv; charset=utf-8`, `Content-Disposition: attachment; filename=\"lead-bank.csv\"`.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                },
                "example": "first_name,last_name,phone_number,email_address,status,credit_score,annual_income,consent_source,consented_at,created_at,lead_id\nAvery,Rivera,+15551234567,avery@example.com,contacted,,,web_form,2026-10-02T16:58:00Z,2026-10-02T17:03:11Z,4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e\n"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "`campaign_not_found` — the `campaign_id` is not one of your campaigns.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "`export_failed`. Retry, or contact support with the `request_id`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/contacts": {
      "get": {
        "tags": [
          "Contacts"
        ],
        "operationId": "listContacts",
        "summary": "List contacts",
        "description": "**Not yet available.** This endpoint answers `501 feature_unavailable` until the contacts store is\nprovisioned; use `/leads` meanwhile. Intended behaviour: newest first. Scope: `contacts:read`.\n",
        "x-scope": "contacts:read",
        "x-lojiq-status": "unavailable",
        "x-lojiq-notes": "The `contacts` table does not exist in production (verified 2026-10-02 via information_schema). v246 answers 501 feature_unavailable instead of a 500. Decide whether to provision it or drop the group from v1.",
        "x-badges": [
          {
            "name": "Not yet available",
            "position": "after"
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/offset"
          }
        ],
        "responses": {
          "200": {
            "description": "OK (once available)",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Contact"
                          }
                        }
                      }
                    },
                    {
                      "$ref": "#/components/schemas/Page"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "501": {
            "$ref": "#/components/responses/FeatureUnavailable"
          }
        }
      },
      "post": {
        "tags": [
          "Contacts"
        ],
        "operationId": "createContact",
        "summary": "Create a contact",
        "description": "**Not yet available.** Answers `501 feature_unavailable` until the contacts store is provisioned; use\n`POST /leads` meanwhile. Intended behaviour: inserts a contact as sent (no deduplication). Scope: `contacts:write`.\n",
        "x-scope": "contacts:write",
        "x-lojiq-status": "unavailable",
        "x-lojiq-notes": "Same missing table as listContacts; v246 answers 501.",
        "x-badges": [
          {
            "name": "Not yet available",
            "position": "after"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created (once available)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contact"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "501": {
            "$ref": "#/components/responses/FeatureUnavailable"
          }
        }
      }
    },
    "/campaigns": {
      "get": {
        "tags": [
          "Campaigns"
        ],
        "operationId": "listCampaigns",
        "summary": "List campaigns",
        "description": "Your campaigns, newest first, with their status and dialing mode. Filter by `status`. Campaigns are built in\nthe Lojiq app; the API reads them and starts or pauses them. Scope: `campaigns:read`.\n",
        "x-scope": "campaigns:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/offset"
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "active",
                "paused",
                "completed",
                "cancelled"
              ]
            },
            "description": "Only campaigns in this state."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/CampaignSummary"
                          }
                        }
                      }
                    },
                    {
                      "$ref": "#/components/schemas/Page"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/campaigns/{id}": {
      "get": {
        "tags": [
          "Campaigns"
        ],
        "operationId": "getCampaign",
        "summary": "Get a campaign",
        "description": "The full stored campaign record. Scope: `campaigns:read`.",
        "x-scope": "campaigns:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Campaign"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/campaigns/{id}/start": {
      "post": {
        "tags": [
          "Campaigns"
        ],
        "operationId": "startCampaign",
        "summary": "Start (or resume) a campaign",
        "description": "Resumes a **paused** campaign: the same path the app's Resume button takes (balance check, actor stamp,\ndialer restart), so the dialer picks it up right away. Scope: `campaigns:write`.\n\nOnly `paused` → `active` is possible here. A campaign that is `draft`, `completed` or `cancelled` answers\n`409 campaign_not_resumable`: launch it once from the Lojiq app, where the launch checks (agent, numbers,\ncalling hours, consent attestation) live. An already active campaign answers `200` with `changed: false`.\n",
        "x-scope": "campaigns:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK — `changed` says whether anything happened.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CampaignStatus"
                },
                "example": {
                  "id": "0d2f6b1e-4a7c-4e8d-9b1a-2c3d4e5f6a7b",
                  "status": "active",
                  "changed": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "`insufficient_balance` — the token wallet is below the minimum to dial. Top up in the Lojiq app.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "insufficient_balance",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`campaign_not_resumable` — the campaign is not paused (`message` names its state).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "campaign_not_resumable",
                  "message": "Only a paused campaign can be started from the API (this one is draft). Launch it once from the Lojiq app, where the launch checks live.",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/campaigns/{id}/stop": {
      "post": {
        "tags": [
          "Campaigns"
        ],
        "operationId": "stopCampaign",
        "summary": "Pause a campaign",
        "description": "Pauses an **active** campaign through the same path as the app's Pause button: the status becomes `paused`\nand the dial queue is cleared; calls already in progress finish. An already paused campaign answers `200`\nwith `changed: false`; any other state answers `409 campaign_not_active`. Scope: `campaigns:write`.\n",
        "x-scope": "campaigns:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK — `changed` says whether anything happened.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CampaignStatus"
                },
                "example": {
                  "id": "0d2f6b1e-4a7c-4e8d-9b1a-2c3d4e5f6a7b",
                  "status": "paused",
                  "changed": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`campaign_not_active` — only an active campaign can be paused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "campaign_not_active",
                  "message": "Only an active campaign can be stopped (this one is completed).",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/calls": {
      "get": {
        "tags": [
          "Calls"
        ],
        "operationId": "listCalls",
        "summary": "List calls",
        "description": "Every call in your organization (campaign, receptionist and API calls), most recent first. Scope: `calls:read`.",
        "x-scope": "calls:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/offset"
          },
          {
            "name": "campaign_id",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "lead_id",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/CallSummary"
                          }
                        }
                      }
                    },
                    {
                      "$ref": "#/components/schemas/Page"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "post": {
        "tags": [
          "Calls"
        ],
        "operationId": "createCall",
        "summary": "Start an ad-hoc call",
        "description": "Starts a call **without a saved agent**: the body carries the prompt and voice inline. Everything else works\nlike `POST /agents/{id}/calls`. Scope: `calls:write`.\n\n**Billing.** Nothing is charged at creation. The call bills AI minutes, carrier minutes and connection fees\non completion, exactly like calls started in the app. Creation is refused with `402 insufficient_balance`\nwhen the token balance is exhausted.\n\n**Phone calls** go through Lojiq's carrier stack with answering-machine detection; the AI connects when a\nhuman answers. Numbers on your organization's do-not-call list are refused (`403 destination_on_dnc`).\n\n**Browser calls** are live immediately; hand the `join_url` to your client through the Lojiq Voice SDK.\n\n**Retries.** Always send an `Idempotency-Key`. A retried request with the same key returns the first answer\n(`Idempotency-Replayed: true`) and never places a second paid call.\n",
        "x-scope": "calls:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AdHocCallRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/Idempotency-Replayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VoiceCall"
                }
              }
            }
          },
          "400": {
            "description": "Validation. `error` is one of `medium_required`, `invalid_medium`, `invalid_to_number`,\n`invalid_from_number`, `from_number_not_owned`, `system_prompt_required`, `system_prompt_must_be_string`,\n`system_prompt_too_long`, `unknown_template_field:<key>`, `unknown_voice`, `temperature_out_of_range`,\n`invalid_first_speaker`, `recording_enabled_must_be_boolean`, `greeting_must_be_string`,\n`greeting_too_long`, `max_duration_out_of_range`, `template_context_*`, `metadata_*`\n(`_must_be_object`, `_too_many_keys`, `_values_must_be_scalar`, `_value_too_long`), `invalid_idempotency_key`, `invalid_json`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "unknown_voice",
                  "message": "No active voice matches \"Kyel\".",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "`insufficient_balance` — top up in the Lojiq app.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "insufficient_balance",
                  "message": "Insufficient token balance for AI voice minutes.",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "403": {
            "description": "`insufficient_scope`, `organization_not_active`, `voice_suspended` (billing), or `destination_on_dnc`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "destination_on_dnc",
                  "message": "This number is on your organization's do-not-call list.",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "404": {
            "description": "`lead_not_found` (the `lead_id` is not yours) or `organization_not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "lead_not_found",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "409": {
            "description": "`no_did_available` — no outbound caller-id number is available for your organization; or `idempotency_in_progress`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_did_available",
                  "message": "No outbound caller-ID number is available for your organization.",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyReused"
          },
          "429": {
            "description": "`rate_limited` (key), `concurrency_limit_reached` (your organization's concurrent-call cap) or `concurrency_limit` (voice provider). Retry shortly.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "concurrency_limit_reached",
                  "message": "Concurrent call limit reached — retry shortly.",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "500": {
            "description": "`call_record_failed`, `org_lookup_failed`, `lead_lookup_failed` or `internal_error`. A `5xx` is not remembered by `Idempotency-Key`, so retry with the same key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "`carrier_dial_failed`, `call_creation_failed`, or `voice_provider_unavailable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "carrier_dial_failed",
                  "message": "The carrier rejected the call.",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "503": {
            "description": "`no_voice_carrier_available` or `voice_carrier_blocked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "no_voice_carrier_available",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          }
        }
      }
    },
    "/calls/{id}": {
      "get": {
        "tags": [
          "Calls"
        ],
        "operationId": "getCall",
        "summary": "Get a call",
        "description": "The full stored call record, including `call_outcome`, `summary`, `transcript` text and `metadata`. Scope: `calls:read`.",
        "x-scope": "calls:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Call"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/calls/{id}/transcript": {
      "get": {
        "tags": [
          "Calls"
        ],
        "operationId": "getCallTranscript",
        "summary": "Get a call transcript",
        "description": "Structured turns (`transcript: [{ role, content, created_at }]`) when the call has them; otherwise\n`transcript: []` plus the plain-text `transcript_text` and the `summary` from the call record, which is the\nform most AI calls store today. Not every call has either: in the last 30 days roughly one completed call in\nseven had transcript text. Subscribe to `call.transcript_ready` instead of polling. Scope: `calls:read`.\n",
        "x-scope": "calls:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Transcript"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/calls/{id}/hangup": {
      "post": {
        "tags": [
          "Calls"
        ],
        "operationId": "hangupCall",
        "summary": "End a live call",
        "description": "Drops the AI leg of a live call; the carrier leg follows. Works for calls started through the API or in the app. Scope: `calls:write`.",
        "x-scope": "calls:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "Ended",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ok"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`call_already_ended`, or `call_not_connected` (still dialing or in answering-machine screening — retry in a few seconds).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "call_not_connected",
                  "message": "The AI leg has not connected yet (still dialing or screening)."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "`hangup_failed` — the voice provider did not accept the request.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/calls/{id}/recording": {
      "get": {
        "tags": [
          "Calls"
        ],
        "operationId": "getCallRecording",
        "summary": "Get a call recording",
        "description": "Answers `302` to the recording audio. Follow the redirect with your API key omitted (the target is a storage\nURL). Not every call has a recording: campaign recording depends on the campaign's consent settings.\nScope: `calls:read`.\n",
        "x-scope": "calls:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to the audio file.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string",
                  "format": "uri"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "`not_found`, or `recording_not_available` when the call exists but has no recording.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "recording_not_available"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/agents": {
      "get": {
        "tags": [
          "Voice agents"
        ],
        "operationId": "listAgents",
        "summary": "List voice agents",
        "description": "Every voice agent in your organization, app- and API-created, newest first. Scope: `agents:read`.",
        "x-scope": "agents:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/offset"
          },
          {
            "name": "created_via",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "app",
                "public_api"
              ]
            },
            "description": "Only agents created in the app, or only agents created through this API."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/VoiceAgent"
                          }
                        }
                      }
                    },
                    {
                      "$ref": "#/components/schemas/Page"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "post": {
        "tags": [
          "Voice agents"
        ],
        "operationId": "createAgent",
        "summary": "Create a voice agent",
        "description": "Creates an API-managed agent from a `call_template`. The voice is validated against `GET /voices`.\nScope: `agents:write`.\n",
        "x-scope": "agents:write",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/VoiceAgentCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created — the full agent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VoiceAgent"
                }
              }
            }
          },
          "400": {
            "description": "`name_required`, `name_too_long`, `call_template_must_be_object`, `unknown_template_field:<key>`, `system_prompt_required`, `system_prompt_too_long`, `unknown_voice`, `temperature_out_of_range`, `invalid_first_speaker`, `greeting_too_long`, `max_duration_out_of_range`, or a storage error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "system_prompt_required"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "description": "`not_provisioned` — the voice-agent columns are missing (should not occur on production).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/agents/{id}": {
      "get": {
        "tags": [
          "Voice agents"
        ],
        "operationId": "getAgent",
        "summary": "Get a voice agent",
        "description": "Scope: `agents:read`.",
        "x-scope": "agents:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VoiceAgent"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "patch": {
        "tags": [
          "Voice agents"
        ],
        "operationId": "updateAgent",
        "summary": "Update a voice agent",
        "description": "Rename and/or replace the `call_template` (wholesale, no deep merge) of an **API-created** agent.\nApp-created agents answer `409 agent_managed_in_app`. Scope: `agents:write`.\n",
        "x-scope": "agents:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/VoiceAgentPatch"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated agent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VoiceAgent"
                }
              }
            }
          },
          "400": {
            "description": "`nothing_to_update`, or any `createAgent` validation code.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "nothing_to_update",
                  "message": "Provide name and/or call_template."
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`agent_managed_in_app`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "agent_managed_in_app",
                  "message": "Only agents created via the public API can be updated here."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "description": "`not_provisioned`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "Voice agents"
        ],
        "operationId": "deleteAgent",
        "summary": "Delete a voice agent",
        "description": "Deletes an **API-created** agent that no campaign uses. Scope: `agents:write`.",
        "x-scope": "agents:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ok"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`agent_managed_in_app`, or `agent_in_use` (assigned to one or more campaigns — unassign it in the app first).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "agent_in_use",
                  "message": "Agent is assigned to one or more campaigns. Unassign it first."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/agents/{id}/phone-number": {
      "post": {
        "tags": [
          "Voice agents"
        ],
        "operationId": "assignAgentPhoneNumber",
        "summary": "Assign an inbound number",
        "description": "Points a number your organization owns at this API-created agent, so the agent answers inbound calls to that\nnumber with its `call_template`. The response adds `inbound_webhook_configured`; when `false`, check the\nnumber's routing in the Lojiq app. Scope: `agents:write`.\n",
        "x-scope": "agents:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PhoneNumberAssign"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The agent, now answering the number.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VoiceAgentWithRouting"
                }
              }
            }
          },
          "400": {
            "description": "`phone_number_required` or `phone_number_not_owned`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "phone_number_not_owned",
                  "message": "The number must belong to your organization (see your numbers in the Lojiq app)."
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`agent_managed_in_app` or `phone_number_in_use` (another agent already answers it).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "phone_number_in_use",
                  "message": "Another agent already answers this number."
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "delete": {
        "tags": [
          "Voice agents"
        ],
        "operationId": "unassignAgentPhoneNumber",
        "summary": "Unassign the inbound number",
        "description": "Stops inbound routing to this API-created agent. The number stays in your organization. Scope: `agents:write`.",
        "x-scope": "agents:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "The agent, with `phone_number: null`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VoiceAgent"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`agent_managed_in_app`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/agents/{id}/calls": {
      "post": {
        "tags": [
          "Voice agents"
        ],
        "operationId": "createAgentCall",
        "summary": "Start a call with an agent",
        "description": "Starts a phone or browser call using the agent's `call_template`, with optional per-call overrides.\nScope: `calls:write`.\n\n* `{\"medium\": {\"web\": {}}}` creates a live browser session and returns a `join_url`.\n* `{\"medium\": {\"phone\": {\"to\": \"+1...\"}}}` dials through Lojiq's carrier stack with answering-machine detection; the AI connects when a human answers.\n\nBilling happens on completion at your organization's standard voice rates (nothing at creation).\nApp-created agents can be called only if you pass `system_prompt` inline, since their prompt is not exposed\nhere (`409 agent_has_no_call_template` otherwise). Orchestrated (multi-sub-agent) app agents cannot be called\nthrough the API (`409 orchestrated_agent_not_supported`).\n\n**Retries.** Always send an `Idempotency-Key`. A retried request with the same key returns the first answer\n(`Idempotency-Replayed: true`) and never places a second paid call.\n",
        "x-scope": "calls:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateCallRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "headers": {
              "Idempotency-Replayed": {
                "$ref": "#/components/headers/Idempotency-Replayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VoiceCall"
                },
                "examples": {
                  "phone": {
                    "summary": "Phone call (dialing)",
                    "value": {
                      "id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
                      "status": "initiated",
                      "medium": "phone",
                      "join_url": null,
                      "agent_id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
                      "lead_id": "4b1e0d1a-3c4e-4d0a-9f2b-7e1a2b3c4d5e",
                      "to_number": "+15551234567",
                      "from_number": "+15550001111",
                      "created_at": "2026-10-02T17:03:11Z"
                    }
                  },
                  "web": {
                    "summary": "Browser call (live)",
                    "value": {
                      "id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8c",
                      "status": "in-progress",
                      "medium": "web",
                      "join_url": "wss://voice.lojiq.ai/join/...",
                      "agent_id": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
                      "lead_id": null,
                      "to_number": null,
                      "from_number": null,
                      "created_at": "2026-10-02T17:03:11Z"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Validation — same codes as `POST /calls`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "invalid_medium",
                  "message": "medium must contain exactly one of \"web\" or \"phone\"."
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "`insufficient_balance`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`insufficient_scope`, `organization_not_active`, `voice_suspended`, or `destination_on_dnc`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` (agent), `lead_not_found`, `organization_not_found`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`agent_has_no_call_template`, `orchestrated_agent_not_supported`, `no_did_available`, or `idempotency_in_progress`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "agent_has_no_call_template",
                  "message": "This agent has no public call_template. Pass system_prompt inline or manage the agent via the public API.",
                  "request_id": "0f3b9c1e-6d2a-4b7f-8e1c-5a9d2b4c6e80"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyReused"
          },
          "429": {
            "description": "`rate_limited`, `concurrency_limit_reached`, or `concurrency_limit`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`call_record_failed`, `org_lookup_failed`, `lead_lookup_failed` or `internal_error`. A `5xx` is not remembered by `Idempotency-Key`, so retry with the same key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "`carrier_dial_failed`, `call_creation_failed`, `voice_provider_unavailable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "`no_voice_carrier_available` or `voice_carrier_blocked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Voice agents"
        ],
        "operationId": "listAgentCalls",
        "summary": "List an agent's calls",
        "description": "Calls attributed to this agent (started through the API with this agent), newest first. Scope: `calls:read`.",
        "x-scope": "calls:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          },
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/offset"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "allOf": [
                              {
                                "$ref": "#/components/schemas/CallSummary"
                              },
                              {
                                "type": "object",
                                "properties": {
                                  "created_at": {
                                    "type": "string",
                                    "format": "date-time"
                                  }
                                }
                              }
                            ]
                          }
                        }
                      }
                    },
                    {
                      "$ref": "#/components/schemas/Page"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/voices": {
      "get": {
        "tags": [
          "Voices"
        ],
        "operationId": "listVoices",
        "summary": "List voices",
        "description": "The active voice catalog, alphabetical. Use `id` or `name` as `voice` in a call template. Scope: `voices:read`.",
        "x-scope": "voices:read",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Voice"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/voices/{id}/preview": {
      "get": {
        "tags": [
          "Voices"
        ],
        "operationId": "previewVoice",
        "summary": "Preview a voice",
        "description": "A short audio clip of the voice reading a fixed sample sentence (you cannot supply your own text). Cached for\n24 hours. `id` may be the voice id or name. Scope: `voices:read`.\n",
        "x-scope": "voices:read",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Voice id or name."
          }
        ],
        "responses": {
          "200": {
            "description": "Audio (`audio/wav` unless the provider returns another type). `Cache-Control: public, max-age=86400`.",
            "content": {
              "audio/wav": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "description": "`unknown_voice`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "`preview_failed` — the voice provider did not return audio.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/appointments": {
      "get": {
        "tags": [
          "Appointments"
        ],
        "operationId": "listAppointments",
        "summary": "List appointments",
        "description": "Soonest first. Filter by `status` or `lead_id`. Scope: `appointments:read`.",
        "x-scope": "appointments:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/offset"
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "scheduled",
                "confirmed",
                "cancelled",
                "completed"
              ]
            }
          },
          {
            "name": "lead_id",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Appointment"
                          }
                        }
                      }
                    },
                    {
                      "$ref": "#/components/schemas/Page"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/appointments/{id}/cancel": {
      "post": {
        "tags": [
          "Appointments"
        ],
        "operationId": "cancelAppointment",
        "summary": "Cancel an appointment",
        "description": "Sets the status to `cancelled`, stores `reason` (up to 500 characters) and emits `appointment.cancelled` with `{ appointment_id, reason, source: \"public_api\" }`. Scope: `appointments:write`.",
        "x-scope": "appointments:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AppointmentCancelRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cancelled",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AppointmentStatus"
                }
              }
            }
          },
          "400": {
            "description": "`invalid_request` (the body is not a JSON object) or `invalid_json`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/deals": {
      "get": {
        "tags": [
          "Deals"
        ],
        "operationId": "listDeals",
        "summary": "List deals",
        "description": "**Not yet available.** Answers `501 feature_unavailable` until the deals store is provisioned.\nIntended behaviour: most recently updated first. Scope: `deals:read`.\n",
        "x-scope": "deals:read",
        "x-lojiq-status": "unavailable",
        "x-lojiq-notes": "The `deals` table does not exist in production (verified 2026-10-02; the app's Deal Pipeline reads a different store). v246 answers 501. Decide whether to map this endpoint onto the CRM deals/opportunities model or drop it from v1.",
        "x-badges": [
          {
            "name": "Not yet available",
            "position": "after"
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/offset"
          }
        ],
        "responses": {
          "200": {
            "description": "OK (once available)",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Deal"
                          }
                        }
                      }
                    },
                    {
                      "$ref": "#/components/schemas/Page"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "501": {
            "$ref": "#/components/responses/FeatureUnavailable"
          }
        }
      }
    },
    "/deals/{id}": {
      "patch": {
        "tags": [
          "Deals"
        ],
        "operationId": "updateDeal",
        "summary": "Update a deal",
        "description": "**Not yet available.** Answers `501 feature_unavailable` until the deals store is provisioned.\nIntended behaviour: updates `status`, `stage`, `amount_cents` and/or `notes`; emits `deal.status_changed` when status or stage changes. Scope: `deals:write`.\n",
        "x-scope": "deals:write",
        "x-lojiq-status": "unavailable",
        "x-lojiq-notes": "Same missing table as listDeals; v246 answers 501.",
        "x-badges": [
          {
            "name": "Not yet available",
            "position": "after"
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DealPatch"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated deal (once available).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Deal"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "501": {
            "$ref": "#/components/responses/FeatureUnavailable"
          }
        }
      }
    },
    "/webhooks": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listWebhooks",
        "summary": "List webhook subscriptions",
        "description": "Every subscription in your organization with its delivery health. Secrets are never returned. Scope: `webhooks:manage`.",
        "x-scope": "webhooks:manage",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "subscriptions": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookSubscription"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "post": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "createWebhook",
        "summary": "Create a webhook subscription",
        "description": "Subscribes an HTTPS endpoint to one or more event types. The response carries the signing `secret`\n**once** — store it. Scope: `webhooks:manage`.\n\nDelivery: `POST` with the JSON event body, headers `X-Lojiq-Signature-256` (`t=<unix>,v1=<hex hmac>`),\n`X-Lojiq-Event`, `X-Lojiq-Event-Id`, `X-Lojiq-Delivery-Attempt`, `User-Agent: lojiq-webhooks/1.0`.\nYour endpoint has 10 seconds to answer `2xx`. Redirects are not followed and count as failures.\nA failed delivery is retried after 30 s, 1 m, 5 m, 30 m, 2 h, 6 h and 12 h (8 attempts in all, about\n20 hours), then marked dead-letter; after 25 consecutive failures the subscription is disabled with a\n`disabled_reason`.\n",
        "x-scope": "webhooks:manage",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookSubscriptionCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created — includes the one-time `secret`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookSubscriptionCreated"
                }
              }
            }
          },
          "400": {
            "description": "`event_types_required`, `unknown_event_type:<type>`, `unknown_field:<name>` (only `name`, `target_url`,\n`event_types` are accepted), or a URL safety code: `malformed_url`,\n`invalid_protocol`, `https_required_in_production`, `userinfo_not_allowed`, `missing_host`,\n`private_hostname`, `private_ipv4`, `private_ipv6`, `dns_lookup_failed`, `dns_no_records`,\n`private_resolved_ipv4`, `private_resolved_ipv6`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "https_required_in_production"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "callbacks": {
          "eventDelivery": {
            "{$request.body#/target_url}": {
              "post": {
                "summary": "Event delivery to your endpoint",
                "description": "Lojiq POSTs each matching event to your `target_url`. Answer `2xx` within 10 seconds.",
                "parameters": [
                  {
                    "name": "X-Lojiq-Signature-256",
                    "in": "header",
                    "required": true,
                    "schema": {
                      "type": "string"
                    },
                    "description": "`t=<unix seconds>,v1=<hex HMAC-SHA256 of \"<t>.<raw body>\" with your secret>`"
                  },
                  {
                    "name": "X-Lojiq-Event",
                    "in": "header",
                    "required": true,
                    "schema": {
                      "type": "string",
                      "example": "lead.created"
                    }
                  },
                  {
                    "name": "X-Lojiq-Event-Id",
                    "in": "header",
                    "required": true,
                    "schema": {
                      "type": "string",
                      "format": "uuid"
                    }
                  },
                  {
                    "name": "X-Lojiq-Delivery-Attempt",
                    "in": "header",
                    "required": true,
                    "schema": {
                      "type": "integer",
                      "example": 1
                    }
                  }
                ],
                "requestBody": {
                  "required": true,
                  "content": {
                    "application/json": {
                      "schema": {
                        "$ref": "#/components/schemas/WebhookEvent"
                      }
                    }
                  }
                },
                "responses": {
                  "2XX": {
                    "description": "Delivered. Any other status, a redirect, or a timeout schedules a retry."
                  }
                }
              }
            }
          }
        }
      }
    },
    "/webhooks/{id}": {
      "patch": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "updateWebhook",
        "summary": "Update a webhook subscription",
        "description": "Change the name, target URL or event types, or enable/disable it. Re-enabling clears the failure counter. Scope: `webhooks:manage`.",
        "x-scope": "webhooks:manage",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookSubscriptionPatch"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookSubscriptionUpdated"
                }
              }
            }
          },
          "400": {
            "description": "Same validation codes as create, plus `unknown_field:<name>` (only `name`, `target_url`, `event_types`, `enabled` are accepted).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "deleteWebhook",
        "summary": "Delete a webhook subscription",
        "description": "Stops all deliveries, including pending retries. Scope: `webhooks:manage`.",
        "x-scope": "webhooks:manage",
        "parameters": [
          {
            "$ref": "#/components/parameters/id"
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted (also `200` when the id did not exist).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ok"
                }
              }
            }
          },
          "400": {
            "description": "Storage error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/openapi.yaml": {
      "get": {
        "tags": [
          "Reference files"
        ],
        "operationId": "getOpenApiSpec",
        "summary": "OpenAPI spec (YAML)",
        "description": "The OpenAPI 3.0 document served by the API itself. No authentication.",
        "security": [],
        "responses": {
          "200": {
            "description": "`application/yaml`",
            "content": {
              "application/yaml": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "500": {
            "description": "`spec_unavailable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/getting-started.md": {
      "get": {
        "tags": [
          "Reference files"
        ],
        "operationId": "getGettingStarted",
        "summary": "Getting-started guide (Markdown)",
        "description": "The quickstart as Markdown. No authentication.",
        "security": [],
        "responses": {
          "200": {
            "description": "`text/markdown; charset=utf-8`",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "500": {
            "description": "`guide_unavailable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/sdk/voice.js": {
      "get": {
        "tags": [
          "Reference files"
        ],
        "operationId": "getVoiceSdk",
        "summary": "Browser voice SDK (ES module)",
        "description": "The Lojiq Voice SDK for joining browser calls: `import { join } from 'https://api.lojiq.ai/v1/sdk/voice.js'`.\nAlways load it from this URL — it seals the `join_url` transport behind a stable facade that will keep\nworking as Lojiq's voice infrastructure changes. Served with `Access-Control-Allow-Origin: *` and cached\nfor 5 minutes. No authentication.\n",
        "security": [],
        "responses": {
          "200": {
            "description": "`application/javascript; charset=utf-8`",
            "content": {
              "application/javascript": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "500": {
            "description": "`sdk_unavailable`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/docs": {
      "get": {
        "tags": [
          "Reference files"
        ],
        "operationId": "getRedocPage",
        "summary": "Built-in reference page",
        "description": "A minimal Redoc page rendering `/openapi.yaml`. The full documentation lives at https://docs.lojiq.ai. No authentication.",
        "security": [],
        "responses": {
          "200": {
            "description": "`text/html`",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    }
  }
}
