This page records changes to the partner-facing surface: endpoints you call, payloads you send, errors you handle, and corrections to these pages. Internal platform work is not listed. Each entry ends with Check your integration when there is something to act on. If you built against these docs before a dated entry below, read that line.
Dates are when the change landed on the lab environment. Production follows in a later promotion. Entries from 2026-10-26 on say whether they are live in production (middleware.kurnl.ca); for older entries, ask your kurnl contact.

2026-11-04 — Equipment tab: one Connection & device section

Production: live since 2026-11-05 (batch 209).
  • The provider dashboard’s customer Equipment tab and an anonymous line’s page now show one Connection & device section: line state, router state, unit, building, speed, device, the customer’s own Wi-Fi network, and an Open device button. On the customer tab a Connection path button opens the path page; on an anonymous line’s page the path is its own card below. Check live status, Run service check and the Reboot button on the tab are gone; Reboot is on the device page.
  • The Health badge reads Building outage, Line down or Device offline where they apply, and clears by itself once the line is back. The red outage banner appears only for a building-wide outage.
  • The connection path shows Unit ONT, Building switch and Kurnl network without device names, ports or ids.
  • Updated: Installs & equipment, Add a customer & subscription.
  • No change to the partner API.
Check your integration: nothing to change.

2026-11-03 — A changed billing e-mail notifies only the old address

Production: live since 2026-11-03 (batch 200). Check your integration: nothing to change, unless you told your customers to expect a mail at the new billing address.

2026-11-02 — Operator discount codes on your own checkout

Production: live since 2026-11-02 (batch 196).
  • The session carries operator_offers: every kurnl operator discount the shopper may use on the session’s building, with the code, headline, duration and the monthly price it gives on the plan.
  • discount_code on the session form of /external-checkout/complete and /external-checkout/anonymous-complete is now honoured, checked as of the hand-over day: a kurnl operator code from the session’s operator_offers or one of your own discount codes. Anything else answers 400 Invalid or expired discount code and creates nothing; on a bulk plan a code answers 422 bulk_discount. The session’s own code sent back counts as no code.
  • kurnl’s own marketplace no longer takes a code for a checkout you host.
Check your integration: if you price from the session, read operator_offers. If you sent discount_code on a session completion before, it was ignored — it now counts: send only a code from operator_offers or one of your own, and handle 400 Invalid or expired discount code and 422 bulk_discount on both completion routes.

2026-11-02 — install.booked can say booked_by: "provider"

Production: live since 2026-11-02.
  • A provider’s staff can now book, move or cancel a third-line technician visit for their customer in the dashboard. A booking or a move made there fires install.booked with booked_by: "provider"; the customer’s own booking keeps customer, kurnl staff keep staff.
  • install_path provider_visit on the session status is described as “a visit kurnl schedules” (it always was kurnl’s); the value itself is unchanged.
Check your integration: if you switch on booked_by, accept provider.

2026-10-29 — Communication preferences: one channel per topic

Production: live since 2026-10-29.
  • GET and PATCH /partner/subscriber/me/preferences carry five channel keys — billing_channel, security_channel, outages_channel, support_channel, marketing_channel — with the values email, sms, both or off as each topic allows, plus updated_at, sms_available and has_phone. The booleans marketing_email, outage_notifications and sms_transactional are now derived from the channels; a PATCH still accepts them.
  • A PATCH that adds texts — sms or both on a topic that had none, or the legacy sms_transactional: true — answers 409 no_phone when the person has no phone number on file, next to the existing 409 sms_not_available. A channel word a topic does not offer (for example marketing_channel: "sms") answers 422.
  • Texts: the billing, outage and ticket events with an active SMS template now text customers who chose sms or both; a customer who chose sms for billing, outages or support is no longer e-mailed for that topic.
Check your integration: if you read sms_transactional to decide whether to show a texts option, read the topic channels instead; if you write it, nothing breaks — it maps onto the channels.

2026-10-28 — A refused password answers 400

Production: live since 2026-10-29.
  • When the sign-in service refuses the subscriber.password of a new account, POST /partner/provisioning/provider-initiated and POST /partner/external-checkout/complete answer 400 with its reason as detail, for example "Password should be at least 6 characters.". Nothing was created; a session can be completed again with another password. Before, they answered 500 "Provisioning failed". A password under 8 characters is still refused with 422 by the request model first.
  • POST /partner/provisioning/bulk reports the same reason as the failed item’s error, where it said Provisioning failed before.
  • External checkout complete now shows the 8-character minimum of subscriber.password, which the request model always had.
Check your integration: on a 400 for these routes, ask the subscriber for another password; sending the same one again fails again.

2026-10-27 — An erased customer is not found

Production: live since 2026-10-28.
  • A customer whose personal data the provider erased on their PIPEDA deletion request is no longer found by the agent API: GET /partner/agent/customers/search leaves them out, and every /partner/agent/customers/{id} route answers 404 not_found, as for a customer that is not the provider’s. Before, an erased customer was still found, with its contact fields empty.
Check your integration: nothing to change if you already treat 404 as “not found”.

2026-10-27 — One change to a line at a time: 409 action_in_progress

Production: live since 2026-10-28.
  • A change to a subscription now runs for one request at a time per line. A second request for the same line while the first is still running answers 409 {"detail": {"code": "action_in_progress", "message": "…"}} and changes nothing. Before, two concurrent requests could both run, and a double cancel could issue a second final invoice and credit note. Affected: POST /partner/subscriber/me/subscriptions/{id}/cancel, POST /partner/subscriptions/{id}/change-plan, the suspend, restore and cancel actions of POST /partner/external-checkout/subscription-status, POST /partner/agent/customers/{id}/plan-change, and the internal /api/v1/subscription/{id}/cancel.
  • A cancel that arrives after another one finished answers 409 already_cancelled.
  • POST /partner/agent/customers/{id}/plan-change refuses a second change the same day with detail as an object, {"code": "change_limit_reached", "message": "…"}; before, detail was the sentence alone. POST /partner/subscriptions/{id}/change-plan keeps its sentence.
  • The read-then-act race the plan-change page described is closed.
