Base URL: https://middleware.kurnl.ca/partner. All calls send X-API-Key; the ones marked verified also require X-Verified-Session (see Authentication). Every response is a typed, documented schema — the same shapes are rendered in the interactive Swagger. Internal fields (Stripe IDs, operator notes, MAC, user_id) are never exposed.

Identify

Search customers

GET /agent/customers/search?q={term}&limit={n} Matches contact name, email, phone, short account id, invoice address, building/unit and service location. Returns masked matches only — enough to identify a caller, not enough to leak PII before verification.
Disambiguation. When ambiguous is true (match_count > 1), ask the caller for their service address or unit, append it to q, and search again — service_locations on each candidate is the discriminator. Never auto-select a match; if it stays ambiguous, ask another distinguishing detail or escalate.

Get a customer

GET /agent/customers/{customerId} Full profile — contacts, invoice address, and your provider’s service locations and subscriptions. Requires a verified session (verify the caller first). Returns 404 if the customer is not owned by your provider (no cross-provider enumeration), and for a customer whose personal data your provider erased on their request. Internal fields are never returned. For a customer another provider also serves, only your provider’s lines appear.

Verify

See Authentication for POST /agent/verify/issue and POST /agent/verify/check. verify/issue accepts channel: "email" (default) or "sms" (SMS is delivered via Twilio and is inert until configured — returns 503 dependency_unconfigured otherwise).

Answer

Billing summary

GET /agent/customers/{customerId}/billing-summary Invoices and AutoPay cover your provider’s subscriptions only. payment_method_on_file means a card saved in your provider’s Stripe account.

Payment history

GET /agent/customers/{customerId}/payments The customer’s payments. (kurnl has no separate payments table — a paid invoice with its Stripe payment-intent is the payment record — so this is derived from paid invoices.)

Network health

GET /agent/customers/{customerId}/network-health Read-only provisioning/activation status of each of your provider’s subscriptions, plus modem (CPE) status from the ACS when available. No switch control, no live-switch queries.

Communication history

GET /agent/customers/{customerId}/tickets The customer’s support tickets with your provider — in your groups, or escalated by you to kurnl — read by their email on file. Another provider’s tickets for the same person never appear.
priority is low, normal, high or urgent (support staff may raise a ticket to urgent; the create takes the first three). state is the ticket’s state name, compared as written: the ticketing system’s new, open, pending reminder, pending close, merged and closed, and kurnl’s In Progress, Waiting on Customer, Waiting on Third Party, Scheduled and Resolved.

Act

Preview a plan change

POST /agent/customers/{customerId}/plan-change/preview Returns the exact billing impact — recurring change plus the one-time prorated adjustment for the rest of this calendar month — without applying anything. Use it to explain the cost before confirming.
Breaking change (September 2026): one_time_adjustment may now come back null (see the three cases below); its type value that used to read charge now reads next_invoice_line. proration_factor (on the response) and due_in_days (inside one_time_adjustment) are deprecated and will be removed in the next release — read days / days_in_period instead (see below).
one_time_adjustment is nullable. It comes back null, with an explanatory note and a normal 200, in three cases:
  • the current calendar month has not been invoiced yet — the monthly invoice will simply reflect the new price, so there is nothing to charge or credit today (note: “This month has not been invoiced yet; the monthly invoice will reflect the new price.”)
  • an invoice exists for the month but its billing-document row is missing (note: “invoice exists, billing document missing — reconcile”)
  • the billing-document row exists but the invoice record is missing (note: “document exists, invoice record missing — reconcile”)
The last two mean the invoice and the correction-tracking document disagree with each other — read that as an operator-escalation state, not a number to quote to the caller.When it is not null, one_time_adjustment.type is one of:
  • next_invoice_line — an upgrade. Nothing is charged today; the amount appears as a labelled line on the customer’s next monthly invoice.
  • credit — a downgrade. The amount is credited now and applied to the next invoice.
  • none — the correction rounds to less than $0.50 and is skipped rather than issued; amount is 0.00. It isn’t lost — it’s folded into whichever correction runs next.
days / days_in_period replace the old proration_factor: the billing period is the calendar month, so the adjustment is price difference x days / days in that month. There is no due_in_days any more — an upgrade is never billed on its own; it always rides the next monthly invoice. A second plan change on the same calendar day is refused with 409 detail.code = "change_limit_reached" — a change day is billed at the new price, so the next change is possible from tomorrow; only a kurnl platform admin can override that, and the AI cannot. While another change to the same line is still running, the answer is 409 action_in_progress: nothing changed, try again later.

Apply a plan change

