Skip to content

Powered by Grav CMS + Helios Theme

Writing a KahunaCart Payment Provider

This page walks a PHP developer through building a KahunaCart payment provider plugin. Work through the sections in order: scaffold, implement, test, package.

An add-on can be more than a payment provider. From Registering a rule operation onwards, this page covers the other contracts KahunaCart publishes: rule operations and attributes, carrier rate drivers, tax engines, schema extensions, compensating hooks, dashboard alerts, messages to the store owner, and putting your add-on's data in a store bundle. Each contract uses an event subscription and a registered object. None requires a payment provider.

Before you begin

  • You have written a Grav plugin before.
  • You have read the Provider Contract. That page is the specification; this one is the walkthrough. Where they disagree, the contract wins.
  • PHP 8.3 or later, matching the base plugin's floor.
  • A checkout of grav-plugin-kahunacart sitting beside your plugin directory. The test suite autoloads the base plugin's classes from there.

How a provider fits together

A provider is an ordinary Grav plugin that hands KahunaCart one object: a subclass of Grav\Plugin\KahunaCart\Provider\AbstractPaymentProvider. Its job is translation.

  • You translate. Build the API request, verify the signature, decode the event, map it to a result object.
  • The base plugin owns state. It opens the transaction row, completes the order, grants downloads, deducts stock, sends mail, updates payment status, and dedupes redeliveries.

A provider never writes order state, never touches the database, and never fires order events. Four events are available to it:

Event Payload Use it to
onKahunaCartRegisterProviders $event['registry'] Register your provider instance
onKahunaCartAdminSections $event['sections'] Add a screen to the KahunaCart admin
onKahunaCartOrderCompleted $event['order'] React once an order is placed
onKahunaCartOrderPaid $event['order_id'] React once money has landed

Tip

To do something after a payment succeeds, listen to onKahunaCartOrderPaid rather than working inside handleWebhook().

Every registered provider appears as a radio button at checkout, in registration order. Registering is enabling; there is no separate checkout setting. The template loops over ProviderRegistry::options(), which is slug() => label().

TWIG
{# templates/kahunacart-checkout.html.twig #}
{% for slug, label in providers %}
<label class="kahunacart-provider"><input type="radio" name="provider" value="{{ slug }}"> {{ label }}</label>
{% endfor %}

Scaffold the plugin

  1. Copy kahunacart-dummy. It is MIT-licensed, free, and implements the whole contract without an API client.
  2. Rename the plugin directory, the plugin class, blueprints.yaml, and the YAML config file to your own slug.
  3. Keep the layout.
TXT
grav-plugin-kahunacart-dummy/
├── .gitignore              .DS_Store, .phpunit.cache/ — vendor/ is NOT ignored
├── blueprints.yaml         GPM manifest + admin config form
├── composer.json           composer.lock, CHANGELOG.md alongside
├── kahunacart-dummy.php    the Grav plugin class
├── kahunacart-dummy.yaml   shipped config defaults
├── classes/
│   └── DummyProvider.php   the contract implementation
├── languages/en.yaml       blueprint strings
├── phpunit.xml
├── tests/                  bootstrap.php + Unit/
└── vendor/                 committed

The plugin class has two responsibilities: autoload, and register.

PHP
// kahunacart-dummy.php
namespace Grav\Plugin;

use Composer\Autoload\ClassLoader;
use Grav\Common\Plugin;
use Grav\Plugin\KahunaCartDummy\DummyProvider;
use RocketTheme\Toolbox\Event\Event;

class KahunaCartDummyPlugin extends Plugin
{
    public static function getSubscribedEvents(): array
    {
        return [
            'onPluginsInitialized' => [['autoload', 100001]],
            'onKahunaCartRegisterProviders' => ['onKahunaCartRegisterProviders', 0],
        ];
    }

    public function autoload(): ClassLoader
    {
        return require __DIR__ . '/vendor/autoload.php';
    }

    public function onKahunaCartRegisterProviders(Event $event): void
    {
        if (!(bool)$this->config->get('plugins.kahunacart-dummy.enabled', false)) {
            return;
        }

        $event['registry']->register(new DummyProvider(/* … */));
    }
}

Use the 100001 priority on autoload, like every KahunaCart plugin. It has to run before anything else touches your classes.

Keep your settings under plugins.<your-slug>, never under plugins.kahunacart. The one exception is per-provider sync direction, in kahunacart.yaml keyed by slug.

Important

Pick a unique slug and never change it. It is the webhook route segment, the ledger's provider column and the sync map's key, and it must match /^[a-z0-9][a-z0-9-]*$/. A malformed slug, one another plugin registered first, or a listener that throws is logged rather than registered, so your provider never appears at checkout. Check logs/grav.log first.

Register only when the provider is usable

Do not register while the active mode's key is missing. Otherwise, customers can select a provider that is not ready to accept payment.

PHP
public function onKahunaCartRegisterProviders(Event $event): void
{
    if (!(bool)$this->config->get('plugins.kahunacart-stripe.enabled', false)) {
        return;
    }

    $mode = (string)$this->config->get('plugins.kahunacart-stripe.mode', 'test') === 'live' ? 'live' : 'test';

    $secretKey = trim((string)$this->config->get("plugins.kahunacart-stripe.{$mode}_secret_key", ''));
    if ($secretKey === '') {
        return;
    }
    // …
}

Make the label configurable. Take it as a constructor argument with a class-constant default, so a merchant can rename it without a code change.

PHP
$event['registry']->register(new PolarProvider(
    // …
    label: $label !== '' ? $label : PolarProvider::DEFAULT_LABEL,
));

Implement initiatePayment()

Before calling you, the base plugin verifies the order is a payable cart, recalculates totals, opens a pending purchase transaction, and resolves the {transaction} placeholder in the return URL. You receive a PaymentContext.

amountMinor is minor units in currency. 1999 is $19.99, and 500 is ¥500 because JPY has no minor unit. Never divide by 100 to talk to an API that wants decimals; convert through the currency's exponent. See Money conventions.

customerName, items and billingAddress are prefill only. The item sum differs from amountMinor on any order carrying a discount, shipping, tax, or a plugin's fee.

Return one of three results. PaymentService::initiate() acts on each one:

Result What the base plugin does
PaymentResult::complete($ref, $paid) Completes the order with payment status paid or, when $paid is false, pending, then marks the ledger row successful. No stock hold, because completion deducts stock outright.
PaymentResult::redirect($url, $ref) Stores $ref on the ledger row, places a stock hold for the customer's trip (stock.hold_minutes, default 15), and sends a 302 to your URL.
PaymentResult::failed($message) Marks the ledger row failed, releases any hold, and throws your message as a KahunaCartException onto the checkout page with the cart intact.

Warning

Never throw for an ordinary API refusal. A thrown exception marks the transaction failed and rethrows as a PaymentException, putting your message in front of a customer. Do what Stripe's transport does: only network failures throw, and API-level errors become PaymentResult::failed().

Never let a secret escape into a message. Stripe and Polar both assert that:

PHP
$this->assertStringNotContainsString('sk_test_supersecret', $result->message);

Hosted redirect

Create a session and return its URL.

PHP
public function initiatePayment(PaymentContext $context): PaymentResult
{
    try {
        $session = $this->request('POST', '/v1/checkout/sessions', $this->buildSessionParams($context));
    } catch (RuntimeException $e) {
        return PaymentResult::failed($e->getMessage());
    }

    if (isset($session['error'])) {
        return PaymentResult::failed($this->errorMessage($session));
    }

    $url = $session['url'] ?? null;
    if (!is_string($url) || $url === '') {
        return PaymentResult::failed('Stripe did not return a checkout URL.');
    }

    return PaymentResult::redirect($url, isset($session['id']) ? (string)$session['id'] : null);
}

Keep buildSessionParams() a separate public method, so the parameter mapping is testable without an HTTP round trip.

The customer comes back to {route}/payment/return/{transactionHash}. That return is advisory: KahunaCartFrontend::showPaymentReturn() hands over the receipt if the order has left the cart state, and otherwise renders a self-refreshing processing page. Completion always comes from the webhook.

On-site tokenized

The checkout page renders the provider's JS, payment details are tokenized in the browser, and initiatePayment() confirms server-side with the token, returning complete or redirect for a 3DS step.

Important

Card data must never reach the Grav server. The base plugin ships no hook for injecting your JS into the checkout template, so this style currently needs a theme override.

Synchronous, with no provider at all

OfflineProvider in the base plugin is the reference.

PHP
public function initiatePayment(PaymentContext $context): PaymentResult
{
    return PaymentResult::complete(null, false);
}

public function handleWebhook(ServerRequestInterface $request): WebhookResult
{
    return WebhookResult::ignored();
}

Currencies

From 1.4.0 a store can sell in more than one currency, and the order you are handed is in exactly one of them: PaymentContext::$currency, and the $currency argument of refund(), capture() and charge(). It is not always the store's base currency, and you never read the store's currency from config to find out what it is. Your driver already gets the currency from the context, and a driver that only ever does that needs no change to keep working. These are the things to do on purpose.

Say which currencies you take, where core can ask. If the platform has a list, return it:

PHP
public function supportedCurrencies(): ?array
{
    return ['AUD', 'CAD', 'EUR', 'GBP', 'JPY', 'USD'];
}

If the answer is a rule, override the question instead and leave the list alone:

PHP
public function supportsCurrency(string $currency): bool
{
    // One store currency, read from the config the merchant saved.
    return strtoupper($currency) === strtoupper((string)$this->config('store_currency'));
}

Core asks supportsCurrency(), and its default reads supportedCurrencies(). Both run for every registered provider each time a payment step is drawn, so answer from the code or from what is already in config, never from an API call. If the platform's list depends on the account (Square's seller location fixes the one currency it can take), keep the value checkConnection() found in the driver's own config and answer from that, and answer true when nothing has been kept yet so a store that never ran the check is not left with no method.

