Skip to content

Powered by Grav

Coming soon — KahunaCart is in final testing. Join the list and be first to know. Join the list

KahunaCart Provider Contract

This page specifies what a payment provider plugin must implement. It is written for PHP developers building a provider against KahunaCart.

Everything documented here is stable: changes after 1.0 are breaking changes and follow semver. Everything else — repositories, internal services, table layouts — is private and may change in any release.

What a provider plugin is

A payment provider is an ordinary Grav plugin, such as kahunacart-stripe or kahunacart-polar, that does four things:

  1. Declares a dependency on kahunacart in its blueprints.yaml.
  2. Ships its own config blueprint (API keys, mode toggles) and vendored SDK.
  3. Subscribes to onKahunaCartRegisterProviders and registers one or more provider instances.
  4. Implements the AbstractPaymentProvider contract.

Multiple providers run at once. Every registered provider appears as a payment option at checkout.

Register a provider

Subscribe to onKahunaCartRegisterProviders and call register() on the ProviderRegistry the event carries.

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

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

    /** @var ProviderRegistry $registry */
    $registry = $event['registry'];
    $registry->register(new StripeProvider($this->config->get('plugins.kahunacart-stripe')));
}

A slug must match /^[a-z0-9][a-z0-9-]*$/ and must be unique. It becomes the webhook route segment, the ledger's provider column, and the sync map's key. Pick it once and never change it.

Registration never throws. The event fires across every provider plugin on the site, so three failures degrade instead of stopping the storefront:

Failure What happens
Duplicate slug The first registration keeps the slug. The later one is dropped and logged.
Malformed slug Dropped and logged, with the required pattern in the log line.
A listener throws Caught by KahunaCart::providers() and logged. Providers that already registered stay registered.

Note

A payment method missing from checkout is explained in logs/grav.log. Registration order is not guaranteed, so two plugins claiming one slug is still a bug to fix.

Contract methods

Grav\Plugin\KahunaCart\Provider\AbstractPaymentProvider requires five methods and offers a sixth.

Method Required Responsibility
slug(): string yes Stable machine name; webhook route segment
label(): string yes Customer-facing name at checkout
capabilities(): array yes list<ProviderCapability>
initiatePayment(PaymentContext $context): PaymentResult yes Start payment
handleWebhook(ServerRequestInterface $request): WebhookResult yes Verify the signature and translate the event
refund(string $transactionRef, int $amountMinor, string $currency): RefundResult no Defaults to a failed result; called only when Refunds is declared

supports(ProviderCapability $capability): bool is final on the base class and derives from capabilities(). Do not shadow it.

initiatePayment() returns one of three results. The base plugin owns every order and transaction state change; a provider translates and nothing else.

Result Meaning
PaymentResult::redirect($url, $transactionRef = null) Send the customer to a hosted page or a 3DS step
PaymentResult::complete($transactionRef = null, $paid = false) Payment finished synchronously; $paid = false places the order with payment status pending
PaymentResult::failed($message) The message reaches the customer as a KahunaCartException on the checkout page

Flow styles

Two flow styles cover every provider studied so far.

Hosted redirect. initiatePayment() creates a provider checkout session and returns PaymentResult::redirect($url). Completion arrives via webhook, which is authoritative. The customer's return to returnUrl is advisory: reconcile against the transaction rather than trusting the return.

On-site tokenized. The checkout page renders the provider's JS, which tokenizes payment details in the browser. initiatePayment() then confirms the intent server-side with the token.

Warning

Card data must never reach the Grav server. A provider that would require raw card handling is not accepted as a first-party plugin.

PaymentContext

initiatePayment() receives a PaymentContext. Every property is read-only.

Field Type Notes
orderId int Internal order id.
orderHash string The order's public hash. Attach it to provider-side metadata.
amountMinor int The charge. Minor units, in currency.
currency string ISO 4217.
returnUrl string Where the customer lands after provider-side success.
cancelUrl string Provider-side cancel or abandon.
webhookUrl string Routed to this provider's handleWebhook().
transactionHash string Hash of the pending ledger row. Attach it too.
customerEmail ?string Prefill.
metadata array<string, mixed> Provider-agnostic extras a caller wants carried through.
customerName ?string Prefill. The billing name, falling back to the customer record's name.
items list<array> The order's lines. Informational.
billingAddress ?array Prefill and AVS. Null when the order carries no billing address at all.

Each items entry has exactly this structure:

PHP
[
    'title'             => 'Blue Widget',  // string
    'qty'               => 2,              // int
    'unit_amount_minor' => 1500,           // int, minor units
    'total_minor'       => 3000,           // int, minor units — the line, after item-level adjustments
    'sku'               => 'WID-BLUE',     // ?string, null when the line has no SKU
]