Check your integration: handle 409 with detail.code = "action_in_progress" by retrying later (not as a final refusal); on the agent plan change read detail.message for the sentence.

2026-10-27 — Operator discounts: discount_code on partner creates, wholesale discount rows

Production: live since 2026-10-26.
  • Kurnl can now run its own operator discounts on chosen buildings, with a code, a start and an end date per building. Kurnl pays: the customer’s price and the wholesale price drop by the same amount for the promo months, so the provider’s margin is unchanged. A provider can opt out of them in the dashboard (Billing → Discounts → Operator discounts). If the plan’s own campaign is cheaper, the campaign stays (compared on the monthly promo price only; a tie keeps the campaign).
  • POST /checkout/validate-discount-code takes optional building_id / location_identifier and answers source (provider | operator), label, promo_price_monthly, list_price_monthly, duration_months, setup_fee, applied and reason (campaign_better, or unavailable when the check could not run, a temporary read failure). A bulk plan answers valid: false. An operator code can now be “expired”.
  • POST /partner/external-checkout/sessions takes discount_code (400 Invalid or expired discount code on a miss, before the session exists); the hosted URL gains &discount_code=. GET /partner/external-checkout/sessions/{id} returns discount_code and operator_discount (code, name, discount_type, discount_value, duration_months, list_price_monthly, promo_price_monthly; 503 if it cannot be read). The public session status returns discount_code and operator_discount_name. At completion kurnl applies its side (the wholesale reduction); the partner passes the discount on to the customer. A code that was valid at the hand-over is honoured at completion; an archived code lapses and the order goes through at campaign or list terms.
  • complete and anonymous-complete take discount_code only in their explicit-fields (sessionless) form; with a session_id the session’s stored code is used and the body’s is ignored. POST /provisioning/provider-initiated and POST /provisioning/bulk accept discount_code. A miss is a 400 (per item in a bulk batch).
  • The billing CSVs (GET /partner/reports/billing, the dashboard’s Wholesale Billing CSV) carry an extra row per subscription and month with Fee Type = Operator discount, the label in Wholesale Product Name, and negative Total Cost and Wholesale price monthly. Fee Type is no longer always Monthly.
  • A plan change carries an operator discount over to the new plan; a provider’s campaign still ends.
Check your integration: read the billing CSV by Fee Type and expect negative rows; pass the shopper’s discount on at your checkout when the session carries discount_code; handle 400 Invalid or expired discount code on the create routes that now take the field.

2026-10-26 — Anonymous lines: what is live in production

Production: live since 2026-10-26. For partners who sell anonymous lines (CKO-03 anonymous-complete), this is what production does since 2026-10-26. The details are in the entries below and in the anonymous checkout guide.
  • Webhooks: subscription.activated fires on every activation path, install.booked tells you about booked or moved visits, and every event carries external_subscription_id and is_anonymous. An optional Backup notification e-mail (dashboard, Checkout page) gets each event by mail as well.
  • Cancel: POST /partner/external-checkout/subscription-status with action: "cancel" and an optional end_date.
  • Mails to your customers: kurnl sends service mails (outage, restored, router shipped, ticket received, visit booking) to the contact_email you send; lifecycle mails only where the provider switched them on; never billing or account mails.
  • Plan change to another provider’s plan: refused with 403 and a message that says to cancel the line and order a new one.
  • Wi-Fi set-up link: live in production since 2026-10-27; see Wi-Fi set-up for anonymous lines below.
Check your integration: answer 2xx to install.booked; read kind on subscription.activated; make sure contact_email is the household’s address and tell your customers to expect kurnl’s service mails.

2026-10-26 — Plan change to another provider on an anonymous line

Production: live since 2026-10-26.
  • A plan change that targets another provider’s plan on an anonymous line (no customer record) is refused with 403 and the message This line has no customer record and cannot switch provider. Cancel it; the new provider orders a new line. Before, the message was the general one about change-provider.
  • Providers get a full page for anonymous lines in the dashboard: health, equipment, connection path, visits and tickets. No API change.
Check your integration: if you show the 403 text of a plan change to your user, expect this second message for anonymous lines, and offer to cancel the line instead.

2026-10-26 — first_charge is the invoice’s gross, tax included, before credits

Production: live since 2026-10-26.
  • The reactivation preview (GET /partner/subscriber/me/subscriptions/{id}/reactivation) quoted billing.first_charge net of tax, and on the activate path as the setup fee plus a whole month, while the action bills the setup fee plus the partial month from today, with tax. first_charge is now the gross of the invoice the action creates today — the same lines, tax and discount, before any credit on the account — and null when it cannot be computed right now.
  • The reactivation answer’s first_charge (path: "remote") is the true-up invoice’s gross as well, no longer the net; the reactivated e-mail states the same figure, also after a technician visit.
  • The order preview (GET /partner/subscriber/me/orders/preview) quotes the first invoice’s gross on the provision route: the setup fee plus the partial month from today, tax included, before credits — no longer the setup fee plus a whole month.
  • billing.has_payment_method reads the customer’s default card in the provider’s Stripe account (the card every charge uses), no longer a stored column.
Check your integration: show first_charge as an amount including tax, and say that a credit on the account is applied first.

2026-10-26 — Wi-Fi set-up for anonymous lines

Production: live since 2026-10-27.
  • kurnl now mails the household of an anonymous line a link to set up its Wi-Fi, once per line, to the contact_email you sent. It goes out when the router first makes contact, or when the line goes live.
  • New: POST /partner/subscriptions/{id}/wifi-link sends a new link (works once, 7 days, at most 5 per line in 24 hours). PUT /partner/subscriptions/{id}/wifi sets the network name and password yourself. Both need the provisioning:write scope on X-API-Key, and accept ?by=external_subscription_id.
  • Errors: 404 for a line that is not yours, 409 with named_line, no_contact_email, no_portal or the reason the line cannot carry Wi-Fi, 422 invalid_wifi, 429 wifi_link_limit. Passwords are never returned.