Refuse there, not at pay time. A refusal inside initiatePayment() happens after the shopper chose the method and ends the checkout with an error. A refusal in supportsCurrency() means the method is never offered for that order, and the shopper picks another one. Keep a defensive check in initiatePayment() for the malformed code or the amount the platform cannot take, and move every "we do not take this currency" check into the new method. Add kahunacart: '>=1.4.0' to the dependencies in blueprints.yaml as soon as you override either method, because core 1.3 never asks them.

Mind the two traps that are the driver's and not the store's. Zero-decimal currencies (JPY, KRW, CLP) have no minor unit, and three-decimal ones (BHD, JOD, KWD, OMR) have a thousandth. Convert through Money::exponent($currency) and never divide by 100. Then check your platform's own rules for them, because they differ from the ISO ones in both directions. Among the drivers in this family: Checkout.com and Chargebee refuse a three-decimal amount that is not a multiple of 10, Checkout.com wants CLP in multiples of 100, PayPal wants HUF and TWD in whole units, and Invoice Ninja refuses an amount finer than its own precision. Say it in a clear refusal from supportsCurrency() when it is about the currency, and from initiatePayment() when it is about the amount, and put the rule in your README.

Report the currency the platform settled in. When a webhook or a refund answer names a currency, put it on the payload as currency and leave amount_minor as the platform gave it. Do not convert it and do not default it to the order's: a platform that says nothing is trusted to have used the order's currency, and a driver that fills in a guess hides the mismatch the store wants to see. Core compares it with the order's. When it differs, core settles the payment for the amount the payment session asked for, keeps your figure and currency on the transaction's payload (reported_amount_minor, currency) and in the order's history, and raises the payment-currency notice. See the provider contract for what is recorded where.

Catalogue sync. pushProduct() receives $product['currency'], the store's base currency, which is what every variant price is in. Send that to the platform; do not default to dollars. A price you pull in another currency should carry currency on its variant in the normalized array, and core will skip it rather than write it as a base price.

$event['order'] in the suitability event can be partial. A listener you write that reads it must cope with an array holding only currency, and with null.

Test all of it with the cases in Testing a payment provider.

Declare capability flags

ProviderCapability is a backed enum. Declare only what you have implemented. Nothing checks at runtime that you kept the promise.

Flag What consults it
Refunds PaymentService::refund() refuses to call you without it
PartialRefunds PaymentService::refund() refuses an amount below the refundable balance without it
SyncProducts SyncService skips you for push and pull without it
SyncOrders Printed by sync --status; the order-sync path lands in a later phase
HostedCheckout Declarative. Declare it if the customer pays on the provider's own page
Tokenization Declarative. Declare it for on-site tokenized flows
MerchantOfRecord The calculator drops every local tax adjustment for an order paid through you
Subscriptions, LicenseKeys Reserved for the subscriptions and licensing add-ons
AgentPayments UCP advertises your Google Pay handler and hands you the wallet token an agent platform collected. Implement AgentPaymentInterface with it. See Agent payments

MerchantOfRecord changes the amount you are handed. The base plugin records your slug on the cart and recalculates with the tax stage contributing nothing, so amountMinor carries no local tax. Declare it only if you really remit tax as merchant of record.

Note

MerchantOfRecord does not change the cart page, which is priced before any payment method is chosen and keeps showing the local estimate. Say so in your README.

Refunds gates the service, not the admin control. The control appears whenever an order's payment status is paid or partially_refunded. Refunding through a provider without Refunds returns a 422: "This payment method cannot refund automatically — record a manual refund instead". Say in your README whether you support refunds.

Some optional interfaces have no flag, and implementing one is the whole declaration. ProviderConnectionInterface gives your card a Check connection button, ProviderWebhookSetupInterface gives it a Set up webhook button, ProviderWebhookStatusInterface lets the card warn when the endpoint at your platform is missing, switched off or short of events (a read-only webhookStatus(string $webhookUrl): WebhookStatus, answered with WebhookStatus::forEndpoint() so every card words a problem the same way), and SettlementLookupInterface tells a store that sells in more than one currency what your platform converted each charge at and paid out. See Payout lookup.

Handle webhooks

The base plugin exposes POST {route}/webhook/{slug}, which with the default route is https://example.com/shop/webhook/stripe. KahunaCartFrontend::respondWebhook() hands your handler the PSR-7 request untouched, converts your WebhookResult to a status code, and answers {"ok": true|false}. The contract lists every action and its status.

The route is matched on path only, so a GET reaches your handler too, with an empty body. A verify-first handler rejects it.

Verify before you trust anything

Read the body, verify it, and do nothing else until that passes.

PHP
public function handleWebhook(ServerRequestInterface $request): WebhookResult
{
    $payload = (string)$request->getBody();
    $header = $request->getHeaderLine('Stripe-Signature');

    if (!$this->verifySignature($payload, $header, time())) {
        return WebhookResult::invalid();
    }

    $event = json_decode($payload, true);
    if (!is_array($event)) {
        return WebhookResult::invalid();
    }

    return $this->mapEvent($event);
}

Verify on the raw bytes, before decoding. Re-encoding JSON changes the bytes and breaks every HMAC. Three things Stripe's verifySignature() gets right:

  • An unset webhook secret fails closed rather than skipping the check.
  • A timestamp outside the replay window (300 seconds) is rejected before the HMAC matters.
  • The comparison is hash_equals with no early return, so timing reveals nothing.

For certificate-chain signing, PayPal rejects a request missing a required header, or naming a certificate URL off a provider host, before making the verification call out.

Return WebhookResult::ignored() generously. Providers retry on non-2xx, so returning 400 for an unhandled event causes repeated retries.

Round-trip both hashes

PaymentContext gives you two hashes. Include them in provider-side metadata so the webhook can return them. Stripe attaches both to the session and to the payment intent, because its later events are about different objects.

PHP
'metadata' => [
    'order_hash' => $context->orderHash,
    'transaction_hash' => $context->transactionHash,
],
'payment_intent_data' => [
    'metadata' => [
        'order_hash' => $context->orderHash,
        'transaction_hash' => $context->transactionHash,
    ],
],

In the webhook result, put the transaction hash in the payload and the order hash in its own argument.

PHP
return new WebhookResult(
    WebhookResult::PAYMENT_COMPLETED,
    $transactionRef,                        // the provider's reference
    $orderHash,                             // from your metadata
    ['transaction_hash' => $transactionHash] // from your metadata
);

PaymentService::locateTransaction() tries payload['transaction_hash'] (checking the row belongs to your slug), then findByReference(slug, transactionRef), then the order's latest pending purchase via orderHash. If all three miss, the event is answered 200 and dropped. Refund actions match on (slug, transactionRef) alone, against refund rows.

Two payload keys are read by name:

  • payload['reason'] — stored when a PAYMENT_FAILED or REFUND_FAILED marks the ledger row failed. Without it the ledger records "payment failed" or "refund failed".
  • payload['amount_minor'] — required for a REFUND_COMPLETED with no matching local row, which is the provider-initiated refund case. Without it, nothing is recorded.

Make handlers idempotent

Providers redeliver. On the payment path the base plugin gates on the order's own state, not the ledger row: CheckoutService::complete() returns the order untouched once it has left the cart state, and markPaid() no-ops on an order already paid. A redelivery after a 500 therefore finishes the job.

That protects order state, not yours. Make your own side effects idempotent. Derive references deterministically from the hashes and redeliveries land on the same ledger row.

PHP
public function referenceFor(string $transactionHash): string
{
    return 'dummy_' . substr(hash('sha256', 'txn:' . $transactionHash), 0, 20);
}

Report a dispute

A dispute is something a payment platform tells a store about, and only the driver that received the webhook knows it happened, so onKahunaCartDisputeOpened is fired by your driver, not by the base plugin. Fire it from your webhook handler once the body is verified.

PHP
Grav::instance()->fireEvent(Events::DISPUTE_OPENED, new Event([
    'order_id' => $orderId,
    'number' => (string)$order['number'],
    'provider' => $this->slug(),
    'provider_name' => $this->name(),
    'dispute_id' => (string)$dispute['id'],
    'amount_minor' => (int)round($dispute['amount'] * 100),
    'currency' => strtoupper((string)$dispute['currency']),
    'reason' => (string)$dispute['reason'],
    'respond_by' => $respondBy,   // an ISO 8601 time, or ''
]));
Key
order_id int, or 0 where the dispute could not be matched to an order
number The order number, where you have it
provider Your slug
provider_name Your brand
dispute_id The platform's own id for the dispute. The store-owner message deduplicates on it, so a redelivered webhook is one message rather than two.
amount_minor, currency What is being disputed
reason The platform's own reason code or sentence
respond_by The deadline as an ISO 8601 time (2026-10-21T23:59:59Z), or '' where the platform gives none. The store-owner message prints it as a plain date, "21 Oct 2026"; the generic webhook channel gets the value as you sent it