billingAddress, when present, has exactly these six keys, all strings. A field the customer left blank is an empty string rather than an absent key.

PHP
[
    'line1'    => '12 Marylebone Rd',
    'line2'    => 'Flat 3',
    'city'     => 'London',
    'region'   => 'Greater London',
    'postcode' => 'NW1 5LS',
    'country'  => 'GB',                    // ISO 3166-1 alpha-2, uppercased
]

customerName, items and billingAddress are prefill conveniences, not instructions. Use them to prefill a hosted checkout and to put recognizable line names on the provider's receipt.

Important

amountMinor is the only authoritative charge amount. The item sum differs from it on any order carrying a discount, shipping, tax, or a fee added by another plugin. A provider that bills the sum of items bills the wrong number. If a provider's API insists on itemization that adds up to the total, add your own balancing line. Do not adjust the charge.

Capability flags

ProviderCapability is a backed enum with nine cases: Refunds, PartialRefunds, HostedCheckout, Tokenization, MerchantOfRecord, SyncProducts, SyncOrders, Subscriptions, LicenseKeys.

Declare only what is implemented. The base plugin and its add-ons feature-gate on these flags: refunds run only with Refunds, and the subscriptions add-on offers only providers with Subscriptions.

Declaring MerchantOfRecord changes the amount handed to you. The base plugin stamps your slug on the cart and recalculates it with no local tax before opening the transaction, because your checkout computes and remits tax authoritatively. See Tax → Merchant-of-record providers.

Webhooks

The base plugin exposes POST {route}/webhook/{slug} and passes the PSR-7 request to handleWebhook() unread.

  1. Verify the provider's signature first. Return WebhookResult::invalid() on failure.
  2. Return WebhookResult::ignored() for event types you do not handle.
  3. Return a new WebhookResult($action, $transactionRef, $orderHash, $payload) for everything else.

The actions are the WebhookResult class constants: PAYMENT_COMPLETED, PAYMENT_FAILED, REFUND_COMPLETED, REFUND_FAILED, SYNC_EVENT, IGNORED, INVALID.

The base plugin converts the result into an HTTP status:

Outcome Status
Any handled or ignored action 200
WebhookResult::invalid() 400
No provider registered under that slug 404
The handler threw 500

Webhook handlers must be idempotent, because providers redeliver. The base plugin protects order state on its own side, but a handler must not assume exactly-once delivery, and it never writes order state itself.

Transaction ledger

The base plugin records every provider interaction in a transaction ledger. Rows move from pending to success or failed, and parent/child rows link captures and refunds to their origin.

PaymentContext carries the transaction hash that round-trips through redirects. Attach it and orderHash to provider-side metadata so webhook events always trace back to a row.

Refund lifecycle

A refund is not always finished when refund() returns. A provider can queue one awaiting balance, hold it for approval, or fail it after submission. The ledger holds a refund in the state it is actually in, and the provider says which state that is.

refund() returns one of three results:

Result Meaning Ledger
RefundResult::succeeded($refundRef) Settled at the provider The refund row is marked successful and the order's payment status is recomputed
RefundResult::pending($refundRef) Accepted, not settled, and it can still fail The row keeps $refundRef and stays pending; the order's payment status does not move
RefundResult::failed($message) Refused The row is marked failed and $message reaches the admin as a 422

A refund row is pending, success, or failed, and only one of the three is money:

State What it means
pending Submitted, awaiting the provider's answer. The amount is held out of the refundable balance, but counts toward no merchant-visible total.
success Confirmed. The only state counting toward the refunded total, toward partially_refunded / refunded, and toward the reversal that returns stock, releases the coupon, and revokes download grants.
failed Refused on submission, or rejected after it. It moves no status, reverses nothing, and releases its held balance so the refund can be retried.

Confirm or reject a pending refund

Two webhook actions settle a pending refund.

PHP
return new WebhookResult(WebhookResult::REFUND_COMPLETED, $refundRef, $orderHash, [
    'amount_minor' => 500,
    'transaction_hash' => $transactionHash,
]);

return new WebhookResult(WebhookResult::REFUND_FAILED, $refundRef, $orderHash, [
    'amount_minor' => 500,
    'reason'       => 'Rejected in review',
    'transaction_hash' => $transactionHash,
]);

REFUND_FAILED means a refund already submitted has been rejected, canceled, or has failed at the provider. On it, payload['amount_minor'] is informational and payload['reason'] is stored on the ledger row.

Important

transactionRef on both actions must be the same refund reference you returned from refund(). That reference is the only thing tying the two halves of an asynchronous refund together. Derive it deterministically if your API allows it, and never regenerate it.