Check your integration: your anonymous customers will now get a Wi-Fi mail from kurnl. If you run your own Wi-Fi step, you can keep it, or use the new calls. Your key needs provisioning:write for them.

2026-10-26 — Campaign discounts are billed; plan lists carry promo; discount codes work on every marketplace order

Production: live since 2026-10-26.
  • A plan’s campaign discount was shown to the customer but never billed. A line created with one is now billed at the promotional price from activation, for the duration’s full calendar months (the first, partial month is at the promotional price too), then at the plan’s price. A plan change ends the promotion; a cancelled line keeps it until it is reactivated, which ends it. The invoice’s plan line names the campaign, for example Spring 20 % promotion · regular price $80.00/mo.
  • Plan versions from GET /partner/marketplace/plans/me and GET /partner/marketplace/plans/{id} carry promo: price_monthly (null for a campaign that only overrides the setup fee), list_price_monthly, label, campaign_name, duration_months (null = for as long as the line runs) and setup_fee (only when overridden); null without a promotion. GET /partner/plans/versions, the plan-change list and the plans of POST /serviceability/check carry no promotion (promo is null or absent) — read it from the marketplace plan reads — a plan change ends the promotion and charges the new plan’s regular price. The customer portal’s GET /partner/subscriber/me/orders/options carries promo for its plans too. The order preview (GET /partner/subscriber/me/orders/preview) gains plan.promo_setup_fee and plan.promo_ends_after_months.
  • Partner provisioning creates apply the plan version’s campaign (never a code). The discount code, which replaces the campaign, now works on all three marketplace order kinds: POST /partner/checkout/home-drop-checkout and /unit-checkout gained the optional discount_code that /create-subscription-checkout already took.
  • The discount types are PERCENTAGE, FIXED_MONTHLY and OVERWRITE_MONTHLY; any other value is refused with 422. The docs showed "percentage" in the discount-code check; the value is PERCENTAGE.
Check your integration: show promo from the marketplace plan version instead of computing a price from the campaign embeds (do not read it from /partner/plans/versions or the plan-change list, where it is always null), and expect a line created with a campaign to bill the promotional price.

2026-10-26 — Anonymous lines receive kurnl service mails

Production: live since 2026-10-26.
  • For an anonymous line, kurnl now sends service mails to the contact_email you send: outage, service restored, router shipped and ticket received, together with the visit booking mails.
  • Where the provider switched a template on for anonymous lines, lifecycle mails (welcome, plan changed, cancellation, service deactivated, visit completed) go there too. Billing and account mails are never sent to an anonymous line.
  • The greeting uses the name from the installation booking, or “Hello,” when there is none.
Check your integration: your anonymous customers will now get kurnl service mails at the contact_email you send. Make sure that address is the household’s, and tell them to expect these mails.

2026-10-26 — Anonymous lines: activation and visit events, partner cancel, backup e-mail

Production: live since 2026-10-26.
  • New webhook install.booked: a technician visit was booked or moved. Fields: appointment_start, appointment_end (ISO 8601 with offset), booked_by (customer or staff), rebooked.
  • subscription.activated now fires whenever a line goes live, including the home-drop, confirm-install and pre-order paths, which sent nothing before. It carries kind (first or reactivated), activated_at and plan_version_id. A line kurnl brings back sends it again with kind: "reactivated"; a restore you request through the status call does not.
  • Every webhook event now also carries external_subscription_id and is_anonymous.
  • POST /partner/external-checkout/subscription-status accepts action: "cancel" with an optional end_date (YYYY-MM-DD, the first day without service; default today) and reason. The response gains ended_at. A past date answers 422; a line already cancelled answers 409 with detail.code = "already_cancelled" (an object, unlike the other 409s on this route).
  • New dashboard setting Backup notification e-mail (Checkout page): for anonymous lines kurnl mails every partner event to this address as well, with a switch to turn it off.
Check your integration: handle install.booked (ignore it if you do not need it, but return 2xx); expect subscription.activated for home-drop lines and read kind before you treat it as a first go-live; if your handler rejects unknown fields, allow external_subscription_id and is_anonymous on every event; handle 409 already_cancelled and 422 on cancel.

2026-10-23 — Retries of a provisioned order answer the replay again; a second line provisions; an occupied port is refused

  • A retry of an order that was already provisioned answered 400 “Subscriber already has an active subscription. Only one subscription per subscriber is allowed.” instead of the documented replay. It now answers 200 with the existing subscription and "Subscription already exists (idempotent replay)" (or "Provisioning already in progress"), as documented. This covers POST /partner/provisioning/provider-initiated, every POST /partner/provisioning/bulk item and POST /partner/external-checkout/complete.
  • A customer who already has a line (with you or with another provider) gets a second line at another location; the same check refused it with that 400.
  • A port that already carries another live order — another subscriber’s, or another plan version of the same subscriber — is refused with 409 {"detail": {"code": "port_in_use", "message": "This port already has a live subscription."}}, and a bulk item fails with error_code: "port_in_use". Before, nothing checked the port and a second line was created on it. When the port cannot be read, the order answers 503 Could not check whether the port is free; try again. Nothing is created in either case. A retry of an order whose line is in PAYMENT_FAILED answers port_in_use too, not the replay.
Check your integration: stop treating that 400 as “the customer exists”; handle 409 with detail.code = "port_in_use" (the port is taken — do not retry) on the order routes and as a bulk error_code.

2026-10-23 — Providers choose plans per region and building; orders for a plan the building does not offer are refused

