Skip to content

Powered by Grav CMS + Helios Theme

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 on this page is stable and follows semver; a change after 1.0 is a breaking change. Repositories, internal services and table layouts are private and can change in any release.

A method is not a provider

A provider is what this page specifies: a PHP integration that holds API keys, handles webhooks and takes money. A payment method is a row a merchant adds in the admin: a label, a logo, instructions, a fee and conditions for when to offer it, naming the provider that settles it. "Visa" and "Klarna" can be two methods over one Stripe provider; "Cash", "Cheque" and "Bank transfer" can be three methods over the built-in offline provider.

Providers do not know methods exist. A store that defines no methods offers its providers directly. A provider that opts in to account overrides can use different credentials for individual methods.

Write your provider against this page and ignore methods, unless you want account overrides. See Payment methods for the merchant-facing configuration.

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 a registration failure is logged without 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 provider missing from checkout is explained in logs/grav.log. Registration order is not guaranteed, so fix any duplicate slug.

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 only translates.

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

Currencies

A store can sell in more than one currency from 1.4.0, and each order is in exactly one of them. A provider says which currencies it takes with two methods on AbstractPaymentProvider:

Method Default Means
supportsCurrency(string $currency): bool asks supportedCurrencies() Can this provider take a payment in this currency. The one core asks
supportedCurrencies(): ?array null A plain list of ISO 4217 codes, or null for any. An empty list takes none

The default supportsCurrency() says yes when supportedCurrencies() is null and otherwise checks the list, comparing codes without regard to case. A driver with a plain list overrides supportedCurrencies() and nothing else. A driver with a rule (a platform with one store currency, a seller account whose location fixes the currency) overrides supportsCurrency(). A driver that overrides neither takes every currency, which is what every driver written before 1.4 does, so none of them has to change.

ProviderSuitability::offer() asks supportsCurrency() with the order's currency before the capability filter and before onKahunaCartProviderSuitability fires, so a method that cannot take the currency is not shown and a listener sees only providers that can. The pay link and checkout both go through it. When the order has no currency (only the agent-feed probe, see above) the caller passes the shopper's own.

It runs for every registered provider each time a payment step is drawn, so answer from what the driver already knows: a list in the code, a config key, a value kept when checkConnection() ran. Never call the platform.

When no method is left, checkout says "No payment method is available for orders in EUR." and the pay page says the same, and bin/plugin kahunacart status and the Payments screen list, for every currency the store sells in, which methods take it and which currency nothing takes. See Currencies.

Add kahunacart: '>=1.4.0' to your blueprints.yaml dependencies once your driver overrides either method. Core 1.3 never calls them, so a driver that relies on them and runs there would be offered for every currency and fail at pay time, which is the behaviour these methods exist to end.

Say the currency a platform settled in. A platform that can present prices in the buyer's currency may report an amount in a currency other than the order's. Put currency (ISO 4217) on the payload of PAYMENT_COMPLETED, PAYMENT_AUTHORIZED and REFUND_COMPLETED whenever the platform names one, and on the payload of a RefundResult when the refund was made in a currency other than the one asked for. Never convert the amount yourself and never default the code when the platform said nothing: leave it out. When the reported currency is present and differs from the order's, core never writes the reported amount_minor into a column in the order's currency. The payment settles for the amount that payment session was opened for, a refund for the amount the refund asked for, the reported amount and currency are kept on the transaction's own payload (as reported_amount_minor and currency) and written to the order's history, and the merchant gets the payment-currency notice. A refund made at the platform that matches no refund row here is not recorded in the ledger at all, because there is no amount it asked for. When the currency matches or is absent nothing changes: the reported amount_minor settles the row as it always did.

Flow styles

Two flow styles cover every provider so far.

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

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 your API insists on itemization that adds up to the total, add a balancing line. Do not change the charge.

Capability flags

ProviderCapability is a backed enum with sixteen cases: Refunds, PartialRefunds, HostedCheckout, Tokenization, MerchantOfRecord, DigitalGoodsOnly, SyncProducts, SyncOrders, Subscriptions, OffSessionCharge, LicenseKeys, Authorize, Capture, Void, ExpressPay, AgentPayments.

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.

AgentPayments (agent_payments) is declared alongside AgentPaymentInterface. See Agent payments.

Declaring MerchantOfRecord changes the amount handed to you. The base plugin records your slug on the cart and recalculates it with no local tax before opening the transaction, since your checkout computes and remits tax. 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

