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.
2026-11-03 — A changed billing e-mail notifies only the old address
Production: live since 2026-11-03 (batch 200).- When the billing contact’s e-mail address changes — through
PATCH /partner/subscriber/me/invoice-contact,POST …/invoice-contact/use-holderor the agent’s contact update withinvoice— kurnl mails the address that stops receiving the invoices. The new address gets no mail. - Corrected: Agent overview, Agent reference and Subscribers said both addresses were told.
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_codeon the session form of/external-checkout/completeand/external-checkout/anonymous-completeis now honoured, checked as of the hand-over day: a kurnl operator code from the session’soperator_offersor one of your own discount codes. Anything else answers400 Invalid or expired discount codeand creates nothing; on a bulk plan a code answers422 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.
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.bookedwithbooked_by: "provider"; the customer’s own booking keepscustomer, kurnl staff keepstaff. install_pathprovider_visiton the session status is described as “a visit kurnl schedules” (it always was kurnl’s); the value itself is unchanged.
booked_by, accept provider.
2026-10-29 — Communication preferences: one channel per topic
Production: live since 2026-10-29.GETandPATCH /partner/subscriber/me/preferencescarry five channel keys —billing_channel,security_channel,outages_channel,support_channel,marketing_channel— with the valuesemail,sms,bothoroffas each topic allows, plusupdated_at,sms_availableandhas_phone. The booleansmarketing_email,outage_notificationsandsms_transactionalare now derived from the channels; aPATCHstill accepts them.- A
PATCHthat adds texts —smsorbothon a topic that had none, or the legacysms_transactional: true— answers 409no_phonewhen the person has no phone number on file, next to the existing 409sms_not_available. A channel word a topic does not offer (for examplemarketing_channel: "sms") answers 422. - Texts: the billing, outage and ticket events with an active SMS template now text customers who chose
smsorboth; a customer who chosesmsfor billing, outages or support is no longer e-mailed for that topic.
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.passwordof a new account,POST /partner/provisioning/provider-initiatedandPOST /partner/external-checkout/completeanswer 400 with its reason asdetail, 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/bulkreports the same reason as the failed item’serror, where it saidProvisioning failedbefore.- External checkout complete now shows the 8-character minimum of
subscriber.password, which the request model always had.
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/searchleaves them out, and every/partner/agent/customers/{id}route answers 404not_found, as for a customer that is not the provider’s. Before, an erased customer was still found, with its contact fields empty.
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, thesuspend,restoreandcancelactions ofPOST /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-changerefuses a second change the same day withdetailas an object,{"code": "change_limit_reached", "message": "…"}; before,detailwas the sentence alone.POST /partner/subscriptions/{id}/change-plankeeps its sentence.- The read-then-act race the plan-change page described is closed.
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-codetakes optionalbuilding_id/location_identifierand answerssource(provider|operator),label,promo_price_monthly,list_price_monthly,duration_months,setup_fee,appliedandreason(campaign_better, orunavailablewhen the check could not run, a temporary read failure). A bulk plan answersvalid: false. An operator code can now be “expired”.POST /partner/external-checkout/sessionstakesdiscount_code(400 Invalid or expired discount codeon a miss, before the session exists); the hosted URL gains&discount_code=.GET /partner/external-checkout/sessions/{id}returnsdiscount_codeandoperator_discount(code,name,discount_type,discount_value,duration_months,list_price_monthly,promo_price_monthly;503if it cannot be read). The public session status returnsdiscount_codeandoperator_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.completeandanonymous-completetakediscount_codeonly in their explicit-fields (sessionless) form; with asession_idthe session’s stored code is used and the body’s is ignored.POST /provisioning/provider-initiatedandPOST /provisioning/bulkacceptdiscount_code. A miss is a400(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 withFee Type=Operator discount, the label inWholesale Product Name, and negativeTotal CostandWholesale price monthly.Fee Typeis no longer alwaysMonthly. - A plan change carries an operator discount over to the new plan; a provider’s campaign still ends.
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-03anonymous-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.activatedfires on every activation path,install.bookedtells you about booked or moved visits, and every event carriesexternal_subscription_idandis_anonymous. An optional Backup notification e-mail (dashboard, Checkout page) gets each event by mail as well. - Cancel:
POST /partner/external-checkout/subscription-statuswithaction: "cancel"and an optionalend_date. - Mails to your customers: kurnl sends service mails (outage, restored, router shipped,
ticket received, visit booking) to the
contact_emailyou send; lifecycle mails only where the provider switched them on; never billing or account mails. - Plan change to another provider’s plan: refused with
403and 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.
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
403and the messageThis 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.
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) quotedbilling.first_chargenet of tax, and on theactivatepath as the setup fee plus a whole month, while the action bills the setup fee plus the partial month from today, with tax.first_chargeis now the gross of the invoice the action creates today — the same lines, tax and discount, before any credit on the account — andnullwhen 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 theprovisionroute: 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_methodreads the customer’s default card in the provider’s Stripe account (the card every charge uses), no longer a stored column.
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_emailyou sent. It goes out when the router first makes contact, or when the line goes live. - New:
POST /partner/subscriptions/{id}/wifi-linksends a new link (works once, 7 days, at most 5 per line in 24 hours).PUT /partner/subscriptions/{id}/wifisets the network name and password yourself. Both need theprovisioning:writescope onX-API-Key, and accept?by=external_subscription_id. - Errors:
404for a line that is not yours,409withnamed_line,no_contact_email,no_portalor the reason the line cannot carry Wi-Fi,422 invalid_wifi,429 wifi_link_limit. Passwords are never returned.
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/meandGET /partner/marketplace/plans/{id}carrypromo:price_monthly(nullfor a campaign that only overrides the setup fee),list_price_monthly,label,campaign_name,duration_months(null= for as long as the line runs) andsetup_fee(only when overridden);nullwithout a promotion.GET /partner/plans/versions, the plan-change list and theplansofPOST /serviceability/checkcarry no promotion (promoisnullor 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’sGET /partner/subscriber/me/orders/optionscarriespromofor its plans too. The order preview (GET /partner/subscriber/me/orders/preview) gainsplan.promo_setup_feeandplan.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-checkoutand/unit-checkoutgained the optionaldiscount_codethat/create-subscription-checkoutalready took. - The discount types are
PERCENTAGE,FIXED_MONTHLYandOVERWRITE_MONTHLY; any other value is refused with 422. The docs showed"percentage"in the discount-code check; the value isPERCENTAGE.
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_emailyou 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.
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(customerorstaff),rebooked. subscription.activatednow fires whenever a line goes live, including the home-drop, confirm-install and pre-order paths, which sent nothing before. It carrieskind(firstorreactivated),activated_atandplan_version_id. A line kurnl brings back sends it again withkind: "reactivated"; arestoreyou request through the status call does not.- Every webhook event now also carries
external_subscription_idandis_anonymous. POST /partner/external-checkout/subscription-statusacceptsaction: "cancel"with an optionalend_date(YYYY-MM-DD, the first day without service; default today) andreason. The response gainsended_at. A past date answers422; a line already cancelled answers409withdetail.code = "already_cancelled"(an object, unlike the other409s 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.
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 coversPOST /partner/provisioning/provider-initiated, everyPOST /partner/provisioning/bulkitem andPOST /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 witherror_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 503Could not check whether the port is free; try again.Nothing is created in either case. A retry of an order whose line is inPAYMENT_FAILEDanswersport_in_usetoo, not the replay.
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-checkoutand/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 503Could not verify the building's plan offer; try again.and create nothing. Sandbox keys are not checked. GET /partner/marketplace/plans/melists 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/checklists your provider at a normal building only when it has a plan selection there or offers an external plan, andplansholds only the live plans it offers at that building. A normal building you cover but have no plans for is no longerserviceable; 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 422plan_not_offeredand is missing fromeligible_plan_versions. - On the first rollout, every plan you had was offered in every region you covered, so no building lost its listing.
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}/reactivationanswers 409reactivation_in_progresswhile 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 theactivatepath and onPOST /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}/payis documented now, with its refusals.- On the
activatepath 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).
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-checkoutand/unit-checkoutnow refuse with 422speed_tier,bulk_planandnot_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-checkoutchecks 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 503Could not read the line's building; try again.instead of skipping the check. GET /partner/subscriptions/{id}/change-plan/previewlists 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.
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 wasPENDING 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_dateis ahead of the provider’s today, or the building is not orderable yet, the returnedcheckout_urlis a Stripe setup-mode session (card saved, nothing charged) instead of a payment session.payment_requiredstaystrue. invoice_ninja_invoice_idandfirst_invoice_totalare now nullable: they arenullfor 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 ornullwhen the line starts at checkout.
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/checkgains"bulk": true|falsenext topresale, and each object inplansgains"is_bulk": true|false. In a bulk buildingplansholds 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 inGET /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.
is_bulk plans at 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 inPAYMENT_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/usageandGET /partner/subscriber/status-historytake an optionalsubscription_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 404Subscription 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_atanddeprovisioned_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-historycarriessubscription_id(nullon older events). With?subscription_id=the timeline holds that line’s events only. GET /partner/subscriber/me/orders/optionsgainsrunning_lines, every running line a mover may end (ACTIVE first, then the newest start, then by id).current_subscriptionstays and is its first entry.end_current_subscription_idon the preview and the order accepts any of them.
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_SUBSCRIBERSorEXISTING_SUBSCRIBERS(plans). GET /partner/marketplace/plans/me, the storefront lists and the serviceabilityplansreturn only versions offered to new customers. The plan-change preview’seligible_plan_versionsreturns only versions offered to existing customers.POST /partner/subscriptions/{subscription_id}/change-plannow refuses a plan version outside its effective window with 400Plan version not active, and a version for new customers only with 422plan_audience(plan change).eligible_plan_versionsis 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.
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 withstatus: "error"anderror_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 aunit_id. Each item result, in the POST response and in the pollGET /partner/provisioning/bulk/{batch_id}, gains an optionalerror_code(alsospeed_tierwhere 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 takespath: "technician".GET /partner/subscriber/me/reactivationreports the new path. POST /partner/subscriber/me/subscriptions/{id}/reactivationtakes an optional body{"expected_path": "remote" | "technician" | "activate" | "none"}(the preview’spath; a withdrawal isnone). When the path now differs it answers 409path_changedand 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.
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 goesACTIVE. 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 ajob_id, as on a first-line building. - CKO-04
POST /partner/provisioning/provider-initiated: such a suite answersroute: "provision". GET /partner/serviceability/unitsgainscpe_readyper suite:truewhen the suite’s router is already online, alwaysfalsefor a suite with several connections. It is a snapshot; the order decides again when it arrives.- CKO-03 anonymous
POST /partner/external-checkout/anonymous-completenow routes by the building, like every other path, instead of by thedelivery_modeof the session or of your payload:- a
directorder (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_droporder on a first-line suite is provisioned at once and answersdelivery_mode: "direct",subscription_status: "ACTIVE"instead of becoming an install. A first-line suite with several connections keeps its held install; delivery_modein the response reports the path taken (direct= provisioned,home_drop= install job).
- a
- Customer portal orders:
GET /partner/subscriber/me/orders/optionsgives each unitcpe_readyandroute; the building’sroutestays the default for a suite whose router is not online.POST /partner/subscriber/me/orderstakes an optionalroute(the preview’s) and answers 409route_changedwhen the suite’s route changed since the preview; nothing is written.
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.
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}/cancelwithrequested_end_dateset 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. Thesubscription.cancelledwebhook still comes first, thensubscription.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) andended_immediately(truewhen the cancel was for today;last_service_dayis then the day of the call).GET /partner/subscriber/mesubscriptions carry both too, plusdeprovisioned_at(nulluntil the port is switched off); the reactivation read’scancellationcarries both. - A customer may now cancel for today in the portal (
POST /partner/subscriber/me/subscriptions/{id}/cancel). - Portal orders:
POST /partner/subscriber/me/ordersand its preview takeend_current_on, required withend_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_currentandended_subscriptioncarrylast_service_dayandends_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.
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 routesPOST /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(insubscription_detailson 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
429above it. - The consent and contract records take the address the request came from; a
client_ipin the body is ignored.
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-codeaccepts only a code of the provider whoseplan_version_idyou send. Another provider’s code now answersvalid: falsewith the sameerroras an unknown code.POST /partner/checkout/check-emailstill answersis_taken; itsmessageno 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 withX-API-Key, otherwise per client IP) and answer429above it. POST /partner/location/returns only the listed address fields; the address record’s ownidand its free-textdescriptionare gone.GET /partner/status/noticesno longer returnsservice_notice_buildings/service_notice_areas. Withoutservice_provider_idit returns only the network-wide notices, not every provider’s. A query parameter that is not a UUID answers422.
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.
2026-10-13 — Only leads with marketing consent receive the provider’s campaigns
A lead’smarketing_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 ofplans 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-scopedcustomer_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 optionalbilling 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:
422 fires only when a session carries neither a port nor a unit_id, and
its text changed to:
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)
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.