A provider now chooses which plans each region (province or division) and each building offers, on the Coverage page. A provider is listed at a normal building only when it has a plan selection there (or offers an external plan); external plans are not part of the plan selection. Bulk Deal buildings keep their fixed plans and are unchanged for partners.
  • Every order path refuses a plan the building does not offer with 422 {"detail": {"code": "plan_not_offered", "message": "<Plan> is not offered in this building.", "building_id": "…"}}: POST /partner/checkout/home-drop-checkout, /unit-checkout and /create-subscription-checkout, external-checkout completion (CKO-03, named and anonymous) and provider-initiated provisioning (CKO-04). When the offer cannot be read, they answer 503 Could not verify the building's plan offer; try again. and create nothing. Sandbox keys are not checked.
  • GET /partner/marketplace/plans/me lists only plans you offer in at least one region or building. A plan with no plan selection anywhere is not listed until you offer it. Bulk and external plan versions are listed as before, and sandbox keys are not narrowed.
  • POST /serviceability/check lists your provider at a normal building only when it has a plan selection there or offers an external plan, and plans holds only the live plans it offers at that building. A normal building you cover but have no plans for is no longer serviceable; Bulk Deal buildings stay listed with their fixed plans.
  • A plan change (POST /partner/subscriptions/{id}/change-plan) is unaffected while no plan selection exists for the building: every plan of yours stays allowed. Once a selection exists, it binds: a plan left out is a 422 plan_not_offered and is missing from eligible_plan_versions.
  • On the first rollout, every plan you had was offered in every region you covered, so no building lost its listing.
Check your integration: handle 422 with detail.code = "plan_not_offered" on every order route, and do not offer a plan the address check does not list for the building. If you cache /partner/marketplace/plans/me, expect fewer plans for a provider that has not offered a new plan yet.

2026-10-23 — One reactivation at a time, an unconfirmed payment says so, and paying a failed invoice is documented

  • POST /partner/subscriber/me/subscriptions/{id}/reactivation answers 409 reactivation_in_progress while another request brings the same line back (a double submit, or the provider’s dashboard at the same time). Nothing is done; try again in a few minutes.
  • A charge Stripe did not confirm answers 409 payment_unconfirmed — on the activate path and on POST /partner/subscriber/me/invoices/{id}/pay — instead of a failure (402) or a server error. The invoice stays open and the provider checks the payment.
  • POST /partner/subscriber/me/invoices/{id}/pay is documented now, with its refusals.
  • On the activate path the first invoice covers today to the end of the month; before, it was dated from the day the cancelled order was placed.
  • The reactivation refusal list names every code the route returns (card_declined, no_payment_method, payment_unconfirmed, activation_incomplete, reactivation_incomplete, reactivation_in_progress, path_changed).
Check your integration: handle 409 with detail.code = "reactivation_in_progress" (offer a retry later) and "payment_unconfirmed" (do not retry at once; show the message) on both routes.

2026-10-21 — Every order path checks the building: coverage, speed tier and bulk offer

Every order path now asks the same three questions about the building before anything is created: does your provider cover it (a ticked division, a picked building or a contract), does the plan fit its speed tier, and does it match its bulk offer. Before, some paths skipped some of them.
  • POST /partner/checkout/home-drop-checkout and /unit-checkout now refuse with 422 speed_tier, bulk_plan and not_covered. They checked none of the three; the 2026-10-16 entry’s “every order path refuses a mismatch” was not true for these two.
  • POST /partner/checkout/create-subscription-checkout checks coverage whenever the location resolves to a building, also without an invoice postal code. Its coverage refusal is now an object, {"detail": {"code": "not_covered", "message": "This building is not covered by <provider>. …", "building_id": "…"}}, instead of a plain string; an address with no building keeps the postal-code sentence, as {"detail": {"code": "not_covered", "message": "Postal code … is not in the service area for this provider. …"}}.
  • External-checkout completion (CKO-03, named and anonymous) and provider-initiated provisioning (CKO-04) add 422 not_covered. When the line’s building cannot be read, they answer 503 Could not read the line's building; try again. instead of skipping the check.
  • GET /partner/subscriptions/{id}/change-plan/preview lists only plans within the building’s speed tier.
  • A plan change (POST /partner/subscriptions/{id}/change-plan) never asks about coverage: a customer keeps changing plan in a building your provider no longer covers.
  • When coverage cannot be read on a write, the API answers 503 Could not verify the building's coverage; try again. Nothing is created.
Check your integration: handle 422 with detail.code = "not_covered" on every order route, and read detail.message instead of detail when you show the location checkout’s coverage refusal.

2026-10-21 — A line cancelled before its install keeps its port and sends no deactivation e-mail

A subscription cancelled while it was PENDING or PENDING_INSTALL (never installed) no longer has its switch port reset to the captive portal or its CPE reset, unless kurnl had already provisioned that port for the install (the port is opened on the visit day). The customer no longer gets the “service deactivated” e-mail for a line that never ran; the cancellation confirmation is unchanged. subscription.deprovisioned still fires for these lines and deprovisioned_at is still set: read it as “the line is closed”, not as “a port was reset”. Orders that time out after 30 days without an install also drop their open technician visits. Check your integration: nothing to change if you treat subscription.deprovisioned as the end of the line. If you used it as proof that a port was reset, do not for a line that never went live.

2026-10-19 — Agent tickets: the create names the state, the list’s priority is bare

POST /agent/tickets now answers the new ticket’s state ("new"); before, it was always null. GET /agent/customers/{customerId}/tickets answers priority as low, normal, high or urgent (the create takes the first three); before, it carried the ticketing system’s ordering prefix ("2 normal"). The examples now show a ticket number as the ticketing system issues it, a string of digits ("31042"). Check your integration: if you matched the list’s priority against "1 low", "2 normal", "3 high" or "4 urgent", match low, normal, high or urgent instead. Treat number as an opaque string.

2026-10-19 — Audit log: provider admins only

GET /api/v1/audit-logs answers 403 to provider staff other than the sp_admin role (support, sales, bookkeeping), and to customer, technician and building-owner logins. Before, any provider login read its provider’s entries. The provider’s own admin account and operators are unchanged. Check your integration: if a tool reads the audit log with a provider staff login, use an sp_admin login.

2026-10-17 — First-line orders start on the move-in date