A reason on WebhookResult::invalid() is what the store owner reads in the Webhook rejected message, so name it: bad_signature, not a bare 400. A reason that starts with retry_, such as retry_order_not_recorded for a dispute that arrived before its payment was recorded, still answers 400 so the provider delivers again, but it is not announced as a refusal. See Report a dispute.

Webhook handlers must be idempotent, because providers redeliver. Never assume exactly-once delivery, and never write order state yourself; the base plugin does that.

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.

Part-paid orders

Deposits do not change this contract. A payment collection sits beside Provider/: it reads what the ledger recorded, records which part of the order the money was for, and listens to onKahunaCartPaymentCaptured and onKahunaCartRefundApplied.

A part-paid order's payment status is partially_paid, whose counts_as_paid flag is false. A provider that reads payment status gets the right answer without knowing about deposits. See Deposits and part payment.

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 keeps a refund in the state the provider reports.

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. Only success counts as a completed refund:

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 link between the initial refund request and its later webhook result. Derive it deterministically if your API allows, 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 covers a merchant refunding inside the provider's own dashboard, which 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; the base plugin never reverses a confirmed refund. 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, and a currency key: the store's base currency, which is what every price_minor in variants is in. Read it instead of guessing from a config key or defaulting to dollars. 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's base currency
            'currency'    => 'USD',     // ?string, the currency price_minor is in; optional, see below
            'is_default'  => true,      // bool
        ],
    ],
]

Missing or unrecognized values have safe defaults: an unknown type reads as physical, an unknown status as draft, and an empty remote_id skips the entry.

Say the currency of a price that is not the base. currency on a variant (or once on the product, for every variant) names the currency price_minor is in. Absent means the base currency, which is what a driver written before the key existed means. A price in another currency is never written as a base price: the price is skipped, one line goes to the Grav log, and the pull's result counts it (skippedPrices() on the service, skipped_prices in the admin's sync answer when it is not zero). The rest of the product is still pulled, and a product new to the store whose default price was skipped comes in as a draft with no price rather than as a published free one.

Note

A pull applies the normalized data to the product and its default variant only: price, sku, title. Extra variants are stored verbatim in the mapping row's data_json under extra_variants and are not created as local variants. Multi-variant sync does not work yet.

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. Unknown entity_type values are ignored, so future entity types are safe to send. 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, since the keys are provider slugs.

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 runs what you asked, and --status prints the mapping counts with each provider's sync capabilities and configured direction.

Pulls never delete local products. Removing a product the provider dropped is an admin action.

Agent payments (optional)

An agent platform such as Google AI Mode or Gemini runs the payment sheet itself. The buyer picks a card in Google Pay on the platform's side, and the store receives an encrypted wallet token when the agent completes a UCP checkout, with no redirect. A provider that can charge such a token declares AgentPayments and implements Grav\Plugin\KahunaCart\Provider\AgentPaymentInterface:

PHP
/** Handler names this provider can take a credential from, e.g. ['com.google.pay']. */
public function agentPaymentHandlers(): array;

/** The tokenization_specification for one handler's config, or null when this provider is not configured for it. */
public function agentTokenization(string $handler): ?array;

/** Charge the credential for $context->amountMinor, or authorise it when captureMode() is Manual. */
public function chargeAgentCredential(PaymentContext $context, AgentCredential $credential, string $idempotencyKey): PaymentResult;

Declare both or neither. Core offers a provider to an agent only when it implements the interface and declares the capability, so a driver can drop the capability at runtime (no publishable key, a mode the platform cannot charge in) and stop being advertised.

What core builds from your answers. One payment_handlers entry per provider and handler, with the stable id kc_{providerSlug}_gpay, in the UCP profile and in every checkout response. For com.google.pay, agentTokenization() answers only the tokenization_specification, for example ['type' => 'PAYMENT_GATEWAY', 'parameters' => ['gateway' => 'stripe', 'stripe:version' => '2018-10-31', 'stripe:publishableKey' => $key]]. Core wraps it in the config Google specifies: API version 2, the ucp.google_pay environment, merchant id, card networks and authentication methods, the seller's name and the site's host, and a full billing address with phone number. Answer null when the provider is not configured for the handler. A half answer (no type or no parameters) is treated the same way.

What arrives in the charge. PaymentService::chargeAgentCredential() is the agentic twin of initiate(): the same payability rule, the same repricing, one live attempt per order, and a ledger row opened as a purchase (or an authorisation under manual capture). The PaymentContext carries metadata['kahunacart_source'] = 'agent'; put it in the platform's metadata with the order and transaction hashes. The AgentCredential carries the handler, the handlerId, the platform's instrumentId and instrumentType, the credentialType, the token, the display details (brand, last_digits, description) and the billingAddress, already mapped onto KahunaCart's address fields.

