Developers
This page is for a PHP developer writing another aggregator driver, or calling the add-on's routes from outside the admin.
Before you begin
- The add-on installed, and KahunaCart 1.3.0 or later.
- The carrier rate contract in KahunaCart itself, which is what this add-on plugs into.
- Provider developer guide for the worked KahunaCart driver, and REST API for the conventions every route below follows.
Why the add-on has its own contract
KahunaCart's CarrierRateProviderInterface is four methods about quoting, and it is right as it is. Labels, voids, tracking and address validation have no business in it. So the add-on publishes its own contract under Aggregator\ and adapts it to KahunaCart's at one edge.
The required half is AggregatorInterface: code(), name(), capabilities(), carriers() and rates(). Six optional interfaces sit beside it, each small, each declared honestly in capabilities().
| Interface | What a driver that lacks it loses |
|---|---|
LabelPurchaseInterface |
The Buy label button. Rates only, which is a real kind of driver. |
LabelVoidInterface |
Void. The merchant cancels in the courier's own console. |
TrackingInterface |
The webhook and the poll. Tracking stops at the number. |
AddressValidationInterface |
The address panel in the buy sheet. |
AggregatorWebhookSetupInterface |
Self-setup for the inbound webhook. |
AggregatorConnectionInterface |
The Check connection button. |
Important
A capability flag that lies is worse than one that says no. A driver that claims it can buy a label and throws when asked has turned a missing feature into a broken one, and the merchant finds out with an order open and a customer waiting.
Two rules the contract inherits from KahunaCart's and restates: amounts are minor units in the currency the carrier was asked in, which is the carrier account's and the order's wherever the two match, and a reason is a language key with its parameters, never English prose, for anything a customer might read. A courier's own message for the merchant is different, and is passed through verbatim.
Write another driver
- Implement
AggregatorInterfaceunderclasses/Aggregator/<Name>/. - Declare its capabilities honestly, including the noes.
- Add a case to
KahunaCartShipping::buildAggregator()and an option to the blueprint's aggregator select. - Add a
CARRIER_<CODE>language key.
Shippo, EasyPost, ShipEngine and Sendcloud share this contract. Wiring also includes encrypted settings, credential selection, the settings tab, carrier display names and controller error handling; use the plugin repository's docs/development.md checklist before adding a driver. The packer, repositories, routes and bridge remain shared.
ShipEngine uses grams and centimetres, explicit carrier-account IDs, shipment references for recovery and shared-header webhook authentication. Its SignedWebhooks capability is false. The shared contract tests allow unsigned drivers to return a boolean from verification when they authenticate a header.
Sendcloud sends grams and millimetres as they are stored, makes one carrier account per Sendcloud carrier, and keeps Sendcloud's own shipping option codes as service codes (royal_mailv2:tracked_24/signature,size=s), so they contain :, /, , and =. It sends the add-on's idempotency key as the shipment's external_reference_id, and Sendcloud answers a repeat with the shipment it already made, so a purchase whose answer was lost is never bought twice. Its webhooks are HMAC-SHA256 signatures over the raw body, keyed with the integration's secret key, and its SignedWebhooks capability is true. It has no sandbox: in test mode SendcloudLabels::shipWith() sends the free sendcloud:letter for every purchase, whatever service was asked for. MultiParcel and AddressValidation are false.
The worked example is classes/Aggregator/Demo/, which is the contract with all the API noise taken out of it: every method, every honest refusal, and not a line of cURL in the way. It is also what the demo store runs on.
Permissions
| Permission | What it allows |
|---|---|
kahunacart.shipping.manage |
The aggregator, the connection check, carrier accounts, boxes, ship-from addresses, the address check, and a product's customs facts. |
kahunacart.shipping.labels |
Quoting an order, buying a label, voiding one, and recording a parcel. |
Reading a label, its file, its tracking history or a packing preview takes KahunaCart's own kahunacart.orders.view.
Admin routes
Every admin route is authenticated, and every one answers JSON except the label file, which streams it. Each has an MCP tool, prefixed kahunacart_shipping_, except the file route. The routes are in the REST API reference, generated from the plugin's own manifest, with their parameters:
- Shipping carriers: the aggregator, its connection check and webhook, and the carrier accounts.
- Boxes and packing: the box catalogue, ship-from addresses and the packing preview.
- Shipping labels: quoting an order, buying, voiding and recording a label, and the label file.
- Parcel tracking: a parcel's tracking history, a fresh look, and the public webhook.
- Addresses and customs: the address check and a product's customs facts.
Public routes
Unauthenticated, and declared to the API plugin's public allowlist rather than reached through KahunaCart's own webhook route, which only opens for a payment provider.
| Route | When it exists | What it is |
|---|---|---|
POST /kahunacart/shipping/public/webhook/{aggregator}/{secret} |
always | Where the aggregator says a parcel has moved. The secret in the path is the credential, compared in constant time. |
HEAD /kahunacart/shipping/public/webhook/{aggregator}/{secret} |
always | Several aggregators probe an endpoint before they will save it. Answered 200 with no body. |
GET /kahunacart/shipping/public/demo-track/{number} |
only with the demo carrier configured | The tracking page the simulation serves for itself. |
Three rules the webhook endpoint follows, and none of them is optional for an inbound endpoint:
- A 256 KB body cap, refused with a 413 before the secret is compared or a line is parsed.
- A delivery log, one row per delivery, kept thirty days. Without it, "the webhook never arrives" and "the webhook arrives and is refused" look identical.
- No outbound call, ever. It checks, logs, enqueues and answers 200, inside the three seconds an aggregator gives a receiver. The job then re-reads the parcel with the store's own key, which is what bounds the damage a forged ping can do.
A wrong secret is a 404, so a scanner learns nothing. A body nobody can read is a 200, because every aggregator treats a 4xx as a reason to retry for days and the payload will not have become readable in the meantime.
CLI commands
| Command | What it does |
|---|---|
bin/plugin kahunacart-shipping seed |
Write the demo store: a ship-from address, four boxes, both simulated carrier accounts, sizes onto the seeded catalogue, an oversized product, methods bound to the demo services, and eleven orders covering every state. Idempotent. |
bin/plugin kahunacart-shipping seed --purge |
Take back exactly what the seeder wrote, and nothing else. Asks first; --yes skips the question. |
bin/plugin kahunacart-shipping demo:tick |
Advance the simulated tracking clock by hand. The same pass the worker makes on its own. |
KahunaCart's own commands do the rest: migrate runs this add-on's migrations, work drains the queue its jobs sit on, and reset clears its rows that belong to an order. See CLI.
Jobs
Four handlers on KahunaCart's worker.
| Job | Booked by | What it does |
|---|---|---|
shipping.reconcile_label |
a purchase or a void whose answer did not arrive | Asks the aggregator again, gives up after six hours, and raises Label outcome unknown when it does. |
shipping.fetch_tracking |
the inbound webhook | Re-reads one parcel's record from the aggregator and writes what it finds. |
shipping.poll_tracking |
the first label ever bought, then itself | Asks about every parcel still in the air, every tracking.poll_hours. |
shipping.delivered_email |
the first delivered event on a parcel |
Sends the customer the delivered message. |
Notifications
| Id | Who | How often |
|---|---|---|
shipping.delivered |
the customer | once per parcel |
shipping.label_failed |
the owner | once per order per day |
shipping.label_unresolved |
the owner | once per label, ever |
shipping.carrier_unreachable |
the owner | once per aggregator per day |
shipping.delivery_failed |
the owner | once per label, ever |
shipping.auto_buy_failed |
the owner | once per order per day |
shipping.auto_void_failed |
the owner | once per label, ever |
shipping.auto_void_failed is high priority, along with shipping.label_unresolved: postage was spent on a parcel that is not going. shipping.auto_buy_failed is sent only when the parcels could not be planned. Both give a plain reason for each case, never the carrier's own words, which can repeat a customer's details. Those stay in the shipping log.
A webhook the store refuses is reported through KahunaCart's own Webhook rejected message by notifications()->webhookRejected(), once per aggregator per reason per day: bad_secret, no_secret_configured, bad_source, bad_signature and no_tracking_driver. A body that is too big, or one that parses and names no parcel, is not reported, because neither is the store's set-up. It needs KahunaCart 1.5.3; on an older core the refusal is in the delivery log and nowhere else. See Report a webhook your own endpoint refused.
The frequencies are dedupe keys rather than counters, so two workers running at once still send one message. See Notifications.
Order history types
| Type | Written when |
|---|---|
shipping.label_bought |
a label is bought and the parcel recorded |
shipping.label_voided |
a refund is accepted by the courier |
shipping.delivered |
a parcel's first delivered tracking event |
Tables
Seven, created through KahunaCart's own migration runner, all carrying store_id.
| Table | Migration | What is in it |
|---|---|---|
kahunacart_shipping_accounts |
shipping_0001_core |
Carrier accounts mirrored from the aggregator, and whether this store offers each one. |
kahunacart_shipping_locations |
shipping_0001_core |
Ship-from addresses, structured the way a courier API needs them. |
kahunacart_shipping_boxes |
shipping_0001_core |
The box catalogue. |
kahunacart_shipping_labels |
shipping_0002_labels |
One row per label bought, attempted or refunded, with its idempotency key uniquely indexed. |
kahunacart_shipping_tracking |
shipping_0002_labels |
Tracking events, with the unique index that makes one delivered email out of three delivered reports. |
kahunacart_shipping_deliveries |
shipping_0002_labels |
The inbound webhook's delivery log, swept at thirty days. |
kahunacart_shipping_item_customs |
shipping_0002_labels |
Per-product and per-variant customs facts. Catalogue facts, so a reset keeps them. |
bin/plugin kahunacart reset --orders takes the labels, the tracking and the deliveries, and leaves the accounts, the locations, the boxes and the customs facts. Uninstalling drops nothing at all, because dropping a store's postage records on an uninstall would be somebody's worst afternoon.
Events
The add-on listens to seventeen and fires none of its own.
| Event | What it does with it |
|---|---|
onKahunaCartRegisterMigrations |
Hands over its two migrations. |
onKahunaCartRegisterResetTables |
Declares which of its tables reset --orders may take. |
onKahunaCartDeclareFeatureCompatibility |
Says yes to order versioning. Silence blocks it for every merchant who installs the add-on. |
onKahunaCartRegisterCarrierRateProviders |
Registers the bridge onto CarrierRateProviderInterface. |
onKahunaCartShippingMethodExcluded |
Replaces carrier_unavailable with item_excluded and names the line. |
onKahunaCartRegisterJobHandlers |
The four jobs above. |
onKahunaCartWorkerTick |
Moves the simulated tracking clock. Nothing else uses it. |
onKahunaCartRegisterNotifications |
The five messages above. |
onKahunaCartRegisterHooks |
Auto-buy, as a compensating hook with a declared unwind rather than a bare listener. The completion run buys only when the order already counts as paid. |
onKahunaCartOrderPaid |
Auto-buy for an order that was not paid when it was placed — a bank transfer, an authorised card, a deposit with the balance owed — which gets its label here rather than at completion. The undo registered at completion covers this label too. |
onKahunaCartAdminSections |
The Shipping carriers section. |
onKahunaCartRegisterZones |
The two order widgets. |
onKahunaCartOrderPanels |
The customer's parcels panel on a receipt. |
onKahunaCartAccountPanels |
The customer's parcels panel on an account order page. |
onKahunaCartCollectEncryptedSettings |
Hands over the driver's own secret config paths. |
onApiRegisterRoutes |
Every route above. |
onApiCollectPublicRoutes |
The public entries, method-scoped. |
Note
The add-on fires no event of its own, and that is a gap rather than a decision. An add-on cannot add a topic to KahunaCart's outbound webhooks, because the topic list and the event-name list are both closed constants on final classes, so shipment.tracking_updated cannot be subscribed to from outside. See Outbound webhooks.
The admin screens
The Shipping carriers section is one file, admin-next/sections/carriers.js, with no imports: KahunaCart serves it by id and the browser gets the bytes as they are on disk. Everything visual comes from the host, so a pill here and a pill on the Orders screen are the same pill. See Admin sections.
The two order widgets, admin-next/widgets/parcels.js and admin-next/widgets/buy-label.js, draw into zones KahunaCart declares. A widget is handed less than a section is, so both carry their own money formatter and their own status vocabulary. See Extension zones.
Three things to know before changing either:
render()runs again on every redraw of the screen around it. Both widgets are pure functions of the order with their answers held at module scope and kept for thirty seconds. A write drops that order's entry and fireskahunacart-shipping:changed.- The buy sheet is a real
<dialog>ondocument.body, opened withshowModal(). A fixed overlay inside two nested shadow roots is at the mercy of any transformed ancestor between it and the page. - A label file is opened through a blob and an object URL, never an
href. The route is authenticated and a new tab would arrive without the token.
Related
- Overview
- Settings reference
- Shipping rates - the carrier rate contract this plugs into
- Provider developer guide
- REST API
- MCP