kurnl’s own checkouts (the storefront and the customer portal) now hold an order on a first-line building when the customer picks a future move-in date (requested_start_date): the card is saved, nothing is charged. kurnl then activates the line, invoices it (prorated from that day) and charges it on the move-in day, or on the go-live day when the building goes live later. presale: true on serviceability answers may now be a first-line building. The partner-mounted order route POST /partner/checkout/create-subscription-checkout (the located-port checkout) shares that behaviour; /home-drop-checkout and /unit-checkout are install paths and are unchanged:
  • When requested_start_date is ahead of the provider’s today, or the building is not orderable yet, the returned checkout_url is a Stripe setup-mode session (card saved, nothing charged) instead of a payment session. payment_required stays true.
  • invoice_ninja_invoice_id and first_invoice_total are now nullable: they are null for a waiting order, because no invoice exists yet.
  • New optional response field starts_on (ISO date): present when the order waits for its move-in day (or for the building’s go-live day); absent or null when the line starts at checkout.
Check your integration: typed clients of create-subscription-checkout must accept null for invoice_ninja_invoice_id and first_invoice_total, and may read starts_on. A checkout response with starts_on means a setup session, so do not expect an invoice id. External-checkout sessions (CKO-03) and provider-initiated provisioning (CKO-04) still take no move-in date; a later entry will announce it.

2026-10-16 — Bulk buildings: plans included in the rent

A bulk building includes internet in the rent. A bulk plan version (is_bulk: true) is your copy of a plan at retail_price_monthly: 0 and setup_fee: 0, offered only in bulk buildings; a bulk building offers only bulk plans.
  • GET /partner/serviceability/check gains "bulk": true|false next to presale, and each object in plans gains "is_bulk": true|false. In a bulk building plans holds only your bulk plan versions, at "retail_price_monthly": 0.0.
  • Every plan version the partner API returns gains "is_bulk": boolean. Bulk versions are not in GET /partner/marketplace/plans/me, which has no building context.
  • Every order and plan-change path (session create and patch excepted; the refusal comes at completion) refuses a mismatch with 422 {"detail": {"code": "bulk_plan", "message": "…", "building_bulk": true|false, "plan_bulk": true|false}}: a paid plan in a bulk building, or a bulk plan anywhere else.
  • When the building’s bulk configuration cannot be read on a write, the API answers 503 Could not verify the building's bulk offer; try again.
  • GET /partner/reports/billing (the wholesale billing CSV) no longer lists bulk lines — they are billed to the building owner, not to you.