REFUND_COMPLETED resolves in three steps:

  1. A pending refund row matching (provider, transactionRef) is confirmed. markSuccess() is the idempotency gate, so redeliveries change nothing.
  2. Failing that, any live (non-failed) row matching (provider, transactionRef) is marked successful.
  3. Failing that, a fresh successful refund row is opened from orderHash plus payload['amount_minor'].

Step 3 is the dashboard-refund fallback: a merchant refunding inside the provider's own dashboard produces a REFUND_COMPLETED for a refund this store never submitted. Send both orderHash and amount_minor on every refund event.

REFUND_FAILED matches on (provider, transactionRef) and has three outcomes, all answered 200. A pending row is marked failed with your reason. A row already confirmed is logged and left alone, because the base plugin will not reverse a confirmed refund on a later contradiction. A reference with no live row is logged and ignored.

Catalog sync

Providers declaring SyncProducts also implement ProviderSyncInterface. SyncService checks both the capability and instanceof, and skips a provider that has one without the other.

Method Responsibility
pushProduct(array $product): string Create or update the product on the provider; return the provider's remote id
pullProducts(): iterable Yield the provider's catalog as normalized product arrays

SyncService owns everything around those two calls: which direction runs, the job queue, kahunacart_provider_map bookkeeping, and every local write.

pushProduct() receives the local product row plus a variants key holding that product's variant rows, default variant first. It returns the remote id, which the base plugin stores in the map. Throw on failure: the base plugin wraps the throwable with your slug and the job queue retries with backoff.

Normalized product array

pullProducts() yields arrays with this structure. A SYNC_EVENT webhook may carry the same structure.

PHP
[
    'remote_id'   => 'prod_123',        // string, required — the provider's stable product id
    'title'       => 'Widget',          // string, required
    'type'        => 'digital',         // 'digital' | 'physical'
    'status'      => 'published',       // 'published' | 'draft' | 'archived'
    'summary'     => 'Short blurb',     // ?string
    'description' => 'Long copy…',      // ?string
    'variants'    => [
        [
            'remote_id'   => 'var_456', // ?string
            'sku'         => 'WID-1',   // ?string
            'title'       => 'Standard',// ?string
            'price_minor' => 1999,      // int, minor units in the store currency
            'is_default'  => true,      // bool
        ],
    ],
]

Anything missing or unrecognized degrades safely: an unknown type reads as physical, an unknown status as draft, and an empty remote_id skips the entry.

Note

A pull applies the normalized data to the product and to its default variant only: price, sku, title. Additional variants are recorded verbatim in the mapping row's data_json under extra_variants and are not created as local variants yet. Multi-variant sync lands with the variant-options work in a later phase.

Sync events over webhooks

A provider that learns about a catalog change through its webhook returns:

PHP
return new WebhookResult(WebhookResult::SYNC_EVENT, orderHash: null, payload: [
    'entity_type' => 'product',
    'remote_id'   => 'prod_123',
    'data'        => $normalizedProduct,   // or null
]);

data => null tells the base plugin to queue a full catalog pull rather than guess. Unknown entity_type values are ignored, so future entity types are safe to send at any time. Verify the signature before returning anything but invalid().

Sync direction config

Sync direction is per provider and set in the site's kahunacart.yaml. There is no admin field, because the keys are provider slugs the base plugin cannot know in advance.

YAML
sync:
  polar:
    direction: remote
Direction Meaning
local The local catalog is master. Admin product writes queue a sync.push_product job per local-master provider.
remote The provider is master. SYNC_EVENT webhooks apply to the local catalog, and sync.pull_catalog jobs re-pull it.
off (default) Nothing runs automatically.

Direction governs automatic work only. bin/plugin kahunacart sync --provider=<slug> --push|--pull always does what the operator asked, and --status prints the mapping counts alongside each provider's sync capabilities and configured direction.

Pulls never delete local products. A provider dropping a product from its catalog leaves the local row alone; removing it is an admin action.

Settings conventions

  • Keep secrets (API keys, webhook secrets) in the provider plugin's own config namespace, entered through password-type blueprint fields.
  • Support environment-variable interpolation, and never echo a secret back into rendered HTML.
  • Ship a test/live mode toggle wherever the provider distinguishes the two.
  • Mirror the base plugin's tab structure in your blueprint so the admin stays uniform.

Reference implementations

Plugin What it demonstrates
OfflineProvider (built into the base plugin) The minimum: synchronous complete, no webhook, no capabilities
kahunacart-dummy The whole contract with no API in the way; MIT and free
kahunacart-stripe Hosted Checkout Session, HMAC signature verification, refunds by intent or charge
kahunacart-paypal Approve-then-capture, certificate-based verification, injected HTTP transport
kahunacart-polar Merchant of record, two-way catalog sync, SYNC_EVENT webhooks

Copy the one whose flow style matches yours.