{"openapi":"3.1.0","info":{"title":"Hawfinch API","description":"Typed surface for the Hawfinch lead distribution platform. Additive only within v1: fields may be added, never removed or retyped, because shipped mobile clients cannot be forced to upgrade.","license":{"name":""},"version":"1.0.0"},"paths":{"/api/v1/alerts":{"get":{"tags":["alerts"],"summary":"Alert rules for this tenant.","operationId":"list_alerts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertListResponse"}}}}}},"post":{"tags":["alerts"],"operationId":"create_alert","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAlertBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertWriteResponse"}}}},"400":{"description":"Unknown event type","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Not allowed to edit alerts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/alerts/{id}":{"put":{"tags":["alerts"],"operationId":"update_alert","parameters":[{"name":"id","in":"path","description":"Alert id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAlertBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertWriteResponse"}}}},"404":{"description":"No such alert for this user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"tags":["alerts"],"operationId":"delete_alert","parameters":[{"name":"id","in":"path","description":"Alert id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"404":{"description":"No such alert for this user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/audit-log":{"get":{"tags":["audit"],"summary":"Recorded actions, newest first.","operationId":"list_audit_log","parameters":[{"name":"from","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"to","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"user_email","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"action","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"resource_type","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"http_method","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"status_class","in":"query","description":"Comma-separated status classes: `2xx`, `4xx`, `5xx`.","required":false,"schema":{"type":["string","null"]}},{"name":"q","in":"query","description":"Free-text search across path, resource id and user.","required":false,"schema":{"type":["string","null"]}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int32","minimum":0}},{"name":"before","in":"query","description":"Cursor from a previous response's `next_before`.","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditLogListResponse"}}}}}}},"/api/v1/audit-log/facets":{"get":{"tags":["audit"],"summary":"The values actually present in the log, for filter dropdowns.","operationId":"audit_facets","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditFacetsResponse"}}}}}}},"/api/v1/campaigns":{"get":{"tags":["campaigns"],"summary":"Campaigns visible to the caller, for filter dropdowns.","operationId":"list_campaigns","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignListResponse"}}}}}},"post":{"tags":["campaigns"],"summary":"Create a campaign.","description":"Everything else about a campaign is edited after it exists. A new one has no\ndestinations, so it will accept leads and deliver none until it is given\nsomewhere to send them — which the campaigns list flags.","operationId":"create_campaign","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCampaignBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCampaignResponse"}}}},"400":{"description":"Name missing or blank","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"403":{"description":"Caller lacks the edit_campaigns permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/campaigns/{id}":{"get":{"tags":["campaigns"],"summary":"Campaign with its routing tiers, the destinations each reaches, and the\nterms of each link.","description":"The whole tree in one query set, rather than the Alpine app's pattern of a\ndetail call plus per-group follow-ups. Ordering matches the engine: groups\nby `priority` ascending, then links by `priority` ascending.","operationId":"get_campaign","parameters":[{"name":"id","in":"path","description":"Campaign id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignDetailResponse"}}}},"404":{"description":"No such campaign in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"put":{"tags":["campaigns"],"summary":"Edit a campaign's name, description, state and settings.","operationId":"update_campaign","parameters":[{"name":"id","in":"path","description":"Campaign id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCampaignBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCampaignResponse"}}}},"403":{"description":"Caller lacks the edit_campaigns permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such campaign in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/campaigns/{id}/api-key":{"post":{"tags":["campaigns"],"summary":"Generate a new ingestion key, invalidating the old one immediately.","operationId":"reset_campaign_api_key","parameters":[{"name":"id","in":"path","description":"Campaign id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResetApiKeyResponse"}}}},"403":{"description":"Caller lacks the edit_campaigns permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such campaign in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/campaigns/{id}/fields":{"put":{"tags":["campaigns"],"summary":"Replace a campaign's custom field definitions.","description":"Separate from the settings endpoint because the storage is shared: `fields`\nlives inside the same settings blob, and the backend merges `fields` in\non its own rather than as part of the settings object. One writer per\nconcept is the point.","operationId":"update_campaign_fields","parameters":[{"name":"id","in":"path","description":"Campaign id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCampaignFieldsBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCampaignResponse"}}}},"403":{"description":"Caller lacks the edit_campaigns permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such campaign in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/campaigns/{id}/groups":{"post":{"tags":["campaigns"],"summary":"Create a routing group on a campaign.","operationId":"create_route_group","parameters":[{"name":"id","in":"path","description":"Campaign id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRouteGroupBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRouteGroupResponse"}}}},"403":{"description":"Caller lacks the edit_destinations permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such campaign in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/campaigns/{id}/groups/{group_id}":{"put":{"tags":["campaigns"],"summary":"Edit a routing group.","description":"The campaign id in the path is not decoration: the group is verified to\nbelong to it before anything is written. Account scoping alone would let a\ncorrect group id under the wrong campaign path silently edit the other\ncampaign's routing.","operationId":"update_route_group","parameters":[{"name":"id","in":"path","description":"Campaign id","required":true,"schema":{"type":"string"}},{"name":"group_id","in":"path","description":"Route group id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRouteGroupBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRouteGroupResponse"}}}},"403":{"description":"Caller lacks the edit_destinations permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such group on that campaign in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"tags":["campaigns"],"summary":"Delete a routing group, and with it every destination link it held.","description":"The destinations themselves survive: this removes the junction rows, not the\ndestination records.","operationId":"delete_route_group","parameters":[{"name":"id","in":"path","description":"Campaign id","required":true,"schema":{"type":"string"}},{"name":"group_id","in":"path","description":"Route group id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"403":{"description":"Caller lacks the edit_destinations permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such group on that campaign in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/campaigns/{id}/groups/{group_id}/destinations":{"post":{"tags":["campaigns"],"summary":"Link an existing destination into a group.","operationId":"link_destination","parameters":[{"name":"id","in":"path","description":"Campaign id","required":true,"schema":{"type":"string"}},{"name":"group_id","in":"path","description":"Route group id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkDestinationBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateGroupDestinationResponse"}}}},"403":{"description":"Caller lacks the edit_destinations permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such group on that campaign in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/campaigns/{id}/groups/{group_id}/destinations/{destination_id}":{"put":{"tags":["campaigns"],"summary":"Edit one destination's link into a group: its price, caps, order and filters.","description":"Scoped to the link. The shared destination row (name, endpoint, delivery\nmethod, timeout) is not touched here — see `UpdateGroupDestinationBody`.\n\nThe campaign and group in the path are both verified against the link\nbefore anything is written, so a valid destination id under the wrong group\nor campaign cannot silently edit another group's terms.","operationId":"update_group_destination","parameters":[{"name":"id","in":"path","description":"Campaign id","required":true,"schema":{"type":"string"}},{"name":"group_id","in":"path","description":"Route group id","required":true,"schema":{"type":"string"}},{"name":"destination_id","in":"path","description":"Destination id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateGroupDestinationBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateGroupDestinationResponse"}}}},"403":{"description":"Caller lacks the edit_destinations permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"That destination is not linked to that group in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"tags":["campaigns"],"summary":"Remove a destination from a group.","description":"If this was the destination's last link, the destination row itself is\ndeleted — existing engine behaviour, reported back in `destination_deleted`\nso the caller can say which of the two happened.","operationId":"unlink_destination","parameters":[{"name":"id","in":"path","description":"Campaign id","required":true,"schema":{"type":"string"}},{"name":"group_id","in":"path","description":"Route group id","required":true,"schema":{"type":"string"}},{"name":"destination_id","in":"path","description":"Destination id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnlinkDestinationResponse"}}}},"403":{"description":"Caller lacks the edit_destinations permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"That destination is not linked to that group","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/campaigns/{id}/groups/{group_id}/reset-caps":{"post":{"tags":["campaigns"],"operationId":"reset_group_caps","parameters":[{"name":"id","in":"path","description":"Campaign id","required":true,"schema":{"type":"string"}},{"name":"group_id","in":"path","description":"Group id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Counts zeroed"},"404":{"description":"No such group in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/campaigns/{id}/routing":{"get":{"tags":["campaigns"],"summary":"Where this campaign's leads actually went, and where they got stuck.","description":"Separate from `GET /campaigns/{id}` on purpose: that reads SQLite and is\ninstant, this hits ClickHouse. Keeping them apart lets the delivery screen\nrender its configuration immediately and fill in the outcomes when they\narrive, instead of blocking the whole tab on an aggregate query.","operationId":"campaign_routing","parameters":[{"name":"id","in":"path","description":"Campaign id","required":true,"schema":{"type":"string"}},{"name":"date_range","in":"query","description":"today, 7d, 30d or 90d. Defaults to 7d.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoutingOutcomesResponse"}}}},"404":{"description":"No such campaign in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/dashboard/stats":{"get":{"tags":["dashboard"],"summary":"Dashboard tiles and charts for the given period.","description":"Delegates to the same aggregation the legacy endpoint uses and reshapes the\nresult, so the two surfaces cannot report different numbers. The reshape is\nwhere legacy camelCase and its duplicate keys (`status`/`delivery_status`,\n`name`/`campaignName`, emitted twice for different clients) collapse into\none spelling.","operationId":"dashboard_stats","parameters":[{"name":"period","in":"query","description":"`today`, `7d`, `30d` or `90d`. Defaults to `7d`.","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DashboardStatsResponse"}}}}}}},"/api/v1/destinations":{"get":{"tags":["destinations"],"summary":"Destinations, transport only.","description":"Deliberately does not return price, caps, filters, schedule, dedupe or\nsuppression, even though `destinations` has columns for all of them. Those\nare terms of a *link*, not properties of an endpoint, and the engine reads\nthem from `group_destinations` — so serving them here would present dead\ncolumns as if they were live configuration. Plan D2.","operationId":"list_destinations","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DestinationListResponse"}}}}}},"post":{"tags":["destinations"],"summary":"Create a destination.","operationId":"create_destination","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertDestinationBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertDestinationResponse"}}}},"403":{"description":"Caller lacks the edit_destinations permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/destinations/test":{"post":{"tags":["destinations"],"summary":"Fire a real request at an endpoint and report what came back.","description":"A refused connection is a successful *test*, so it returns 200 with\n`ok: false` and the transport error, not a 4xx. The caller wants to know\nwhat the endpoint did, and \"it did not answer\" is an answer.","operationId":"test_destination","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestDestinationBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestDestinationResponse"}}}},"403":{"description":"Caller lacks the edit_destinations permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/destinations/{id}":{"get":{"tags":["destinations"],"summary":"One destination: where to send and how to shape the request.","operationId":"get_destination","parameters":[{"name":"id","in":"path","description":"Destination id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DestinationDetailResponse"}}}},"404":{"description":"No such destination in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"put":{"tags":["destinations"],"summary":"Update a destination: its transport and how its request is built.","description":"This is the shared row. A destination linked into several groups is edited\nonce here and every group that routes to it is affected — which is exactly\nwhy price, caps and filters are not on this endpoint.","operationId":"update_destination","parameters":[{"name":"id","in":"path","description":"Destination id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertDestinationBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertDestinationResponse"}}}},"403":{"description":"Caller lacks the edit_destinations permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such destination in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"tags":["destinations"],"summary":"Delete a destination outright, along with every group link to it.","operationId":"delete_destination","parameters":[{"name":"id","in":"path","description":"Destination id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"403":{"description":"Caller lacks the edit_destinations permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such destination in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/destinations/{id}/reset-caps":{"post":{"tags":["destinations"],"operationId":"reset_destination_caps","parameters":[{"name":"id","in":"path","description":"Destination id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Counts zeroed"},"404":{"description":"No such destination in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/device-tokens":{"post":{"tags":["alerts"],"operationId":"register_device_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterDeviceTokenBody"}}},"required":true},"responses":{"200":{"description":"Registered"},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/device-tokens/{id}":{"delete":{"tags":["alerts"],"operationId":"delete_device_token","parameters":[{"name":"id","in":"path","description":"Device token id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Removed"},"404":{"description":"No such token for this user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/entities/{kind}":{"get":{"tags":["entities"],"summary":"List buyers or suppliers.","description":"One handler for both because the tables carry identical columns. The Alpine\napp has two near-identical modals for this pair and three more of the same\nshape besides (plan D7).","operationId":"list_entities","parameters":[{"name":"kind","in":"path","description":"buyers or suppliers","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityListResponse"}}}},"404":{"description":"Unknown entity type","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"tags":["entities"],"summary":"Create a buyer or supplier.","operationId":"create_entity","parameters":[{"name":"kind","in":"path","description":"buyers or suppliers","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Entity"}}}}}}},"/api/v1/entities/{kind}/{id}":{"put":{"tags":["entities"],"summary":"Update a buyer or supplier.","operationId":"update_entity","parameters":[{"name":"kind","in":"path","description":"buyers or suppliers","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Entity id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Entity"}}}},"404":{"description":"Not found in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"tags":["entities"],"summary":"Delete a buyer or supplier.","operationId":"delete_entity","parameters":[{"name":"kind","in":"path","description":"buyers or suppliers","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Entity id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"404":{"description":"Not found in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/imports":{"get":{"tags":["imports"],"summary":"Import history, newest first.","operationId":"list_imports","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"page_size","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportListResponse"}}}}}},"post":{"tags":["imports"],"summary":"Begin importing an uploaded file.","description":"The bytes are pushed by the legacy upload endpoints, which move opaque file\ncontent and gain nothing from being typed. This is the step with a real\ncontract: which campaign, which columns map where, and which validations to\nrun.","operationId":"start_import","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartImportRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartImportResponse"}}}},"400":{"description":"Unknown campaign or bad mapping","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/imports/{id}":{"get":{"tags":["imports"],"summary":"Progress for one import job.","operationId":"get_import","parameters":[{"name":"id","in":"path","description":"Job id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportProgress"}}}},"404":{"description":"No such job in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/leads":{"get":{"tags":["leads"],"summary":"List leads, newest first.","operationId":"list_leads","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"page_size","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"campaign_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"status","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"search","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"date_from","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"date_to","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeadListResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/leads/export":{"post":{"tags":["leads"],"summary":"Export the leads matching a search as CSV.","description":"Takes the same `LeadSearchBody` as `POST /api/v1/leads/search` and runs it\nthrough the same `search_body_to_legacy` conversion, so an export cannot\ncontain different rows from the table the operator was looking at when they\npressed the button. The old app had a separate Export page with its own\ncopy of the leads filter card, which is precisely how those two drift.\n\nStreams the upstream response rather than buffering: an unbounded export of\na 6M-row table must not be held in memory first.","operationId":"export_leads","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeadSearchBody"}}},"required":true},"responses":{"200":{"description":"CSV of every matching lead","content":{"text/csv":{}}},"403":{"description":"Caller lacks the export_data permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/leads/facets":{"get":{"tags":["leads"],"summary":"Values for the source and supplier filter dropdowns.","description":"Its own endpoint rather than a corner of the search response, which is\nwhere the legacy API put it. That coupling caused two problems worth not\nrepeating: the lists were computed only on page 1, so \"show more\" silently\nreturned empty arrays that a careless client would write over good state;\nand because they rode along with every search, the shape had to be\nre-derived by each caller instead of being named once.\n\nBoth columns are `LowCardinality`, so `DISTINCT` is cheap.\n\nThe values are plain strings. The legacy response said the same thing, but\nthe mobile client typed them as `{ name }` and `{ id, name }` objects and\nrendered `s.name` — which is `undefined` on a string, so both dropdowns\nshowed a list of blank rows. Naming the shape in the schema is what stops\nthat being a matter of opinion.","operationId":"lead_facets","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeadFacetsResponse"}}}}}}},"/api/v1/leads/insights":{"post":{"tags":["leads"],"summary":"Where the value in the current lead selection is coming from.","description":"Takes the same filter body as `/api/v1/leads/search` and runs it through the\nsame `build_lead_where_clause`, so the totals here can never disagree with\nthe count on the leads page. The legacy version hand-rolled a subset of the\nfilters and silently ignored field filters, delivery statuses, value range,\nlead method, search and attempted-buyer — so the modal showed unfiltered\ntotals whenever any of those were set.","operationId":"lead_insights","parameters":[{"name":"dimension","in":"query","description":"`source`, `hour`, `day`, `campaign`, `buyer` or `trend`.","required":false,"schema":{"type":["string","null"]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeadSearchBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightsResponse"}}}}}}},"/api/v1/leads/redistribute":{"post":{"tags":["leads"],"summary":"Re-queue leads for distribution.","description":"Returns as soon as the leads are queued; the distribution worker runs after.\nThat is why there is no `sold` count in the response.","operationId":"redistribute","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedistributeBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedistributeResponse"}}}},"403":{"description":"Caller lacks the redistribute_leads permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/leads/search":{"post":{"tags":["leads"],"summary":"Search leads across every available dimension.","description":"`GET /api/v1/leads` remains for the simple cases; this is the one the leads\nscreen uses. It maps onto the same `build_lead_where_clause` the legacy\nendpoint uses, so supplier and buyer scoping, reference-list exclusion and\nevery filter's semantics are shared rather than reimplemented.","operationId":"search_leads","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeadSearchBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeadListResponse"}}}}}}},"/api/v1/leads/{id}":{"get":{"tags":["leads"],"summary":"Lead detail, delivery attempts and the raw inbound request, in one response.","description":"Replaces the legacy pair of `/api/leads/detail` + `/api/leads/distribution`,\nwhich the Alpine app renders as two mutually exclusive modals about the\nsame lead, each fetching independently (plan D7).","operationId":"get_lead","parameters":[{"name":"id","in":"path","description":"Lead id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeadDetailResponse"}}}},"404":{"description":"No such lead in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/leads/{id}/return":{"post":{"tags":["leads"],"summary":"Return a sold lead: reverse the money and give back the caps it consumed.","description":"The lead is identified by the path. Unlike the legacy endpoint this does not\naccept `campaign_id`, `route_id` or `buyer_id` from the caller: those are\nresolved from the lead's own account-scoped row, which is what stops one\ntenant driving down another's caps.","operationId":"return_lead","parameters":[{"name":"id","in":"path","description":"Lead id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReturnLeadBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReturnLeadResponse"}}}},"403":{"description":"Caller lacks the delete_leads permission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such lead in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/me/filter-tabs":{"get":{"tags":["leads"],"summary":"This user's saved filter tabs.","operationId":"get_filter_tabs","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FilterTabsResponse"}}}}}},"put":{"tags":["leads"],"summary":"Replace this user's saved filter tabs.","operationId":"put_filter_tabs","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FilterTabsResponse"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FilterTabsResponse"}}}}}}},"/api/v1/meta/enums":{"get":{"tags":["meta"],"summary":"The vocabulary this server implements: filter types and their per-type\noperator sets, alert event types, delivery and distribution methods, lead\nand delivery statuses.","description":"Clients render from this rather than hard-coding any of it.","operationId":"get_enums","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnumsResponse"}}}}}}},"/api/v1/notifications":{"get":{"tags":["alerts"],"operationId":"list_notifications","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int32"}},{"name":"page_size","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int32"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationListResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/notifications/read":{"post":{"tags":["alerts"],"operationId":"mark_all_notifications_read","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AffectedResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/reports":{"get":{"tags":["reports"],"summary":"Campaign and buyer performance over a date range.","description":"Runs the same two aggregations the legacy reports tabs use, so the numbers\ncannot disagree between the surfaces. Only the shaping differs: the legacy\nresponse nests parallel `labels`/`values` arrays, which forces every client\nto zip them back together and lets them drift out of step. Here a chart\nseries is a list of points and a table is a list of rows.","operationId":"reports","parameters":[{"name":"date_range","in":"query","description":"`today`, `7d`, `30d`, `90d`, or a named preset the server understands.","required":false,"schema":{"type":["string","null"]}},{"name":"date_from","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"date_to","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"campaign_id","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportsResponse"}}}}}}},"/api/v1/reports/entities":{"get":{"tags":["reports"],"summary":"Per-entity performance for the period.","operationId":"entity_stats","parameters":[{"name":"date_range","in":"query","description":"`today`, `7d`, `30d`, `90d`, or a named preset the server understands.","required":false,"schema":{"type":["string","null"]}},{"name":"date_from","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"date_to","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"campaign_id","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityStatsResponse"}}}}}}},"/api/v1/reports/explore":{"post":{"tags":["reports"],"summary":"Group leads by one or two dimensions and total the money.","description":"Delegates to the existing explorer so the pivot maths, the supplier scoping\nand the reference-list exclusion are the ones already in use, and reshapes\nthe result into rows that carry their own labels rather than parallel\narrays the client has to zip.","operationId":"explore","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExploreRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExploreResponse"}}}},"400":{"description":"Unknown dimension","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/reports/views":{"get":{"tags":["reports"],"summary":"This user's saved explorer configurations.","operationId":"list_report_views","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportViewListResponse"}}}}}},"post":{"tags":["reports"],"summary":"Save an explorer configuration.","operationId":"create_report_view","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateReportView"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportView"}}}}}}},"/api/v1/reports/views/{id}":{"delete":{"tags":["reports"],"summary":"Delete a saved view.","operationId":"delete_report_view","parameters":[{"name":"id","in":"path","description":"View id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"404":{"description":"Not yours, or gone","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/settings/apns":{"get":{"tags":["settings"],"summary":"Read this organisation's Apple push credentials.","description":"Handed back in full, like the phone-checking ones and for the same reason.\nBehind super admin: the `.p8` key signs pushes as your app.","operationId":"get_apns_settings","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApnsSettingsView"}}}},"403":{"description":"Not allowed to read settings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"put":{"tags":["settings"],"summary":"Change them. Written exactly as sent, since the screen loads them first.","operationId":"put_apns_settings","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApnsSettings"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApnsSettingsView"}}}},"403":{"description":"Not allowed to change settings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/settings/hlr":{"get":{"tags":["settings"],"summary":"Read this organisation's phone-checking settings.","description":"The secrets are not sent back. The screen needs to know whether a key is\nconfigured, not what it is, and returning it would put working credentials\ninto every browser that opens the page.","operationId":"get_hlr_settings","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HlrSettingsView"}}}},"403":{"description":"Not allowed to read settings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"put":{"tags":["settings"],"summary":"Change this organisation's phone-checking settings.","description":"An empty key or secret leaves the stored one alone, so the screen can save\na change to the test-mode switch without having to send the credentials\nback, and so a saved key is never cleared by accident. Sending a single\nspace clears it deliberately.","operationId":"put_hlr_settings","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HlrSettings"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HlrSettingsView"}}}},"403":{"description":"Not allowed to change settings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets":{"get":{"tags":["sheets"],"summary":"Every sheet in the account, newest first.","description":"Sheets made from an import inherit that import's campaign access, so a\nuser scoped to a subset of campaigns sees only the files they could have\nopened from the import list anyway. Files uploaded straight to the viewer\nhave no campaign, so scoped users do not see them at all.","operationId":"list_sheets","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetListResponse"}}}}}},"post":{"tags":["sheets"],"summary":"Make an uploaded import file viewable as a sheet.","description":"Returns immediately with `status: preparing`; conversion runs in the\nbackground because a 251MB file takes about five seconds and a 10M-row one\nlonger. Calling this twice for the same import returns the existing sheet\nrather than converting again.","operationId":"create_sheet","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSheetBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sheet"}}}},"400":{"description":"The file cannot be converted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such import in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}":{"get":{"tags":["sheets"],"summary":"One sheet's metadata. Poll this while `status` is `preparing`.","operationId":"get_sheet","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sheet"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"tags":["sheets"],"summary":"Forget a sheet.","description":"Removes the record, not the Parquet in object storage — deleting that needs\nS3 request signing this server does not do yet, so the key is logged rather\nthan silently abandoned. Until that lands, sheets are cheap to make and\ntheir storage is not reclaimed.","operationId":"delete_sheet","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/columns":{"put":{"tags":["sheets"],"summary":"Put the columns in a given order.","description":"The whole list, not a move: an order is a single fact, and describing it as\na series of moves invites two clients to disagree about where a column\nended up.","operationId":"reorder_sheet_columns","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReorderColumnsBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sheet"}}}},"400":{"description":"Not the same set of columns","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"tags":["sheets"],"summary":"Add a column the file does not have.","description":"It starts empty and is filled by editing, which the overlay already does —\na new column needs no space in the stored file until the sheet is saved,\nand saving writes it like any other.","operationId":"add_sheet_column","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddColumnBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sheet"}}}},"400":{"description":"Bad or duplicate name","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"tags":["sheets"],"summary":"Remove a column.","description":"From the sheet, not from the stored file — that stays as it is until the\nnext save, which simply writes the columns that remain. Anything typed into\nthe column goes with it: leaving edits behind for a column nobody can see\nwould resurrect them if the name were ever reused.","operationId":"drop_sheet_column","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DropColumnBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sheet"}}}},"400":{"description":"Unknown column, or the last one","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"patch":{"tags":["sheets"],"summary":"Rename a column.","description":"The stored file is not touched: renaming a column in five million rows\nmeans rewriting all of them to change a word. The sheet remembers which\nstored column the new name reads from, and the next save writes the new\nname for real.","operationId":"rename_sheet_column","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenameColumnBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sheet"}}}},"400":{"description":"Unknown or duplicate name","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/commit":{"post":{"tags":["sheets"],"summary":"Save every change into a new version of the file.","description":"The previous version stays in storage untouched, so this is additive: the\nsheet moves forward, and the state before it is still there.","operationId":"commit_sheet","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"base_version","in":"query","description":"Omit to skip the check, which is what a caller that has not read the\nsheet would do. Every screen in this app sends it.","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sheet"}}}},"400":{"description":"Nothing to save","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/computed":{"post":{"tags":["sheets"],"summary":"Add a column worked out from other columns.","description":"One call, because the column and the rule that fills it are the same\nthought: a computed column with no rule is an empty column nobody asked\nfor, and a rule with no column has nowhere to go.\n\nLike every other change to a column this is not written into the file until\nthe sheet is saved — the value is worked out as the file is read, so a\ncomputed column over five million rows costs a row in a table until you\ndecide to keep it.","operationId":"add_computed_column","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"base_version","in":"query","description":"Omit to skip the check, which is what a caller that has not read the\nsheet would do. Every screen in this app sends it.","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ComputedColumnBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sheet"}}}},"400":{"description":"Unknown column or operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/counts":{"post":{"tags":["sheets"],"summary":"How many rows share the values in the selected cells.","description":"The question a spreadsheet user asks by selecting a cell and looking at the\nstatus bar: is this value one of many or one of a few? Answered over the\nwhole file, not the page on screen — the page is a hundred rows out of\nmillions and its counts would mean nothing.","operationId":"count_sheet_values","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetCountBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetCounts"}}}},"400":{"description":"Unknown column, or too many cells","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/dedupe":{"post":{"tags":["sheets"],"summary":"Remove rows that repeat a value, keeping one of each.","description":"A new version, like every other change to the rows, so a deduplication\nthat removed the wrong thing is one restore away rather than gone.\n\nRows with nothing in the chosen column are all kept. They are unknown, not\nidentical, and collapsing a hundred thousand rows with no phone number into\none is never what \"remove duplicates on phone\" meant.","operationId":"dedupe_sheet","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"base_version","in":"query","description":"Omit to skip the check, which is what a caller that has not read the\nsheet would do. Every screen in this app sends it.","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DedupeBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sheet"}}}},"400":{"description":"Unknown column, or nothing to remove","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"The file has moved on, or has unsaved changes","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/delete-rows":{"post":{"tags":["sheets"],"summary":"Remove every row matching the filters, as a new version.","description":"A new version rather than a change in place, like every other edit here:\nthe version it came from is untouched, so a deletion that turns out to be\nwrong is one restore away rather than gone.","operationId":"delete_sheet_rows","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"base_version","in":"query","description":"Omit to skip the check, which is what a caller that has not read the\nsheet would do. Every screen in this app sends it.","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteRowsBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sheet"}}}},"400":{"description":"No filters, or nothing matched","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/duplicates":{"get":{"tags":["sheets"],"summary":"Values that appear more than once in a column.","operationId":"sheet_duplicates","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"col","in":"query","description":"Column to look in","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetDuplicates"}}}},"400":{"description":"Unknown column","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/edits":{"post":{"tags":["sheets"],"summary":"Record a batch of cell edits.","description":"Nothing is written to the file. Edits accumulate as an overlay that every\nread lays over the original, so the uploaded file stays exactly as it was\nand any edit can be taken back.","operationId":"edit_sheet","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetEditsBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetEditSummary"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"This sheet cannot be edited yet","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"delete":{"tags":["sheets"],"summary":"Throw away every uncommitted change — typed cells and bulk rules alike —\nreturning the sheet to the file as uploaded.","description":"Both, because the button says \"undo all changes\". Clearing only the cells\nwould leave the rules quietly in force while claiming everything was back\nto normal.","operationId":"discard_sheet_edits","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetEditSummary"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/export":{"get":{"tags":["sheets"],"summary":"Download the sheet as CSV, with every change applied.","description":"Streamed straight from the query engine rather than assembled here: a\nfive-million-row export held in memory would be hundreds of megabytes on a\nbox that is also serving live lead traffic.","operationId":"export_sheet","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"version","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"from","in":"query","description":"First row to include, by its number in the file. With `to`, this is how\na file is split between buyers.","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"to","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"filters","in":"query","description":"The view's filters, as the rows endpoint takes them.\n\nAn export is meant to be what was on screen. Without these it was the\nwhole file, so filtering to a few hundred rows and exporting handed\nback millions, and splitting a filtered file cut up the unfiltered one.","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"The sheet as CSV","content":{"text/csv":{}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/formula":{"post":{"tags":["sheets"],"summary":"Fill a column from a written formula.","description":"A name the file does not have adds a column; a name it does have replaces\nwhat that column holds. Like every other change to a column this is worked\nout as the file is read and not written into it until the sheet is saved,\nso a formula over five million rows costs one row in a table until you\ndecide to keep it, and the version before it is still there to go back to.","operationId":"add_sheet_formula","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"base_version","in":"query","description":"Omit to skip the check, which is what a caller that has not read the\nsheet would do. Every screen in this app sends it.","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FormulaColumnBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sheet"}}}},"400":{"description":"The formula does not make sense here","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/formula/check":{"post":{"tags":["sheets"],"summary":"Check a formula without saving it.","description":"Its own endpoint so the box someone is typing into can say what is wrong\nwhile they type. Answering 200 with `ok: false` rather than a 400 is\ndeliberate: a half-typed formula is the normal state of this endpoint, not\na failed request, and a stream of 400s in the log would say otherwise.","operationId":"check_sheet_formula","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FormulaColumnBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FormulaCheck"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/history":{"get":{"tags":["sheets"],"operationId":"get_sheet_history","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetHistory"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/import":{"post":{"tags":["sheets"],"summary":"Import a sheet's current contents into a campaign.","description":"The sheet is written to a file first, with edits and bulk rules applied,\nand the ordinary import runs on that. Importing the uploaded file by its\nkey would be quicker and would quietly import the data as it arrived rather\nthan as the user has it — the difference being invisible until the leads\nwere already in a campaign.","operationId":"import_sheet","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetImportBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetJobStarted"}}}},"404":{"description":"No such sheet or campaign","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"The sheet is not ready","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/match-leads":{"post":{"tags":["sheets"],"summary":"Find the leads whose postcode appears in a column of this file.","description":"The answer is a new sheet rather than a filter on this one, because the\nrows it holds are leads, not the file's rows: one postcode can match\nseveral leads and most match none. Written as a sheet so the tools already\nhere — filter, dedupe, export — work on the result.\n\nConsent counts come back alongside the total deliberately. On this data\nonly about one lead in eleven records marketing consent, so a caller that\nreported \"4,000 leads matched\" and stopped there would be describing a\ncontact list that mostly cannot be contacted.","operationId":"match_leads","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MatchLeadsBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MatchLeadsResponse"}}}},"400":{"description":"No such column, or nothing matched","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/restore":{"post":{"tags":["sheets"],"summary":"Make an older version current again.","description":"The old file is not copied. A restore is a new version that reads the file\nan earlier one already points at, so restoring a five-million-row version\ncosts nothing but a row — and the version it was restored from stays\nexactly where it was, since nothing is overwritten.","operationId":"restore_sheet_version","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"base_version","in":"query","description":"Omit to skip the check, which is what a caller that has not read the\nsheet would do. Every screen in this app sends it.","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestoreVersionBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sheet"}}}},"400":{"description":"Already the current version","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet or version","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/rows":{"get":{"tags":["sheets"],"summary":"A page of rows, read straight from object storage.","operationId":"get_sheet_rows","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"offset","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"version","in":"query","description":"Which version to read. Defaults to the current one.","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"sort","in":"query","description":"Column to sort by. Absent means the file's own order.","required":false,"schema":{"type":["string","null"]}},{"name":"dir","in":"query","description":"`desc` to reverse the sort; anything else is ascending.","required":false,"schema":{"type":["string","null"]}},{"name":"filters","in":"query","description":"Filters, as a JSON array of `{col, op, value}`. A query string is a\npoor place for structured data, but the alternative — a POST to read a\npage — would make every scroll uncacheable.\n\nOperators: `eq`, `ne`, `contains`, `starts`, `empty`, `not_empty`.","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetPage"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"The sheet is not ready yet","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/stats":{"get":{"tags":["sheets"],"summary":"What each column holds, for the ones that hold numbers.","operationId":"sheet_stats","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"filters","in":"query","description":"Same filters the rows endpoint takes","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetStats"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/summary":{"get":{"tags":["sheets"],"summary":"What is in a column, and how much of each.","operationId":"sheet_summary","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"col","in":"query","description":"Column to summarise","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetSummary"}}}},"400":{"description":"Unknown column","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/suppress":{"post":{"tags":["sheets"],"summary":"Remove rows whose value appears in another file.","operationId":"suppress_sheet","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"base_version","in":"query","description":"Omit to skip the check, which is what a caller that has not read the\nsheet would do. Every screen in this app sends it.","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuppressBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sheet"}}}},"400":{"description":"Unknown column, or nothing matched","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"409":{"description":"The file has moved on, or has unsaved changes","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/transforms":{"get":{"tags":["sheets"],"summary":"Every bulk rule on a sheet.","operationId":"get_sheet_transforms","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetTransformList"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}},"post":{"tags":["sheets"],"summary":"Apply a bulk change to one column.","description":"Stored as a rule, not as edited cells: \"replace every X with Y\" across five\nmillion rows is one row here and applies in the same query that reads the\nfile. As a per-cell overlay it would be five million rows to write, store\nand merge.","operationId":"add_sheet_transform","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"base_version","in":"query","description":"Omit to skip the check, which is what a caller that has not read the\nsheet would do. Every screen in this app sends it.","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetTransformBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetTransformList"}}}},"400":{"description":"Unknown column or operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/transforms/{transform_id}":{"delete":{"tags":["sheets"],"summary":"Take a bulk rule back off.","operationId":"delete_sheet_transform","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}},{"name":"transform_id","in":"path","description":"Rule id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetTransformList"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/sheets/{id}/verify":{"post":{"tags":["sheets"],"summary":"Check a column of phone numbers against the mobile networks.","description":"Same shape as import: the sheet is written out as it currently reads, and\nthe ordinary verify job runs on that file. Each lookup costs money, so the\ncolumn is named explicitly rather than guessed.","operationId":"verify_sheet_column","parameters":[{"name":"id","in":"path","description":"Sheet id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetVerifyBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SheetJobStarted"}}}},"400":{"description":"No such column","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}},"404":{"description":"No such sheet in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}},"/api/v1/verify":{"get":{"tags":["verify"],"summary":"Past verification runs, newest first.","operationId":"list_verify_jobs","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"page_size","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyJobListResponse"}}}}}}},"/api/v1/verify/{id}":{"get":{"tags":["verify"],"summary":"One verification run, for polling progress while it works.","operationId":"get_verify_job","parameters":[{"name":"id","in":"path","description":"Verify job id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyJob"}}}},"404":{"description":"No such job in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}}}}}}},"components":{"schemas":{"AddColumnBody":{"type":"object","description":"Add a column that the file does not have.","required":["name"],"properties":{"name":{"type":"string"}}},"AffectedResponse":{"type":"object","description":"How many rows an action affected.","required":["affected"],"properties":{"affected":{"type":"integer","format":"int64"}}},"Alert":{"type":"object","description":"An alert rule.\n\n`event_type` is validated server-side against the same table\n`/api/v1/meta/enums` serves, so a client rendering its dropdown from the\nenums endpoint structurally cannot offer a value that will 400 on save.","required":["id","name","event_type","enabled","notify_push","notify_in_app","campaign_ids","new_lead_scope"],"properties":{"campaign_ids":{"type":"array","items":{"type":"string"},"description":"Empty means every campaign."},"enabled":{"type":"boolean"},"event_type":{"type":"string"},"id":{"type":"string"},"last_fired_at":{"type":["string","null"]},"name":{"type":"string"},"new_lead_scope":{"type":"string","description":"Which leads a `new_lead` rule fires on: `all`, `not_duplicate` or\n`sold`. Ignored by every other event type.\n\nA duplicate is not a new lead, which is why `not_duplicate` is the\ndefault — but a duplicate caught by campaign dedupe used to alert\nnobody while one caught during routing did, so this also exists to\nmake that a decision rather than an accident."},"notify_in_app":{"type":"boolean"},"notify_push":{"type":"boolean"},"threshold_count":{"type":["integer","null"],"format":"int64","description":"Revenue target, used by `daily_revenue_target`."},"threshold_pct":{"type":["number","null"],"format":"double","description":"Percentage threshold. Used by `buyer_cap_approaching` and\n`delivery_failure_rate`; the enums endpoint says which types read it."},"window_minutes":{"type":["integer","null"],"format":"int64"}}},"AlertEventType":{"type":"object","required":["value","label","description","has_threshold","has_window","has_revenue_target"],"properties":{"description":{"type":"string"},"has_revenue_target":{"type":"boolean"},"has_threshold":{"type":"boolean"},"has_window":{"type":"boolean"},"label":{"type":"string"},"value":{"type":"string"}}},"AlertListResponse":{"type":"object","required":["alerts"],"properties":{"alerts":{"type":"array","items":{"$ref":"#/components/schemas/Alert"}}}},"AlertWriteResponse":{"type":"object","description":"The rule that was written.","required":["id"],"properties":{"id":{"type":"string"}}},"ApnsSettings":{"type":"object","description":"Apple's push notification credentials for this organisation.\n\nRead from `tenant_configs` on this server, like the phone-checking ones.","properties":{"bundle_id":{"type":"string"},"key_id":{"type":"string"},"p8_key":{"type":"string","description":"The .p8 private key, in full. Long, multi-line, and secret."},"team_id":{"type":"string"}}},"ApnsSettingsView":{"type":"object","description":"What the settings screen sends and receives for Apple push credentials.\n\nRead from `tenant_configs` on this server, like the phone-checking ones,\nand handed back in full for the same reason: an administrator should be\nable to read the key they have rather than only be told one exists.","required":["key_id","team_id","p8_key","bundle_id","configured"],"properties":{"bundle_id":{"type":"string"},"configured":{"type":"boolean"},"key_id":{"type":"string"},"p8_key":{"type":"string"},"team_id":{"type":"string"}}},"AuditFacetsResponse":{"type":"object","description":"The values actually present in the log, for building filter dropdowns.\n\nServed rather than hard-coded for the same reason as `/meta/enums`: a\nclient listing actions it believes exist offers filters that match nothing\nand omits ones that do.","required":["actions","users","resource_types","methods"],"properties":{"actions":{"type":"array","items":{"$ref":"#/components/schemas/FacetValue"}},"methods":{"type":"array","items":{"$ref":"#/components/schemas/FacetValue"}},"resource_types":{"type":"array","items":{"$ref":"#/components/schemas/FacetValue"}},"users":{"type":"array","items":{"$ref":"#/components/schemas/FacetValue"}}}},"AuditLogEntry":{"type":"object","description":"One recorded action: who did what, to which record, and what came back.","required":["id","created_at","user_email","user_role","action","resource_type","resource_id","http_method","http_path","http_status","duration_ms","ip_address"],"properties":{"action":{"type":"string","description":"The domain action, e.g. `update_campaign`. Filterable; the available\nvalues are served by the facets endpoint rather than listed by clients."},"created_at":{"type":"string"},"details":{"description":"What actually changed, as the audit middleware recorded it. Shape varies\nby action, so it is passed through rather than modelled: a diff for an\nupdate, a payload for a create, absent for a read."},"duration_ms":{"type":"integer","format":"int64"},"http_method":{"type":"string"},"http_path":{"type":"string"},"http_status":{"type":"integer","format":"int64"},"id":{"type":"string"},"ip_address":{"type":"string"},"resource_id":{"type":"string"},"resource_type":{"type":"string"},"user_email":{"type":"string"},"user_role":{"type":"string"}}},"AuditLogListResponse":{"type":"object","required":["logs","total"],"properties":{"logs":{"type":"array","items":{"$ref":"#/components/schemas/AuditLogEntry"}},"next_before":{"type":["string","null"],"description":"Cursor for the next page: pass as `before`. Null when the last page has\nbeen reached. Preferred over an offset because OFFSET gets expensive on\nClickHouse as the log grows."},"total":{"type":"integer","format":"int64"}}},"BuyerPerformanceRow":{"type":"object","description":"One buyer's performance over the selected range.\n\n`payout` is emitted as 0.0 by the underlying report today. Kept in the\ncontract because the column is real and the value is expected to become\nmeaningful; the UI renders it as absent rather than as £0.00 earned.","required":["buyer_name","total_leads","delivered","failed","total_revenue","payout","price","success_rate"],"properties":{"buyer_name":{"type":"string"},"delivered":{"type":"integer","format":"int64"},"failed":{"type":"integer","format":"int64"},"payout":{"type":"number","format":"double"},"price":{"type":"number","format":"double","description":"Average revenue per delivered lead."},"success_rate":{"type":"number","format":"double"},"total_leads":{"type":"integer","format":"int64"},"total_revenue":{"type":"number","format":"double"}}},"CampaignDetailResponse":{"type":"object","required":["id","name","description","active","is_reference_list","hash_format","settings","fields","groups"],"properties":{"active":{"type":"boolean"},"api_key":{"type":["string","null"],"description":"The campaign's ingestion key.\n\nOnly populated for callers who may edit campaigns. It is the credential\na supplier posts leads with, so a read-only or supplier-scoped account\nhas no business seeing it; `null` here means \"not shown to you\" rather\nthan \"not set\"."},"description":{"type":"string"},"fields":{"type":"array","items":{"$ref":"#/components/schemas/CampaignField"},"description":"Custom fields captured on inbound leads."},"groups":{"type":"array","items":{"$ref":"#/components/schemas/RouteGroup"},"description":"Ordered tiers. The engine walks these by `priority` ascending."},"hash_format":{"type":"string","description":"`plain`, `md5` or `sha256` — how this campaign's values were hashed\nbefore they arrived. Only meaningful when it is used as a suppression\nlist; see `services/suppression.rs`."},"id":{"type":"string"},"is_reference_list":{"type":"boolean"},"name":{"type":"string"},"settings":{"$ref":"#/components/schemas/CampaignSettings"}}},"CampaignField":{"type":"object","description":"One custom field this campaign captures on inbound leads.","required":["name"],"properties":{"name":{"type":"string"},"required":{"type":"boolean"},"source_path":{"type":"string","description":"Where to read the value from in a nested payload. Empty means top level."},"type":{"type":"string","description":"`text`, `number`, `boolean`, `email`, `phone` or `date`."},"validation":{"type":"string"}}},"CampaignListResponse":{"type":"object","required":["campaigns"],"properties":{"campaigns":{"type":"array","items":{"$ref":"#/components/schemas/CampaignSummary"}}}},"CampaignPerformanceRow":{"type":"object","description":"One campaign's performance over the selected range.","required":["funnel","campaign_id","campaign_name","total_leads","total_revenue","total_payout","avg_revenue","delivery_rate"],"properties":{"avg_revenue":{"type":"number","format":"double"},"campaign_id":{"type":"string"},"campaign_name":{"type":"string"},"delivery_rate":{"type":"number","format":"double","description":"Percentage of this campaign's leads that reached a buyer."},"funnel":{"type":"array","items":{"$ref":"#/components/schemas/StatusCount"},"description":"Where this campaign's leads actually ended up, by delivery status.\n\nThe counts behind `delivery_rate`, kept rather than collapsed into it.\nA single rate says 41% got through; this says 1,853 were duplicates and\n7,442 were turned away by filters, which is the difference between\nknowing there is a problem and knowing where it is."},"total_leads":{"type":"integer","format":"int64"},"total_payout":{"type":"number","format":"double"},"total_revenue":{"type":"number","format":"double"}}},"CampaignSettings":{"type":"object","description":"Campaign-level settings, as the API exposes them.\n\nThe stored JSON is inconsistently cased — `maxAttempts` and `defaultDelay`\nare camelCase while `dedupe_enabled` and friends are snake_case — because it\nwas written straight from an older UI. The v1 surface is snake_case\nthroughout and the handler does the mapping, so a storage quirk does not\nbecome part of the contract every client then has to know.\n\n`None` means \"not configured\", which the engine reads as its own default.\nSending a field sets it; omitting it leaves the stored value alone.","properties":{"dedupe_enabled":{"type":["boolean","null"]},"dedupe_lookback_days":{"type":["integer","null"],"format":"int64","description":"How far back the duplicate check looks."},"default_delay":{"type":["integer","null"],"format":"int64","description":"Seconds between attempts when a group does not set its own delay."},"max_attempts":{"type":["integer","null"],"format":"int64","description":"Delivery attempts a lead gets before it is given up on."},"require_api_key":{"type":["boolean","null"],"description":"When true, a submission without a valid key is rejected."}}},"CampaignSummary":{"type":"object","required":["id","name","active","is_reference_list","hash_format","group_count","destination_count"],"properties":{"active":{"type":"boolean"},"destination_count":{"type":"integer","format":"int64","description":"Destination links across all of this campaign's groups."},"group_count":{"type":"integer","format":"int64","description":"Routing tiers on this campaign. Zero means nothing will be delivered,\nwhich is worth seeing in a list rather than only on the detail screen."},"hash_format":{"type":"string","description":"How this campaign's values were hashed before they arrived: `plain`,\n`md5` or `sha256`. Only meaningful when it is used as a suppression\nlist, and served here so the picker can say which lists are hashed —\nchoosing a hashed one changes what the engine compares."},"id":{"type":"string"},"is_reference_list":{"type":"boolean","description":"Reference lists hold match values rather than leads, and are excluded\nfrom reporting. Served so the UI can label or hide them."},"name":{"type":"string"}}},"Caps":{"type":"object","description":"Caps and their current counts, for a group or a link.","required":["daily_cap","weekly_cap","monthly_cap","daily_count","weekly_count","monthly_count"],"properties":{"daily_cap":{"type":"integer","format":"int64"},"daily_count":{"type":"integer","format":"int64"},"monthly_cap":{"type":"integer","format":"int64"},"monthly_count":{"type":"integer","format":"int64"},"weekly_cap":{"type":"integer","format":"int64"},"weekly_count":{"type":"integer","format":"int64"}}},"ComputedColumnBody":{"type":"object","description":"Add a column worked out from other columns.","required":["name","kind","sources"],"properties":{"amount":{"type":["string","null"],"description":"The count or position, for the operations that take one."},"find":{"type":["string","null"],"description":"The separator, for the operations that split on one."},"kind":{"type":"string","description":"`combine`, `part`, `before`, `after`, `left`, `right`, `phone_uk` or\n`domain`."},"name":{"type":"string","description":"What to call it."},"sources":{"type":"array","items":{"type":"string"},"description":"Columns it reads from. `combine` takes several; the rest take one."}}},"ConfigPair":{"type":"object","description":"One key/value pair sent with every delivery.\n\nValues interpolate `{{lead_field}}` placeholders — the live ESB integration\nis eighteen of these, carrying the whole lead in the query string.","required":["key","value"],"properties":{"key":{"type":"string"},"value":{"type":"string"}}},"CreateAlertBody":{"type":"object","description":"A new alert rule.\n\nDeliberately not the legacy body. That one accepts `campaign_id` as well as\n`campaign_ids`, and either as a string or a number, because it grew around\nseveral callers; a typed surface gets one shape and the handler maps it.","required":["name","event_type"],"properties":{"campaign_ids":{"type":"array","items":{"type":"string"},"description":"Campaigns this rule watches. Empty means every campaign."},"enabled":{"type":"boolean"},"event_type":{"type":"string","description":"One of `GET /api/v1/meta/enums` → `alert_event_types`."},"name":{"type":"string"},"new_lead_scope":{"type":["string","null"],"description":"`all`, `not_duplicate` or `sold`. Only used by `new_lead`."},"notify_in_app":{"type":"boolean"},"notify_push":{"type":"boolean"},"threshold_count":{"type":["integer","null"],"format":"int64","description":"Absolute threshold, used by `daily_revenue_target` as a £ figure."},"threshold_pct":{"type":["number","null"],"format":"double","description":"Percentage threshold, for the rules that use one."},"window_minutes":{"type":["integer","null"],"format":"int64","description":"Window in minutes for `campaign_no_leads` and `delivery_failure_rate`."}}},"CreateCampaignBody":{"type":"object","description":"Create a campaign.\n\nName only, deliberately. Routing, custom fields, dedupe and suppression are\nall edited on the campaign once it exists, and asking for them up front\nwould put a wall in front of the one thing this is for. A new campaign has\nno destinations, so it accepts leads and delivers nothing until it is\ngiven somewhere to send them.","required":["name"],"properties":{"active":{"type":["boolean","null"],"description":"Defaults to active: a campaign made now is usually wanted now."},"description":{"type":["string","null"]},"is_reference_list":{"type":["boolean","null"],"description":"A reference list holds match values for the `campaign_match` filter, not\nreal leads, so its rows are kept out of dashboards and reports."},"name":{"type":"string"}}},"CreateCampaignResponse":{"type":"object","description":"A newly created campaign.","required":["campaign_id","api_key"],"properties":{"api_key":{"type":"string","description":"The ingestion key suppliers post to. Also readable later, so this is a\nconvenience rather than the only chance to see it."},"campaign_id":{"type":"string"}}},"CreateReportView":{"type":"object","required":["name","config"],"properties":{"config":{},"name":{"type":"string"}}},"CreateRouteGroupBody":{"type":"object","description":"Create a routing group on a campaign.","required":["name"],"properties":{"delay_seconds":{"type":["integer","null"],"format":"int32"},"delivery_count":{"type":["integer","null"],"format":"int32"},"distribution_method":{"type":["string","null"],"description":"One of the `distribution_methods` served by `GET /api/v1/meta/enums`."},"filters_enabled":{"type":["boolean","null"]},"group_daily_cap":{"type":["integer","null"],"format":"int32"},"group_monthly_cap":{"type":["integer","null"],"format":"int32"},"group_weekly_cap":{"type":["integer","null"],"format":"int32"},"group_weight":{"type":["integer","null"],"format":"int32"},"name":{"type":"string"},"priority":{"type":["integer","null"],"format":"int32","description":"Lower runs first. Defaults to the end of the existing tiers."},"smart_filters":{"type":["array","null"],"items":{},"description":"Shape per filter type; vocabulary served by `GET /api/v1/meta/enums`."}}},"CreateRouteGroupResponse":{"type":"object","required":["group_id"],"properties":{"group_id":{"type":"string"}}},"CreateSheetBody":{"type":"object","description":"Make a sheet from an already-uploaded import file.","properties":{"file_key":{"type":["string","null"],"description":"A file uploaded straight to the viewer, not through an import. This is\nthe key returned by the upload endpoints, and must belong to the\ncaller's tenant — it is checked, not trusted."},"file_name":{"type":["string","null"]},"import_job_id":{"type":["string","null"],"description":"Make an existing import viewable. Mutually exclusive with `file_key`."}}},"DashboardStatsResponse":{"type":"object","required":["total_leads","qualified_leads","total_revenue","conversion_rate","active_campaigns","leads_change","revenue_change","conversion_change","lead_performance","revenue_performance","lead_sources","delivery_breakdown","hourly_distribution","top_campaigns"],"properties":{"active_campaigns":{"type":"integer","format":"int64"},"conversion_change":{"type":"number","format":"double"},"conversion_rate":{"type":"number","format":"double","description":"Percentage, one decimal place."},"delivery_breakdown":{"type":"array","items":{"$ref":"#/components/schemas/NamedCount"}},"hourly_distribution":{"type":"array","items":{"$ref":"#/components/schemas/HourlyPoint"}},"lead_performance":{"type":"array","items":{"$ref":"#/components/schemas/TimeSeriesPoint"}},"lead_sources":{"type":"array","items":{"$ref":"#/components/schemas/NamedCount"}},"leads_change":{"type":"number","format":"double","description":"Percentage change against the immediately preceding window of equal\nlength. Positive means up."},"qualified_leads":{"type":"integer","format":"int64"},"revenue_change":{"type":"number","format":"double"},"revenue_performance":{"type":"array","items":{"$ref":"#/components/schemas/RevenuePoint"}},"top_campaigns":{"type":"array","items":{"$ref":"#/components/schemas/TopCampaign"}},"total_leads":{"type":"integer","format":"int64"},"total_revenue":{"type":"number","format":"double"}}},"DedupeBody":{"type":"object","description":"Remove rows that repeat a value.","required":["cols"],"properties":{"cols":{"type":"array","items":{"type":"string"},"description":"Columns that together identify a duplicate. Two rows are duplicates\nwhen all of these match."},"keep_last":{"type":"boolean","description":"Keep the last occurrence rather than the first."}}},"DeleteRowsBody":{"type":"object","description":"Remove every row matching the filters.","required":["filters"],"properties":{"filters":{"type":"array","items":{"$ref":"#/components/schemas/SheetFilterSpec"},"description":"The same shape the rows endpoint takes. Required and non-empty: an\nabsent filter would mean deleting the file."},"keep_matching":{"type":"boolean","description":"Keep the rows that match and remove the rest, rather than the other way\nround.\n\nFiltering is used both ways: to find rows to get rid of, and to whittle\na file down to the rows worth keeping. The second is the one where the\nfilters on screen describe what should survive, and inverting each of\nthem by hand to say so is both tedious and easy to get wrong — the\nopposite of \"not empty and not no\" is not two more filters, it is a\ndifferent shape of question."}}},"Destination":{"type":"object","description":"A destination: *where to send*, and nothing else.\n\nPlan D2 observes that `destinations` and `group_destinations` are the same\ntable twice — 21 columns exist on both and are resolved by COALESCE at query\ntime. Only 9 are genuinely destination-level, and this serves those: the\nendpoint, the method, the transport settings.\n\nEverything commercial and behavioural — price, caps, filters, schedule,\ndedupe, suppression — is a property of *this group selling to that\ndestination*, so it lives on the link and appears in campaign detail. Making\nthat split visible in the API is the cheapest way to demonstrate the D2\ndecision without migrating anything yet.","required":["id","name","buyer_id","buyer_name","endpoint_url","ping_endpoint","post_endpoint","delivery_method","method","timeout_ms","active","link_count"],"properties":{"active":{"type":"boolean"},"buyer_id":{"type":"string"},"buyer_name":{"type":"string"},"delivery_method":{"type":"string","description":"`direct` or `ping_post`."},"endpoint_url":{"type":"string"},"id":{"type":"string"},"link_count":{"type":"integer","format":"int64","description":"How many group links point at this destination. Zero means it is\nconfigured but unreachable — no campaign can send to it."},"method":{"type":"string","description":"HTTP verb."},"name":{"type":"string"},"ping_endpoint":{"type":"string"},"post_endpoint":{"type":"string"},"timeout_ms":{"type":"integer","format":"int64"}}},"DestinationConfig":{"type":"object","description":"How a destination's request is assembled.\n\nOnly `query_params` and `headers` are modelled. The stored `config` blob can\nalso hold `fieldMappings`, `staticFields`, `bodyTemplate`, `responseMappings`,\n`statusMappings`, `retries` and `retryDelay`, but **no destination on the\nsystem uses any of them** — every configured integration is query-string\nbased. Unmodelled keys are preserved on save rather than dropped, so an\nintegration built elsewhere is not silently destroyed by an edit here.","properties":{"headers":{"type":"array","items":{"$ref":"#/components/schemas/ConfigPair"}},"query_params":{"type":"array","items":{"$ref":"#/components/schemas/ConfigPair"}}}},"DestinationDetailResponse":{"type":"object","description":"A destination as its own entity: where to send, and how to shape the request.\n\nPrice, caps, filters and schedule are **not** here. Those are terms of one\ncampaign group sending to this destination and live on the link, because a\ndestination can be linked into several groups on different terms.","required":["id","name","buyer_id","buyer_name","delivery_method","method","endpoint_url","ping_endpoint","post_endpoint","timeout_ms","active","link_count","config"],"properties":{"active":{"type":"boolean"},"buyer_id":{"type":"string"},"buyer_name":{"type":"string"},"config":{"$ref":"#/components/schemas/DestinationConfig"},"delivery_method":{"type":"string"},"endpoint_url":{"type":"string"},"id":{"type":"string"},"link_count":{"type":"integer","format":"int64","description":"How many groups route to this destination. Zero means nothing can ever\nreach it; more than one means edits here affect several campaigns."},"method":{"type":"string"},"name":{"type":"string"},"ping_endpoint":{"type":"string"},"post_endpoint":{"type":"string"},"timeout_ms":{"type":"integer","format":"int64"}}},"DestinationListResponse":{"type":"object","required":["destinations"],"properties":{"destinations":{"type":"array","items":{"$ref":"#/components/schemas/Destination"}}}},"DistributionAttempt":{"type":"object","description":"One step in a lead's distribution timeline.\n\nRead from `leads.distribution_data.attempts`, not from the `group_attempts`\ntable. That distinction matters: `group_attempts` only records attempts that\nactually reached a destination, so a lead screened out earlier — by a group\nfilter, or as a duplicate — has no rows there at all. Over a recent two-day\nwindow, 750 of the leads with zero `group_attempts` rows were `filtered` or\n`duplicate`, and reading that table alone renders them as \"no delivery\nattempts recorded\", which says nothing about why.\n\n`distribution_data` records those outcomes inline with the reason attached,\nwhich is what the Alpine timeline has always shown and what this now serves.","required":["result","response_time_ms"],"properties":{"buyer_id":{"type":["string","null"]},"buyer_name":{"type":["string","null"]},"delivery_method":{"type":["string","null"]},"error_message":{"type":["string","null"]},"filter_reason":{"type":["string","null"],"description":"Why a filter turned the lead away, in words, e.g.\n`Field filter failed: marketing_consent_email equals \"true\" — lead had \"false\"`."},"group_id":{"type":["string","null"]},"group_name":{"type":["string","null"]},"price":{"type":["number","null"],"format":"double","description":"Price this attempt was made at. Written as `buying_price`; nullable\nbecause attempts recorded before commit c8e0cef did not store one."},"response_body":{"type":["string","null"]},"response_time_ms":{"type":"integer","format":"int64"},"result":{"type":"string","description":"`accepted`, `rejected`, `error`, `duplicate`, `group_filtered`,\n`group_capped`, `no_routes`, `suppressed`."},"route_id":{"type":["string","null"]},"route_name":{"type":["string","null"]},"sent_data":{"description":"The request actually sent to the buyer, when one was sent."}}},"DistributionSummary":{"type":"object","description":"Roll-up of what happened when the lead was distributed.","required":["total_groups","total_attempts","total_time_ms","sold","payout","sold_to"],"properties":{"duplicate_info":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/DuplicateInfo","description":"Present only when the lead was turned away as a duplicate."}]},"payout":{"type":"number","format":"double"},"sold":{"type":"boolean"},"sold_to":{"type":"array","items":{"type":"string"}},"total_attempts":{"type":"integer","format":"int64"},"total_groups":{"type":"integer","format":"int64"},"total_time_ms":{"type":"integer","format":"int64"}}},"DropColumnBody":{"type":"object","description":"Remove a column from the sheet.","required":["name"],"properties":{"name":{"type":"string"}}},"DuplicateInfo":{"type":"object","description":"Why a lead was rejected as a duplicate.","properties":{"matched_field":{"type":["string","null"]},"previous_buyer_name":{"type":["string","null"]},"previous_lead_date":{"type":["string","null"]}}},"Entity":{"type":"object","description":"A buyer or supplier. Both tables carry the same columns, so one shape\nserves both and the UI can drive them from one editor rather than the two\nnear-identical modals the Alpine app has (plan D7).","required":["id","name","email","phone","active","settings","created_at"],"properties":{"active":{"type":"boolean"},"created_at":{"type":"string"},"email":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"phone":{"type":"string"},"settings":{"description":"Free-form JSON config. On buyers this holds `price`, `daily_cap`,\n`delivery_method` and `endpoint`, all of which also exist as columns on\n`destinations` — the third home for the same concepts that plan D5 calls\nout. Served as-is until that is settled rather than pretending it is\nstructured."}}},"EntityBody":{"type":"object","required":["name"],"properties":{"active":{"type":["boolean","null"]},"email":{"type":["string","null"]},"name":{"type":"string"},"phone":{"type":["string","null"]},"settings":{}}},"EntityListResponse":{"type":"object","required":["entities"],"properties":{"entities":{"type":"array","items":{"$ref":"#/components/schemas/Entity"}}}},"EntityStat":{"type":"object","description":"Per-entity performance. One shape for campaigns, buyers, suppliers and\ndestinations: they differ in what they are named by, not in what is\nmeasured about them.","required":["name","total_leads","delivered","failed","total_payout","rate"],"properties":{"delivered":{"type":"integer","format":"int64"},"failed":{"type":"integer","format":"int64"},"name":{"type":"string"},"rate":{"type":"number","format":"double","description":"Percentage delivered. Named per-entity in the legacy response\n(`delivery_rate` for campaigns, `success_rate` for buyers); one name\nhere, because it is the same number."},"total_leads":{"type":"integer","format":"int64"},"total_payout":{"type":"number","format":"double"}}},"EntityStatsResponse":{"type":"object","required":["campaigns","buyers","suppliers","destinations"],"properties":{"buyers":{"type":"array","items":{"$ref":"#/components/schemas/EntityStat"}},"campaigns":{"type":"array","items":{"$ref":"#/components/schemas/EntityStat"}},"destinations":{"type":"array","items":{"$ref":"#/components/schemas/EntityStat"}},"suppliers":{"type":"array","items":{"$ref":"#/components/schemas/EntityStat"}}}},"EnumValue":{"type":"object","description":"One selectable value plus the text a client should show for it.","required":["value","label"],"properties":{"label":{"type":"string"},"value":{"type":"string"}}},"EnumsResponse":{"type":"object","required":["filter_types","alert_event_types","delivery_methods","distribution_methods","lead_statuses","delivery_statuses","rejection_reasons","import_fields","explore_dimensions","roles","account_statuses","actions","nav_tabs","configurable_roles","default_permissions"],"properties":{"account_statuses":{"type":"array","items":{"type":"string"},"description":"The states an account can be in."},"actions":{"type":"array","items":{"type":"string"},"description":"Every action a role can be granted, and every sidebar item it can be\nshown, so the permission editor offers exactly what the server honours\nrather than a copy that drifts from it."},"alert_event_types":{"type":"array","items":{"$ref":"#/components/schemas/AlertEventType"}},"configurable_roles":{"type":"array","items":{"type":"string"},"description":"The roles whose permissions can be edited. admin and super_admin always\nget everything; unapproved gets nothing."},"default_permissions":{"type":"array","items":{"$ref":"#/components/schemas/RoleDefaults"},"description":"What each role can do and see when nothing has been customised.\n\nThe settings screen needs these to show what is in force. A stored\noverride replaces a role's defaults rather than adding to them, so a\nscreen showing an empty grid where defaults apply would invite somebody\nto tick one box and cut a manager from thirteen actions to one."},"delivery_methods":{"type":"array","items":{"$ref":"#/components/schemas/EnumValue"}},"delivery_statuses":{"type":"array","items":{"$ref":"#/components/schemas/EnumValue"}},"distribution_methods":{"type":"array","items":{"$ref":"#/components/schemas/EnumValue"}},"explore_dimensions":{"type":"array","items":{"$ref":"#/components/schemas/EnumValue"},"description":"What the report explorer can group by."},"filter_types":{"type":"array","items":{"$ref":"#/components/schemas/FilterType"}},"import_fields":{"type":"array","items":{"$ref":"#/components/schemas/EnumValue"},"description":"Targets a CSV column can map onto. Unmapped columns become custom\nfields, so this is not exhaustive of what an import can store."},"lead_statuses":{"type":"array","items":{"$ref":"#/components/schemas/EnumValue"}},"nav_tabs":{"type":"array","items":{"type":"string"}},"rejection_reasons":{"type":"array","items":{"$ref":"#/components/schemas/EnumValue"}},"roles":{"type":"array","items":{"type":"string"},"description":"Every role an account can hold, from the shared crate, so a settings\nscreen cannot drift from what the system accepts."}}},"ExploreRequest":{"type":"object","required":["group_by"],"properties":{"campaign_id":{"type":["string","null"]},"date_from":{"type":["string","null"]},"date_range":{"type":["string","null"]},"date_to":{"type":["string","null"]},"group_by":{"type":"array","items":{"type":"string"},"description":"One or two dimensions from `explore_dimensions` in the served enums."}}},"ExploreResponse":{"type":"object","required":["group_by","is_pivot","rows","totals"],"properties":{"group_by":{"type":"array","items":{"type":"string"},"description":"The dimensions actually grouped by, echoed back so a client rendering\nthe result does not have to remember what it asked for."},"is_pivot":{"type":"boolean"},"rows":{"type":"array","items":{"$ref":"#/components/schemas/ExploreRow"}},"totals":{"$ref":"#/components/schemas/ExploreTotals"}}},"ExploreRow":{"type":"object","description":"One cell of the explorer grid.\n\n`key2` and `label2` are only populated for a two-dimension pivot, which is\nwhy they are optional rather than empty strings — \"no second dimension\" and\n\"second dimension is blank\" are different things.","required":["key","label","leads","sold","revenue","payout","profit","conversion_rate"],"properties":{"conversion_rate":{"type":"number","format":"double","description":"Percentage of leads in this bucket that sold."},"key":{"type":"string"},"key2":{"type":["string","null"]},"label":{"type":"string"},"label2":{"type":["string","null"]},"leads":{"type":"integer","format":"int64"},"payout":{"type":"number","format":"double"},"profit":{"type":"number","format":"double"},"revenue":{"type":"number","format":"double"},"sold":{"type":"integer","format":"int64"}}},"ExploreTotals":{"type":"object","required":["leads","sold","revenue","payout","profit","conversion_rate"],"properties":{"conversion_rate":{"type":"number","format":"double"},"leads":{"type":"integer","format":"int64"},"payout":{"type":"number","format":"double"},"profit":{"type":"number","format":"double"},"revenue":{"type":"number","format":"double"},"sold":{"type":"integer","format":"int64"}}},"FacetValue":{"type":"object","description":"One value a filter can take, with how often it occurs.","required":["value","count"],"properties":{"count":{"type":"integer","format":"int64"},"value":{"type":"string"}}},"FieldFilter":{"type":"object","description":"One custom-field condition, e.g. `uprn is_not_empty`.\n\nOperators come from `GET /api/v1/meta/enums` under the `field` filter type,\nso a client renders the dropdown from the same list the engine implements.","required":["field","operator"],"properties":{"field":{"type":"string"},"operator":{"type":"string"},"value":{"type":["string","null"]}}},"FilterOption":{"type":"object","description":"One configurable key within a filter type, and the values it accepts.\n\n`default` mirrors the `unwrap_or(...)` in the matching evaluator, so a\nclient that omits the key gets the same behaviour the engine assumes.","required":["key","label","values","free_text"],"properties":{"default":{"type":["string","null"]},"free_text":{"type":"boolean","description":"`true` when the client should render a free-text input alongside the\ndropdown (the value being compared against)."},"key":{"type":"string","description":"Key inside the filter's `config` object, e.g. `operator`."},"label":{"type":"string"},"values":{"type":"array","items":{"$ref":"#/components/schemas/EnumValue"}}}},"FilterTab":{"type":"object","description":"A saved set of lead filters, per user, synced across devices.\n\n`filter_state` is stored opaquely: it is whatever the client saved, and the\nserver does not interpret it. That keeps a saved tab from breaking when the\nfilter set grows, at the cost of the client having to tolerate a tab saved\nby an older or newer build.","required":["id","name"],"properties":{"filter_state":{},"has_changes":{"type":"boolean"},"id":{"type":"integer","format":"int64"},"name":{"type":"string"}}},"FilterTabsResponse":{"type":"object","required":["tabs","next_tab_id"],"properties":{"active_tab_id":{"type":["integer","null"],"format":"int64"},"next_tab_id":{"type":"integer","format":"int64","description":"Next id to hand out, so two devices do not mint the same one."},"tabs":{"type":"array","items":{"$ref":"#/components/schemas/FilterTab"}}}},"FilterType":{"type":"object","required":["value","label","options"],"properties":{"label":{"type":"string"},"options":{"type":"array","items":{"$ref":"#/components/schemas/FilterOption"}},"value":{"type":"string"}}},"FormulaCheck":{"type":"object","description":"A formula checked without saving it.","required":["ok","error","sources","sample"],"properties":{"error":{"type":"string","description":"Why not, in words for the person who wrote it. Empty when `ok`."},"ok":{"type":"boolean","description":"Whether it makes sense against this file's columns."},"sample":{"type":"array","items":{"type":"string"},"description":"What it works out for the first few rows, so it can be seen before it\nis kept. Empty when it does not compile, or when the rows could not be\nread — a preview is a convenience and never the reason a check fails."},"sources":{"type":"array","items":{"type":"string"},"description":"The columns it reads. Empty when it does not compile."}}},"FormulaColumnBody":{"type":"object","description":"Add or replace a column with one worked out by a written formula.","required":["name","formula"],"properties":{"formula":{"type":"string","description":"The formula itself, written with column names in brackets, such as\n`[price] * [qty] * 1.2` or `IF([status] = \"sold\", [revenue], 0)`."},"name":{"type":"string","description":"The column to put the answer in. A name the file does not have yet adds\na column; an existing one replaces what that column holds."}}},"GroupDestination":{"type":"object","description":"One destination as reached through one group: the transport plus the terms.","required":["link_id","destination_id","name","buyer_id","buyer_name","endpoint_url","delivery_method","method","timeout_ms","active","priority","weight","price","caps","smart_filters","active_days","rate_limit","suppression_campaign_ids"],"properties":{"active":{"type":"boolean"},"active_days":{"type":"string"},"buyer_id":{"type":"string"},"buyer_name":{"type":"string"},"caps":{"$ref":"#/components/schemas/Caps"},"delivery_method":{"type":"string"},"destination_id":{"type":"string"},"endpoint_url":{"type":"string"},"link_id":{"type":"string","description":"`group_destinations.id` — the link, which is what you edit."},"method":{"type":"string"},"name":{"type":"string"},"price":{"$ref":"#/components/schemas/PriceResolution"},"priority":{"type":"integer","format":"int64"},"rate_limit":{"type":"integer","format":"int64"},"smart_filters":{},"suppression_campaign_ids":{"type":"array","items":{"type":"string"},"description":"Campaigns whose leads this link refuses, by id.\n\nStored on the link as a JSON array and honoured by the distribution\nworker, but absent from this type until now — so the screen could not\nshow a suppression list that was live, and could not set one. Any\ncampaign can serve as a suppression source; there is no separate kind\nof campaign for it."},"timeout_ms":{"type":"integer","format":"int64"},"weight":{"type":"integer","format":"int64"}}},"GroupOutcomes":{"type":"object","description":"What actually happened at one routing group over the period.","required":["group_id","group_name","attempts","accepted","turned_away","outcomes","reasons"],"properties":{"accepted":{"type":"integer","format":"int64","description":"Attempts a destination in this group accepted."},"attempts":{"type":"integer","format":"int64","description":"Every attempt that reached this group."},"group_id":{"type":"string"},"group_name":{"type":"string"},"outcomes":{"type":"array","items":{"$ref":"#/components/schemas/RoutingOutcome"}},"reasons":{"type":"array","items":{"$ref":"#/components/schemas/RoutingReason"},"description":"Most common explanations first."},"turned_away":{"type":"integer","format":"int64","description":"Attempts that did not place here and moved on down the tiers."}}},"HlrSettings":{"type":"object","description":"The phone-checking service's credentials for this organisation.\n\nRead from `tenant_configs` on this server, which is the copy the checker\nactually uses. The gateway keeps a copy of the same key that nothing here\nreads, so the two can differ and only this one has any effect.","properties":{"api_key":{"type":"string","description":"Blank when nothing is configured. Never returned in full once set: see\n`api_key_set`."},"api_secret":{"type":"string"},"test_mode":{"type":"boolean","description":"Send lookups to the provider's free testing endpoint, which returns\ninvented results and spends no credit. Off for real checking."}}},"HlrSettingsView":{"type":"object","description":"What the settings screen is told.\n\nThe credentials are sent back in full, matching the screen this replaces,\nso an administrator can read and copy the key they have rather than only\nbeing told one exists. That is a deliberate choice with a cost: anyone who\ncan open this page, or read a cached response, can read live credentials.\nIt is gated on super admin for that reason, and the screen masks the values\nuntil asked to reveal them.","required":["api_key","api_secret","test_mode","configured"],"properties":{"api_key":{"type":"string"},"api_secret":{"type":"string"},"configured":{"type":"boolean","description":"Whether anything is stored at all, so the screen can say \"not set up\"\nwithout having to reason about empty strings."},"test_mode":{"type":"boolean"}}},"HourlyPoint":{"type":"object","required":["hour","count"],"properties":{"count":{"type":"integer","format":"int64"},"hour":{"type":"integer","format":"int64"}}},"ImportJob":{"type":"object","description":"A CSV import job.\n\nRow counts are nullable because a job that has not started processing has\nnot counted anything yet, and `rows_total` in particular is only known once\nthe file has been read. Reporting an unknown count as 0 would render a\nqueued job as \"0 of 0 imported\", which reads as failure.","required":["job_id","file_name","campaign_id","campaign_name","status","hlr_enabled","created_at"],"properties":{"campaign_id":{"type":"string"},"campaign_name":{"type":"string"},"completed_at":{"type":["string","null"]},"created_at":{"type":"string"},"error_message":{"type":["string","null"]},"file_name":{"type":"string"},"hlr_enabled":{"type":"boolean"},"job_id":{"type":"string"},"processing_started_at":{"type":["string","null"]},"rows_duplicate":{"type":["integer","null"],"format":"int64"},"rows_hlr_rejected":{"type":["integer","null"],"format":"int64"},"rows_imported":{"type":["integer","null"],"format":"int64"},"rows_invalid":{"type":["integer","null"],"format":"int64"},"rows_total":{"type":["integer","null"],"format":"int64"},"status":{"type":"string","description":"`pending`, `processing`, `completed`, `failed`."},"user_email":{"type":["string","null"],"description":"Who started it."}}},"ImportListResponse":{"type":"object","required":["imports","pagination"],"properties":{"imports":{"type":"array","items":{"$ref":"#/components/schemas/ImportJob"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"ImportProgress":{"allOf":[{"$ref":"#/components/schemas/ImportJob"},{"type":"object","required":["in_progress"],"properties":{"in_progress":{"type":"boolean","description":"True while the job is queued or running, so a client knows to keep\npolling without having to know the status vocabulary."},"percent":{"type":["number","null"],"format":"double","description":"0-100 once a total is known, otherwise absent."}}}],"description":"Progress for a running job.\n\n`rows_total` is `None` until the file has been counted, so a client must\ntreat \"processing with no total\" as indeterminate rather than as 0%."},"InsightsOverall":{"type":"object","description":"Totals across everything the current filters match.","required":["total_leads","total_revenue","avg_revenue","min_revenue","max_revenue"],"properties":{"avg_revenue":{"type":"number","format":"double"},"max_revenue":{"type":"number","format":"double"},"min_revenue":{"type":"number","format":"double"},"total_leads":{"type":"integer","format":"int64"},"total_revenue":{"type":"number","format":"double"}}},"InsightsResponse":{"type":"object","required":["overall","rows"],"properties":{"overall":{"$ref":"#/components/schemas/InsightsOverall"},"rows":{"type":"array","items":{"$ref":"#/components/schemas/InsightsRow"}}}},"InsightsRow":{"type":"object","description":"One bucket, whatever the dimension. Normalising to `label`/`leads`/`revenue`\nrather than a differently-named key per dimension means the client renders\nsix views from one shape instead of six.","required":["label","leads","revenue","avg_revenue"],"properties":{"avg_revenue":{"type":"number","format":"double"},"label":{"type":"string"},"leads":{"type":"integer","format":"int64"},"revenue":{"type":"number","format":"double"}}},"LeadDetailResponse":{"allOf":[{"$ref":"#/components/schemas/LeadSummary"},{"type":"object","required":["custom_data","attempts","distribution"],"properties":{"attempts":{"type":"array","items":{"$ref":"#/components/schemas/DistributionAttempt"}},"custom_data":{"description":"Custom fields as stored, flattened, with the structural keys removed\n(`raw_request`, `custom_fields`, `__hlr`). Those are transport plumbing\nrather than lead data, and the legacy detail endpoint strips the same\nthree (`leads.rs:2067`). May be empty."},"distribution":{"$ref":"#/components/schemas/DistributionSummary"},"raw_request":{"description":"The inbound payload as received, when one was recorded.\n\nLives at `custom_data.raw_request`, not on `distribution_data` — an\nearlier version of this endpoint read the latter and reported \"no raw\nrequest recorded\" for every lead, while 1593 of 1593 recent leads had\none all along."}}}],"description":"Lead detail, distribution history and the raw inbound request in one\nresponse.\n\nThe Alpine app splits these across two modals that share one `modal` state\nvariable, so they are mutually exclusive: you close one to open the other,\nabout the same lead, each issuing its own fetch. Plan D7. One fetch here."},"LeadFacetsResponse":{"type":"object","description":"The distinct values the lead filter dropdowns offer.","required":["sources","suppliers"],"properties":{"sources":{"type":"array","items":{"type":"string"},"description":"Distinct non-empty `source` values, ascending."},"suppliers":{"type":"array","items":{"type":"string"},"description":"Distinct non-empty `supplier_name` values, ascending."}}},"LeadListResponse":{"type":"object","required":["leads","pagination","total_revenue"],"properties":{"leads":{"type":"array","items":{"$ref":"#/components/schemas/LeadSummary"}},"pagination":{"$ref":"#/components/schemas/Pagination"},"total_revenue":{"type":"number","format":"double","description":"Summed revenue across every row matching the filter, not just this page."}}},"LeadSearchBody":{"type":"object","description":"Everything the leads list can be narrowed by.\n\nA POST body rather than query params: the Alpine app's equivalent has ~20\ndimensions including several arrays, and cramming those into a query string\nis where filter carry-through bugs come from — a param the client sends,\nserde silently drops, and the user sees unfiltered results that look right.\nEvery field here is declared, so an unknown one is visible rather than\nquietly ignored.","properties":{"attempted_buyer":{"type":["string","null"],"description":"Buyer the lead was *offered* to, sold or not."},"buyer":{"type":["string","null"],"description":"Buyer the lead was sold to."},"campaign_id":{"type":["string","null"],"description":"Single campaign. `campaign_ids` takes precedence when both are given."},"campaign_ids":{"type":["array","null"],"items":{"type":"string"}},"date_from":{"type":["string","null"]},"date_range":{"type":["string","null"],"description":"Named preset (`today`, `7d`, `30d`, …), resolved as Europe/London days."},"date_to":{"type":["string","null"]},"delivery_statuses":{"type":["array","null"],"items":{"type":"string"},"description":"Delivery outcomes to include."},"field_filters":{"type":["array","null"],"items":{"$ref":"#/components/schemas/FieldFilter"},"description":"Conditions on custom fields."},"hlr_statuses":{"type":["array","null"],"items":{"type":"string"},"description":"HLR phone-verification outcomes. `NOT_VALIDATED` matches leads with no\nHLR result at all."},"ignore_campaign_filter":{"type":["boolean","null"],"description":"Search custom fields across every campaign that has them, ignoring the\ncampaign filter. Supplier scoping is still enforced."},"import_ids":{"type":["array","null"],"items":{"type":"string"},"description":"Restrict to leads from specific CSV imports."},"lead_method":{"type":["string","null"]},"max_value":{"type":["number","null"],"format":"double"},"min_value":{"type":["number","null"],"format":"double","description":"Revenue bounds."},"page":{"type":["integer","null"],"format":"int64"},"page_size":{"type":["integer","null"],"format":"int64"},"search":{"type":["string","null"],"description":"Free text across name, email and phone."},"sold_statuses":{"type":["array","null"],"items":{"type":"string"},"description":"`sold`, `not_sold` or `returned`. (`unsold` is accepted as an alias\nfor `not_sold`, for filter tabs saved while the web app sent it.)"},"source":{"type":["string","null"]},"status":{"type":["string","null"],"description":"Lead status (`imported`, `pending`, …)."},"supplier":{"type":["string","null"]}}},"LeadSummary":{"type":"object","description":"One row in the leads table.\n\nThe legacy endpoint issues `SELECT *` and hands raw ClickHouse rows to the\nclient, which is how `buyer_price` happened: the UI read a field name that\nno query ever produced, and an empty column looked like missing data rather\nthan a bug. Here the column list is explicit and the field names are part\nof the published contract.","required":["id","campaign_id","campaign_name","email","phone1","firstname","lastname","fullname","company","status","delivery_status","source","supplier_id","supplier_name","buyer_id","buyer_name","revenue","payout","sold","created_at"],"properties":{"buyer_id":{"type":"string"},"buyer_name":{"type":"string"},"campaign_id":{"type":"string"},"campaign_name":{"type":"string","description":"Resolved from SQLite; falls back to the id when the campaign is gone."},"company":{"type":"string"},"created_at":{"type":"string"},"delivery_status":{"type":"string"},"email":{"type":"string"},"firstname":{"type":"string"},"fullname":{"type":"string"},"id":{"type":"string"},"lastname":{"type":"string"},"payout":{"type":"number","format":"double","description":"What we paid out for it. Distinct from `revenue`; see the plan's D1 on\nprice having had three homes."},"phone1":{"type":"string"},"revenue":{"type":"number","format":"double","description":"What we earned for this lead."},"sold":{"type":"boolean"},"source":{"type":"string"},"status":{"type":"string"},"supplier_id":{"type":"string"},"supplier_name":{"type":"string"}}},"LinkDestinationBody":{"type":"object","description":"Link an existing destination into a group.\n\nLinking, not creating. The destination row already exists and may be linked\ninto other groups too; this adds the junction row that carries this group's\nown terms. Creating a brand new destination is a separate concern with a\ndifferent blast radius.","required":["destination_id"],"properties":{"buying_price":{"type":["number","null"],"format":"double","description":"Leave unset to inherit the destination's own default price."},"daily_cap":{"type":["integer","null"],"format":"int32"},"destination_id":{"type":"string"},"priority":{"type":["integer","null"],"format":"int32","description":"Trial order within the group. Defaults to the end."},"weight":{"type":["integer","null"],"format":"int32"}}},"MatchLeadsBody":{"type":"object","description":"Find leads whose postcode appears in a column of this file.","required":["col"],"properties":{"address_col":{"type":["string","null"],"description":"Optional column holding the address line. When given, a lead only\ncounts as a match if the number on the door agrees too, which on real\ndata removes about one match in five as a different house."},"col":{"type":"string","description":"The column in this file holding postcodes."}}},"MatchLeadsResponse":{"type":"object","description":"What matching a file's postcodes against the leads table found.","required":["sheet_id","matched_leads","matched_postcodes","source_postcodes","source_postcode_shaped","with_sms_consent","with_email_consent","different_property","no_house_number"],"properties":{"different_property":{"type":"integer","format":"int64","description":"Leads sharing a postcode with the file but sitting at a different\nnumber. Excluded from an address match, and the reason to run one.\nZero when matching on postcode alone, where they are included instead."},"matched_leads":{"type":"integer","format":"int64"},"matched_postcodes":{"type":"integer","format":"int64","description":"Distinct postcodes in the file that matched at least one lead."},"no_house_number":{"type":"integer","format":"int64","description":"Leads in a matching postcode whose address line carries no number, so\nthere is nothing to compare. Excluded from an address match, because\n\"might be\" is not a good enough reason to text somebody."},"sheet_id":{"type":"string","description":"A new sheet holding the matching leads, ready to open."},"source_postcode_shaped":{"type":"integer","format":"int64","description":"How many of those actually look like a UK postcode. Far below\n`source_postcodes` means the wrong column was picked — a mistake this\notherwise makes quietly, because a column holding postcodes half the\ntime finds half the leads and looks like a working answer."},"source_postcodes":{"type":"integer","format":"int64","description":"Distinct postcodes in the file, matched or not. The gap between this\nand `matched_postcodes` is the part of the list you have nobody for."},"with_email_consent":{"type":"integer","format":"int64"},"with_sms_consent":{"type":"integer","format":"int64","description":"Of the matched leads, how many recorded consent. Usually far fewer than\n`matched_leads`: most leads carry no consent value at all."}}},"NamedCount":{"type":"object","required":["name","count"],"properties":{"count":{"type":"integer","format":"int64"},"name":{"type":"string"}}},"NotificationItem":{"type":"object","description":"One in-app notification.","required":["id","body","title","event_type","read","created_at"],"properties":{"body":{"type":"string","description":"The line the list renders. It is shown without the title, so it has to\nstand on its own."},"created_at":{"type":"string"},"event_type":{"type":"string"},"id":{"type":"string"},"read":{"type":"boolean"},"title":{"type":"string"}}},"NotificationListResponse":{"type":"object","required":["notifications","unread"],"properties":{"notifications":{"type":"array","items":{"$ref":"#/components/schemas/NotificationItem"}},"unread":{"type":"integer","format":"int64"}}},"Pagination":{"type":"object","description":"Standard pagination envelope for list endpoints.","required":["page","page_size","total","total_pages"],"properties":{"page":{"type":"integer","format":"int64"},"page_size":{"type":"integer","format":"int64"},"total":{"type":"integer","format":"int64","description":"Total rows matching the filter, ignoring pagination."},"total_pages":{"type":"integer","format":"int64"}}},"PriceResolution":{"type":"object","description":"Where a link's price comes from.\n\nPrice inherits exactly like the other duplicated columns: the link\noverrides, the destination is the default, the buyer's `settings.price` is\nthe last resort.\n\n```text\nCOALESCE(NULLIF(gd.buying_price, 0), NULLIF(d.buying_price, 0),\n         json_extract(b.settings,'$.price'), 0.0)\n```\n\nIt did not always. Price alone used to skip the destination and jump\nstraight to the buyer, which is why setting £11 on the ESB destination\nchanged nothing and the lead priced at the buyer's £10. Serving the source\nalongside the value keeps that legible.","required":["resolved","source","link_value"],"properties":{"buyer_settings_value":{"type":["number","null"],"format":"double","description":"`buyers.settings.price`. Last resort, and a legacy home worth retiring\nnow that destinations carry a real default."},"destination_value":{"type":["number","null"],"format":"double","description":"`destinations.buying_price`. The destination's own default."},"link_value":{"type":"number","format":"double","description":"`group_destinations.buying_price`. Overrides when non-zero."},"resolved":{"type":"number","format":"double","description":"What the engine will use for this link."},"source":{"type":"string","description":"`link`, `destination`, `buyer_settings`, or `none`."}}},"ProblemDetails":{"type":"object","description":"An error response in [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)\nProblem Details form.\n\n`success` and `error` are not part of RFC 9457. They are extension members,\nwhich section 3.2 explicitly permits, and they are here because both shipped\nclients read `error`: the Alpine app at `desktop/src/js/app.js:733` and\nmobile-ionic at `src/services/api.ts:51`. An App Store build cannot be\nforced to upgrade, so removing them would break error messages on phones\nalready in the field.\n\nThey are declared as real fields rather than quietly appended so the\npublished spec describes what the server actually sends. Delete them once\nboth clients read `detail`, as a deliberate breaking change in a new API\nversion.","required":["type","title","status","detail","success","error"],"properties":{"detail":{"type":"string","description":"What went wrong on this particular occurrence."},"error":{"type":"string","description":"Legacy compatibility, a copy of `detail`. Superseded by `detail`."},"instance":{"type":["string","null"],"description":"The request path this refers to, when the emitter knows it."},"status":{"type":"integer","format":"int32","description":"The HTTP status code, repeated so the body stands alone.","minimum":0},"success":{"type":"boolean","description":"Legacy compatibility, always `false`. Superseded by the status code."},"title":{"type":"string","description":"Short human-readable summary. Identical for every occurrence of a given\n`type`, as RFC 9457 requires; the occurrence-specific part is `detail`."},"type":{"type":"string","description":"Stable URI identifying the kind of error. Branch on this rather than\nstring-matching the English in `detail`."}}},"RedistributeBody":{"type":"object","description":"Body for re-queuing leads for distribution.\n\nThe `skip_*` flags are per-request overrides that bypass one class of gate\nfor this redistribute only. They do not change any stored configuration.","required":["lead_ids"],"properties":{"lead_ids":{"type":"array","items":{"type":"string"}},"skip_active_days":{"type":"boolean"},"skip_buyer_dedupe":{"type":"boolean"},"skip_caps":{"type":"boolean"},"skip_filters":{"type":"boolean"},"skip_suppression":{"type":"boolean"}}},"RedistributeResponse":{"type":"object","description":"What a redistribute achieved.\n\nThere is deliberately no `sold` count. The distribution worker has not run\nwhen this returns, so any number here would be a guess; the legacy endpoint\nreports `summary.sold: 0` unconditionally, which reads as \"nothing sold\"\nrather than \"not known yet\". Poll the lead to find out.","required":["queued","total","errors"],"properties":{"errors":{"type":"array","items":{"type":"string"},"description":"One message per lead that could not be queued. Empty on a clean run."},"queued":{"type":"integer","format":"int32","description":"Leads accepted onto the distribution queue.","minimum":0},"total":{"type":"integer","format":"int32","description":"Leads submitted, queued or not.","minimum":0}}},"RegisterDeviceTokenBody":{"type":"object","description":"A device asking to receive pushes.","required":["token"],"properties":{"platform":{"type":"string","description":"`ios` or `android`."},"token":{"type":"string","description":"The APNs token, as the OS gave it. Case is normalised server-side —\niOS can return upper-case hex and APNs rejects it."}}},"RenameColumnBody":{"type":"object","description":"Rename a column.","required":["from","to"],"properties":{"from":{"type":"string"},"to":{"type":"string"}}},"ReorderColumnsBody":{"type":"object","description":"Put the columns in a given order.","required":["columns"],"properties":{"columns":{"type":"array","items":{"type":"string"},"description":"Every column the sheet has, in the order they should appear. Refused\nunless it is exactly that — a partial list would silently drop columns."}}},"ReportMetrics":{"type":"object","required":["total_leads","total_revenue","total_payout","avg_revenue","delivery_rate"],"properties":{"avg_revenue":{"type":"number","format":"double"},"delivery_rate":{"type":"number","format":"double"},"total_leads":{"type":"integer","format":"int64"},"total_payout":{"type":"number","format":"double"},"total_revenue":{"type":"number","format":"double"}}},"ReportView":{"type":"object","description":"A saved explorer configuration.\n\n`config` is stored opaquely: whatever the client saved is what it gets\nback. That keeps an old saved view from breaking when the explorer grows a\nnew control, at the cost of the client tolerating a view saved by another\nbuild.","required":["id","name","config","created_at"],"properties":{"config":{},"created_at":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}}},"ReportViewListResponse":{"type":"object","required":["views"],"properties":{"views":{"type":"array","items":{"$ref":"#/components/schemas/ReportView"}}}},"ReportsResponse":{"type":"object","required":["metrics","lead_trend","status_breakdown","campaigns","buyers"],"properties":{"buyers":{"type":"array","items":{"$ref":"#/components/schemas/BuyerPerformanceRow"}},"campaigns":{"type":"array","items":{"$ref":"#/components/schemas/CampaignPerformanceRow"}},"lead_trend":{"type":"array","items":{"$ref":"#/components/schemas/TimeSeriesPoint"},"description":"Leads per day across the range, pre-labelled in Europe/London."},"metrics":{"$ref":"#/components/schemas/ReportMetrics"},"status_breakdown":{"type":"array","items":{"$ref":"#/components/schemas/NamedCount"},"description":"Delivery-status split across the range."}}},"ResetApiKeyResponse":{"type":"object","description":"A freshly generated ingestion key.","required":["campaign_id","api_key"],"properties":{"api_key":{"type":"string","description":"The new key. The old one stops working immediately, so every supplier\nposting to this campaign must be given this before their next send."},"campaign_id":{"type":"string"}}},"RestoreVersionBody":{"type":"object","description":"Make an older version current again.","required":["version"],"properties":{"version":{"type":"integer","format":"int64"}}},"ReturnLeadBody":{"type":"object","description":"Body for returning a sold lead. The lead is identified by the path, so the\nonly thing to send is why.","properties":{"reason":{"type":["string","null"],"description":"Free-text reason kept for the audit trail. Optional."}}},"ReturnLeadResponse":{"type":"object","description":"What returning a lead did.","required":["lead_id","delivery_status","revenue_reversed"],"properties":{"delivery_status":{"type":"string","description":"Always `returned` on success. Returned so a client can render the new\nstate without a refetch."},"lead_id":{"type":"string"},"revenue_reversed":{"type":"boolean","description":"A return zeroes `revenue` and `sold` on the row. Stated explicitly so\nthe UI can tell the operator what happened to the money rather than\nleaving them to assume."}}},"RevenuePoint":{"type":"object","required":["date","revenue"],"properties":{"date":{"type":"string"},"revenue":{"type":"number","format":"double"}}},"RoleDefaults":{"type":"object","description":"One role's out-of-the-box permissions.","required":["role","actions","nav_tabs"],"properties":{"actions":{"type":"array","items":{"type":"string"}},"nav_tabs":{"type":"array","items":{"type":"string"}},"role":{"type":"string"}}},"RouteGroup":{"type":"object","required":["id","name","priority","distribution_method","delivery_count","active","filters_enabled","smart_filters","caps","delay_seconds","group_weight","destinations"],"properties":{"active":{"type":"boolean"},"caps":{"$ref":"#/components/schemas/Caps"},"delay_seconds":{"type":"integer","format":"int64","description":"Pause between delivery attempts within this group."},"delivery_count":{"type":"integer","format":"int64","description":"How many destinations in this group may receive the lead."},"destinations":{"type":"array","items":{"$ref":"#/components/schemas/GroupDestination"}},"distribution_method":{"type":"string","description":"`priority`, `round-robin` or `weighted`."},"filters_enabled":{"type":"boolean"},"group_weight":{"type":"integer","format":"int64","description":"Share of traffic when several groups sit at the same priority."},"id":{"type":"string"},"name":{"type":"string"},"priority":{"type":"integer","format":"int64"},"smart_filters":{}}},"RoutingOutcome":{"type":"object","description":"One outcome bucket: how many attempts ended this way.","required":["result","count"],"properties":{"count":{"type":"integer","format":"int64"},"result":{"type":"string"}}},"RoutingOutcomesResponse":{"type":"object","description":"Where a campaign's leads actually went, and where they got stuck.\n\nRead from `leads.distribution_data.attempts` rather than the\n`group_attempts` table. That table only records successes for this campaign\n— one group, `accepted` only — so it cannot answer \"why did a lead not\nplace\", which is the entire question. The inline attempts carry the full\nvocabulary plus `filter_reason` and the group identity.","required":["date_range","total_attempts","pre_routing","groups"],"properties":{"date_range":{"type":"string"},"groups":{"type":"array","items":{"$ref":"#/components/schemas/GroupOutcomes"}},"pre_routing":{"type":"array","items":{"$ref":"#/components/schemas/RoutingOutcome"},"description":"Attempts recorded with no group: screened before routing ever started,\nso they never reached a tier. Duplicates and suppression land here."},"total_attempts":{"type":"integer","format":"int64"}}},"RoutingReason":{"type":"object","description":"Why attempts were turned away, in the engine's own words.\n\n`filter_reason` is written by the distribution engine and already reads as\nan explanation (\"Field filter failed: marketing_consent_email equals\n\\\"true\\\" — lead had \\\"false\\\"\"), so it is passed through rather than\nre-worded here. Re-wording it in the client would be another copy of\nsomething the server owns.","required":["result","reason","count"],"properties":{"count":{"type":"integer","format":"int64"},"reason":{"type":"string"},"result":{"type":"string"}}},"Sheet":{"type":"object","description":"A CSV made queryable: converted to Parquet in object storage and readable\na page at a time without loading it anywhere.","required":["id","file_name","status","columns","row_count","created_at"],"properties":{"columns":{"type":"array","items":{"type":"string"},"description":"Column names in file order, sanitised to valid identifiers."},"created_at":{"type":"string"},"editable":{"type":"boolean","description":"Whether cells can be changed. A sheet converted before row numbers\nexisted has no stable way to name a cell, so it can be read but not\nedited until it has been converted again."},"error_message":{"type":["string","null"]},"file_name":{"type":"string"},"id":{"type":"string"},"import_job_id":{"type":["string","null"],"description":"The import job this was made from, if any."},"row_count":{"type":"integer","format":"int64"},"status":{"type":"string","description":"`preparing`, `ready` or `failed`. Conversion runs in the background, so\na sheet exists before it can be read — clients poll until `ready`."},"version":{"type":"integer","format":"int64","description":"How many times changes have been written into the file. Every commit\nleaves the previous version in storage, so this counts up."}}},"SheetColumnStats":{"type":"object","required":["col","numeric","empty"],"properties":{"col":{"type":"string"},"empty":{"type":"integer","format":"int64"},"max":{"type":["number","null"],"format":"double"},"min":{"type":["number","null"],"format":"double"},"numeric":{"type":"integer","format":"int64","description":"How many values in this column read as numbers. Zero means the column\nis text and the figures below say nothing."},"sum":{"type":["number","null"],"format":"double"}}},"SheetCountBody":{"type":"object","description":"Cells to count. Deduplicated by the client, since two cells holding the\nsame value in the same column are one question.","required":["cells"],"properties":{"cells":{"type":"array","items":{"$ref":"#/components/schemas/SheetCountCell"}}}},"SheetCountCell":{"type":"object","required":["col","value"],"properties":{"col":{"type":"string"},"value":{"type":"string"}}},"SheetCountResult":{"type":"object","required":["col","value","rows"],"properties":{"col":{"type":"string"},"rows":{"type":"integer","format":"int64"},"value":{"type":"string"}}},"SheetCounts":{"type":"object","required":["counts","combined","total"],"properties":{"combined":{"type":"integer","format":"int64","description":"Rows matching every cell at once. Equal to the single count when only\none cell was asked about."},"counts":{"type":"array","items":{"$ref":"#/components/schemas/SheetCountResult"},"description":"One count per cell asked about, in the order they were given."},"total":{"type":"integer","format":"int64","description":"Rows in the file, so a count can be read as a proportion."}}},"SheetDuplicate":{"type":"object","required":["value","rows"],"properties":{"rows":{"type":"integer","format":"int64"},"value":{"type":"string"}}},"SheetDuplicates":{"type":"object","description":"Values that appear more than once in a column.","required":["col","values","repeated_rows"],"properties":{"col":{"type":"string"},"repeated_rows":{"type":"integer","format":"int64","description":"Rows that are part of a repeat, so the size of the problem is visible\neven when only the worst offenders are listed."},"values":{"type":"array","items":{"$ref":"#/components/schemas/SheetDuplicate"}}}},"SheetEdit":{"type":"object","required":["row_no","col"],"properties":{"col":{"type":"string"},"row_no":{"type":"integer","format":"int64"},"val":{"type":["string","null"],"description":"The new value. `null` reverts the cell to whatever the file says."}}},"SheetEditSummary":{"type":"object","description":"How much uncommitted work a sheet is carrying.","required":["edited_cells"],"properties":{"edited_cells":{"type":"integer","format":"int64"}}},"SheetEditsBody":{"type":"object","description":"A batch of cell edits.\n\nA batch, never one call per keystroke: a single-row insert measured 107ms\nagainst 60ms for two hundred of them together.","required":["edits"],"properties":{"edits":{"type":"array","items":{"$ref":"#/components/schemas/SheetEdit"}}}},"SheetFilterSpec":{"type":"object","required":["col","op"],"properties":{"col":{"type":"string"},"op":{"type":"string","description":"`eq`, `ne`, `contains`, `starts`, `empty`, `not_empty`, `bad_email`,\n`bad_phone`, `gt`, `gte`, `lt`, `lte`, `between`."},"value":{"type":["string","null"]},"value2":{"type":["string","null"],"description":"The upper bound for `between`, which is inclusive at both ends."}}},"SheetHistory":{"type":"object","description":"Everything that has happened to one file.","required":["versions","imports","checks"],"properties":{"checks":{"type":"array","items":{"$ref":"#/components/schemas/SheetHistoryCheck"}},"imports":{"type":"array","items":{"$ref":"#/components/schemas/SheetHistoryImport"}},"versions":{"type":"array","items":{"$ref":"#/components/schemas/SheetVersion"}}}},"SheetHistoryCheck":{"type":"object","required":["job_id","status","created_at"],"properties":{"created_at":{"type":"string"},"error_message":{"type":["string","null"]},"job_id":{"type":"string"},"rows_invalid":{"type":["integer","null"],"format":"int64"},"rows_processed":{"type":["integer","null"],"format":"int64"},"rows_verified":{"type":["integer","null"],"format":"int64"},"sheet_version":{"type":["integer","null"],"format":"int64"},"status":{"type":"string"}}},"SheetHistoryImport":{"type":"object","required":["job_id","campaign_name","status","created_at"],"properties":{"campaign_name":{"type":"string"},"created_at":{"type":"string"},"error_message":{"type":["string","null"]},"job_id":{"type":"string"},"rows_imported":{"type":["integer","null"],"format":"int64"},"rows_invalid":{"type":["integer","null"],"format":"int64"},"sheet_version":{"type":["integer","null"],"format":"int64","description":"The version that was imported. Null for jobs that predate this being\nrecorded — \"imported into Energy Premium\" says nothing about what was\nimported once the file has moved on."},"status":{"type":"string"}}},"SheetImportBody":{"type":"object","description":"Import a sheet's current contents into a campaign.","required":["campaign_id","column_mapping"],"properties":{"campaign_id":{"type":"string"},"column_mapping":{"type":"object","description":"Sheet column name -> lead field. Anything left out is stored as a\ncustom field, the same as a normal import.","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"distribute":{"type":"boolean"}}},"SheetJobStarted":{"type":"object","required":["job_id"],"properties":{"job_id":{"type":"string"}}},"SheetListResponse":{"type":"object","description":"Every sheet in the account, newest first.","required":["sheets"],"properties":{"sheets":{"type":"array","items":{"$ref":"#/components/schemas/Sheet"}}}},"SheetPage":{"type":"object","description":"One page of a sheet.\n\nRows are arrays rather than objects, positionally matching `columns`. A\nforty-column row as an object repeats every key on every row, which on a\n500-row page is most of the payload.","required":["matching_rows","version","is_current","columns","rows","row_numbers","edited","edited_cells","offset","limit","row_count"],"properties":{"columns":{"type":"array","items":{"type":"string"}},"edited":{"type":"array","items":{"type":"array","items":false,"prefixItems":[{"type":"integer","format":"int64"},{"type":"integer","minimum":0}]},"description":"Which cells on this page differ from the file, as `[row_number, column\nindex]` pairs. The grid needs to mark them, and recomputing it in the\nclient would mean shipping the original values alongside the edited\nones for every page."},"edited_cells":{"type":"integer","format":"int64","description":"How many cells in the whole sheet differ from the file, not just on\nthis page.\n\n`edited` is scoped to the rows returned, which is right for marking\nthem and wrong for deciding whether there is anything to save. The page\nasking this question fetches a single row, so an edit made to row five\nthousand left the client believing the sheet was unchanged and hiding\nthe button that would have committed it."},"is_current":{"type":"boolean"},"limit":{"type":"integer","format":"int64"},"matching_rows":{"type":"integer","format":"int64","description":"Rows matching the filters, which is what the pager counts through. Equal\nto the file's row count when nothing is filtered."},"offset":{"type":"integer","format":"int64"},"row_count":{"type":"integer","format":"int64","description":"Total rows in the sheet, for the scrollbar."},"row_numbers":{"type":"array","items":{"type":"integer","format":"int64"},"description":"The row's number in the original file, one per row in `rows`. Edits\nname a row by this, so the grid must not infer it from position."},"rows":{"type":"array","items":{"type":"array","items":{"type":"string"}}},"version":{"type":"integer","format":"int64","description":"Which version these rows came from. An older version is read as it was\nsaved: uncommitted edits and bulk rules belong to the current version\nand are not applied to it."}}},"SheetStats":{"type":"object","description":"What a column contains, for the columns that hold numbers.","required":["columns","rows"],"properties":{"columns":{"type":"array","items":{"$ref":"#/components/schemas/SheetColumnStats"}},"rows":{"type":"integer","format":"int64"}}},"SheetSummary":{"type":"object","description":"What is in a column, and how much of each.","required":["col","values","rows_covered","distinct_listed"],"properties":{"col":{"type":"string"},"distinct_listed":{"type":"integer","format":"int64"},"rows_covered":{"type":"integer","format":"int64","description":"Rows covered by the values listed. Less than the file when the list is\ncapped, which is how a caller knows it is seeing the top of a longer\ntail rather than everything."},"values":{"type":"array","items":{"$ref":"#/components/schemas/SheetSummaryValue"}}}},"SheetSummaryValue":{"type":"object","required":["value","rows"],"properties":{"rows":{"type":"integer","format":"int64"},"value":{"type":"string"}}},"SheetTransform":{"type":"object","required":["id","col","kind","find","replace"],"properties":{"col":{"type":"string"},"find":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"},"replace":{"type":"string"},"sources":{"type":"array","items":{"type":"string"},"description":"Columns this rule reads from. Empty for a rule that changes a column\nusing its own value; non-empty means the column is worked out from\nthese."}}},"SheetTransformBody":{"type":"object","description":"A bulk change to one column.","required":["col","kind"],"properties":{"col":{"type":"string"},"find":{"type":["string","null"]},"kind":{"type":"string","description":"`trim`, `upper`, `lower`, `replace`, `fill_empty` or `clear`."},"replace":{"type":["string","null"]}}},"SheetTransformList":{"type":"object","required":["transforms"],"properties":{"transforms":{"type":"array","items":{"$ref":"#/components/schemas/SheetTransform"}}}},"SheetVerifyBody":{"type":"object","description":"Check a column of phone numbers against the mobile networks.","required":["column"],"properties":{"column":{"type":"string"}}},"SheetVersion":{"type":"object","required":["version","created_at","created_by","action","is_current"],"properties":{"action":{"type":"string","description":"What was done to produce it: \"Removed rows found in list.csv\", \"Kept\none row per Email\", \"Restored v2\". Empty for the version a file arrived\nas, and for versions written before this was recorded.\n\nWithout it the history says who and when but not what, which is the\npart you need when deciding which version to go back to."},"created_at":{"type":"string"},"created_by":{"type":"string","description":"Who saved this version. Empty for the version the file arrived as,\nbecause nobody made that one."},"is_current":{"type":"boolean"},"parent_version":{"type":["integer","null"],"format":"int64","description":"The version this one was saved from. Null for the version a file\narrives as, and for versions that predate this being recorded."},"row_count":{"type":["integer","null"],"format":"int64","description":"How many rows the file had at this version, so the history shows the\nshape of it changing. Filled in on first look for versions that predate\nthis being recorded, and for the version a file arrives as, which is\nwritten before the conversion that would know the answer."},"version":{"type":"integer","format":"int64"}}},"StartImportRequest":{"type":"object","description":"What to import, and how.","required":["file_key","campaign_id","column_mapping"],"properties":{"campaign_id":{"type":"string"},"column_mapping":{"type":"object","description":"CSV header name → lead field. Unmapped headers land in custom_data.","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"csv_headers":{"type":["array","null"],"items":{"type":"string"}},"distribute":{"type":"boolean","description":"Distribute the imported leads rather than only storing them."},"email_enabled":{"type":"boolean","description":"Run email validation during the import."},"file_key":{"type":"string","description":"Key returned by the upload step."},"file_name":{"type":["string","null"]},"hlr_enabled":{"type":"boolean","description":"Run HLR phone verification during the import."},"total_rows":{"type":["integer","null"],"format":"int64"}}},"StartImportResponse":{"type":"object","required":["job_id"],"properties":{"job_id":{"type":"string"}}},"StatusCount":{"type":"object","description":"One stage of a funnel: how many leads ended in this delivery status.","required":["status","count"],"properties":{"count":{"type":"integer","format":"int64"},"status":{"type":"string"}}},"SuppressBody":{"type":"object","description":"Compare this file with another and keep one side of the answer.\n\nNamed `SuppressBody` still because the endpoint is `/suppress` and renaming\nit would break the clients generated from this spec. What it does is more\ngeneral: with `keep = \"matching\"` the same machinery answers \"which of\nthese rows are on the eligibility list\", which is the same question as\nsuppression with the sign flipped.","required":["col","against_sheet_id","against_col"],"properties":{"address_col":{"type":["string","null"],"description":"A second column on each side, matched together with the first. For\naddresses this is the house number: a postcode matches a street, not a\nbuilding, so an address comparison without it is wrong by a factor of\nhowever many houses are on the road."},"against_address_col":{"type":["string","null"]},"against_col":{"type":"string","description":"The column in that sheet to match against."},"against_sheet_id":{"type":"string","description":"The sheet holding the values to compare against."},"col":{"type":"string","description":"The column in this file to match on."},"keep":{"type":["string","null"],"description":"`missing` (the default) keeps rows NOT found in the other file, which\nis suppression. `matching` keeps the rows that were found, which is an\neligibility check."}}},"TestDestinationBody":{"type":"object","description":"Fire a real request at an endpoint to see what comes back.","required":["url"],"properties":{"body":{"type":["string","null"],"description":"Raw request body, if the method takes one."},"headers":{"type":["array","null"],"items":{"$ref":"#/components/schemas/ConfigPair"}},"method":{"type":["string","null"]},"timeout_ms":{"type":["integer","null"],"format":"int64"},"url":{"type":"string"}}},"TestDestinationResponse":{"type":"object","description":"What the endpoint said.\n\nA connection failure is a *successful* test — the test worked, the endpoint\ndid not — so this comes back on a 200 with `ok: false` rather than as an\nerror response.","required":["ok","duration_ms","response_body"],"properties":{"duration_ms":{"type":"integer","format":"int64"},"error":{"type":["string","null"],"description":"Transport-level failure: DNS, TLS, timeout, refused."},"ok":{"type":"boolean"},"response_body":{"type":"string"},"status":{"type":["integer","null"],"format":"int64","description":"HTTP status the endpoint returned, absent if it never answered."}}},"TimeSeriesPoint":{"type":"object","description":"A single point on a time series. `date` is a pre-formatted display label in\nEurope/London, matching the bucketing the server applied.","required":["date","count"],"properties":{"count":{"type":"integer","format":"int64"},"date":{"type":"string"}}},"TopCampaign":{"type":"object","required":["campaign_id","campaign_name","leads","qualified","revenue"],"properties":{"campaign_id":{"type":"string"},"campaign_name":{"type":"string"},"leads":{"type":"integer","format":"int64"},"qualified":{"type":"integer","format":"int64"},"revenue":{"type":"number","format":"double"}}},"UnlinkDestinationResponse":{"type":"object","required":["destination_id","group_id","destination_deleted"],"properties":{"destination_deleted":{"type":"boolean","description":"**True when the destination row itself was deleted**, not just unlinked.\n\nRemoving a destination from its last remaining group deletes the\nunderlying `destinations` row and its filters outright — that is the\nexisting engine behaviour, not something added here. A caller must be\nable to tell the operator which of the two just happened, because one is\nreversible by re-linking and the other is not."},"destination_id":{"type":"string"},"group_id":{"type":"string"}}},"UpdateAlertBody":{"type":"object","description":"Changes to an alert rule. Every field is optional; omitted means unchanged.","properties":{"campaign_ids":{"type":["array","null"],"items":{"type":"string"},"description":"Replaces the list wholesale. An empty array means every campaign."},"enabled":{"type":["boolean","null"]},"event_type":{"type":["string","null"]},"name":{"type":["string","null"]},"new_lead_scope":{"type":["string","null"],"description":"`all`, `not_duplicate` or `sold`. Only used by `new_lead`."},"notify_in_app":{"type":["boolean","null"]},"notify_push":{"type":["boolean","null"]},"threshold_count":{"type":["integer","null"],"format":"int64"},"threshold_pct":{"type":["number","null"],"format":"double"},"window_minutes":{"type":["integer","null"],"format":"int64"}}},"UpdateCampaignBody":{"type":"object","description":"Edit a campaign's settings. Every field optional; only what is sent changes.","properties":{"active":{"type":["boolean","null"]},"description":{"type":["string","null"]},"hash_format":{"type":["string","null"],"description":"`plain`, `md5` or `sha256`. Set this when the campaign holds a\nsuppression list a partner sent already hashed; the engine then hashes\neach incoming lead the same way before comparing."},"is_reference_list":{"type":["boolean","null"],"description":"A reference list holds match values for the `campaign_match` filter, not\nreal leads, so its rows are kept out of dashboards and reports."},"name":{"type":["string","null"]},"settings":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CampaignSettings","description":"Merged over the stored settings, never replacing them wholesale: a save\nof two keys must not drop `fields`, suppression lists or HLR config the\ncaller never knew about."}]}}},"UpdateCampaignFieldsBody":{"type":"object","description":"Replace a campaign's custom field definitions.","required":["fields"],"properties":{"fields":{"type":"array","items":{"$ref":"#/components/schemas/CampaignField"},"description":"The complete list. Sending fewer removes the rest, so a client must send\nwhat it read, minus what the operator deleted."}}},"UpdateCampaignResponse":{"type":"object","required":["campaign_id","changed"],"properties":{"campaign_id":{"type":"string"},"changed":{"type":"boolean"}}},"UpdateGroupDestinationBody":{"type":"object","description":"Edit one destination's link into a routing group.\n\n**Link-scoped only, on purpose.** Name, endpoint, delivery method and\ntimeout live on the shared `destinations` row, and a destination can be\nlinked into several groups — Endpoint One, Two and Three are each in three\ndifferent groups on the live system, which is only possible because the\ndestination carries its own defaults and each link overrides them. Editing\nthose fields from a screen that says \"this group\" would silently change\nevery other group using the same destination. They belong to a destination\neditor, not here.\n\nEvery field is optional; only what is sent is changed.","properties":{"active":{"type":["boolean","null"]},"active_days":{"type":["array","null"],"items":{"type":"integer","format":"int64"},"description":"Days this link accepts leads, as day indices with **Monday = 0** through\nSunday = 6, evaluated in Europe/London. An empty array means every day;\nsee `check_active_days` in the distribution worker. These are numbers,\nnot day names — sending strings makes the engine fail to parse the array\nand silently fall back to \"every day\", quietly removing the gate."},"buying_price":{"type":["number","null"],"format":"double","description":"Overrides the destination's own default price for this link only."},"daily_cap":{"type":["integer","null"],"format":"int32","description":"0 means unlimited."},"monthly_cap":{"type":["integer","null"],"format":"int32"},"priority":{"type":["integer","null"],"format":"int32","description":"Trial order within the group when its method is `priority`."},"smart_filters":{"type":["array","null"],"items":{},"description":"Replaces this link's filters wholesale. Same shape as a group's:\nthe vocabulary is served by `GET /api/v1/meta/enums`."},"suppression_campaign_ids":{"type":["array","null"],"items":{"type":"string"},"description":"Campaigns whose leads this link refuses, by id. Replaces the list\nwholesale; an empty array clears it. Omit to leave it alone.\n\nAny campaign can be a suppression source — there is no separate kind —\nso this accepts the id of any campaign in the tenant."},"weekly_cap":{"type":["integer","null"],"format":"int32"},"weight":{"type":["integer","null"],"format":"int32","description":"Share of traffic when the group's method is `weighted`."}}},"UpdateGroupDestinationResponse":{"type":"object","description":"Result of editing a group-destination link.","required":["destination_id","group_id","changed"],"properties":{"changed":{"type":"boolean","description":"Whether anything actually changed."},"destination_id":{"type":"string"},"group_id":{"type":"string"}}},"UpdateRouteGroupBody":{"type":"object","description":"Edit a routing group.\n\nEvery field is optional: only what is sent is changed, so a client can save\none field without round-tripping the rest.","properties":{"active":{"type":["boolean","null"]},"delay_seconds":{"type":["integer","null"],"format":"int32"},"delivery_count":{"type":["integer","null"],"format":"int32"},"distribution_method":{"type":["string","null"],"description":"One of the `distribution_methods` served by `GET /api/v1/meta/enums`."},"filters_enabled":{"type":["boolean","null"]},"group_daily_cap":{"type":["integer","null"],"format":"int32","description":"0 means unlimited."},"group_monthly_cap":{"type":["integer","null"],"format":"int32"},"group_weekly_cap":{"type":["integer","null"],"format":"int32"},"group_weight":{"type":["integer","null"],"format":"int32","description":"Share of traffic when several groups sit at the same priority."},"name":{"type":["string","null"]},"priority":{"type":["integer","null"],"format":"int32"},"smart_filters":{"type":["array","null"],"items":{},"description":"The group's smart filters, replacing the existing set wholesale.\n\nEach entry's shape depends on its `type`, and that vocabulary — the\ntypes, their config keys and the operators each accepts — is served by\n`GET /api/v1/meta/enums`. It is deliberately not modelled per variant\nhere: a second copy of the filter vocabulary in this file is exactly\nthe drift the served-enums endpoint exists to prevent. Each entry\ncarries `logic: \"and\" | \"or\"` to say which block it belongs to."}}},"UpdateRouteGroupResponse":{"type":"object","description":"Result of editing a routing group.","required":["group_id","changed"],"properties":{"changed":{"type":"boolean","description":"Whether anything actually changed. A save that matches the stored values\nis not an error, but the UI should not claim it updated something."},"group_id":{"type":"string"}}},"UpsertDestinationBody":{"type":"object","description":"Create or update a destination. Every field optional on update.","properties":{"active":{"type":["boolean","null"]},"buyer_id":{"type":["string","null"]},"config":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/DestinationConfig","description":"Replaces query params and headers. Other config keys are preserved."}]},"delivery_method":{"type":["string","null"],"description":"`direct` or `ping_post`, per `delivery_methods` in the served enums."},"endpoint_url":{"type":["string","null"]},"method":{"type":["string","null"],"description":"HTTP verb, e.g. `POST`."},"name":{"type":["string","null"]},"ping_endpoint":{"type":["string","null"]},"post_endpoint":{"type":["string","null"]},"timeout_ms":{"type":["integer","null"],"format":"int64"}}},"UpsertDestinationResponse":{"type":"object","required":["destination_id"],"properties":{"destination_id":{"type":"string"}}},"VerifyJob":{"type":"object","description":"One HLR verification run over an uploaded file.","required":["job_id","file_name","status","rows_total","rows_processed","rows_verified","rows_invalid","created_at"],"properties":{"completed_at":{"type":["string","null"]},"created_at":{"type":"string"},"error_message":{"type":["string","null"]},"file_name":{"type":"string"},"job_id":{"type":"string"},"rows_invalid":{"type":"integer","format":"int64","description":"Numbers HLR rejected, including ABSENT subscribers."},"rows_processed":{"type":"integer","format":"int64","description":"Rows looked up so far. This is what a progress bar reads; it moves\nduring the run, where `rows_verified` only settles at the end."},"rows_total":{"type":"integer","format":"int64","description":"Rows in the file. 0 until the header pass finishes."},"rows_verified":{"type":"integer","format":"int64","description":"Numbers HLR confirmed as reachable."},"status":{"type":"string","description":"`pending`, `processing`, `completed` or `failed`."}}},"VerifyJobListResponse":{"type":"object","required":["jobs","pagination"],"properties":{"jobs":{"type":"array","items":{"$ref":"#/components/schemas/VerifyJob"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}},"tags":[{"name":"leads","description":"Lead search and detail"},{"name":"dashboard","description":"Aggregate tiles and charts"},{"name":"reports","description":"Campaign and buyer performance"},{"name":"entities","description":"Buyers and suppliers"},{"name":"destinations","description":"Delivery endpoints (transport only)"},{"name":"alerts","description":"Alert rules"},{"name":"imports","description":"CSV import jobs"},{"name":"campaigns","description":"Campaign list for filters"},{"name":"meta","description":"Served enums: the vocabulary the engine implements"}]}