Check your integration: render is_bulk plans at 0(yourstorefrontshowsthemasincludedintherent,notasa0 (your storefront shows them as included in the rent, not as a 0 offer), and handle 422 with detail.code = "bulk_plan" on checkout, order and plan change.

2026-10-16 — Cancel preview: payment-failed lines and the open balance

A line in PAYMENT_FAILED is still in service and still invoiced every month, so its cancel preview now quotes the same money lines as an ACTIVE one: the full months still billed, the refund or final invoice. The cancellation settles them the same way. The cancel preview (GET /partner/subscriber/me/subscriptions/{id}/cancel-preview) also carries open_balance_cents, what the customer still owes on open invoices before the cancellation. Cancelling does not clear it; the open invoices stay due. The change is additive and needs no action.

2026-10-16 — Usage months follow the provider’s time zone

GET /partner/subscriber/me/usage now counts calendar months in the provider’s time zone (the one set on the ISP profile), not in UTC. A month runs from local midnight to local midnight, month (YYYY-MM) is the local month, and the first month is the local month the subscription started in. The last month of a line that has ended is the month of its last service day: a line ending at midnight on 1 November lists October as its last month. Without a start date you get the last 6 complete local months plus the current one. The response shape is unchanged. Check your integration: if you compare usage months with your own billing or reporting months, read them in the provider’s time zone; a month near a month boundary can differ from the UTC split you saw before.

2026-10-16 — Usage and the connection timeline per line; every running line in the order options

For a customer with more than one line:
  • GET /partner/subscriber/me/usage and GET /partner/subscriber/status-history take an optional subscription_id. Without it, usage reads the primary line and the timeline lists every line’s events, as before. An id that is not one of the caller’s own subscriptions answers 404 Subscription not found.
  • Usage now stops where the line does: a line that has ended or been switched off lists no months after the earlier of its ended_at and deprovisioned_at, and its last month counts only up to that moment. Before, the current month was always read up to now.
  • Each event in status-history carries subscription_id (null on older events). With ?subscription_id= the timeline holds that line’s events only.
  • GET /partner/subscriber/me/orders/options gains running_lines, every running line a mover may end (ACTIVE first, then the newest start, then by id). current_subscription stays and is its first entry. end_current_subscription_id on the preview and the order accepts any of them.
Check your integration: if you show usage or an outage timeline for an account with several lines, pass subscription_id and label each line; if you let a mover choose which line ends, offer running_lines instead of current_subscription only.

2026-10-15 — Plan versions no longer carry a term or a cancellation charge

minimum_contract_months and early_termination_fee are no longer returned by GET /partner/marketplace/plans/me, GET /partner/marketplace/plans/{id}, the plan-change list, or the plan_version inside /subscriber/me. contract_end_at is no longer returned on a subscription. kurnl subscriptions have no minimum term; a cancellation can take any date from today on. Check your integration: do not read these fields; show no term or cancellation charge for a kurnl plan.

2026-10-14 — Plan audience: plans for new or existing customers

A plan version can now be offered to new customers only, to existing customers only, or to both. Existing means the customer has a running line with the provider; a customer who cancelled counts as new again.
  • Every public plan response carries target_audience: ALL, NEW_SUBSCRIBERS or EXISTING_SUBSCRIBERS (plans).
  • GET /partner/marketplace/plans/me, the storefront lists and the serviceability plans return only versions offered to new customers. The plan-change preview’s eligible_plan_versions returns only versions offered to existing customers.
  • POST /partner/subscriptions/{subscription_id}/change-plan now refuses a plan version outside its effective window with 400 Plan version not active, and a version for new customers only with 422 plan_audience (plan change). eligible_plan_versions is now what the POST accepts, apart from the building’s speed tier.
  • Ordering a plan for existing customers only — provider-initiated, bulk, and the CKO-03 callbacks — answers 422 with {"detail": {"code": "plan_audience", "message": "…", "target_audience": "EXISTING_SUBSCRIBERS"}} before anything is created.
  • A plan change booked for a later day is refused when the booking is made if the version is not on sale today. The hourly pass checks again on the day and cancels the change when the version has left its window or its audience no longer fits.
Check your integration: handle 422 with detail.code = "plan_audience" on order and plan-change calls, and 400 Plan version not active on plan change. Do not rely on plans/me to list a plan you want to move an existing customer to; use eligible_plan_versions.

2026-10-15 — The return-page status says how the order gets connected

GET /partner/external-checkout/sessions/{id}/status gains install_path: book_visit (a technician visit the customer books from an e-mailed link), provider_visit (a visit the ISP schedules), router_shipment (a router is shipped to the suite), or null when the order is not waiting for an install: it is only set while the status is PENDING_INSTALL, and null in every other status or when there is no subscription yet. Check your integration: if you build your own return page, branch on install_path instead of promising a technician visit for every PENDING_INSTALL.

2026-10-15 — Bulk provisioning takes first-line buildings only; reactivation comes back remotely when the router is online

  • Bulk provisioning POST /partner/provisioning/bulk: an item whose port is not in a first-line building (second or third line, an unclassified building, or a switch with no building) now fails with status: "error" and error_code: "install_required"; nothing is created for it and the rest of the batch runs. Order those suites one by one with CKO-04 and a unit_id. Each item result, in the POST response and in the poll GET /partner/provisioning/bulk/{batch_id}, gains an optional error_code (also speed_tier where that refusal applies).
  • Reactivation and resume of a line in a second- or third-line suite whose router is online (it answered within the last 15 minutes, single connection) no longer book a visit. A cancelled-and-ended line comes back remotely (path: "remote") and charges the rest of the period at once; a never-installed order activates at once (path: "activate") and is billed as a first order. A suite whose router is not online still takes path: "technician". GET /partner/subscriber/me/reactivation reports the new path.
  • POST /partner/subscriber/me/subscriptions/{id}/reactivation takes an optional body {"expected_path": "remote" | "technician" | "activate" | "none"} (the preview’s path; a withdrawal is none). When the path now differs it answers 409 path_changed and writes nothing; any other value answers 422. Without a body nothing changes.
  • Operator-created lines on such a suite get an install job with no booking and no shipment; the operator confirms it in the dashboard. This does not change the partner API.
Check your integration: handle install_required per bulk item (and read error_code rather than the text), don’t assume technician for a reactivation on second or third line, and send expected_path with the preview’s path if you want a reactivation to refuse instead of taking another route than the one you showed.

2026-10-15 — A suite whose router is already online is provisioned on every path

On a second- or third-line building, an order for a suite whose router is already online (it answered within the last 15 minutes) and that has a single connection is now provisioned at once: no technician visit, no shipped router, the line goes ACTIVE. Until now only first-line buildings were provisioned this way. A suite whose router is not online, a suite with several connections, and every suite in an unclassified building still take the install path. When kurnl cannot read the router’s state, the order takes the install path.
  • CKO-03 POST /partner/external-checkout/complete, with and without a session: such a suite answers 200 with a job_id, as on a first-line building.
  • CKO-04 POST /partner/provisioning/provider-initiated: such a suite answers route: "provision".
  • GET /partner/serviceability/units gains cpe_ready per suite: true when the suite’s router is already online, always false for a suite with several connections. It is a snapshot; the order decides again when it arrives.
  • CKO-03 anonymous POST /partner/external-checkout/anonymous-complete now routes by the building, like every other path, instead of by the delivery_mode of the session or of your payload:
    • a direct order (a port, no suite) on a building that is not first line answers 422 (Building line type '…' requires an operator install job, but the session has no unit_id — refusing to provision.). A port whose switch belongs to no building counts as unclassified and is refused the same way;
    • a home_drop order on a first-line suite is provisioned at once and answers delivery_mode: "direct", subscription_status: "ACTIVE" instead of becoming an install. A first-line suite with several connections keeps its held install;
    • delivery_mode in the response reports the path taken (direct = provisioned, home_drop = install job).
  • Customer portal orders: GET /partner/subscriber/me/orders/options gives each unit cpe_ready and route; the building’s route stays the default for a suite whose router is not online. POST /partner/subscriber/me/orders takes an optional route (the preview’s) and answers 409 route_changed when the suite’s route changed since the preview; nothing is written.
Check your integration: branch on route (CKO-04), the response’s delivery_mode (anonymous) and the subscription status, not on the building’s line type or the delivery_mode you sent. Handle the 422 for a direct anonymous order outside first line by sending the suite instead.

2026-10-15 — Anonymous orders that need a visit require contact_email

POST /partner/external-checkout/anonymous-complete now refuses an order that is not provisioned at once and carries no contact_email. That is any order on a second- or third-line building, unless the suite’s router is already online, and any order on an unclassified building, even when the suite’s router is online. The e-mail is how the household gets the booking link for the visit; until now such an order was accepted and waited in the operator’s queue for an address.
  • The answer is 422 before anything is written (no subscription, no install job, no ticket): {"detail": {"code": "contact_email_required", "message": "This order needs a technician visit or a shipped router. Send contact_email so the household gets the booking link."}}.
  • It applies to both the session and the no-session payload. A retry of an order that already exists still answers its idempotent replay, with or without contact_email.
  • First-line orders, and second- or third-line suites whose router is already online, need no e-mail; it stays optional there.
Check your integration: collect an e-mail for every suite outside first line and send it as contact_email, or handle the 422 with detail.code = "contact_email_required" by asking the household for one and sending the order again.

2026-10-15 — Cancelling for today switches the line off at once

A cancellation with today’s date now ends the line at call time. Until now the port was reset by the hourly pass, up to an hour later.
  • POST /api/v1/subscription/{id}/cancel with requested_end_date set to today (in the provider’s time zone) resets the port, resets the CPE’s Wi-Fi handover and sends the customer’s e-mail within seconds of the call. The call does not wait for the switch. The subscription.cancelled webhook still comes first, then subscription.deprovisioned. If the reset fails, the hourly pass retries it. A later date is unchanged: the line runs through its last day and is switched off when that day ends. A line that was never live (PENDING, PENDING_INSTALL) cancelled for today is also switched off at once.
  • The cancel response (and the customer portal’s) gains last_service_day (YYYY-MM-DD, the last day of service in the provider’s time zone) and ended_immediately (true when the cancel was for today; last_service_day is then the day of the call). GET /partner/subscriber/me subscriptions carry both too, plus deprovisioned_at (null until the port is switched off); the reactivation read’s cancellation carries both.
  • A customer may now cancel for today in the portal (POST /partner/subscriber/me/subscriptions/{id}/cancel).
  • Portal orders: POST /partner/subscriber/me/orders and its preview take end_current_on, required with end_current_subscription_id: the day the running line ends, earliest today in its provider’s time zone. It replaces the move-in date as the old line’s end date. Missing or past, the order answers 422 and creates nothing. ends_current and ended_subscription carry last_service_day and ends_immediately / ended_immediately.
  • The cancellation e-mail names the last day of service (or says the service ended when you cancelled) instead of the exclusive end date.
Check your integration: if you cancel with today’s date (CKO-04), the port is reset at call time now, not within the hour. Do not expect the line to stay up after the call, and treat subscription.deprovisioned as arriving within seconds. If you call the portal order route with end_current_subscription_id, send end_current_on.

2026-10-14 — Checkout orders never reuse an existing account

The order routes POST /partner/checkout/create-subscription-checkout, /home-drop-checkout and /unit-checkout are public: they carry no API key or login. Until now an e-mail that already had an account placed the order on that account. They now refuse it.
  • An e-mail that already has an account answers 409 before anything is written: {"detail": {"code": "account_exists", "message": "This e-mail already has an account. Sign in to your provider's portal to order another line."}}. An existing customer orders another line in their portal. The answer says nothing about the account’s lines.
  • Each successful response carries a new field status_token (in subscription_details on create-subscription-checkout, at the top level on the other two). The kurnl marketplace uses it for its order progress page; an integration can ignore it.
  • The three routes are limited to 30 requests / minute per client IP and answer 429 above it.
  • The consent and contract records take the address the request came from; a client_ip in the body is ignored.
These routes are not part of the documented partner flows (CKO-03 sessions and CKO-04 provider-initiated orders, which keep reusing an account you name). Check your integration: if you call these routes directly, handle 409 with detail.code = "account_exists" by sending the customer to sign in.

2026-10-13 — Public checkout helpers, location lookup and notice feed tightened

  • POST /partner/checkout/validate-discount-code accepts only a code of the provider whose plan_version_id you send. Another provider’s code now answers valid: false with the same error as an unknown code.
  • POST /partner/checkout/check-email still answers is_taken; its message no longer repeats the address ("Email is available" / "Email is already registered").
  • The three checkout helpers and POST /partner/location/ are limited to 60 requests / minute each (per API key with X-API-Key, otherwise per client IP) and answer 429 above it.
  • POST /partner/location/ returns only the listed address fields; the address record’s own id and its free-text description are gone.
  • GET /partner/status/notices no longer returns service_notice_buildings / service_notice_areas. Without service_provider_id it returns only the network-wide notices, not every provider’s. A query parameter that is not a UUID answers 422.
Check your integration: send service_provider_id to the notice feed if you show your own notices, and do not read the two targeting arrays or the address id / description. A server that calls the helpers for many shoppers without X-API-Key shares one IP bucket — send your key, or back off on 429.
A lead’s marketing_consent now decides whether it receives the provider’s marketing campaigns: only granted does. A lead you create through POST /partner/leads without the field is stored as unknown and receives none. Every campaign carries an unsubscribe link; when the person uses it, the lead’s marketing_consent becomes denied. Check your integration: if your form asks the shopper for marketing consent, send marketing_consent (granted or denied) with the lead. Nothing else changes in the request or the response.

2026-10-12 — The anonymous CKO-03 callback refuses plans the building cannot deliver

The 2026-10-07 entry below named the CKO-03 callbacks among the paths that refuse a plan faster than the building delivers per line. The anonymous variant did not check it until now: for an anonymous order, POST /partner/external-checkout/anonymous-complete now answers 422 with {"detail": {"code": "speed_tier", "message": "…", "speed_tier_mbps": 1000, "plan_download_mbps": 1500}} before anything is created, for both direct and home_drop delivery, and 503 Could not verify the building's speed tier; try again. when the tier cannot be read. Check your integration: handle 422 with detail.code = "speed_tier" on anonymous-complete too (refund, or move the subscriber to a plan within the tier), and retry a 503.

2026-10-07 — Plans a building cannot deliver are no longer offered

kurnl records per building the speed it delivers per line (1G, 2.5G or 10G). A plan whose wholesale download speed is above it is left out of plans in POST /serviceability/check and is refused with 422 where it would be ordered or changed to: provider-initiated orders, the CKO-03 callbacks and plan changes. The body is {"detail": {"code": "speed_tier", "message": "…", "speed_tier_mbps": 1000, "plan_download_mbps": 1500}}. A plan at the tier still fits (a 1000 Mbps plan in a 1G building). Buildings kurnl has not classified yet gate nothing, and existing subscriptions are not changed. A plan change booked for a later day is checked again on that day; if it no longer fits it is not applied, the subscriber stays on the current plan and you get a ticket. Check your integration: take the plans you offer at an address from POST /serviceability/check rather than from a cached catalogue, and handle 422 with detail.code = "speed_tier" by offering a plan within the tier.

2026-09-29 — Customer numbers are digits only

The provider-scoped customer_number changes from four letters and four digits (ABCD-1234) to seven random digits with a dash after the fourth (4711-203). Every existing customer gets a new number in the new format. The old number is retired — it is no longer returned by the API — but customer search still finds the customer by it. New invoices carry the new number, invoices already issued keep the old one. Check your integration: if you store customer_number, read it again from GET /partner/subscriber/me/details rather than keeping the old value. If you validate its shape, accept ^[0-9]{4}-[0-9]{3}$, and keep it a string — leading zeros (0482-117) are part of the number. Pages: Subscribers.

2026-09-22 — A CKO-04 order can hand the billing to kurnl

New optional billing block on POST /partner/provisioning/provider-initiated and on every item of POST /partner/provisioning/bulk: owner: "partner" | "kurnl" (default partner — every existing call is unchanged), stripe_customer_id (the customer in your Stripe account whose card your storefront saved) and billing_day (1–10). With owner: "kurnl", kurnl checks before writing anything that it will be able to bill — new errors billing_not_available (409), stripe_customer_not_found (422), stripe_customer_has_no_payment_method (422), stripe_customer_in_use (409), stripe_unavailable (502); detail starts with the code — then adopts the customer and its payment method and bills the subscription like a marketplace order: a prorated first invoice charged at activation, then monthly on billing_day, with the provider’s lifecycle e-mails and dunning. Responses and bulk item results gain billing_owner. Check your integration: nothing changes unless you send the block. If you do, save the card with a SetupIntent (usage: "off_session") and do not charge the first period yourself — kurnl does. See Who bills.

2026-09-17 — Partners can change a subscription’s plan directly

New endpoints: POST /partner/subscriptions/{subscription_id}/change-plan and GET .../change-plan/preview. Previously, changing a subscriber’s plan required a dashboard session — the partner API only covered checkout and suspend/restore. Now your own dashboard or billing system can change the plan on any of your ACTIVE subscriptions directly, once per calendar day (UTC). The change is immediate; there is no scheduled option. Call the preview first to check eligibility and disable your own control instead of failing on submit. Billing follows the subscription: if kurnl doesn’t invoice it (true for CKO-03 subscriptions), kurnl issues no invoice line or credit note — your billing is authoritative. bandwidth_updated: false in the response means the plan change applied but the port needs an operator to confirm it; it is not a reason to retry. Check your integration: this is new — nothing existing breaks. If you were waiting on plan-change support to build a self-serve upgrade/downgrade flow, you can build it now. Pages: plan change, Subscriber Management.

2026-09-17 — A first-line building can be ordered by suite

POST /partner/external-checkout/complete used to reject a session that carried a building and a suite when the building was classified first-line, with:
A first-line building is reached two ways. From the captive portal the port is known and kurnl provisions it immediately — unchanged. From address search or a QR landing there is no port, only a building and a suite; that order now takes the install path, exactly as a second- or third-line building does. The kurnl marketplace has behaved this way since the day before; the partner path now matches. The remaining 422 fires only when a session carries neither a port nor a unit_id, and its text changed to:
Check your integration: if you special-cased the old 422 message, remove it — that order now succeeds and returns the PENDING_INSTALL shape (200 without job_id). If you match on error text anywhere, match on the status code and your own logic instead. Pages: complete, CKO-03 direct, CKO-03 home-drop.

2026-09-16 — Customers book their own installation

Where an order takes the install path, the customer receives a booking link and picks their own appointment slot, rather than an operator scheduling it. The booking invitation is held until the move-in date is inside the region’s booking window. Check your integration: nothing to change. Your thank-you page can tell the customer to expect a booking e-mail. Pages: CKO-03 home-drop, CKO-03 anonymous, anonymous complete.

2026-09-15 — Per-provider Stripe connection

Providers who use kurnl’s checkout now connect their own Stripe account, and the plan payload carries whether they have done so. Check your integration: nothing to change for external checkout (CKO-03) — you take payment yourself and kurnl is not involved in it. Pages: plans, subscribers.

2026-09-05 — Calendar-month billing

Subscription billing moved to calendar months across the partner surface, and the subscriber and reporting payloads reflect it. Check your integration: only relevant if you read kurnl’s billing fields. Partners who bill their own customers (the CKO-03 anonymous variant) are unaffected. Pages: subscribers, CKO-04 provider-initiated.

2026-08-25 — External checkout starts on the marketplace (correction)

This entry corrected documentation that was wrong for six days. If you drafted your CKO-03 integration between 19 and 25 August 2026, read this one carefully.
Between 19 and 25 August the CKO-03 guides described a store-originated flow with a “step 0” address check: your storefront would call the serviceability endpoints, then create a checkout session. That was never how CKO-03 works, and the guides were reverted. The actual contract: external checkout always starts on the kurnl marketplace. The shopper picks your plan there, the marketplace resolves the address, picks the building and suite, creates the session, and redirects to your checkout page with ?session_id=. A CKO-03 partner therefore never checks addresses, never picks a building or unit, and never creates sessions. Your page reads the session and posts the completion. The serviceability endpoints are real, but they are for CKO-04 — a storefront that runs its own funnel end to end and never touches the marketplace. Check your integration: if you built an address check or a session-creation call into your checkout page, remove it. A session your store creates bypasses the marketplace’s serviceability gate and will not route correctly. Also in this change: the suspend/restore state guards were documented. suspend applies only to an ACTIVE subscription and restore only reverses a suspend; anything else returns 409 with an explicit message. Pages: overview, CKO-03 direct, CKO-03 anonymous, subscription-status, serviceability.

2026-08-17 — Documentation re-verified against the code

Roughly thirty pages were corrected against the running implementation after an audit found divergences in endpoint paths, payload fields and error codes. Check your integration: if you built against these docs before 17 August 2026, re-read the pages you implemented. Field names and error codes changed on several of them.

2026-08-13 — No WordPress plugin; integrate against the API

The kurnl-maintained WooCommerce plugin was dropped. Partners integrate against the partner API with their own code instead, in whatever stack their store runs on. Check your integration: if you were waiting for a plugin, you are not — build against the integration guides. kurnl never calls your store; your store always calls kurnl. Pages: introduction, overview, subscription-status.