POST /agent/customers/{customerId}/plan-change Applies the change. Requires confirm: true — call it only after the customer has seen the preview and agreed. Proration is handled automatically.
Plan-change apply is live wherever your key has access — there is no sandbox gate: a call with confirm: true applies a real, billing-affecting change in production. Use it deliberately. Planned-but-not-shipped hardening: a signed quote_token (preview→apply), an Idempotency-Key, and a change-confirmation/rollback flow (all planned, none shipped). Validate the read + verify + escalate loop first.
Without confirm: true the call returns 400 confirmation_required.

Update contact fields

PATCH /agent/customers/{customerId}/contact Update the account holder’s contact (contract) or the billing contact (invoice). The change goes through the same rules as an edit in the dashboard and applies immediately — there is no confirmation email or rollback yet (see Change confirmation & rollback).
Changing where the invoices go:
  • contact_type: contract (default) | invoice. Only the fields you send are changed; empty fields are ignored.
  • contract — name, phone and company. The holder’s e-mail is their sign-in address and is refused with 422 email_is_sign_in_address; the provider changes it in the dashboard. Where the invoices go to the holder, the billing contact follows the change. The change is recorded in the audit log under your provider.
  • invoice — the billing contact. If the invoices still go to the account holder, the change creates a separate billing contact and leaves the holder untouched. Every change is synced to billing; a changed billing e-mail also notifies the old address, not the new one. Refusals: email_required, email_invalid, name_required, same_as_primary (the details are the holder’s own — the invoices already go to them), all 422.

Password reset / resend activation

POST /agent/customers/{customerId}/password-reset Emails the customer a self-service portal link — a password-reset link (15-min token) or a re-sent account set-up link (14-day token; the provider’s “Account setup email” template). The customer completes it themselves. The email comes from your provider and the link opens your customer portal, also for a customer another provider serves.
  • action: password_reset (default) | resend_activation.
  • resend_activation only applies while the customer has not yet chosen a password; afterwards it answers 409 already_activated — send a password_reset instead.

Resend an invoice

POST /agent/customers/{customerId}/invoices/{invoiceId}/resend Emails a copy of the invoice to the customer: your provider’s own invoice e-mail, with the PDF when that template attaches it. It goes to the customer’s invoice contact.
  • 409 no_invoice_template: your provider has no active invoice e-mail template.
  • 422 no_address: the customer has no e-mail address to send to; 422 also when the invoice has no billing document yet.
  • 502 not_sent: the e-mail could not be sent; try again later.

Add a support note

POST /agent/customers/{customerId}/notes Append an internal note to the customer’s record, attributed to the AI agent. Your staff see it on the customer’s Activity tab. Never shown to the customer.

Escalation

Create a ticket

POST /agent/tickets Opens an enriched support ticket routed to the provider’s support group. Not verification-gated, so it works even when the caller can’t be verified — the primary escalation path on repeated auth failure or any downstream error. Provide customer_id (must be your provider’s) or a contact_email.
  • priority: low | normal | high.
  • ai_summary and ai_diagnosis go into the customer-facing article; transcript is stored as an internal note.

Change confirmation & rollback

Not yet implemented. Contact/personal-data changes are applied immediately with no confirmation email and no rollback link — do not present this as a safeguard to callers or rely on it as a mitigation against socially-engineered verification. Two limits do hold: the agent cannot change the account holder’s e-mail (their sign-in address), and a changed billing e-mail notifies the old address — a notice to the address that stops receiving the invoices, not a confirmation, and the new address is not told. An out-of-band confirmation + rollback flow is planned; until it ships, treat every agent-initiated change as final and escalate doubtful requests to a human instead.

Rate limits

Exceeding a limit returns 429 (standard rate-limit response; not the error envelope below). Back off and retry.

Errors

Most /agent errors return a normalized envelope with a stable, machine-readable code and a request_id you can quote to support — so your agent branches on the code, not the message:
Envelope gaps — handle plain {"detail": "…"} bodies too: the plan-change apply endpoint delegates internally and returns plain HTTPException bodies without a code (400 “Only active subscriptions…”, 400 same-plan, 404 “New plan version not found”, 400 cross-provider); truly unhandled errors return FastAPI’s default 500 {"detail": "Internal Server Error"}; and an account with API access disabled surfaces as 403 with code: invalid_api_key (not agent_access_disabled) because auth errors are re-wrapped. Branch on code when present, fall back to detail + status otherwise.
Recommended default on downstream (502/503) and server (500) errors: graceful handover — create an escalation ticket and offer a human callback rather than retrying. Back off on 429, re-verify on session errors. These behaviours are configurable to your agent design.