The drivers that report a dispute today are Stripe (charge.dispute.created), PayPal (CUSTOMER.DISPUTE.CREATED) and Mollie (a chargeback on a payment, read when the payment's webhook arrives). Polar sends no dispute webhook. Each writes a dispute.opened entry on the order's history before it fires the event, and uses that entry to report a redelivered dispute only once. Affiliates listens to the event and holds the partner's commission.

  • Fire it once per dispute, not once per delivery. The store-owner message deduplicates per dispute_id for good, so a redelivery is silent there, but anything else listening may not be. Firing from a path that runs on every delivery is a bug the dedupe hides.
  • Do not change the order. A dispute is not a refund and not a reversal: the money has not moved and the goods are not back. A driver that reversed the order on a dispute would take a customer's download away over a chargeback they may well win. Fire the event and leave the order alone.
  • A dispute can arrive before the payment is recorded. A test card can raise one within seconds of the charge, and a platform can deliver it ahead of the delivery that settles the payment. Look the order up by the payment's reference, then by any row of yours with that reference (a pending one is still under the id the checkout opened with), then by the hashes the platform carries. When nothing matches and the payment is under a day old, do not acknowledge it. Return WebhookResult::invalid('retry_order_not_recorded') so the platform delivers it again. A dispute older than that which matches nothing is somebody else's, because a payment account is often shared: return WebhookResult::ignored('unknown_order', $disputeId), a 200.
  • Put the order on the result you acknowledge. Pass the order's hash as the third argument of new WebhookResult(WebhookResult::IGNORED, $disputeId, $orderHash, [], [], 'dispute_recorded') and the webhook log row carries the order id, so the log can be filtered by order.

A request to deliver again is not a refusal

A reason that starts with retry_ (WebhookResult::RETRY_PREFIX) on WebhookResult::invalid() answers 400 like any other, so the platform tries again, but it is not announced as a refused webhook. The store-owner Webhook rejected message is for deliveries the store turns away because something is wrong, and a delivery that arrived a moment early is not that. Needs KahunaCart 1.5.3: on an older core the owner hears about it.

Test a webhook end to end

  1. Point your provider's tooling at the webhook route. Stripe's CLI does it with stripe listen --forward-to https://your-site.test/shop/webhook/stripe, which prints the whsec_… to paste in. PayPal has a simulator plus any HTTPS tunnel; Polar needs a public URL.

  2. Walk a real cart through checkout to the processing page.

  3. Drive the webhook by hand. The dummy provider needs nothing but curl.

    BASH
    curl -k -X POST https://your-site.test/shop/webhook/dummy \
      -H 'Content-Type: application/json' \
      -d '{"action":"payment_completed","transaction_hash":"TXN_HASH","order_hash":"ORDER_HASH"}'
    
  4. Read back what arrived with bin/plugin kahunacart webhooks. Every delivery leaves one row, and the command prints recent deliveries, per-provider totals and failure streaks. Rows are swept after payments.webhook_log_days days (default 30).

Tip

Install kahunacart-dummy even while building a different provider. Set it to redirect and walk a cart to the processing page: you then have a live order in the state your own handler will meet.

Implement refunds

PHP
public function refund(string $transactionRef, int $amountMinor, string $currency): RefundResult

The base class default returns RefundResult::failed(...), so a provider without refunds implements nothing.

$transactionRef is the reference column of the order's successful purchase transaction: whatever initiatePayment() returned, possibly overwritten by the transactionRef your webhook later reported. Make sure it is a reference you can refund against.

$amountMinor is already validated: greater than zero, no more than the captured amount minus what has been refunded, and equal to the full remaining balance unless you declare PartialRefunds. $currency is the order's currency; most APIs derive it from the original charge.

Return succeeded($refundRef), pending($refundRef), or failed($message). The contract sets out what each does to the ledger.

Finish an asynchronous refund

Return pending() whenever your provider has not moved the money yet: queued awaiting balance, waiting on approval, or able to fail after submission.

Finish the refund from your webhook, quoting the same reference you returned.

PHP
// Settled.
return new WebhookResult(WebhookResult::REFUND_COMPLETED, $refundRef, $orderHash, [
    'amount_minor' => $amountMinor,
]);

// Rejected, canceled, or failed after submission.
return new WebhookResult(WebhookResult::REFUND_FAILED, $refundRef, $orderHash, [
    'amount_minor' => $amountMinor,
    'reason'       => $failureReason,
]);

Handle refunds you did not initiate. A merchant refunding inside the provider's dashboard produces a REFUND_COMPLETED with no pending row. The base plugin then opens a refund transaction from orderHash plus payload['amount_minor'], but only if you supplied both. Read the amount from the individual refund rather than a running total.

Know when a refund fails

onKahunaCartRefundFailed fires when a refund the store had submitted comes back from the provider rejected, cancelled or failed. The customer was told their money was on its way, and it is not. Your driver does not fire it: return REFUND_FAILED from the webhook as above and the base plugin fires it.

The refund's row in the ledger is already marked failed with the provider's reason, and the order's payment status is left exactly where it was. It fires once per refund. A provider redelivering the same failure finds no pending refund and fires nothing, and neither does a failure for a reference the ledger never opened, such as a refund issued in the provider's own dashboard.

Key
order_id int
order The order row, or null if it could not be read
transaction_id The refund's row in the ledger
provider The driver's slug
provider_name The provider's brand
amount_minor, currency What the refund was for
reference The provider's reference for the refund
reason The provider's own words, or refund failed when it gave none

The store owner's Refund failed message listens to it. So might a helpdesk add-on that wants a ticket, or one that retries. Throwing from a listener does not fail the webhook delivery: it is logged and swallowed. Put a sentence in payload['reason'] that a person can read, because it goes into the owner's message.

Add catalog sync

This step is optional, for providers that keep their own catalog. Declare SyncProducts and implement ProviderSyncInterface. SyncService checks both and skips a provider with one but not the other.

PHP
class PolarProvider extends AbstractPaymentProvider implements ProviderSyncInterface

pushProduct(array $product): string receives the local product row plus a variants key, default variant first, and returns the remote id, which the base plugin records in kahunacart_provider_map. Throw on failure: the base plugin wraps the throwable with your slug and the job queue retries with backoff.

You are given local data only, never the remote id you were mapped to before. Polar stamps the local slug into provider-side metadata under a kahunacart_slug key and looks products up by it.

pullProducts(): iterable yields normalized product arrays, whose structure the contract specifies. Return a generator for a large catalog.

A SYNC_EVENT webhook lets a provider announce a change instead of waiting for a poll.

PHP
return new WebhookResult(WebhookResult::SYNC_EVENT, $id, null, [
    'entity_type' => 'product',
    'remote_id'   => $id,
    'data'        => $this->normalizeProduct($polarProduct),  // or null
]);

SyncService::ingest() drops a non-product entity type or an empty remote_id. If the site has not configured your provider as remote-master, the change only marks the mapping stale; if it has, data is applied directly, and data => null queues a full catalog pull.

Direction is the site's choice, not yours. It lives in kahunacart.yaml keyed by slug.

YAML
sync:
  polar:
    direction: remote   # local | remote | off (default)

Direction governs automatic work only. bin/plugin kahunacart sync --provider=<slug> --push|--pull always does what the operator asked, and running it with no action flag prints mapping counts, capabilities, interface support, and direction. Pulls never delete local products.

Note

A pull applies to the product and its default variant only. Extra variants are stored verbatim in the mapping row's data_json under extra_variants and are not created locally yet.

Add an admin section

This step is also optional. To add a screen, such as credentials with a connection test, a webhook status panel or a rate card, subscribe to onKahunaCartAdminSections and append an array.

PHP
public function onKahunaCartAdminSections(Event $event): void
{
    $sections = $event['sections'];
    $sections[] = [
        'id' => 'paypal',
        'label' => 'PayPal',
        'icon' => 'CreditCard',
        'position' => 200,
        'script_path' => 'plugin://kahunacart-paypal/admin-next/sections/paypal.js',
    ];
    $event['sections'] = $sections;
}

The script defines one custom element named kahunacart-section-<id> and guards its own registration, because the host may load it more than once.

JS
if (!customElements.get('kahunacart-section-paypal')) {
    customElements.define('kahunacart-section-paypal', KahunaCartPayPalSection);
}

Both section endpoints require kahunacart.orders.view, which is a viewing permission. If your screen writes settings, check kahunacart.settings on your own endpoints. See Admin sections for the full specification.

Registering a rule operation

A sale rule calculates its discount through an operation: a class with a code, a blueprint of its arguments, and one method that returns the adjustments to apply. One declaration gives you the runtime behaviour, the admin form, the translated label and the persisted arguments. Your operation appears in the Sales editor's picker as soon as your plugin is enabled.

Six operations ship with KahunaCart: percent_off, amount_off, quantity_tiers, bogo, cheapest_free and free_gift. Yours joins them in the same picker. Read free_gift first: it changes what is in the basket rather than what the basket costs. The gift line is written at zero, so the operation returns no adjustment and the cart does the adding and removing. See Rules and conditions.

Subscribe to onKahunaCartRegisterOperations and register an instance.

PHP
public static function getSubscribedEvents(): array
{
    return ['onKahunaCartRegisterOperations' => ['onKahunaCartRegisterOperations', 0]];
}

public function onKahunaCartRegisterOperations(Event $event): void
{
    $event['registry']->register(new GiftWrapFree(), 'my-plugin');
}

And the operation itself.

PHP
use Grav\Plugin\KahunaCart\Rules\Adjustment;
use Grav\Plugin\KahunaCart\Rules\ConfigurableOperation;
use Grav\Plugin\KahunaCart\Rules\RuleContext;

final class GiftWrapFree implements ConfigurableOperation
{
    public function code(): string { return 'gift_wrap_free'; }
    public function labelKey(): string { return 'PLUGIN_MY_PLUGIN.OPERATION_GIFT_WRAP_FREE'; }
    public function descriptionKey(): string { return 'PLUGIN_MY_PLUGIN.OPERATION_GIFT_WRAP_FREE_HELP'; }

    // SCOPE_LINE runs once per covered line. SCOPE_SET runs once with the whole
    // covered set, which is what an operation that has to count the basket needs.
    public function scope(): string { return self::SCOPE_LINE; }

    public function args(): array
    {
        return [
            'over' => [
                'type' => 'money',
                'label' => 'PLUGIN_MY_PLUGIN.OPERATION_ARG_OVER',
                'help' => 'PLUGIN_MY_PLUGIN.OPERATION_ARG_OVER_HELP',
            ],
        ];
    }

    public function validate(array $args, string $currency): array
    {
        $over = (int)($args['over_minor'] ?? 0);
        if ($over <= 0) {
            throw new \InvalidArgumentException('Gift wrapping needs a threshold above zero');
        }

        return ['over_minor' => $over];
    }

    public function apply(RuleContext $context, array $args, array $source): array
    {
        $out = [];
        foreach ($context->lines as $line) {
            if ($line->unitPriceMinor < (int)$args['over_minor']) {
                continue;
            }
            $out[] = Adjustment::line($line->itemId, 500 * $line->qty, 'Gift wrap', $source + [
                'over_minor' => (int)$args['over_minor'],
            ]);
        }

        return $out;
    }
}

Three rules apply.

Return adjustments, never mutate. apply() is handed a read-only context and answers with a list. Two add-ons' operations can then run in the same recalculation without knowing about each other.

Arguments are declared in the blueprint or they do not exist. The admin renders your form from args(), so a merchant cannot set a field you never declared. The types are text, number, money, percent, select, toggle, product, variant, category and tag. Anything else is refused when you register, with the field named.

Note

The admin draws a real control for toggle and select today. Every other type renders as a text input, so a product argument asks for an id rather than offering a picker.

Carry the source through. The $source you receive already identifies the sale, the rule, the operation and its arguments. Add any details specific to your operation and return the complete source on every adjustment.

A duplicate operation code is refused when you register, naming the plugin that got there first. An unknown code is refused when the sale is saved and again when it is applied, with the code in the message.

Registering a rule attribute

A condition is an attribute, an operator and a value. The attributes are published through GET /kahunacart/rules/vocabulary and the MCP tool list_rule_attributes, so a merchant can pick them and a client does not have to guess. Register yours on onKahunaCartRegisterRuleAttributes.

PHP
use Grav\Plugin\KahunaCart\Rules\Operator;
use Grav\Plugin\KahunaCart\Rules\RuleAttribute;
use Grav\Plugin\KahunaCart\Rules\RuleContext;
use Grav\Plugin\KahunaCart\Rules\RuleLine;

public function onKahunaCartRegisterRuleAttributes(Event $event): void
{
    $event['registry']->register(RuleAttribute::of(
        'subscriber_months',
        'PLUGIN_MY_PLUGIN.RULE_ATTR_SUBSCRIBER_MONTHS',
        Operator::TYPE_NUMBER,
        RuleAttribute::APPLIES_CUSTOMER,
        fn (RuleContext $context, ?RuleLine $line): int => $this->monthsFor($context->customerEmail),
    ), 'my-plugin');
}

You declare the type; it is never inferred. Both sides of a comparison then coerce the same way.

appliesTo identifies what your attribute describes. A line attribute is answered per line and is the only kind handed a RuleLine. A cart, customer or order attribute is answered once for the basket. A line attribute asked without a line is false rather than an error, and the admin groups its picker by this.

A duplicate key is refused when you register, naming the plugin that got there first.

Note

Conditions apply to sales today. Coupons, shipping methods, tax zones and payment gateways do not take them yet.

See Rules and conditions for the whole engine, the vocabulary table and the operations reference.

A carrier rate driver

A live carrier plugs into shipping on onKahunaCartRegisterCarrierRateProviders. The event carries one key, registry, and you call add() on it with an object implementing Shipping\Carrier\CarrierRateProviderInterface.

KahunaCart registers no carrier of its own. The drivers belong to the KahunaCart Shipping add-on. The base plugin ships the contract they plug into: the interface, the registry, the quote cache, the timeout and the failure mode.

The merchant decides which methods exist and the driver decides what one costs. A merchant creates a shipping method with your code() in its carrier_code and one of your service codes in its carrier_service_code. You are asked for a price for that service and answer with a number or a reason. A driver never invents a method, never reaches into the offer list and never sees a cart.

Implement the interface

You are handed a ShippingContext carrying the destination address, the package, the currency, the declared value and the weight. You answer with a CarrierQuoteResult: the rates you can offer, and the services you are excluding with a language key saying why.

PHP
use Grav\Plugin\KahunaCart\Shipping\Carrier\CarrierQuoteResult;
use Grav\Plugin\KahunaCart\Shipping\Carrier\CarrierRate;
use Grav\Plugin\KahunaCart\Shipping\Carrier\CarrierRateProviderInterface;
use Grav\Plugin\KahunaCart\Shipping\Carrier\ExcludedMethod;
use Grav\Plugin\KahunaCart\Shipping\Carrier\ShippingContext;

final class AcmeCourier implements CarrierRateProviderInterface
{
    public function code(): string
    {
        return 'acme';
    }

    public function labelKey(): string
    {
        // A language key, never a sentence: core prints no English a driver
        // handed it, and a key is the only form a theme can override.
        return 'PLUGIN_ACME_SHIPPING.CARRIER_NAME';
    }

    public function supports(ShippingContext $context): bool
    {
        // Cheap and local. This is where you decline a destination without
        // spending any of the timeout on it.
        return $context->country() === 'GB' && $context->weightGrams() <= 30000;
    }

    public function quote(ShippingContext $context): CarrierQuoteResult
    {
        $response = $this->http->post('https://api.acme.example/rates', [
            'postcode' => $context->postcode(),
            'grams' => $context->weightGrams(),
            'value' => $context->declaredValueMinor,
        ]);

        $rates = [];
        foreach ($response['services'] as $service) {
            $rates[] = new CarrierRate(
                $service['code'],
                $service['name'],
                (int)$service['pence'],
                $context->currency,
                (int)$service['days']
            );
        }

        // Say why you offered nothing rather than answering with an empty list:
        // a customer shown a shorter list with no sentence cannot act on it.
        $excluded = [];
        if ($response['saturday'] === false) {
            $excluded[] = new ExcludedMethod(
                'acme-saturday',
                'PLUGIN_ACME_SHIPPING.NO_SATURDAY_HERE',
                ['postcode' => $context->postcode()]
            );
        }

        return new CarrierQuoteResult($rates, $excluded);
    }
}
PHP
public function onKahunaCartRegisterCarrierRateProviders(Event $event): void
{
    $event['registry']->add(new AcmeCourier());
}

Five things the contract asks of you

  • Answer in the order's currency. $context->currency is the store's one currency; a rate in another is discarded rather than converted by core. Core does keep exchange rates now, but the pricer never converts a carrier's quote on your behalf, so answer in the order's currency. A driver that does want to convert its own quote can call KahunaCart::instance()->rates()->convert(), which answers null when the store has no rate. See Currencies → ExchangeRates.
  • Amounts are minor units. Everything in KahunaCart is. See Money conventions.
  • Reasons are language keys, with their placeholders in reason_params. Ship your translations with your add-on.
  • Respect your deadline. Core hands you one and enforces it. If the response is late, it is discarded, your methods are shown unavailable, and checkout uses the merchant's fallback method. Set the deadline in your HTTP client to avoid this.
  • You may throw. Every call is wrapped and \Throwable is caught, so a failing driver loses its own quotes and logs an error without affecting other drivers.

Quotes are cached, and a failure never blocks checkout

A quote is cached on a hash of what it depends on: destination, package, weight, declared value and currency. The cache has a TTL (shipping.carrier_rates.cache_ttl, default 900 seconds), a tag so the whole set drops when a merchant saves their shipping, and an admin rebuild action at POST /kahunacart/shipping/carriers/cache/clear. A late answer is never cached.

The runner catches everything. A driver that times out, refuses, errors or is switched off has its methods shown present but unavailable with the reason attached, and the merchant's nominated offline fallback method carries the checkout.

The reference implementation is tests/Support/TestCarrierProvider.php in the base plugin, driven by the acceptance tests: it can quote, exclude a service, sleep past the timeout and fail outright. Copy that rather than the excerpt above.

See Shipping rates for the merchant's half of this, and Shipping for the methods a driver prices.

A parcel arrived

onKahunaCartShipmentDelivered fires once per parcel, the first time it is marked delivered, with order_id and shipment (now carrying delivered_at). The admin's Mark delivered button fires it, and so can a carrier add-on that tracks parcels, through the one write path:

PHP
$result = KahunaCart::instance()->fulfillment()->markDelivered($shipmentId, $deliveredAt); // null is now
// ['shipment' => [...], 'changed' => true]

The write only happens while delivered_at is still empty, so a merchant's click and your poll racing each other record one time and fire the event once. The loser gets changed: false and the time the winner wrote. A time in the future is taken as now, and one before the parcel shipped is raised to the shipping time. For an order an agent placed through UCP, the event sends the platform a delivered order webhook. See UCP checkout → Orders and webhooks.

Tax engines and tax ID validators

Two contracts, two events, and the base plugin implements neither of them.

Event Registry What you are registering
onKahunaCartRegisterTaxEngines Tax\Engine\TaxEngineRegistry Something that quotes tax for a region through your own service instead of the store's rate table
onKahunaCartRegisterTaxIdValidators Tax\Engine\TaxIdValidatorRegistry Something that checks a VAT number against the authority that issued it

TaxEngineInterface

PHP
namespace Grav\Plugin\KahunaCart\Tax\Engine;

interface TaxEngineInterface
{
    public function code(): string;
    public function label(): string;
    public function quote(TaxContext $context): TaxQuoteResult;

    public function providesRateAdministration(): bool;
    public function adminNavItems(): array;
    public function adminActionHtml(): ?string;
}
Method What it answers
code() The stable code a zone's engine column holds and the bind route names. Lowercase letters, digits and dashes.
label() A language key naming the engine in the admin, or a plain name. The admin prints an unresolvable key as it stands, so a driver with no translations returns its own name.
quote() The tax for one order, from a TaxContext carrying the basis address, the lines, the shipping total, the currency and the inclusivity in force.
providesRateAdministration() Whether the built-in zone, class and rate editors still mean anything once this engine is bound.
adminNavItems() Items to put where the built-in editors were, each ['id' => …, 'label' => …, 'href' => …].
adminActionHtml() A block of admin markup rendered in place of the rate table — a Sync button, an account status, a link to the service's own dashboard.
PHP
public function onKahunaCartRegisterTaxEngines(Event $event): void
{
    $event['registry']->register(new MyTaxEngine($this->config), 'my-tax-plugin');
}

The first registration of a code wins, and a duplicate is refused. The second argument is your plugin's name, and the refusal message names it.

Warning

adminActionHtml() is rendered as trusted admin markup. Nothing escapes it for you, so escape everything at the driver, including anything your service handed you.

An engine binds to a tax zone, never to the whole store. One install can quote a connector for the United States and keep its own EU VAT rates for Europe. PUT /kahunacart/tax/engines/bind attaches a zone to an engine, an empty engine unbinds it, and GET /kahunacart/tax/engines lists everything registered along with the store-level default and the registered validators.

A zone bound to an engine nobody registered, usually because the plugin that owned it was switched off, falls back to the merchant's own rates. The admin says which zones are in that state.

Taking the rate editors away

An engine declaring providesRateAdministration(): false administers its own rates, and three things happen. The zone, class and rate editors disappear from the admin, replaced by your nav items and action HTML. The built-in rate administration stops being granted, which the admin reads off the API. And POST, PATCH and DELETE on /kahunacart/tax/zones, the EU VAT preset import and the rate CSV import all answer 409 with a message naming your engine.

The bind route is not among them: it is what undoes a binding, so a store can always unlock its own rate table.

Because binding is per zone, the admin has three states: full (nothing bound, or everything bound administers its own rates), partial (some zones are quoted by a non-administering engine and some are not, so the editors stay and the screen says which regions they still govern), and none (the editors go and the routes answer 409).

TaxIdValidatorInterface

PHP
interface TaxIdValidatorInterface
{
    public function code(): string;
    public function label(): string;
    public function countries(): array;
    public function validate(string $countryCode, string $taxId): TaxIdValidationResult;
}

KahunaCart ships this interface and implements nothing behind it. VAT capture, validation and reverse charge belong to the tax add-on. A test scans classes/ on every run and fails if anything in core implements this or TaxEngineInterface.

Two things to get right. A service being down is not an invalid number. Treating a timeout as invalid charges VAT to a company entitled to zero-rating, so fail loudly on a transport problem and return an invalid result only when the authority said no. Cache with the timestamp: checked_at is what makes a cached answer re-checkable rather than merely old.

A validator declaring no countries answers for all of them. One naming countries wins over one that does not, so a merchant running an HMRC plugin and a VIES plugin gets the specialist for the United Kingdom and the generalist elsewhere.

See Tax drivers for the whole contract in one place, and Tax for the rate table an engine replaces.

Schema extensions

Add your own data to a KahunaCart response under a namespace nobody else can write to, with a schema declaring what goes there. Register on onKahunaCartRegisterSchemaExtensions, which carries an Extension\Schema\SchemaExtensionRegistry.

Important

Every documented response can carry a top-level extensions object. It is appended, never inserted, so no documented key moves, and it is absent from a store where nobody registered a namespace. Write a new client to expect the key and ignore what it does not recognise.

Everything an add-on returns lands inside that one reserved key, one object per namespace:

JSON
{
  "totals": { "total": "48.00" },
  "extensions": {
    "kahunacart-licenses": { "seats": 5, "expires_at": 1780000000 },
    "kahunacart-subscriptions": { "renews_at": 1780000000 }
  }
}

Two add-ons can both call a key status and neither can read the other's. See Storefront JSON API → The extensions object for what it looks like on the wire.

Register an extension

Call register($point, $namespace, $schema, $callback, $plugin) once per point you extend, and return a map of the keys you declared from the callback.

PHP
use Grav\Plugin\KahunaCart\Extension\Schema\SchemaExtensionPoint;

public static function getSubscribedEvents(): array
{
    return ['onKahunaCartRegisterSchemaExtensions' => ['onRegisterSchemaExtensions', 0]];
}

public function onRegisterSchemaExtensions(Event $event): void
{
    $event['registry']->register(
        SchemaExtensionPoint::STOREFRONT_LINE_ITEM,
        'kahunacart-licenses',
        [
            'seats' => 'integer',
            'expires_at' => ['type' => 'integer', 'nullable' => true, 'description' => 'UTC epoch, null for a perpetual licence'],
        ],
        fn (array $context): array => $this->licenceFor((int)($context['item']['id'] ?? 0)),
        'kahunacart-licenses'
    );
}

The callback is handed the rows the payload was built from.

The eleven extension points

The list is closed: six storefront resources, four admin reads, and the catalog fragment's Twig context.

Point Fires on
storefront.cart GET/POST cart responses in the storefront JSON API
storefront.line_item Each line inside a cart response
storefront.shipping The shipping-options response
storefront.payment_methods The payment-methods response
storefront.checkout The place-order response
storefront.countries The countries and one-country responses
admin.order GET /kahunacart/orders/{id}
admin.product GET /kahunacart/products/{id}
admin.customer GET /kahunacart/customers/{id}
admin.config GET /kahunacart/config
twig.catalog_fragment The catalog fragment render, as an extensions Twig variable

Registering against anything else is refused at registration time, with a message listing the points that exist.

The types

Type Accepts
string A PHP string
integer A PHP int. The string "5" is not one
number An int or a float
boolean A PHP bool
array A JSON list — consecutive integer keys from zero
object A JSON object — a map

Declare a key as ['type' => …, 'nullable' => true] to let it come back null. Types are declared and checked, never inferred from the value.

Nothing here is quiet

Situation Result
An unknown extension point Refused at registration, listing the points that exist
A malformed namespace or key Refused at registration
A namespace another plugin already claimed Refused at registration, with both plugin names in the message
The same plugin registering one namespace against one point twice Refused at registration. Register a point once and return every key from the one callback
A key the callback returns but did not declare Dropped, with a log line naming the namespace, the key and the point
A value whose type does not match the declared one Dropped, with the same kind of log line. A declared integer holding "5" goes
A callback that fails, or returns something that is not a map That namespace is missing from this response. Every other namespace, and the documented payload, still go out

Every refusal at registration also appears under conflicts on GET /kahunacart/schema-extensions. Look there first: a plugin whose namespace was refused runs with none of its data reaching a client. That route also answers with every registration: the point, the namespace, the owning plugin and every declared key with its type. It is the list_schema_extensions MCP tool.

Compensating hooks

Run a side effect when an order completes, a payment is captured, a shipment goes out, an order is cancelled or a refund lands, and declare how to undo it. The undos run in reverse when a later step fails. Register on onKahunaCartRegisterHooks, which carries an Extension\Hooks\HookRegistry.

Point Fires when Carries
order_completed An order leaves the cart state with its number allocated and its stock taken order
payment_captured Money a provider was holding has actually been taken order, transaction, amount_minor, currency, full
fulfillment_created A shipment is recorded against an order shipment, items
order_canceled A sale is called off: the admin's Cancel, or an authorisation voided that takes the order with it order
order_refunded A refund the provider confirmed lands on the goods order, transaction, items, shipping_minor, full

The subject of every run is an order, and the order id is what the run record is keyed on.

The first three points handle sale processing; the last two handle cancellation and refunds. This distinction determines which points an unwind reverses. See How an unwind runs.

order_refunded runs on the first confirmed refund against an order and not again, because a run is claimed once per point, handler and order, the same idempotency payment_captured has. full says whether this refund took the whole order back. A handler that has to see every refund separately listens to onKahunaCartRefundApplied, which fires each time and has no undo attached.

Register a handler

Call on($point, $name, $run, $compensate, $plugin, $description). Pick $name once and never change it: it is the handler's stable id and what the run record is keyed on, so renaming it makes every past run invisible and the handler runs again on orders it has already handled.

PHP
use Grav\Plugin\KahunaCart\Extension\Hooks\HookContext;
use Grav\Plugin\KahunaCart\Extension\Hooks\HookPoint;

public static function getSubscribedEvents(): array
{
    return ['onKahunaCartRegisterHooks' => ['onRegisterHooks', 0]];
}

public function onRegisterHooks(Event $event): void
{
    $event['registry']->on(
        HookPoint::ORDER_COMPLETED,
        'kahunacart-licenses.issue',
        fn (HookContext $context) => $this->issueFor($context->orderId()),
        fn (HookContext $context) => $this->revokeFor($context->orderId()),
        'kahunacart-licenses',
        'Issues a licence key per licensed line, and revokes them all again on an unwind.'
    );
}

A handler name is your plugin slug, a dot, and what the handler does. A name another plugin already owns is refused with both plugin names in the message, and so is registering one name against one point twice.

How an unwind runs

Handlers run in registration order. When one fails, everything that already ran on that call is compensated in the reverse of the order things actually ran. Once a handler has been skipped, that is not the reverse of registration order.

If a compensation fails, the failure is logged, its run row is marked compensation_failed, and the unwind continues to the next compensation.

An unwind also runs from outside the flow. onKahunaCartOrderReversed, a cancellation or a full refund, unwinds the three forward points on the order, newest first within each.

order_canceled and order_refunded are not unwound there, because they run at that same moment and unwinding them would compensate a handler the instant it finished. Their compensations run the way every other one does, when a later handler at the same point throws on the same call.

When a sale is reversed, stock is returned and the coupon is released first, then the forward points are unwound, then order_canceled runs. A cancellation handler therefore reads an order whose sale has already been reversed.

Idempotency

One row in kahunacart_hook_runs per (point, handler, subject_type, subject_id). A UNIQUE index over those columns enforces this without a race between checking and inserting.

Situation What happens
A duplicated webhook reaches the same point again The claim is refused. The handler does not run
A handler that has already been compensated The claim is refused. It does not run again
A compensation asked to run twice The conditional update matches nothing the second time. It runs once
A row left in running by a crashed process Left alone, and never reclaimed. A handler whose external effect may or may not have happened is for a person to look at, not for a retry to guess at

A run row is never deleted when its compensation runs.

A handler with no compensation

$compensate is optional. The handler is then recorded as non-compensating and listed under non_compensating on GET /kahunacart/hook-points, and it means this: if a later step in the same flow fails, what your handler did stays done and somebody has to undo it by hand.

Leaving it out is reasonable for a handler whose only effect is a row the order already owns. It is wrong for one that issues a licence, calls somebody else's API, or sends anything to a customer.

What core puts through it

Download grants are issued on order completion through this mechanism, and the compensation revokes every grant on the order, so a grant issued against a payment that then failed is taken back. extensions.hooks.enabled: false puts grants back on the direct path the plugin shipped with, and takes every add-on's compensation with it.

GET /kahunacart/hook-points, and the list_hook_points tool, answer with every point, its handlers, which of them declared no undo, and how many runs are in each state.

Dashboard alerts

Tell a merchant about something in your add-on that needs their attention, on the dashboard rather than in an email. Register an AlertDefinition on onKahunaCartRegisterDashboardAlerts.

PHP
public function onRegisterAlerts(Event $event): void
{
    $event['registry']->register(new CallableAlert(
        'licenses_expiring',
        // What the check finds. Return an array. Every other callback here is
        // handed it, and it is what the payload carries.
        fn (): array => ['count' => $this->expiringSoon()],
        // Seconds before it runs again.
        recheckInterval: 300,
        // Whether there is anything to say. A check that finds nothing wrong
        // returns data and answers false here.
        shouldShow: fn (array $d): bool => $d['count'] > 0,
        title: fn (array $d): array => [
            'key' => 'PLUGIN_LICENSES.ALERT_EXPIRING',
            'args' => [(string)$d['count']],
        ],
        detail: fn (): ?array => ['key' => 'PLUGIN_LICENSES.ALERT_EXPIRING_DETAIL', 'args' => []],
        severity: fn (): string => 'warning',
        actions: fn (): array => [[
            'code' => 'open',
            'label_key' => 'PLUGIN_LICENSES.ALERT_EXPIRING_ACTION',
            'route' => '/section/licenses?filter=expiring',
        ]],
        // How long a dismissal lasts, in seconds. Null means until the user puts it back.
        dismissWindow: 86400,
    ));
}

Severity is info, warning or error, the same three the notices system uses.

Three things to get right. Return data from the check and decide in shouldShow(): the result is cached for the recheck interval and handed to every other callback. Make the check cheap, or give it an interval, since it runs on every admin screen that draws the alert strip. Publish language keys, not sentences, for the title, the detail and every action's label_key, with arguments matching the placeholders.

If your check fails, that alert disappears and the rest of the dashboard still renders.

To have the alert also reach the owner's phone, see Push an alert you already have.

See Dashboard alerts for the four checks that ship, the two config keys, and the merchant's view of the strip.

Telling the store owner

Everything on the store-owner half of the Notifications screen is a row an add-on can add to. A store-owner message has no customer to go to, so it goes to the channels the owner ticks on its row: an email address, Slack, Discord, Telegram, a phone. See Notifications for what the owner sees.

Register a store-owner message

Register the message on onKahunaCartRegisterNotifications, with audience: merchant and a priority.

PHP
public static function getSubscribedEvents(): array
{
    return ['onKahunaCartRegisterNotifications' => ['registerNotifications', 0]];
}

public function registerNotifications(Event $event): void
{
    $event['registry']->register(new NotificationDefinition(
        code: 'polar.api_version_fallback',
        labelKey: 'PLUGIN_KAHUNACART_POLAR.NOTIFICATION_API_VERSION_FALLBACK',
        descriptionKey: 'PLUGIN_KAHUNACART_POLAR.NOTIFICATION_API_VERSION_FALLBACK_DESCRIPTION',
        audience: NotificationDefinition::AUDIENCE_MERCHANT,
        priority: Priority::NORMAL,
    ));
}

That is the whole registration. There is no listener, because a store-owner message is not sent by a listener. You record it where the fact is already known, and the channels ticked on the row receive it.

PHP
KahunaCart::instance()->notifications()->record(
    'polar.api_version_fallback',
    ['title' => 'Polar no longer serves the pinned API version', 'pinned' => $pinned, 'serving' => $serving],
    'polar.api_version:' . $pinned          // the dedupe key
);
  • The code is the event name. One name for the thing that happened, the row that switches it, and the channels ticked for it. record('polar.api_version_fallback', …) reaches the row registered under that code and nothing else.
  • A new row goes to the store's default channels. Until the owner ticks channels on it, the row follows New messages go to on the Channels page, so your messages reach their Slack or phone the day your add-on is installed. See New messages go to.
  • A chatty row can start switched off. A message that is worth having but frequent, such as one per affiliate commission, passes enabledByDefault: false. The row appears switched off, records nothing, and goes out once the owner switches it on. Their saved switch wins from then on, either way.
  • Facts that cost something go in a callable. record() takes a callable in place of the data array and calls it only once the message is known to be going out, after the channel check and after the dedupe key is claimed. A count, a query or an API round trip belongs in one.
  • A problem message needs a dedupe key. A message that fires on every request it describes is muted inside a day and then never read again. Pass a third argument to record(): the first call queues and every later one is a no-op. The base plugin's own rows use a key per order per day, per provider per reason per day, per dispute.
  • Give it a priority that matches how bad it is. The owner can override it, but the default is what most stores will live with. emergency on Pushover repeats every minute for an hour until somebody acknowledges it, so it belongs to a chargeback and nothing else.
PHP
$event['registry']->register(new NotificationDefinition(
    code: 'affiliates.owner_commission',
    labelKey: 'PLUGIN_KAHUNACART_AFFILIATES.NOTIFICATION_OWNER_COMMISSION',
    descriptionKey: 'PLUGIN_KAHUNACART_AFFILIATES.NOTIFICATION_OWNER_COMMISSION_DESCRIPTION',
    audience: NotificationDefinition::AUDIENCE_MERCHANT,
    priority: Priority::LOW,
    enabledByDefault: false,
));

What reaches a chat service. Slack, Discord, Telegram, Pushover, ntfy, Gotify and text messages are drawn from a summary built from named keys and nothing else, so a customer's email or postal address cannot reach one by being added to your data. A title in your data becomes the headline, and up to six other scalar values become fields, each cut at 120 characters. The generic webhook and the email channel get the whole of data. Keep a customer's email address, phone number, postal address and any key or credential out of data you expect a chat channel to show.

enabledByDefault and the default channels need KahunaCart 1.5.3. An add-on that also runs on an older core can pass enabledByDefault only when the constructor accepts it.

Push an alert you already have

If your add-on already detects a problem for the dashboard strip, do not write a second detector to tell the owner's phone. Give the alert a push priority instead.

PHP
$event['registry']->register(new CallableAlert(
    'myplugin_feed_stopped',
    fn (): array => ['stopped' => $this->feedStopped()],
    recheckInterval: 300,
    shouldShow: fn (array $d): bool => $d['stopped'],
    title: fn (): array => ['key' => 'PLUGIN_MYPLUGIN.ALERT_FEED_STOPPED', 'args' => []],
    notify: Priority::HIGH,
    label: 'PLUGIN_MYPLUGIN.ALERT_FEED_STOPPED_LABEL',
));

Core registers the row (alert.myplugin_feed_stopped) for you, and the worker sends one message when the alert starts showing and a "Cleared" one when it stops. Do not register a NotificationDefinition or call record() for it yourself. A class-based alert implements NotifyingAlert, which adds notify() and label(), to do the same. See Dashboard alerts → Pushing an alert to your phone.

Report a webhook your own endpoint refused

A payment provider's refusals reach onKahunaCartWebhookRejected through the base plugin's webhook route, and the owner hears about them. A plugin that takes webhooks at a route it owns, such as a print provider, a carrier or an email service, refuses a bad delivery before the base plugin sees it, and that refusal used to go to the plugin's own log and nowhere else. One call puts it in front of the owner, worded and deduplicated exactly as a payment provider's is.

PHP
$notifications = KahunaCart::instance()->notifications();
$notifications->webhookRejected('printful', 'Printful', 'bad_signature', ['status' => 401]);

The first argument is a slug for who sent it, the second is the name the owner knows it by, and the third is the reason: bad_signature, no_secret_configured, stale_timestamp, missing_headers, unparseable_body, not_an_object, or http_<status> where nothing names one. status in the fourth is what your endpoint answered with. The message is queued once per source per reason per day, however many deliveries you turn away, and it goes to the channels ticked on the Webhook rejected row. The call returns true when it queued a message and never throws. The count of refusals in a row comes from the base plugin's delivery log, which your endpoint's refusals are not in, so it reads as the first. Pass 'streak' => $n if you keep a count of your own.

Report what is wrong with the store's set-up, such as a wrong secret or a bad signature. Print on demand and Shipping leave out a body that is too big, one that names nothing the store knows and a provider slug nobody configured, because none of those is the store's doing and anyone can send them.

Store export and restore

Import & Export → Export writes the whole store into one bundle, and a restore reads it back into another store, in full or merged into one that is running. See Export and restore. Core carries its own tables. Yours travel only if you register them. Until you do, the Export screen and the restore report both name your add-on under "not included", because a merchant moving their shop needs to know what is staying behind.

Subscribe to onKahunaCartRegisterExports and add a set. For an add-on whose rows live in its own tables, Transfer\TableExportSet does the whole job, both ways. This is the test provider's, from classes/Listeners/ExportSets.php in kahunacart-dummy:

PHP
use Grav\Plugin\KahunaCart\Transfer\ExportRegistry;
use Grav\Plugin\KahunaCart\Transfer\TableExportSet;
use Grav\Plugin\KahunaCart\Transfer\TableSpec;

public static function getSubscribedEvents(): array
{
    return ['onKahunaCartRegisterExports' => ['onKahunaCartRegisterExports', 0]];
}

public function onKahunaCartRegisterExports(Event $event): void
{
    $event['registry']->add(new TableExportSet(
        'dummy.subscriptions',            // the set's id: its folder in the bundle, and --only= on the CLI
        'Test provider subscriptions',    // what the merchant reads beside the tick
        'Test provider',                  // the heading it is listed under: your add-on's name
        [
            new TableSpec(
                'kahunacart_dummy_subscriptions',
                key: ['ref'],                                  // what a merge matches a row on
                refs: [                                        // columns holding a core row's id
                    'order_id' => 'kahunacart_orders',
                    'variant_id' => 'kahunacart_variants',
                ],
            ),
        ],
        dependsOn: ['orders', 'catalog'], // restored after these
        permission: 'kahunacart.orders.view',
    ));
}

The dummy keeps the body in a static ExportSets::register() so its tests can call it without a Grav container, and its listener checks that $event['registry'] is an ExportRegistry before it adds anything. The set itself is the one above.

What each part does:

  • The id is short and stable: lowercase, with dots or dashes. It names the set's folder in the bundle and the set on the command line, so never change it once a release has shipped it. Start it with your add-on's own word.
  • The tables are usually the same ones you declare for reset. Name configuration tables too if a merchant would expect them to move, such as plans or products of your own. A bundle moves the whole store, which a reset does not. List parent tables before the tables that reference them. Inside one set the tables are restored in the order you list them (dependsOn orders sets, not tables), and a merge skips a child whose parent it has not mapped yet.
  • key is how a merge finds a row the store already has: a reference, a code, a slug, or a list of columns. A table with no key has no identity of its own, so its rows are created only under a parent the merge created. createOnly: true never updates a row that is already there, which is what core does with orders.
  • refs lists every column that holds the id of a row in another table, core's or yours. A full restore keeps ids, so nothing changes. A merge rewrites each one through the id map to wherever that row landed in this store. On a new row, a reference to a row that did not come across becomes null rather than pointing at somebody else's order. A row the store already has keeps its own value. A column whose table depends on another column (entity + entity_id) is written ['by' => 'entity', 'map' => ['order' => 'kahunacart_orders', …]].
  • parent names the ref a child row hangs off, so a merge skips the children of a parent it did not bring across. A child with a key of its own is still matched and updated under a parent the store already has. newParentOnly: true writes it only under a parent the merge created. Core does that with an order's custom field values, so an order the store already has is left whole.
  • A reference to a row in the same table, such as a customer merged into another, is translated after every row of the table is in, so it holds when the row it points at comes later in the bundle.
  • secret names columns holding credentials or tokens, each with the group the restore report lists it under. They travel only sealed in secrets.enc with the export's passphrase. Without it, a new row gets none (null), or a fresh random token for a column also named in regenerate. In a merge, a row the store already has keeps its own value for every secret column, credential or token, rather than being emptied or given a new token each time. A merge never matches on a fresh token. secretRows treats every row of the table as a secret, as with saved payment methods. drop names columns that never travel, such as single-use tokens.
  • dependsOn lists the sets your rows point at. Core's ids are settings, webhooks, customer_groups, catalog, media, files, customers, promotions, orders, credit and reviews. The restore runs those first, and tells the merchant what it means when they untick one.
  • permission is what a person needs, on top of kahunacart.export or kahunacart.restore, to take your set out or put it back. Use the permission that already guards the screen where the data is edited.

Settings files. Your plugin's settings file under user/config/plugins/ travels with core's settings set when your add-on is a KahunaCart add-on (its name starts with kahunacart-) or declares an encrypted setting. Every key you declare as encrypted is taken out and sealed with the passphrase, so declare every secret and you need nothing more. A payment provider declares them in encryptedSettings(). Any other add-on appends plugins.<plugin>.<key> paths on onKahunaCartCollectEncryptedSettings, as in $event['keys'] = [...(array)$event['keys'], 'plugins.kahunacart-shipping.account_token']; (see Events). A credential you never declare is not sealed, and travels in the bundle as written unless its name happens to look like a secret. A restore writes only the keys that differ, and only for an add-on that is installed and enabled on the target store.

A set of your own. When a table export does not fit, because rows have to be written through your own code or the data is files, implement Transfer\ExportSet and Transfer\ImportableSet yourself. export() writes rows with BundleWriter::rows(), readable copies with text(), files with file(), and anything secret with secret() and nowhere else. import() reads them back through the BundleReader, matches or creates rows, records each old id against its new one in the id map with $ctx->ids()->set(), and counts with $ctx->created(), updated(), skipped() and failed(). A set that only writes a readable file is still worth having; the restore screen says it cannot be restored.

Rules a restore keeps:

  • It sends nothing. No email, notification, webhook delivery, provider call, order event or job. Write rows directly, not through code that announces them.
  • It runs each set in its own transaction, and a dry run always rolls it back. Never write a file during a dry run; check $ctx->dryRun().
  • A file that has to agree with your rows, such as a settings file, goes through $ctx->afterCommit('What it is', fn () => ...). It is written once your set's transaction commits, never on a dry run, and dropped when the set is rolled back.
  • A set that fails is rolled back whole, along with the ids it put in the id map and its counts, and the report says it failed.
  • It never deletes in a merge.

A listener that throws loses its own sets and nothing else, and the bundle's manifest records the failure against your add-on's name.

Agent payments

An agent platform such as Google AI Mode runs the payment sheet itself and hands the store an encrypted Google Pay token when it completes a UCP checkout. A provider that can charge such a token declares ProviderCapability::AgentPayments and implements AgentPaymentInterface. The full contract, with everything core checks before and after your call, is Provider contract → Agent payments. This is the walkthrough.

PHP
use Grav\Plugin\KahunaCart\Provider\AgentCredential;
use Grav\Plugin\KahunaCart\Provider\AgentPaymentInterface;

final class AcmeProvider implements PaymentProviderInterface, AgentPaymentInterface
{
    public function capabilities(): array
    {
        $caps = [ProviderCapability::Refunds];
        if ($this->publishableKey() !== '') {
            $caps[] = ProviderCapability::AgentPayments;   // off when there is nothing to advertise
        }

        return $caps;
    }

    public function agentPaymentHandlers(): array
    {
        return ['com.google.pay'];
    }

    public function agentTokenization(string $handler): ?array
    {
        if ($handler !== 'com.google.pay' || $this->publishableKey() === '') {
            return null;
        }

        return [
            'type' => 'PAYMENT_GATEWAY',
            'parameters' => ['gateway' => 'acme', 'gatewayMerchantId' => $this->merchantId()],
        ];
    }

    public function chargeAgentCredential(PaymentContext $context, AgentCredential $credential, string $idempotencyKey): PaymentResult
    {
        try {
            $charge = $this->client()->charge([
                'amount' => $context->amountMinor,
                'currency' => $context->currency,
                'wallet_token' => $credential->token,           // passed on, never kept
                'capture' => $this->captureMode() !== CaptureMode::Manual,
                'metadata' => $context->metadata,               // carries kahunacart_source=agent
            ], idempotencyKey: 'kc-' . $context->orderHash . '-' . $idempotencyKey);
        } catch (AcmeDeclined $e) {
            return PaymentResult::failed($e->reasonForHumans());  // never the token, never a redirect
        }

        return $charge->captured
            ? PaymentResult::complete($charge->id, true)
            : PaymentResult::authorized($charge->id, $context->amountMinor);
    }
}

Four things matter more here than anywhere else in a driver, because nobody is watching the charge happen:

  • The token is a credential. Pass it to your platform and nowhere else: not a log line, not an exception message, not a column.
  • There is no browser. A card that needs a 3-D Secure challenge is a failed() result with the platform's reason. A redirect answer is read as a failure.
  • A retry is the same charge. Build your platform's idempotency key from $idempotencyKey and the order, so a completion the agent retries after a lost answer charges once.
  • Answer null rather than half a config. agentTokenization() returning null, or dropping the capability, takes the handler out of the profile and every checkout.

Your platform's own payment webhook still arrives afterwards. Report it as the ordinary completion, and core settles nothing twice. The Stripe driver is the reference implementation.

Test agent payments

Every scenario is compulsory when the driver declares agent_payments. Run them with UCP on, Google Pay in the TEST environment and the platform's sandbox, so the wallet hands back test tokens.

# Scenario What to verify
G1 The handler is advertised /.well-known/ucp lists com.google.pay with the id kc_{slug}_gpay and your tokenization_specification inside the config core builds. Remove the driver's key: the handler disappears from the profile and the checkout responses rather than appearing with half a config
G2 A completion charges and settles One purchase row settles to success with the platform's reference, the order is completed and paid, the confirmation email is sent, and the platform's record carries the order hash, the transaction hash and kahunacart_source=agent. The amount equals the order's total_minor to the minor unit
G3 The idempotency key reaches the platform Replay the same completion with the same Idempotency-Key: the platform shows one charge, the ledger one row
G4 A declined token The result is failed with the platform's reason, the row is failed, the order's history has payment.failed, the order is still a cart, and the agent is told payment_failed
G5 A token that needs a challenge The platform's 3-D Secure test card fails with the platform's reason, never a redirect and never a pending row left open
G6 The webhook that follows settles nothing After G2, the platform's payment webhook arrives. The ledger still has one success row, with no second completion and no second email
G7 Manual capture With CaptureMode::Manual, the completion authorises instead of charging, and Capture and Void in the admin work on it
G8 The token is never kept Search the database, the Grav log, the webhook log and anything the driver writes, after a success and a failure, for the token and any identifier inside it. None may appear
G9 A credential the store must refuse A DIRECT credential, a token that is not JSON, and a token object carrying a card number are each refused before the platform is called, without quoting the token

Account overrides

By default a gateway uses the one set of credentials in its plugin settings. A gateway can opt in to account overrides, so a payment method can use a different account at the same gateway. A store selling through two companies uses it to take card payments into one Stripe account for each. See Selling through more than one company for the merchant's side.

A gateway opts in by implementing three methods on AbstractPaymentProvider:

  • accountOverrideFields(): array is the schema. It maps field names to type (string or boolean), an optional required, an optional secret and a translated label. The default schema is empty, so a gateway that doesn't opt in runs exactly as it did.
  • withAccountOverride(array $override): static returns a separate instance that uses those credentials. It must validate the fields without quoting the credentials back in an error, and it must leave the registered default instance alone.
  • accountIdentity(): string names the account. It has to stay the same when a key is rotated, and it has to tell a sandbox account from a live one.

Core does the rest. It validates the fields against the schema, encrypts declared secrets with SettingsCipher, masks them in payment method answers and decrypts them only to build a scoped provider.

What your code is handed. PaymentContext::accountOverride holds the selected method's private settings, and paymentAccountToken identifies their revision. Keep both out of metadata, returned payloads and logs. JSON, PHP serialization and debug dumps of the context leave out the credentials, and leave out the account token when there is no override. The context's webhook URL already ends in the revision token.

Calls with no context. Core picks the scoped instance for these too: webhook handling, capture, void, return verification, refunds, the account connection check and automatic webhook registration. Use the scoped credentials, Managed Payments settings and API version for every request your transport makes.

Webhooks. Webhook registration returns its signing secret through WebhookSetupResult only. The payment-method controller stores it encrypted in the same revision, without moving the endpoint. Never write an override back into the default plugin config. A reset must delete only the exact token endpoint, because other revisions can still receive events for old payments. Core rejects a verified delivery whose transaction belongs to another revision. Refunds and captures use the revision recorded on the transaction, even after the method has been changed or deleted. Customer references use the gateway plus a hash of accountIdentity(), while the default account keeps the gateway-only key.

Checklist for a second gateway.

  • Declare the schema and the secret flags.
  • Validate and clone the account settings.
  • Give accounts a stable identity that knows the mode.
  • Use the clone for every request.
  • Verify token endpoints with their own secret.
  • Limit a registration reset to the exact endpoint.
  • Sanitize remote errors.
  • Keep every default request exactly as it was.
  • Add fake tests for account switching, customer references, a delivery to the wrong endpoint, capture, void and refunds after a method is deleted.

Saved cards, express wallets, agent wallet tokenization and recurring agreements stay on the default account in this release, and an account override uses hosted checkout.

Write the tests

Every first-party provider runs a standalone PHPUnit suite that never boots Grav.

Wire the sibling autoload. The base plugin is not on Packagist, so autoload-dev maps its namespace at a relative path.

JSON
"autoload-dev": {
    "psr-4": {
        "Grav\\Plugin\\KahunaCartDummy\\Tests\\": "tests/",
        "Grav\\Plugin\\KahunaCart\\": "../grav-plugin-kahunacart/classes/"
    }
}

tests/bootstrap.php is then one line, require __DIR__ . '/../vendor/autoload.php';, and phpunit.xml declares a single Unit suite over tests/Unit with failOnWarning="true".

Keep the translation pure. Use public, side-effect-free methods for parameter building, event mapping and signature verification, and inject the clock so you can test the replay window without waiting five minutes.

PHP
public function verifySignature(string $payload, string $header, int $now): bool

Make the API base injectable, so tests can point it at a stub or a dead port.

PHP
private readonly string $apiBase = 'https://api.stripe.com',

Cover these cases.

Test What it asserts
BehaviorMatrixTest One case per outcome, what an unrecognized config value does, and that capabilities() says what you mean
WebhookMappingTest Every event type you handle, both hashes surviving the round trip, unknown types ignored, unparsable bodies invalid
WebhookGatingTest That the check in front of your handler rejects for every action, and that rejection is the default
RefundTest Full and partial refunds, and that two different refunds do not collide on one reference
SignatureVerificationTest A good signature passes; a tampered body, wrong secret, stale timestamp, malformed header, and unconfigured secret all fail
TransportFailureTest With the API base at http://127.0.0.1:1, initiatePayment() and refund() degrade to failed results without throwing, and neither message contains the secret key

PayPal adds one more pattern: it injects an HttpTransport and asserts with a recording stub which calls happen, and which do not when a webhook arrives unsigned.

Then test against a real store. Install your plugin plus kahunacart-dummy on a sandbox site and walk a cart through checkout. bin/plugin kahunacart status reports what the base plugin thinks is registered.

Package for GPM

blueprints.yaml is both the GPM manifest and the admin form. Declare the dependencies explicitly, because GPM will not install the base plugin for you otherwise.

YAML
dependencies:
  - { name: grav, version: '>=2.0.0' }
  - { name: api, version: '>=1.0.0' }
  - { name: kahunacart, version: '>=0.1.0' }

compatibility:
  grav: ['2.0']

Set premium: true for a commercial plugin, premium: false for a free one like kahunacart-dummy (whose license field is then the license name, MIT, rather than a URL), and testing: true to keep a pre-1.0 release out of the default GPM channel.

Provider plugins vendor Composer: .gitignore lists only .DS_Store and .phpunit.cache/, and autoload requires vendor/autoload.php at runtime. Keep production dependencies tiny; the first-party providers vendor nothing but psr/http-message. Put test-only packages such as phpunit/phpunit and nyholm/psr7 in require-dev, set "platform-check": false, and match the base plugin's floor with "php": ">=8.3".

Mirror the base plugin's tab structure in your blueprint, translate through PLUGIN_<YOURPLUGIN>.* keys in languages/en.yaml, and ship a test/live toggle. Stripe uses test/live; Polar uses sandbox/production, because those are separate accounts with separate catalogs.

Warning

API keys and webhook secrets are password-type fields in your own config namespace. They support environment-variable interpolation so a merchant can keep them out of version control. Never log them, never echo them into rendered HTML, and never interpolate them into an error message.

Release checklist

  • Slug is lowercase, unique, and final; the provider does not register while unconfigured.
  • capabilities() lists exactly what is implemented.
  • initiatePayment() returns failed for API refusals and reserves throwing for the genuinely exceptional.
  • Amounts are minor units end to end, with no / 100 anywhere; JPY and KWD both work.
  • orderHash and transactionHash are on provider-side metadata, for every object your webhooks are about.
  • handleWebhook() verifies before it decodes, on raw bytes, with a replay window and a constant-time comparison, and fails closed when the secret is missing.
  • Unhandled event types return ignored(), not invalid().
  • Payloads carry transaction_hash, plus reason on failures and amount_minor on refunds.
  • Handlers are idempotent, including any side effect of your own.
  • The reference you report is one refund() can refund against, and an unsettled refund returns pending(), finished later by a webhook quoting that same reference.
  • Provider-initiated refunds issued in the provider's dashboard are handled.
  • No secret appears in any message, log line, or rendered field, with a test proving it.
  • With agent_payments: chargeAgentCredential() charges $context->amountMinor (or authorises it under manual capture), passes $idempotencyKey on, never redirects, and never stores, logs or echoes the token. agentTokenization() answers null, not a partial array, when unconfigured.
  • composer install && vendor/bin/phpunit is green, and vendor/ is committed.
  • blueprints.yaml declares the kahunacart dependency and a Grav 2.0 compatibility range.
  • The README states the webhook URL, which events to subscribe to, how to test locally, and whether refunds are supported.
  • CHANGELOG has a dated entry and the version matches blueprints.yaml.

Copy the reference implementation whose flow style matches yours. The contract lists what each one demonstrates.