Skip to content

Powered by Grav CMS + Helios Theme

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

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

  1. Implement AggregatorInterface under classes/Aggregator/<Name>/.
  2. Declare its capabilities honestly, including the noes.
  3. Add a case to KahunaCartShipping::buildAggregator() and an option to the blueprint's aggregator select.
  4. 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 fires kahunacart-shipping:changed.
  • The buy sheet is a real <dialog> on document.body, opened with showModal(). 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.