Before your driver is called, core has taken the token out of the request before the card-number check ran over the rest, and checked the token itself. DIRECT credentials are refused, the token is at most 16 KB of JSON, a Google Pay ECv1/ECv2 envelope must hold its card data as ciphertext, and any other token object (a PSP token such as Stripe's tok_) must carry no card number.

What you answer. PaymentResult::complete($ref, true) settles the order through settle(), exactly as a webhook would. PaymentResult::authorized($ref, $amountMinor) places it at the authorized payment status, to be captured or voided like any other hold. PaymentResult::failed($reason) fails the row, records payment.failed, fires onKahunaCartPaymentFailed, leaves the order where it was, and hands your reason back to the agent. A driver that throws is treated as a failure carrying the exception's message.

Four rules, because nobody is watching the charge happen:

  • Never store, log or echo $credential->token. AgentCredential redacts it from var_dump() and serialize() and leaves it out of jsonSerialize(). A driver that copies it into a log line or an exception message undoes all three. Core scrubs the token and every identifying part of it out of a failure message before writing it anywhere.
  • Never redirect. There is no browser. A card that needs a 3-D Secure challenge is a failed() result, and a redirect answer is read as a failure.
  • Pass $idempotencyKey to the platform's own idempotency mechanism. It is the key the agent sent on the completion, and the same string on a retry, so a retried completion becomes one charge. Derive the platform key from it and the order hash.
  • Charge $context->amountMinor exactly, and authorise under manual capture.

Nothing settles twice. A retried completion finds the order no longer payable and is refused before your driver is called. Your platform's own payment webhook for the same payment finds its ledger row already a success; report it as the ordinary PAYMENT_COMPLETED and core settles nothing again.

The reference implementation is the Stripe driver, which charges a Google Pay token as a confirmed PaymentIntent. Provider developer guide → Test agent payments lists the scenarios you must run for agent_payments.

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.

Payout lookup (optional)

Implement SettlementLookupInterface and a store that sells in more than one currency records what your platform converted each charge at and paid out, beside its own fx_to_base. The order screen shows it under the charge ("Paid out as $100.90 at 1.1202") and the order exports carry it. See Currencies.

PHP
public function settlement(string $reference, int $amountMinor, string $currency, array $payload = []): ?Settlement
{
    $line = $this->payoutLineFor($reference);          // your platform's API
    if ($line === null) {
        throw new SettlementNotReady();                 // not settled yet: asked again later
    }
    if ($line['rate'] === null) {
        return null;                                    // taken in the account's own currency
    }

    return new Settlement($line['currency'], $line['amount_minor'], $line['rate'], $line['id']);
}

Core calls it once per succeeded sale or capture, from the payments.settlement job on the worker, never inside a checkout or a webhook. $reference is the transaction's reference, the same string refund() is handed. $payload is what your driver reported when the charge settled. You are called on the provider built for the transaction's own payment account, so your own credentials are the right ones.

  • rate is text, never a float. Pass the platform's own digits. A platform that sends the rate as a JSON number decodes to a float, so turn it back into its shortest text first: json_encode($number) gives that under PHP's default serialize_precision. Settlement rounds it once, half up, to twelve places, and refuses anything that isn't a positive number.
  • Mind the direction. rate is the platform's conversion rate. An amount in the charge's currency times rate is the converted amount in the payout currency before your platform's fee. That is how Stripe's exchange_rate and PayPal's exchange_rate.value read. €94.00 × 1.1202 = $105.30 before the fee, and amountMinor is the $100.90 left after a $4.40 fee.
  • amountMinor is in the payout currency's minor units. It is what reached the merchant's account for this charge, after your platform's fee, so it can be matched against a payout. Stripe's is the balance transaction's net, PayPal's is receivable_amount.
  • Three answers, no others. A Settlement when the money was converted. Null when it wasn't, which is recorded as nothing and never asked again. SettlementNotReady when the platform hasn't settled it, which is asked again ten minutes, an hour, four hours, twelve hours and a day after the charge. Anything else you throw is logged and the charge isn't asked about again, which is fine: a missing settlement isn't a broken store.

No capability flag goes with it. A store that sells in one currency never asks, and its Payments screen doesn't change. Stripe and PayPal are the reference implementations.

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, and agent payments with Google Pay
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.