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:
- Declares a dependency on
kahunacartin itsblueprints.yaml. - Ships its own config blueprint (API keys, mode toggles) and vendored SDK.
- Subscribes to
onKahunaCartRegisterProvidersand registers one or more provider instances. - Implements the
AbstractPaymentProvidercontract.
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.
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:
[
'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.
[
'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.
- Verify the provider's signature first. Return
WebhookResult::invalid()on failure. - Return
WebhookResult::ignored()for event types you do not handle. - 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.
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:
- A pending refund row matching
(provider, transactionRef)is confirmed.markSuccess()is the idempotency gate, so redeliveries change nothing. - Failing that, any live (non-failed) row matching
(provider, transactionRef)is marked successful. - Failing that, a fresh successful refund row is opened from
orderHashpluspayload['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.
[
'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:
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.
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.