CyberSource UC Signing · HMAC HTTP-Signature Checkout returns · jwt (capture context)

Start here

Overview

You resolve one gateway from the factory and call its actions. Every method below takes a request DTO and returns a result DTO, and reads the same across drivers — only createCheckoutSession and the signing scheme differ.

$gateway = $gateways->make(GatewayName::CybersourceUnifiedCheckout);
// every action below is called on $gateway

An action a gateway doesn't support throws UnsupportedOperationException, so the action list only shows what this driver actually implements.

Checkout

Create checkout

createCheckoutSession(CheckoutSessionRequest): CheckoutSession

Starts a payment. The result carries what this gateway needs next — a jwt, a redirectUrl, and a reference to reconcile against later.

$session = $gateway->createCheckoutSession(new CheckoutSessionRequest(
    money: Money::minor(10000, 'EGP'),
    targetOrigins: ['https://shop.test'],       // origin(s) that embed the widget
    orderReference: 'ORDER-123',                // reconcile — and default the charge idempotency key — off this
    allowedCardNetworks: [CybersourceCardNetwork::Visa, CybersourceCardNetwork::Mastercard], // defaults to [Visa, Mastercard]
    allowedPaymentTypes: [CybersourcePaymentType::PanEntry, CybersourcePaymentType::GooglePay], // cards + wallets (defaults to [PanEntry])
    enable3ds: true,                          // orchestrated flow → completeMandate.consumerAuthentication
    options: new CybersourceCheckoutOptions(billingType: 'FULL', requestPhone: true), // captureMandate — what the widget collects
));

// load the widget script from $session->clientLibrary (pin it with
// $session->clientLibraryIntegrity), then initialise the widget with
// the capture context in $session->jwt
$session = $gateway->createCheckoutSession(new CheckoutSessionRequest(
    money: Money::minor(15000, 'EGP'),
    orderReference: 'ORDER-124',
    returnUrl: 'https://shop.test/return',
    customer: new Customer(email: 'ada@shop.test'),
));

// redirect the payer to $session->redirectUrl
$session = $gateway->createCheckoutSession(new CheckoutSessionRequest(
    money: Money::minor(15000, 'EGP'),
    orderReference: 'ORDER-125',
    paymentMethod: 'card',
    options: new PaymobCheckoutOptions(integrationId: 111111, iframeId: 222222),
));

// redirect to $session->redirectUrl (the Paymob iframe)
$session = $gateway->createCheckoutSession(new CheckoutSessionRequest(
    money: Money::minor(25000, 'USD'),
    orderReference: 'ORDER-126',
    description: 'Gold Plan',
    returnUrl: 'https://shop.test/return',
    options: new PaylinkCheckoutOptions(iframe: true), // embeddable checkout URL
));

// embed $session->redirectUrl in an <iframe> (or redirect without the option)
$session = $gateway->createCheckoutSession(new CheckoutSessionRequest(
    money: Money::minor(12030, 'SAR'),
    orderReference: 'ORDER-127',
    returnUrl: 'https://shop.test/return',
    paymentMethod: 'invoice',      // 'invoice' | 'paylink' | 'managed' — selects the integration type
    options: new PaytabsCheckoutOptions(
        iframe: true,                       // embed the page in an <iframe> instead of redirecting
        tokenise: 2,                        // save the card as a reusable token
        agreement: new PaytabsAgreement(/* … */),  // repeat billing — PayTabs auto-bills the schedule
        splitPayout: [new PaytabsSplitPayout(/* … */)], // split the settled funds across beneficiaries
        webhookUrl: 'https://shop.test/ipn',
    ),
));

// redirect to $session->redirectUrl (hosted page); $session->reference is the tran_ref
$session = $gateway->createCheckoutSession(new CheckoutSessionRequest(
    money: Money::minor(10000, 'USD'),
    orderReference: 'ORDER-127',
    description: 'Gold Plan',
    returnUrl: 'https://shop.test/return', // PayPal redirects here after approval
    paymentMethod: 'authorize',        // omit to capture on approval (intent CAPTURE)
    options: new PayPalCheckoutOptions(  // typed options — no ambiguous array
        cancelUrl: 'https://shop.test/cancel',
        brandName: 'Example',
        userAction: PayPalUserAction::PayNow,
    ),
));

// redirect to $session->redirectUrl (PayPal approval page);
// $session->reference is the order id — pass it to charge() after approval
$session = $gateway->createCheckoutSession(new CheckoutSessionRequest(
    money: Money::minor(25000, 'USD'),
    orderReference: 'ORDER-128',          // becomes the MPGS order id
    description: 'Goods and Services',
    options: new MpgsCheckoutOptions(
        operation: 'PURCHASE',               // PURCHASE | AUTHORIZE | VERIFY
        merchantName: 'Example LLC',
        returnUrl: 'https://shop.test/return',
    ),
));

// launch Hosted Checkout on the front end with $session->reference (the session id),
// loading the checkout script from $session->clientLibrary
$session = $gateway->createCheckoutSession(new CheckoutSessionRequest(
    money: Money::minor(10000, 'USD'), // sent to Airwallex as major units (100.00)
    orderReference: 'ORDER-130',          // merchant_order_id + request_id (idempotent)
    returnUrl: 'https://shop.test/return',
    // paymentMethod: 'authorize',           // manual-capture hold; capture() settles it later
));

// hand $session->reference (intent id) + $session->jwt (client_secret) to the
// Airwallex element on the front end — it collects the card and confirms the intent
$session = $gateway->createCheckoutSession(new CheckoutSessionRequest(
    money: Money::minor(30000, 'SAR'), // 300.00 SAR, sent to Tamara as major units
    orderReference: 'ORDER-1',
    returnUrl: 'https://shop.test/tamara/return',
    customer: new Customer(email: 'ada@shop.test', firstName: 'Ada', lastName: 'Lovelace'),
));

// redirect the customer to $session->redirectUrl (Tamara's hosted BNPL page);
// $session->reference is the Tamara order_id. After the order_approved webhook,
// call $gateway->authorise($session->reference) before capture().

Checkout

Confirm orchestrated payment

confirmOrchestratedPayment(ConfirmOrchestratedPaymentRequest): OrchestratedPaymentResult

The orchestrated (autoProcessing) alternative to charge: pass a completeMandate to createCheckoutSession so the widget runs Decision Manager, 3-D Secure, authorization, and TMS tokenization client-side and resolves with a signed result JWT, then verify that JWT here and trust it — no /pts/v2/payments call and no transaction-search lag.

// 1. Mint a capture context that tells the widget to auto-complete the payment.
$session = $gateway->createCheckoutSession(new CheckoutSessionRequest(
    money: Money::minor(10000, 'EGP'),
    targetOrigins: ['https://shop.test'],
    orderReference: 'ORDER-123',
    completeMandate: MandateCompletionType::Capture, // Capture (sale) | Auth (hold only)
));

// hand $session->jwt to checkout.mount(); it resolves with a signed result JWT.

// 2. Verify that result JWT against the capture context and trust the outcome.
$result = $gateway->confirmOrchestratedPayment(new ConfirmOrchestratedPaymentRequest(
    resultJwt: $resultJwt,                // returned by checkout.mount() on the front end
    captureContextJwt: $session->jwt, // source of the RS256 verification key (flx.jwk)
    expectedMoney: Money::minor(10000, 'EGP'),
    orderReference: 'ORDER-123',
));

// $result->status is Captured (Authorized for an AUTH mandate). For a real card,
// $result->instrumentIdentifierId / paymentInstrumentId / customerId are the reusable
// TMS token for later installments; a wallet sets $result->isWallet with no token.

Checkout

Microform session

createMicroformSession(CheckoutSessionRequest): CheckoutSession

Flex Microform is CyberSource's low-level, PCI-friendly card-field tokenizer — a build-your-own alternative to the Unified Checkout widget. Mint a Microform v2 capture context here, load Microform.js with the returned JWT to render the secure card/expiry/CVV fields, and the browser mints a transient token you charge through the same charge path. The context carries no order amount or capture mandate — the amount is applied at charge time. Supply an optional billTo billing address and it is added to the context (orderInformation.billTo) for AVS and risk screening. Like confirmOrchestratedPayment it is a CyberSource-specific method outside the shared PaymentGatewayInterface.

// 1. Mint a Microform capture context for the secure card fields.
$session = $gateway->createMicroformSession(new CheckoutSessionRequest(
    money: Money::minor(10000, 'EGP'), // currency only; the amount is applied at charge
    targetOrigins: ['https://shop.test'],       // origins allowed to launch Microform
    allowedCardNetworks: [CybersourceCardNetwork::Visa, CybersourceCardNetwork::Mastercard],
    billTo: new BillingAddress(firstName: 'Jane', lastName: 'Doe', country: 'EG'), // optional — captured for AVS / risk
));

// hand $session->jwt (+ $session->clientLibrary) to Microform.js; it mints a transient token.

// 2. Charge the transient token server-side — the same path Unified Checkout uses.
$result = $gateway->charge(new ChargeRequest(
    transientToken: $transientToken,          // returned by Microform on the front end
    money: Money::minor(10000, 'EGP'),
    orderReference: 'ORDER-123',
));

Before charging

Card offers (BIN lookup)

lookupBin(BinLookupRequest): BinLookupResult

Offers keyed on card type — a Visa promotion, a premium-tier perk, an installment plan only some issuers support — have to be decided before the authorization, and they carry real money, so the answer must be one the shopper cannot forge. lookupBin asks the networks what the credential actually is. Pass a transient token or a vault reference and no card number reaches your server. Do not read the transient token's own claims for this — that JWT is decoded without verifying its signature, so it can be edited to claim an offer.

$bin = $gateway->lookupBin(BinLookupRequest::forTransientToken($transientToken));

if (! $bin->isResolved()) {
    // MULTIPLE or NO MATCH — attributes untrustworthy. Charge normally;
    // "unknown card" is never a reason to refuse a payment.
    return $this->chargeWithoutOffer();
}

$offer = match ($bin->network()) {
    CybersourceCardNetwork::Visa       => $this->visaPromotion(),
    CybersourceCardNetwork::Mastercard => $this->mastercardPromotion(),
    CybersourceCardNetwork::Amex       => $this->amexPromotion(),
    default                            => null,
};

// Brand is often the least useful attribute. BIN lookup also tells you:
$bin->fundingSource;                   // Credit | Debit | Prepaid | …
$bin->platform?->isCommercial();      // consumer vs business/corporate
$bin->cardProduct;                     // "Visa Infinite" — premium-tier offers
$bin->issuerCountry;                   // "US" — geo-gated promotions
$bin->supportsInstallments();          // only offer EMI where the issuer allows it

// network() returns one typed enum wherever the brand came from — a BIN lookup,
// a vaulted card ($instrument->network()), or a verified payment ($result->network()).

Before charging

DCC rate

requestDccRate(DccRateRequest): DccQuote

Quote a Dynamic Currency Conversion rate so a foreign cardholder pays in their own currency. Thread the returned DccQuote into charge — and capture / refund / reverseAuthorization — so the same quoted rate is echoed across the lifecycle. Set money to the quote's convertedAmount.

$quote = $gateway->requestDccRate(new DccRateRequest(
    money: Money::minor(48000, 'EGP'), // 480.00 EGP
    cardNumber: '4111111111111111',
));

if ($quote->offered) {
    $gateway->charge(new ChargeRequest(
        transientToken: $tokenFromWidget,
        money: $quote->convertedAmount, // billing currency, quoted rate
        dcc: $quote,
    ));
}

Before charging

Installments

charge(ChargeRequest + Installment): PaymentResult

Split a charge into issuer-funded installments — common across MENA, LATAM, and Turkey — by attaching an Installment to the ChargeRequest. The issuer funds and splits the plan, so you authorise once instead of charging the card yourself. Only totalCount is required; it maps to processingInformation.installment.

$result = $gateway->charge(new ChargeRequest(
    transientToken: $tokenFromWidget,
    money: Money::minor(48000, 'EGP'),
    installment: new Installment(totalCount: 6), // six issuer installments
));

Fraud screening

Device fingerprinting

Decision Manager profiles the shopper's device so a charge can be fraud-screened. It has two halves: a profiling tag you embed on the checkout page, and a session id you send on the request. The browser tag is yours to embed — this package never renders HTML.

org_id is CyberSource's standard Decision Manager profiling org id (shared, not a per-merchant secret): 1snn5n9w in test, k8vif92e in production. session_id is your merchant id concatenated with a fresh per-page-load crypto.randomUUID() (max 88 chars, [A-Za-z0-9_-]). Add it above </body> — tags.js supersedes the legacy check.js:

const orgId      = isTestMode ? '1snn5n9w' : 'k8vif92e';
const sessionId  = crypto.randomUUID();               // the <session id>; send THIS to the API
const tag        = document.createElement('script');
tag.src = 'https://h.online-metrix.net/fp/tags.js?org_id=' + orgId +
          '&session_id=' + encodeURIComponent(merchantId + sessionId);
document.head.appendChild(tag);

On the request, send only the <session id> part as deviceFingerprintId — it maps to deviceInformation.fingerprintSessionId. It is accepted on charge, chargeStoredCredential, chargeWallet, and enrollPayerAuth:

$result = $gateway->charge(new ChargeRequest(
    transientToken: $tokenFromWidget,
    money: Money::minor(10000, 'EGP'),
    deviceFingerprintId: $sessionId, // the UUID from the tag (the <session id> part), NOT merchantId + UUID
));

Set useRawFingerprintSessionId: true only when you sent the session id to the tag without the merchant-id prefix; for the standard tag above, leave it false.

In the orchestrated flow (a completeMandate on createCheckoutSession) there is no fingerprintSessionId to send — the widget runs Decision Manager itself. Toggle it with decisionManager on CheckoutSessionRequest (default true), emitted as completeMandate.decisionManager. Likewise enable3ds (default true) is emitted as completeMandate.consumerAuthentication so the widget runs 3-D Secure as part of the mandate — it applies to the orchestrated flow only; the manual transient-token flow runs 3DS through enrollPayerAuth / validatePayerAuth. The widget's collected fields (billing/email/phone/shipping, accepted-network icons) come from a CybersourceCheckoutOptions on options, which maps to the capture context's captureMandate.

3-D Secure

Setup payer auth

setupPayerAuth(PayerAuthSetupRequest): PayerAuthSetupResult

Primes 3-D Secure device data collection before enrollment — returns the device-data-collection URL to load in a hidden iframe, plus the reference id to carry into enrollPayerAuth.

$setup = $gateway->setupPayerAuth(new PayerAuthSetupRequest(
    transientToken: $tokenFromWidget,
    orderReference: 'ORDER-123',
));
// Load $setup->deviceDataCollectionUrl in a hidden iframe, then enroll with $setup->referenceId

3-D Secure

Enroll payer auth

enrollPayerAuth(PayerAuthEnrollRequest): PayerAuthResult

Starts the 3-D Secure challenge for a transient token before you charge it.

$auth = $gateway->enrollPayerAuth(new PayerAuthEnrollRequest(/* … */));
// $auth->status drives the challenge on the front end

CyberSource: pass the referenceId from setupPayerAuth and the collected browser device data via a BrowserDeviceData on PayerAuthEnrollRequest.device. The SDK maps it onto CyberSource's deviceInformation and marks the device channel as Browser, so the issuer can risk-assess the browser and grant a frictionless (no-challenge) authentication more often.

CyberSource — ECI enforcement: a completed authentication (a frictionless enroll, or validate after a challenge) whose resolved ECI is not fully authenticated — 02 (Mastercard) or 05 (Visa, American Express, JCB, Diners Club, Discover) — is marked unsuccessful, and a PayerAuthenticationEciRejected event is dispatched. This stops an attempted or unauthenticated result from carrying a spurious liability shift into the charge; a pending step-up challenge and a response with no ECI are unaffected.

3-D Secure

Validate payer auth

validatePayerAuth(ValidatePayerAuthRequest): PayerAuthResult

Confirms the challenge result once the payer completes it — check it before charging.

$result = $gateway->validatePayerAuth(new ValidatePayerAuthRequest(
    authenticationTransactionId: $auth->authenticationTransactionId,
    money: Money::minor(10000, 'USD'),
    transientToken: $tokenFromWidget, // CyberSource references the card by the token's jti
));
// $result->consumerAuthenticationInformation carries the CAVV/ECI for the charge

MPGS: pass 3-D Secure browser device data via a BrowserDeviceData on ValidatePayerAuthRequest.device — the user agent, browserDetails (screen size, colour depth, language, time zone, challenge window size), and client IP. MPGS forwards it as device on the AUTHENTICATE_PAYER call so the issuer can risk-assess and grant a frictionless (no-challenge) authentication more often.

$result = $gateway->validatePayerAuth(new ValidatePayerAuthRequest(
    authenticationTransactionId: $authTxnId,
    money: Money::minor(10000, 'USD'),
    device: new BrowserDeviceData(
        ipAddress: $request->ip(),
        userAgent: $request->userAgent(),
        colorDepth: 24, screenHeight: 640, screenWidth: 480,
        language: 'en-US', timeZone: 273, javaScriptEnabled: true,
        challengeWindowSize: 'FULL_SCREEN',
    ),
));

Charging

Normal Charge

charge(ChargeRequest): PaymentResult

Charge server-to-server against the one-time reference the checkout step returned — the token or approved-order id captured up front. The idempotency key defaults to the order reference, so a retried charge for the same order is deduplicated.

$result = $gateway->charge(new ChargeRequest(
    transientToken: $tokenFromWidget,
    money: Money::minor(10000, 'EGP'),
    orderReference: 'ORDER-123', // = idempotency key
));
$result = $gateway->charge(new ChargeRequest(
    transientToken: $paymentToken, // Own Form browser-generated token
    money: Money::minor(9500, 'SAR'),
    orderReference: 'ORDER-127',
));

// 3-D Secure card → status Pending + $result->raw['redirect_url'] to send the payer
$result = $gateway->charge(new ChargeRequest(
    transientToken: $session->reference, // the approved PayPal order id
    money: Money::minor(10000, 'USD'),
    capture: true,                    // capture the order; false authorizes a hold instead
));

// status Captured (or Authorized when capture: false);
// $result->transactionId is the capture / authorization id for follow-ons
$result = $gateway->charge(new ChargeRequest(
    transientToken: $sessionId,        // the MPGS Hosted Session id holding the card
    money: Money::minor(10000, 'USD'),
    orderReference: 'ORDER-128',      // the MPGS order id
    idempotencyKey: 'txn-1',          // becomes the MPGS transaction id
));

// PAY → status Captured; capture: false authorises instead (AUTHORIZE)
$result = $gateway->charge(new ChargeRequest(
    transientToken: $opaqueDataValue, // the Accept.js opaqueData dataValue (nonce)
    money: Money::minor(5000, 'USD'),
    orderReference: 'ORDER-129',    // Authorize.Net invoiceNumber / refId
    capture: true,                 // false authorises only (authOnlyTransaction)
));

// status Captured (Authorized when capture: false); $result->transactionId is the transId
$result = $gateway->charge(new ChargeRequest(
    transientToken: $paymentMethodId, // Airwallex.js createPaymentMethod id (mtd_...)
    money: Money::minor(10000, 'USD'),
    orderReference: 'ORDER-130',
    capture: true,                 // false authorises only (capture_method: manual)
));

// creates a PaymentIntent then confirms it with the payment_method id — one server call each;
// a 3-D Secure challenge resolves to a pending status with the next_action in $result->raw

Charging

Wallet Charge

chargeWallet(WalletChargeRequest): PaymentResult

Charges a native Apple Pay / Google Pay token from your own wallet button. Pass a WalletToken in either shape — the SDK never decrypts it or handles the cleartext PAN. CyberSource tags the charge with paymentSolution 001 (Apple Pay) or 012 (Google Pay).

// Canonical: decrypt the wallet payload in your app, send the network-token fields
$result = $gateway->chargeWallet(new WalletChargeRequest(
    token: new DecryptedWalletToken(
        number: $dpan,           // decrypted device PAN
        cryptogram: $cryptogram,
        expiryMonth: '12',
        expiryYear: '2031',
        eci: '05',             // optional
        cardType: '001',        // optional network code (001 Visa, 002 Mastercard, …)
    ),
    wallet: WalletType::ApplePay,
    money: Money::minor(2599, 'USD'),
));

// Or forward the raw encrypted token for CyberSource to decrypt (fluidData):
// token: new EncryptedWalletToken($applePayPaymentDataJson)

Charging

Authorization Charge

capture(CaptureRequest): PaymentResult

Settles a prior authorization. Pass a key unique to the operation; partial captures each need their own.

$result = $gateway->capture(new CaptureRequest(
    transactionId: '7040000000000000001',
    money: Money::minor(15000, 'EGP'),
    idempotencyKey: 'capture:invoice-123',
));

Charging

Declines & messages

DeclineClassifier::fromResult(PaymentResult): DeclineOutcome

Turn a declined charge into a retry decision and a safe, specific message for the cardholder — without leaking a raw processor code. Pair customerMessage() with the stable reason code to localise your own copy, and keep the bank's own response for your logs and for support to quote back.

if (! $result->success) {
    $decline = DeclineClassifier::fromResult($result);

    // What to show and what to do
    $message = $decline->customerMessage(); // "Your card was declined for insufficient funds…"
    $retry   = $decline->isRetryable();     // false for expired / blocked / invalid cards
    $reason  = $decline->reason;              // stable code to localise your own copy
    $status  = $decline->status;              // raw transaction status, e.g. DECLINED

    // What the bank actually said — log this, never show it
    $result->code;      // errorInformation.reason, else the processor response code
    $result->message;   // the gateway's own message for that code

    // Straight from the issuer, for support and for retry policy
    data_get($result->raw, 'processorInformation.responseCode');
    data_get($result->raw, 'processorInformation.merchantAdvice.code'); // network retry guidance
    data_get($result->raw, 'processorInformation.approvalCode');
}

The two layers answer different questions. customerMessage() is deliberately vague — issuers do not want the real reason relayed to a cardholder, and a raw code in the checkout UI helps a fraudster more than a customer. The bank response is the opposite: keep processorInformation.responseCode and merchantAdvice.code in your logs, because they are what support quotes back and what the classifier itself reads to decide whether a retry is worth attempting at all.

Corrections

Void

void(VoidRequest): PaymentResult

Cancels an authorization before it settles. Nothing is captured.

$result = $gateway->void(new VoidRequest(
    transactionId: '7040000000000000001',
    idempotencyKey: 'void:invoice-123',
));

Corrections

Refund

refund(RefundRequest): RefundResult

Returns settled funds and accepts partials. Retrying with the same key is a no-op at the gateway — never a double refund — so give each partial its own key.

$result = $gateway->refund(new RefundRequest(
    transactionId: '7040000000000000001',
    money: Money::minor(2500, 'EGP'),
    idempotencyKey: 'refund:invoice-123:1',
));

Corrections

Reverse authorization

reverseAuthorization(ReversalRequest): PaymentResult

Releases an auth hold without capturing. A partial money releases only part of the hold. Use it when an order is abandoned after authorization.

$result = $gateway->reverseAuthorization(new ReversalRequest(
    transactionId: '7040000000000000001',
    money: Money::minor(15000, 'EGP'),
    idempotencyKey: 'reverse:auth-123',
));

Corrections

Increment authorization

incrementAuthorization(IncrementAuthorizationRequest): PaymentResult

Raises the amount already authorized, keeping one hold. For open-ended stays and rentals where the final bill is not known when the card is presented: a hotel authorizes an estimate on arrival and increments as charges accumulate. Authorizing again instead would place a second hold and withhold the funds twice. The request carries the amount to add, not the new total.

$gateway->incrementAuthorization(new IncrementAuthorizationRequest(
    transactionId: $paymentId,
    additionalAmount: Money::minor(5000, 'USD'), // adds $50 to the existing hold
    reason: '5',
));

Corrections

Refund a capture

refundCapture(RefundRequest): RefundResult

Needed when authorization and capture were requested separately. The capture is then its own resource with its own id, and refund — which addresses the payment id — cannot reach it. Pass the capture id as transactionId.

$gateway->refundCapture(new RefundRequest(
    transactionId: $captureId, // the capture id, not the payment id
    money: Money::minor(1000, 'USD'),
));

Corrections

Void a capture

voidCapture(VoidRequest): PaymentResult

Cancels a capture that has not yet settled. void addresses a payment — which CyberSource documents as being for authorization and capture requested together; this addresses a capture requested independently, which that call cannot reach.

$gateway->voidCapture(new VoidRequest(transactionId: $captureId));

Corrections

Standalone credit

creditPayment(CreditRequest): PaymentResult

Pushes money to a card with no original transaction behind it — a goodwill payment, a rebate, a settlement, or a refund whose original transaction is past the gateway's refund window. Unlike a refund it is bounded by nothing: no amount cap, no sale to tie it to. That is why processors watch credits, and why this belongs behind the same authorisation you would put on a payout rather than on a refund.

$gateway->creditPayment(new CreditRequest(
    money: Money::minor(2500, 'USD'),
    transientToken: $token,              // or paymentInstrumentId / customerId
    merchantTransactionId: 'mtid-credit-1', // so a lost reply can still be voided
));

Corrections

Void a credit

voidCredit(VoidRequest): PaymentResult

Cancels a standalone credit before it settles — the only way to undo one, since a credit has no payment to reverse against. Worth wiring up alongside creditPayment: a credit sent in error is money out the door once it settles.

$gateway->voidCredit(new VoidRequest(transactionId: $creditId));

Corrections

Void a refund

voidRefund(VoidRequest): PaymentResult

Cancels a refund before it settles, so an over-refund caught in time costs nothing.

$gateway->voidRefund(new VoidRequest(transactionId: $refundId));

Corrections

Timeout void

timeoutVoid(TimeoutVoidRequest): PaymentResult

Cancels a payment, capture, refund, or credit whose reply never arrived. When a request times out you cannot know whether it landed, and you have no transaction id to void with — the response that carried it never came. This matches on the merchant transaction id from the original request and reverses it if it landed, or does nothing if it did not.

It only works if that id was sent in the first place. Set merchantTransactionId on anything you may need to undo blind — it cannot be supplied retrospectively.

// On the original call — without this, the timeout void below is impossible:
$gateway->charge(new ChargeRequest(
    transientToken: $token, money: $money,
    merchantTransactionId: 'mtid-charge-1',
));

// … the request times out; you have no transaction id …

$gateway->timeoutVoid(new TimeoutVoidRequest(
    merchantTransactionId: 'mtid-charge-1',
));

Complements, rather than replaces, reconciling by reference: findSuccessfulTransactionByReference tells you whether a lost request settled and is eventually consistent; this undoes it, and acts immediately.

Corrections

Timeout reversal

timeoutReversal(TimeoutVoidRequest): PaymentResult

The authorization-side counterpart of the timeout void, subject to the same precondition. Without it, a timed-out authorization strands a hold on the cardholder's card until the issuer expires it — which can be days.

$gateway->timeoutReversal(new TimeoutVoidRequest(
    merchantTransactionId: 'mtid-auth-1',
));

Money transfer

Pull funds (AFT)

pullFunds(PullFundsRequest): FundsTransferResult

Debits the sender's card to fund a transfer — an Account Funding Transaction. It is not a purchase and must not be modelled as one: the cardholder is buying nothing, the networks price and rule it differently, and declaring a funding transaction as a sale is the usual reason a transfer programme gets shut down.

$pull = $gateway->pullFunds(new PullFundsRequest(
    money: Money::minor(25000, 'USD'),
    cardNumber: $senderCard,
    expirationMonth: '01', expirationYear: '2030',
    businessApplicationId: BusinessApplicationId::PersonToPerson,
    sender: $sender,
    merchantTransactionId: 'mtid-pull-1',
));

Money transfer

Push funds (OCT)

pushFunds(PushFundsRequest): FundsTransferResult

Credits the recipient's card — an Original Credit Transaction, usually within seconds. That is what separates it from a refund or a credit: those move money back along the rail a sale arrived on, while this pushes funds to any eligible card whether or not it ever paid you.

Carry the same businessApplicationId as the matching pull, so the networks see one coherent transfer rather than an unrelated debit and credit.

$push = $gateway->pushFunds(new PushFundsRequest(
    money: Money::minor(25000, 'USD'),
    cardNumber: $recipientCard,     // or paymentInstrumentId for a vaulted card
    expirationMonth: '12', expirationYear: '2031',
    businessApplicationId: BusinessApplicationId::PersonToPerson,
    sender: $sender,         // screened by the networks — see below
    recipient: $recipient,
    merchantTransactionId: 'mtid-push-1',
));

$push->reconciliationId; // what a support conversation will turn on — keep it
$push->approvalCode;

The two legs fail independently

A pull that succeeds followed by a push that declines leaves the sender debited and the recipient unpaid. That is a reconciliation problem, not a failed transfer: the pull must be reversed, not retried. Record both legs as separate facts.

if (! $push->success) {
    $gateway->reversePullFunds($pull->transferId); // before it settles
    // … or refundPullFunds() once it has
}

Money transfer

Transfer purpose

BusinessApplicationId — 20 documented values

The business application id tells the networks what a transfer is for. It is not a label: it drives interchange, the rules the transfer is judged against, and in some markets whether it is permitted at all. Declaring a payroll disbursement as a person-to-person transfer is a compliance problem, not a mislabelling.

The set divides in two. Money transfer moves funds between parties and the networks expect sender identification with it. Funds disbursement pays money out from a business and generally does not.

BusinessApplicationId::PersonToPerson->isMoneyTransfer();      // true
BusinessApplicationId::PayrollAndPension->isFundsDisbursement(); // true

BusinessApplicationId::moneyTransfers();      // AA BI CD FT LA PP WT
BusinessApplicationId::fundsDisbursements();  // BB BP CP FD GD GP LO MD MI OG PD RP TU

Which ids an account may actually use varies by gateway and configuration; CyberSource falls back to the merchant's configured default when none is sent.

Money transfer

Transfer parties

TransferParty — who is sending, who is receiving

Card transfers are not anonymous. For a money transfer the networks require the parties to be identified, and that is a regulatory obligation rather than a gateway preference: name, address, and often date of birth and a government id travel with the transaction so it can be screened against sanctions and AML rules. A transfer that omits them is refused at the network, not declined by the issuer.

$sender = new TransferParty(
    firstName: 'Ada', lastName: 'Lovelace',
    type: TransferPartyType::Individual,
    address1: '1 Market St', locality: 'London',
    country: 'GB', postalCode: 'EC1A 1BB',
    dateOfBirth: '19151210',              // sender only — YYYYMMDD
    personalIdentification: ['type' => 'PASSPORT', 'id' => 'X1234567'],
);

Which fields are mandatory varies by corridor, processor, and the transfer's purpose, so everything is optional and only what you supply is sent. As a rule card transfers need at minimum a name and an address; cross-border and higher-value transfers need date of birth and identification too. Date of birth, reference number, and tax id are sender-only — the recipient block does not accept them, and the SDK drops them there rather than letting the gateway reject the call.

Money transfer

Transfer FX and lookup

payoutFxRates(array): array · queryPayout(string): array

Cross-border transfers settle at the network's rate, not yours, so quoting first is how a sender is shown what the recipient will actually receive before the transfer is committed. queryPayout looks a transfer up by id, for reconciling one whose outcome is unclear. Both take and return raw arrays: the shapes vary by corridor, so the SDK does not invent a DTO it cannot verify.

$gateway->payoutFxRates(['fxQuote' => ['currency' => 'GBP']]);
$gateway->queryPayout($transferId);

After the payment

Get transaction

getTransaction(id): TransactionSnapshot

Fetches the authoritative status by gateway transaction id. Returns a TransactionSnapshot with a normalized PaymentStatus, the amount and the order reference. The Reconcile action below batches this lookup across many ids.

$snapshot = $gateway->getTransaction('7040000000000000001');

// $snapshot->status, $snapshot->amount, $snapshot->orderReference

After the payment

Search transaction

searchTransaction(ref): TransactionSnapshot

Looks the payment up by your own order reference instead of the gateway id — handy when you only kept the reference you sent.

$snapshot = $gateway->searchTransaction('ORDER-124');

// same TransactionSnapshot, resolved from your reference

After the payment

Reconcile

reconcile(GatewayName, ids): ReconciliationOutcome[]

Reconcile a batch of transaction ids against this gateway in one call. Each id is fetched via getTransaction and returned as a ReconciliationOutcome — a failed lookup is captured as an error instead of aborting the batch, so every id is accounted for. Inject the TransactionReconciler use-case; it's registered in the container.

$outcomes = $reconciler->reconcile(GatewayName::CybersourceUnifiedCheckout, [
    '7040000000000000001',
]);

foreach ($outcomes as $outcome) {
    // $outcome->reconciled(), $outcome->snapshot?->status, $outcome->error
}
$outcomes = $reconciler->reconcile(GatewayName::Fawry, [
    'ORDER-124', 'ORDER-125',
]);

foreach ($outcomes as $outcome) {
    // $outcome->reconciled(), $outcome->snapshot?->status, $outcome->error
}
$outcomes = $reconciler->reconcile(GatewayName::Paymob, [
    '123456789',
]);

foreach ($outcomes as $outcome) {
    // $outcome->reconciled(), $outcome->snapshot?->status, $outcome->error
}
$outcomes = $reconciler->reconcile(GatewayName::Paylink, [
    'INV-0001',
]);

foreach ($outcomes as $outcome) {
    // $outcome->reconciled(), $outcome->snapshot?->status, $outcome->error
}
$outcomes = $reconciler->reconcile(GatewayName::Paytabs, [
    'TST2000000000001',
]);

foreach ($outcomes as $outcome) {
    // $outcome->reconciled(), $outcome->snapshot?->status, $outcome->error
}
$outcomes = $reconciler->reconcile(GatewayName::PayPal, [
    '7NK74838L4813105R', // a PayPal order id
]);

foreach ($outcomes as $outcome) {
    // $outcome->reconciled(), $outcome->snapshot?->status, $outcome->error
}
$outcomes = $reconciler->reconcile(GatewayName::Mpgs, [
    'ORDER-128', // MPGS order ids
]);

foreach ($outcomes as $outcome) {
    // $outcome->reconciled(), $outcome->snapshot?->status, $outcome->error
}
$outcomes = $reconciler->reconcile(GatewayName::AuthorizeNet, [
    '40000000001', // Authorize.Net transaction ids (transId)
]);

foreach ($outcomes as $outcome) {
    // $outcome->reconciled(), $outcome->snapshot?->status, $outcome->error
}
$outcomes = $reconciler->reconcile(GatewayName::Airwallex, [
    'int_hkdm...', // Airwallex PaymentIntent ids
]);

foreach ($outcomes as $outcome) {
    // $outcome->reconciled(), $outcome->snapshot?->status, $outcome->error
}
$outcomes = $reconciler->reconcile(GatewayName::Tamara, [
    'f56a3123-9e23-45e4-87a2-95366d3b0bca', // Tamara order ids
]);

foreach ($outcomes as $outcome) {
    // $outcome->reconciled(), $outcome->snapshot?->status, $outcome->error
}

After the payment

Refresh payment status

refreshPaymentStatus(string): PaymentResult

Asks CyberSource to re-check a payment with the processor, for the alternative payment methods that settle asynchronously. Unlike getTransaction, which reads CyberSource's own record, this makes CyberSource go back to the processor — so reach for it when the record itself looks stale rather than merely unfinished.

$result = $gateway->refreshPaymentStatus($paymentId);

Webhooks

Verify webhook

verifyWebhook(payload, headers): WebhookEvent

Verify the signature first — it's unauthenticated until you do, and carries no timestamp. Then apply your own idempotency on the transaction or invoice id.

$event = $gateway->verifyWebhook($request->getContent(), $request->headers->all());

if ($event->verified) {
    // $event->eventType, $event->transactionId, $event->status
}

Webhooks

Webhook security key

createWebhookSecurityKey(array, string): WebhookSecurityKey

Start here. CyberSource signs every notification with a symmetric key, and that key is the same secret verifyWebhook validates against — so a subscription created before the key exists has nothing to sign with. The key is returned once, at creation, and cannot be read back: store it before discarding the response, or every notification from that subscription becomes unverifiable and the only fix is a new key. Treat it as a credential — never log it, never return it from an endpoint.

$key = $gateway->createWebhookSecurityKey([
    'provider' => 'nrtd',          // values come from CyberSource's webhook guide
    'tenant'   => 'pxsecurity',      // for your account — they are not universal
    'keyType'  => 'sharedSecret',
]);

if (! $key->hasKey()) {
    throw new RuntimeException('No key returned — do not create the subscription yet.');
}

$this->storeWebhookSecret($key->key); // this IS the webhook_secret credential
$key->keyId;                        // referenced from the subscription's securityConfig

Webhooks

Webhook products

listWebhookProducts(?string): array

Which products and event types an account may subscribe to depends on its entitlements, so discover them rather than hard-coding product ids and event names into a subscription — a name your account is not entitled to is rejected at create time.

$catalogue = $gateway->listWebhookProducts();

// each entry pairs a productId with the eventTypes available under it,
// which is exactly what CreateWebhookRequest::$products expects

Webhooks

Create webhook

createWebhook(CreateWebhookRequest): WebhookSubscription

Subscribes one of your endpoints to notifications. Supply a healthCheckUrl if you can: without one, a subscription CyberSource suspends after repeated delivery failures has to be reactivated by hand.

Security type decides whether you can verify anything. Only WebhookSecurityType::Key produces the signed notifications verifyWebhook checks. The two oAuth variants make CyberSource fetch a bearer token from your authorization server instead — authenticated, but not by this SDK, and verifyWebhook would fail closed on them.

Scope is worth setting explicitly. SCOPE_SELF delivers only this organization's events; SCOPE_DESCENDANTS — CyberSource's default — also delivers those of every organization beneath it. For a partner or portfolio account that is the whole platform's volume versus one merchant's.

$webhook = $gateway->createWebhook(new CreateWebhookRequest(
    name: 'Payments',
    webhookUrl: 'https://shop.test/webhook',
    products: [new WebhookProduct('payments', [
        'payments.payments.accept',
        'payments.refunds.accept',
    ])],
    healthCheckUrl: 'https://shop.test/health',
    securityType: WebhookSecurityType::Key,
    securityConfig: ['keyId' => $key->keyId],
    notificationScope: CreateWebhookRequest::SCOPE_SELF,
));

Retries: the algorithm is not a detail

With firstRetry: 10 and interval: 30:

Arithmetic  a + r(n-1)   ->  10, 40, 70 minutes      (all within the hour)
Geometric   a * r^(n-1)  ->  10, 300, 9,000 minutes  (the third is six days later)

deactivateOnFailure changes the failure shape entirely. On, an exhausted sequence suspends the subscription and queues everything behind it until your health-check URL returns 200, then delivers the backlog — nothing is lost, but you must have a health-check endpoint. Off, each notification exhausts its own retries and is then dropped while the subscription stays active. Queue-and-recover for settlement events you cannot lose; drop for high-volume events where a backlog is worse than a gap.

retryPolicy: new WebhookRetryPolicy(
    algorithm: WebhookRetryAlgorithm::Arithmetic,
    firstRetry: 10, interval: 30, numberOfRetries: 3,
    deactivateOnFailure: true,
),

Webhooks

Test webhook

testWebhook(string): array

Sends a sample notification to the subscription's endpoint, carrying representative product and event values drawn from the subscription itself. It reports how your endpoint replied, so a receiver — including its signature verification — can be proven before any real payment depends on it.

$result = $gateway->testWebhook($webhook->webhookId);

// run this in CI against a staging receiver: a signature check that
// silently fails is otherwise only discovered by a missed payment

Webhooks

List webhooks

listWebhooks(?string, ?string, ?string): WebhookSubscription[]

When an integration goes silent, this is the first thing to check. Usually the subscription was suspended rather than the gateway falling over — deliveries stop with no error on your side, because nothing is being sent.

foreach ($gateway->listWebhooks() as $w) {
    $w->isDelivering();          // false once CyberSource suspends it
    $w->isSignatureVerifiable(); // false for the oAuth types
    $w->eventTypes();            // every event, flattened across products
    $w->webhookUrl;               // still pointing at the old deploy?
}

// Narrow it when an account has many subscriptions:
$gateway->listWebhooks(productId: 'payments', eventType: 'payments.payments.accept');

Webhooks

Get webhook

getWebhook(string): WebhookSubscription

Reads one subscription by id — its endpoint, status, security type, and the products and events it receives.

$webhook = $gateway->getWebhook($webhookId);

$webhook->status;                    // WebhookStatus::Active | Inactive
$webhook->eventTypes();
$webhook->isSignatureVerifiable();  // assert this at boot if your receiver assumes signatures

Webhooks

Update webhook

updateWebhook(UpdateWebhookRequest): WebhookSubscription

A partial patch — only the fields you set are sent, so a URL change does not restate the product list or the retry policy. It deliberately cannot change the status, so a routine edit can never silently reactivate a subscription you had paused; that is a separate, explicit call.

$gateway->updateWebhook(new UpdateWebhookRequest(
    webhookId: $id,
    webhookUrl: 'https://shop.test/webhook-v2',
));

Webhooks

Webhook status

setWebhookStatus(string, WebhookStatus): bool

The one to reach for when redeploying a receiver: it stops delivery while keeping the subscription, its id, and its whole configuration. Pair it with a retry policy that has deactivateOnFailure on and notifications queue rather than drop while you are down.

$gateway->setWebhookStatus($id, WebhookStatus::Inactive); // pause
// … deploy …
$gateway->setWebhookStatus($id, WebhookStatus::Active);   // resume

Webhooks

Delete webhook

deleteWebhook(string): bool

Permanent. The notification history survives, but the subscription does not — and recreating it yields a new id, which breaks anything that referenced the old one. Prefer setWebhookStatus unless the endpoint is genuinely gone for good.

$gateway->deleteWebhook($id);

Stored credentials

Vault instrument

vaultInstrument(TokenizeInstrumentRequest): VaultedInstrument

Tokenizes a card and returns a VaultedInstrument you keep for later charges. Charge it later with chargeStoredCredential.

$vaulted = $gateway->vaultInstrument(new TokenizeInstrumentRequest(/* … */));
// keep $vaulted->paymentInstrumentId
$vaulted = $gateway->vaultInstrument(new TokenizeInstrumentRequest(
    cardNumber: '4111111111111111',
    expirationMonth: '02',
    expirationYear: '2027',
));

// keep $vaulted->paymentInstrumentId (PayPal vault id) and $vaulted->customerId
$vaulted = $gateway->vaultInstrument(new TokenizeInstrumentRequest(
    cardNumber: '5123450000000008',
    expirationMonth: '05',
    expirationYear: '2027',
));

// keep $vaulted->paymentInstrumentId (the MPGS token)
$vaulted = $gateway->vaultInstrument(new TokenizeInstrumentRequest(
    cardNumber: '4111111111111111',
    expirationMonth: '05',
    expirationYear: '2027',
    billTo: new BillingAddress(     // PayLink requires the cardholder name + country/address/city
        firstName: 'Jane', lastName: 'Roe',
        country: 'SA', address1: '1 Main St', locality: 'Riyadh',
    ),
));

// keep $vaulted->paymentInstrumentId (the PayLink card token);
// revoke it later with $gateway->deleteToken($token)
$vaulted = $gateway->vaultInstrument(new TokenizeInstrumentRequest(
    transientToken: $opaqueDataValue, // Accept.js nonce — PAN-free (or pass cardNumber/expirationMonth/expirationYear)
));

// CIM profile ids: $vaulted->customerId (customerProfileId) + $vaulted->paymentInstrumentId (paymentProfileId)
$vaulted = $gateway->vaultInstrument(new TokenizeInstrumentRequest(
    cardNumber: '4111111111111111',
    expirationMonth: '12',
    expirationYear: '2030',
    customerReference: $airwallexCustomerId, // required — the Airwallex customer id
));

// creates a PaymentConsent then verifies it with the card; $vaulted->paymentInstrumentId is the
// payment_consent_id. Verification may require a 3-D Secure step before the consent is usable.

Stored credentials

Charge stored credential

chargeStoredCredential(StoredCredentialChargeRequest): PaymentResult

Charges a saved token for merchant- or customer-initiated (MIT/CIT) transactions. The driver stamps the right network stored-credential metadata for the initiator and settles the charge.

$result = $gateway->chargeStoredCredential(new StoredCredentialChargeRequest(
    paymentInstrumentId: $vaulted->paymentInstrumentId,
    money: Money::minor(9900, 'EGP'),
    initiator: CredentialInitiator::Merchant, // MIT
));
$result = $gateway->chargeStoredCredential(new StoredCredentialChargeRequest(
    paymentInstrumentId: $savedToken, // token from a tokenise checkout
    money: Money::minor(50000, 'SAR'),
    initiator: CredentialInitiator::Merchant, // recurring; Customer → ecom
));

// create the token by passing new PaytabsCheckoutOptions(tokenise: 2) on any checkout
$result = $gateway->chargeStoredCredential(new StoredCredentialChargeRequest(
    paymentInstrumentId: $vaulted->paymentInstrumentId, // the PayPal vault id
    money: Money::minor(50000, 'USD'),
    initiator: CredentialInitiator::Merchant, // MIT → RECURRING; Customer → ONE_TIME
));

// vault the card first with vaultInstrument() to get $vaulted->paymentInstrumentId
$result = $gateway->chargeStoredCredential(new StoredCredentialChargeRequest(
    paymentInstrumentId: $vaulted->paymentInstrumentId, // the MPGS token
    money: Money::minor(10000, 'USD'),
    initiator: CredentialInitiator::Merchant, // MIT → adds the stored-credential agreement
    orderReference: 'ORDER-129',
));
$result = $gateway->chargeStoredCredential(new StoredCredentialChargeRequest(
    paymentInstrumentId: $vaulted->paymentInstrumentId, // the PayLink card token
    money: Money::minor(10000, 'USD'),
    initiator: CredentialInitiator::Merchant, // MIT rebill; Customer → CIT
    orderReference: 'ORDER-9',
));

// billing is reused from tokenize time — no cardholder/address is resent
$result = $gateway->chargeStoredCredential(new StoredCredentialChargeRequest(
    paymentInstrumentId: $vaulted->paymentInstrumentId, // CIM customerPaymentProfileId
    customerId: $vaulted->customerId,                 // CIM customerProfileId — required
    money: Money::minor(9900, 'USD'),
    initiator: CredentialInitiator::Merchant, // MIT → isSubsequentAuth; Customer → isStoredCredentials (CIT)
));
$result = $gateway->chargeStoredCredential(new StoredCredentialChargeRequest(
    paymentInstrumentId: $paymentConsentId, // Airwallex PaymentConsent id
    money: Money::minor(9900, 'USD'),
    initiator: CredentialInitiator::Merchant,
    orderReference: 'ORDER-131',
));

// creates a PaymentIntent, then confirms it against the stored consent — one server call each
// the payment_consent_id comes from vaultInstrument() or a consent created client-side by Elements

Stored credentials

Subscriptions

createSubscription(CreateSubscriptionRequest): SubscriptionResult

Hands the billing schedule to CyberSource Recurring Billing instead of running it yourself. Where chargeStoredCredential bills a saved token once per call from your own scheduler, a subscription enrols a vaulted TMS customer on a cadence CyberSource charges on its own. Vault the card first — the request references paymentInformation.customer.id and never carries card data — and nothing is charged here: the first charge falls on startDate. Take the cadence from a planId, from an inline billingPeriod/billingCycles, or from both with the inline values winning. Like confirmOrchestratedPayment, these are CyberSource-specific methods outside the shared PaymentGatewayInterface.

// 1. Vault the card first — the subscription bills a TMS customer token.
$vaulted = $gateway->vaultInstrument(new TokenizeInstrumentRequest(transientToken: $transientToken));

// 2. Enrol it on a schedule CyberSource runs itself.
$subscription = $gateway->createSubscription(new CreateSubscriptionRequest(
    name: 'Pro monthly',
    customerId: $vaulted->customerId,
    startDate: '2026-10-01',                    // UTC; a bare date becomes midnight UTC
    billingPeriod: BillingPeriod::monthly(),      // or ::monthly(3) / ::weekly() / ::yearly()
    billingCycles: 12,                          // omit to bill until cancelled
    billingAmount: Money::minor(4999, 'USD'),
    orderReference: 'ORDER-9',
));

// $subscription->status is Pending until the first billing date, then Active.
// $subscription->requestStatus is CyberSource's verdict on the call itself (COMPLETED).

// 3. Drive it afterwards by id.
$gateway->getSubscription($subscription->subscriptionId);
$gateway->suspendSubscription($id);                              // pause — reversible
$gateway->activateSubscription($id, processMissedPayments: false); // resume without a catch-up bill
$gateway->cancelSubscription($id);                               // terminal — cannot be reactivated

Stored credentials

Update subscription

updateSubscription(UpdateSubscriptionRequest): SubscriptionResult

Amends a live subscription in place — a partial update, so any field you leave out keeps its current value. Its reach is narrower than a create's by CyberSource's own schema: the cycle count and the amounts can change, but the billing period and the currency cannot. Switching monthly to annual means cancelling and re-creating, and a subscription always bills in the currency it was created with — so a Money here contributes its amount and its currency is ignored. The customer token cannot be swapped either; to move the subscription onto another card, update the payment instrument behind the existing TMS customer. An update can also come back PENDING_REVIEW: accepted, but held rather than applied.

$updated = $gateway->updateSubscription(new UpdateSubscriptionRequest(
    subscriptionId: $id,
    billingAmount: Money::minor(5999, 'USD'), // re-price — currency is fixed at create
    billingCycles: 24,                        // extend the run
    name: 'Pro annual',
));

// Accepted but held for review rather than applied:
$heldForReview = $updated->requestStatus === 'PENDING_REVIEW';

Stored credentials

List subscriptions

listSubscriptions(ListSubscriptionsRequest): SubscriptionPage

CyberSource's getAllSubscriptions, returning a page rather than the whole book: the records plus totalCount for the entire filtered set and the window they came from. CyberSource defaults to 20 records and caps a page at 100, so a merchant with hundreds of subscriptions silently gets only the first 20 unless you page through. Filters are optional and combine — filtering by SubscriptionStatus::Delinquent is the practical way to find the subscriptions whose last rebill failed and need dunning.

$request = new ListSubscriptionsRequest(
    status: SubscriptionStatus::Delinquent, // last rebill failed
    limit: 100,                             // CyberSource caps a page at 100
);

do {
    $page = $gateway->listSubscriptions($request);

    foreach ($page->subscriptions as $subscription) {
        // each is a SubscriptionResult — the same shape getSubscription() returns
    }

    $request = $request->nextPage(); // keeps every filter and the page size
} while ($page->hasMore());       // $page->totalCount spans the whole filtered set

Stored credentials

Plans

createPlan(CreatePlanRequest): PlanResult

A plan is the reusable template a subscription is built from — cadence, cycle count, and price — and is what CreateSubscriptionRequest::$planId points at. One asymmetry to keep straight: updatePlan can change the billing period, updateSubscription cannot. A plan is a template with nothing billing against it; a subscription is a live agreement. A plan change governs subscriptions created afterwards — it does not retroactively re-price those already running.

$plan = $gateway->createPlan(new CreatePlanRequest(
    name: 'Pro monthly',
    billingPeriod: BillingPeriod::monthly(),
    billingAmount: Money::minor(4999, 'USD'),
    billingCycles: 12,
    status: PlanStatus::Draft, // stage it; only an Active plan is subscribable
));

$gateway->activatePlan($plan->planId);
$gateway->deactivatePlan($plan->planId); // closes it to NEW sign-ups only
$gateway->deletePlan($plan->planId);     // only when nothing depends on it

// Diagnose a DELINQUENT subscription without waiting for the webhook.
$payments = $gateway->listSubscriptionPayments($subscriptionId);

Stored credentials

Vault lifecycle

getPaymentInstrument · listPaymentInstruments · updatePaymentInstrument · deletePaymentInstrument

vaultInstrument creates tokens; the rest of their life is managed separately. Reading a stored instrument back catches a dead card at rest rather than at charge time — an expired or closed card behind a subscription otherwise fails every rebill permanently, and you only learn of it from the decline. When a card is reissued, re-dating beats re-collecting: updating the stored expiry keeps every subscription already pointing at that instrument working. The card number is not updatable — it belongs to the instrument identifier behind the instrument.

$instrument = $gateway->getPaymentInstrument($customerId, $paymentInstrumentId);

$instrument->expiry();                // "12/2030"
$instrument->isExpired();             // no network call — the vault already told us
$instrument->state?->isChargeable(); // false once the issuer closes the account

// Every card a customer holds (paged: 20 default, 100 max).
$page = $gateway->listPaymentInstruments($customerId, limit: 100);
$page->default();                     // the instrument payments fall back to

// Reissued card — keep every existing subscription working, no new checkout.
$gateway->updatePaymentInstrument(new UpdatePaymentInstrumentRequest(
    customerId: $customerId,
    paymentInstrumentId: $paymentInstrumentId,
    expirationMonth: '01',
    expirationYear: '2032',
));

// Erasure. A customer's DEFAULT instrument can't be deleted while they hold others —
// promote another first with makeDefault: true.
$gateway->deletePaymentInstrument($customerId, $paymentInstrumentId);
$gateway->deleteCustomer($customerId); // and every instrument under them

Stored credentials

Account Updater

createAccountUpdaterBatch(CreateAccountUpdaterBatchRequest): AccountUpdaterBatch

The standing fix for recurring-billing churn. Account Updater asks the card networks whether the cards behind your stored tokens have been reissued, re-dated, or closed, and pushes the answers back into the vault. Only token ids are sent — no card number leaves the vault. Without it, a reissued card fails every scheduled charge permanently. Processing is asynchronous (hours to days), so the create returns a batch id to poll, not results. Amex is a separate network flow and needs a registration batch.

$batch = $gateway->createAccountUpdaterBatch(
    CreateAccountUpdaterBatchRequest::forTokenIds($tokenIds, merchantReference: 'NIGHTLY-2026-09-01'),
);

// Hours to days later — the networks answer on their own schedule.
$status = $gateway->getAccountUpdaterBatchStatus($batch->batchId);

if ($status->isComplete() && $status->hasUpdates()) {
    $report = $gateway->getAccountUpdaterBatchReport($batch->batchId);
    // reconcile per-card changes; retire closed accounts instead of retrying them
}

// Amex must go in its own registration batch, not the oneOff one:
// CreateAccountUpdaterBatchRequest::forTokenIds($amexTokens, AccountUpdaterBatchType::AmexRegistration)

Reporting

Reports

createReport(CreateReportRequest): bool · listReports(ListReportsRequest): Report[] · downloadReport(DownloadReportRequest): ReportFile

Reconciliation and settlement files from CyberSource Reporting. Generation is asynchronous: createReport only queues the work and CyberSource answers with an empty 201, so there is no report id to return — find the queued report with listReports and download it once its status is ready. The download is keyed by report name and date, never by report id, and that date is the end of the period covered in the report's own timezone — the usual cause of a 404 on a report that plainly exists. Report::downloadRequest() derives the name, date, and format for you. The file comes back unparsed, because a report's columns depend on its definition.

// 1. Queue a one-off report (returns bool — CyberSource replies with an empty 201).
$gateway->createReport(new CreateReportRequest(
    name: 'settlement-sept',
    definitionName: ReportDefinitionName::TransactionRequest, // or a raw string for a custom report
    startTime: '2026-09-01',                    // a bare date becomes midnight UTC
    endTime: '2026-09-30',
    fields: ['Request.RequestID', 'Request.TransactionDate'],
));

// 2. Find it, then download once ready.
$reports = $gateway->listReports(new ListReportsRequest(
    startTime: '2026-09-01',
    endTime: '2026-09-30',
    status: ReportStatus::Completed,
    name: 'settlement-sept',
));

foreach ($reports as $report) {
    $download = $report->downloadRequest(); // null while still generating

    if ($download !== null) {
        $file = $gateway->downloadReport($download);
        file_put_contents($file->filename(), $file->content); // raw CSV/XML
    }
}

// ReportStatus tells the three outcomes apart:
//   isInProgress() -> PENDING/QUEUED/RUNNING, keep polling
//   NO_DATA        -> a SUCCESSFUL run that matched nothing (no file to fetch)
//   isFailed()     -> ERROR, the only real failure

Reporting

Report subscriptions

createReportSubscription(CreateReportSubscriptionRequest): bool · listReportSubscriptions(): ReportSubscription[]

For files you need on a cadence, subscribe instead of polling: the gateway generates the report on a schedule and leaves each run waiting to be downloaded. The endpoint is a PUT keyed by report name, so creating a subscription under a name that already exists replaces that schedule. startTime is a clock time of day as hhmm, not a date; weekly and monthly cadences also need startDay, and a user-defined one needs an ISO 8601 interval — the SDK sends each only for the cadence that uses it.

$gateway->createReportSubscription(new CreateReportSubscriptionRequest(
    name: 'nightly-settlement',
    definitionName: ReportDefinitionName::TransactionRequest,
    fields: ['Request.RequestID'],
    startTime: '0200',                     // 2am, as hhmm — not a date
    frequency: ReportFrequency::Daily,
    timezone: 'GMT',
));

$gateway->listReportSubscriptions();
$gateway->getReportSubscription('nightly-settlement');
$gateway->deleteReportSubscription('nightly-settlement'); // stops future runs only

Reporting

Report definitions

listReportDefinitions(): ReportDefinition[] · getReportDefinition(name): ReportDefinition

The 19 reports CyberSource documents as standard are typed as ReportDefinitionName, and CreateReportRequest::$definitionName accepts either that enum or a raw string — because a merchant's real catalogue depends on their entitlements and may hold custom definitions. The fields are not enumerable at all: they are a property of each definition, so a transaction report's columns are not a chargeback report's. Discover both from the gateway.

// What can this merchant actually run?
foreach ($gateway->listReportDefinitions(ReportSubscriptionType::Custom) as $definition) {
    $definition->name;                // pass as CreateReportRequest::$definitionName
    $definition->definitionName();     // the enum case, or null for a custom report
}

// Which columns may this report carry?
$definition = $gateway->getReportDefinition(
    ReportDefinitionName::TransactionRequest,
    format: ReportFormat::Csv,
);

$definition->fieldNames();         // everything on offer -> CreateReportRequest::$fields
$definition->requiredFieldNames(); // columns the report always carries
$definition->supports(ReportFormat::Xml);

// ReportSubscriptionType picks the family a name resolves against (Custom | Standard | Classic)
// — asking under the wrong one is why a name that plainly exists comes back not found.

Bank accounts

Validate bank account

validateBankAccount(ValidateBankAccountRequest): BankAccountValidationResult

The Visa Bank Account Validation Service checks that a routing / account pair is a real, open account before an ACH debit is attempted — how Nacha's account-validation mandate for WEB debits is met. It authorises nothing and moves no money. Read the two codes separately: resultCode is the verdict on the account and only 00 is a documented pass, while rawValidationCode says whether the check could run at all — -1 and -2 are inconclusive, so retry them rather than rejecting the customer's details. Routing and account numbers never reach the SDK's logs.

$result = $gateway->validateBankAccount(new ValidateBankAccountRequest(
    routingNumber: '071000013',
    accountNumber: '4100',
    orderReference: 'ORDER-1',
));

match (true) {
    $result->isValid()        => $this->debitByAch(),
    $result->isInconclusive() => $this->retryLater(),   // service down — not a bad account
    default                   => $this->askForAnotherAccount(),
};

// Or validate an already-vaulted account, so the raw numbers never leave the vault:
// new ValidateBankAccountRequest(customerId: $vaulted->customerId)

Cross-cutting

Events

Event::listen(PaymentEvent::class, listener): void

Every driver emits a typed domain event after each operation. All events implement the PaymentEvent interface, so one listener receives them all — or target a single event type. Events fire on completion (success or decline); the result carries the outcome. Payloads are queue-safe: ids, amount and result, never the raw request or card data.

// one subscriber, every event — match on the concrete type
final class PaymentEventSubscriber
{
    public function handle(PaymentEvent $event): void
    {
        match (true) {
            $event instanceof PaymentCaptured => $this->markOrderPaid($event->orderReference, $event->result),
            $event instanceof PaymentRefunded => $this->recordRefund($event->transactionId, $event->result),
            $event instanceof WebhookReceived => $this->applyWebhook($event->webhook),
            default => null, // charge/void/vault/checkout — ignored here
        };
    }
}

// register once; receives every event via the interface
Event::listen(PaymentEvent::class, PaymentEventSubscriber::class);

// config/gateway.php — on by default; 'log' attaches the redaction-safe audit listener
'events' => ['enabled' => true, 'log' => false]

Every event, and when it fires

Eleven concrete events implement PaymentEvent. Each fires after the operation completes — including when it declines, so a listener must read the result rather than assume success.

EventEmitted byFires when
CheckoutSessionCreatedcreateCheckoutSessionA checkout session is created for the customer to complete payment
PaymentChargedchargeA charge completes — read the result's success/status for the outcome
WalletChargedchargeWalletA digital-wallet charge (Apple Pay / Google Pay) completes
StoredCredentialChargedchargeStoredCredentialA charge against a stored (vaulted) credential completes
PaymentCapturedcaptureA capture of a previously authorized payment completes
PaymentRefundedrefundA refund of a settled payment completes
PaymentVoidedvoidAn authorized-but-uncaptured payment is voided
AuthorizationReversedreverseAuthorizationAn existing authorization is reversed and its held funds released
InstrumentVaultedvaultInstrumentA payment instrument is tokenized for later reuse
WebhookReceivedverifyWebhookAn inbound webhook is verified and parsed
PayerAuthenticationEciRejectedenrollPayerAuth / validatePayerAuthA 3-D Secure result is rejected because its ECI is not fully authenticated

PayerAuthenticationEciRejected is the odd one out: it fires when a completed 3-D Secure result is rejected for a non-authenticated ECI. It is the only event that reports something the return value cannot — the charge never happens — so it is worth alerting on rather than merely recording.

Payloads are queue-safe by construction

Events carry ids, amount, and the normalised result — never the raw request, the card, or the gateway payload. That is deliberate: an event that can be serialised onto a queue and retried must not become a place card data comes to rest. If a listener needs the raw response it should read it from the result it was handed, in-process.

Two built-in listeners, and the off switch

LoggingPaymentEventListener writes one redaction-safe audit line per event, and RecordingPaymentEventListener persists events through the activity store that backs the dashboard. Dispatch itself is a decorator, so it can be removed entirely:

// config/gateway.php
'events' => [
    'enabled' => env('GATEWAY_EVENTS', true),   // false returns bare drivers, no dispatch
    'log'     => env('GATEWAY_EVENTS_LOG', false), // attach the audit listener
],

With no listeners registered the overhead is negligible, so leaving it enabled costs nothing until you actually subscribe.

Cross-cutting

Operation logging

LoggingGateway(driver, logger): PaymentGatewayInterface

Wrap every driver in a LoggingGateway that logs each operation — charge, capture, refund, getTransaction, verifyWebhook, … — with its duration and a safe correlation context (gateway, order/transaction ids, amount) through your PSR-3 logger. The context carries no PAN, cvv or tokens, and the underlying LogsAction trait masks sensitive keys as a backstop. Distinct from http.logging, which logs the lower-level HTTP request/response metadata.

With gateway.logging.operations enabled the factory wraps every driver for you. To compose it yourself — outside Laravel, or around a driver you built — wrap it with any PSR-3 logger:

use Hyprpay\Payments\Application\PaymentGatewayFactory;
use Hyprpay\Payments\Infrastructure\Gateway\LoggingGateway;
use Illuminate\Support\Facades\Log;

final class PaymentGatewayProvider
{
    public function __construct(private PaymentGatewayFactory $factory) {}

    /**
     * Resolve a PayPal gateway that logs every operation with its duration.
     *
     * Wraps the driver in a LoggingGateway, so each call is recorded as
     * [LoggingGateway] {operation} through the "payments" channel with a
     * masked, PAN-free context. Enabling gateway.logging.operations makes the
     * factory do this automatically, so this wrapper becomes unnecessary.
     */
    public function payments(): PaymentGatewayInterface
    {
        return new LoggingGateway(
            $this->factory->make(GatewayName::PayPal),
            Log::channel('payments'),
        );
    }
}

Each call then lands in your PSR-3 log at info — the message plus a structured context:

[paypal] charge
{
    "gateway": "paypal",
    "order_reference": "ORDER-123",
    "amount": "100.00",
    "currency": "USD",
    "duration_ms": 84.2
}

The log is identified by gateway + operation; the message carries the operation, the context the gateway. Request-scoped fields — request_id, ip, url — aren't added by the SDK (it stays framework-agnostic and runs in CLI/queue where there is no request). Add them once to your app's log context so they land on every line, these included; the timestamp is already stamped by the logger. For the initiator's own name, use the LogsAction trait in your action class — there the action field is your class.

// e.g. in middleware — attaches request_id/ip/url to every subsequent log line
Log::shareContext([
    'request_id' => (string) Str::uuid(),
    'ip' => $request->ip(),
    'url' => $request->fullUrl(),
]);

// or tag one wrapper with static extra fields via the constructor hook:
new LoggingGateway($driver, $logger, ['component' => 'checkout']);

The SDK logs to its own daily file — storage/logs/hyprpay-2026-08-08.log by default, kept out of your app log. With shared context in place the line reads — timestamp from the logger, request_id/ip/url from your shared context, the rest from the SDK:

[2026-08-08 10:15:42] production.INFO: [paypal] charge
{
    "request_id": "9b1e5b1e-3c2a-4f77-9c1e-2b0f5a7d1e42",
    "ip": "203.0.113.7",
    "url": "https://shop.test/checkout",
    "gateway": "paypal",
    "order_reference": "ORDER-123",
    "amount": "100.00",
    "currency": "USD",
    "duration_ms": 84.2
}

What is logged, and what never is

Each operation is logged as [LoggingGateway] {op} with the gateway, correlation ids, amount, and an elapsed duration_ms. The context is chosen to be safe rather than filtered afterwards — no PAN, no CVV, no tokens, no raw payloads — and LogsAction masks sensitive keys as a backstop rather than as the primary defence.

It wraps the interface, not the driver

This matters for CyberSource specifically. LoggingGateway implements PaymentGatewayInterface, so it only sees the shared operations. Everything CyberSource exposes outside that contract — lookupBin, validateBankAccount, the vault lifecycle, subscriptions, plans, reporting, the webhook management calls — passes straight through the concrete driver and is not logged at all.

That is a security property worth relying on: a raw PAN passed to lookupBin, or routing and account numbers passed to validateBankAccount, are never seen by the logging decorator. It is also an observability gap worth knowing about — if you need those calls timed, wrap them yourself.

Layering with HTTP logging

GATEWAY_HTTP_LOGGING is a different layer: it logs the outbound HTTP round trip rather than the SDK operation. Enable both and you get the business action and the wire call as separate lines; enable only the first for an audit trail without request-level noise.

Cross-cutting

Credential resolver

CredentialResolver::resolve(GatewayName): GatewayCredentials

The factory never reads keys itself — it asks a CredentialResolver port. The default ConfigCredentialResolver loads the gateway.gateways.{key} block from Laravel config and hydrates a GatewayCredentials DTO, throwing MissingCredentialsException when the block is missing, blank or incomplete. Rebind the port to source credentials from anywhere — a per-tenant table, a secrets manager, an encrypted vault — and every driver the factory builds picks them up, no driver code touched.

use Hyprpay\Payments\Domain\Contract\CredentialResolver;
use Hyprpay\Payments\Domain\Enum\GatewayName;
use Hyprpay\Payments\Domain\Exception\MissingCredentialsException;
use Hyprpay\Payments\Domain\ValueObject\GatewayCredentials;

// resolve the active tenant's keys instead of static config
final readonly class TenantCredentialResolver implements CredentialResolver
{
    public function __construct(private TenantContext $tenant) {}

    public function resolve(GatewayName $gateway): GatewayCredentials
    {
        $secrets = $this->tenant->current()
            ->gatewaySecrets($gateway->value)
            ?? throw MissingCredentialsException::forGateway($gateway);

        return GatewayCredentials::fromConfig($secrets);
    }
}

// swap the port once, in a service provider — the factory now uses it everywhere
$this->app->bind(CredentialResolver::class, TenantCredentialResolver::class);

Multi-tenancy is the point

Because the factory asks the port on every make(), a resolver that reads the current tenant returns that tenant's keys — no driver, payload, or call site changes. The same hook covers a secrets manager, an encrypted column, or per-environment keys.

The extra bag carries what the DTO does not

GatewayCredentials::$extra holds per-gateway settings that are not universal credentials, read with extra('some.key') by dot path. For CyberSource that includes capture_context_endpoint (switching the Unified Checkout session URL) and organization_id — which reporting, Account Updater, and the webhook calls scope themselves to, falling back to the merchant id. Set it for a portfolio or partner account whose reports live under a different organization.

Failing loudly beats failing at the gateway

ConfigCredentialResolver throws MissingCredentialsException when a block is missing, blank, or incomplete, so a misconfigured environment fails at resolution with a clear message rather than surfacing later as an opaque 401 from the gateway. GatewayCredentials::isComplete() lets you assert the same thing at boot.

Cross-cutting

HTTP client

HttpClient::send(HttpRequest): HttpResponse

Every driver reaches its gateway API through the HttpClient port — one send(HttpRequest): HttpResponse method, no Laravel HTTP types leaking into the domain. The container assembles the default stack for you from the http.* config: a LaravelHttpClient transport, optionally wrapped by RateLimitingHttpClient and LoggingHttpClient, with RetryingHttpClient on the outside. Each decorator is just another HttpClient, so you can add your own — tracing, a circuit breaker, a signing proxy — by extending the binding, keeping the retry/rate-limit/logging stack the SDK already built.

use Hyprpay\Payments\Domain\Contract\HttpClient;
use Hyprpay\Payments\Domain\Http\HttpRequest;
use Hyprpay\Payments\Domain\Http\HttpResponse;

// a decorator that records every outbound call on your APM span
final readonly class TracingHttpClient implements HttpClient
{
    public function __construct(private HttpClient $inner) {}

    public function send(HttpRequest $request): HttpResponse
    {
        return Tracer::span("http {$request->method} {$request->url}", fn () => $this->inner->send($request));
    }
}

// wrap whatever the SDK already built — its stack stays underneath yours
$this->app->extend(HttpClient::class, fn (HttpClient $inner) => new TracingHttpClient($inner));

In tests, bind the same port to FakeHttpClient: it records every request and replays queued responses in order (falling back to a 200 {}), so you assert on what the driver sent without touching the network.

use Hyprpay\Payments\Domain\Contract\HttpClient;
use Hyprpay\Payments\Infrastructure\Http\FakeHttpClient;

$http = (new FakeHttpClient())
    ->queueJson(['status' => 'captured']);

$this->app->instance(HttpClient::class, $http);

// ... exercise a gateway action, then assert on the captured request
$this->assertSame(1, $http->requestCount());
$this->assertSame('POST', $http->lastRequest()->method);

A decorator stack, assembled from config

The port is one method, so behaviour is layered by wrapping rather than by flags inside a client. The service provider composes, outermost first: rate limiting, then retries, then logging, around the Laravel HTTP client.

RateLimitingHttpClient  token bucket — smooths bursts against a gateway's own limit
  RetryingHttpClient    exponential backoff on transient failures
    LoggingHttpClient   one line per outbound request
      LaravelHttpClient the real call

The defaults, and why they are what they are

RetryingHttpClient retries 408, 429, 500, 502, 503, 504 — statuses that indicate the request did not get a considered answer — with a 200 ms base delay and exponential growth. A 4xx that is not 408 or 429 is never retried, because the gateway has answered and repeating the call will not change the reply. RateLimitingHttpClient is a token bucket, defaulting to 10 requests per second, which shapes bursts rather than capping throughput.

// config/gateway.php → http
'timeout'                 => env('GATEWAY_HTTP_TIMEOUT', 30),
'retries'                 => env('GATEWAY_HTTP_RETRIES', 2),
'retry_base_delay_ms'     => env('GATEWAY_HTTP_RETRY_BASE_MS', 200),
'logging'                 => env('GATEWAY_HTTP_LOGGING', false),
'rate_limit'              => env('GATEWAY_HTTP_RATE_LIMIT', false),
'rate_limit_max_requests' => env('GATEWAY_HTTP_RATE_LIMIT_MAX', 10),
'rate_limit_per_seconds'  => env('GATEWAY_HTTP_RATE_LIMIT_PER', 1),

Retries and money

A retry is a second attempt at a request that may already have been received. That is safe here because every write carries an idempotency key and the payload builders are deterministic — no uniqid(), no timestamps — so the same inputs produce a byte-identical request the gateway can deduplicate.

But CyberSource's deduplication window is bounded. A retry seconds later is caught by the key; one hours later is not. For anything spaced beyond the immediate, findSuccessfulTransactionByReference() before re-charging, and set a merchantTransactionId so a timed-out attempt can be reversed outright.

Testing without the network

FakeHttpClient implements the same port: queue responses, then assert on the requests that were sent — method, URL, headers, signed body. Every gateway test in this package is written against it, which is why the suite runs in under a second and needs no sandbox credentials.

Cross-cutting

Configuration

config('gateway.*'): mixed

Publish it with php artisan vendor:publish --tag=gateway-config. Every key, its env var, and default, broken down below.

/**
 * config/gateway.php — every setting at a glance.
 *
 * default                       string  Gateway used when make() is called without one.  (GATEWAY_DEFAULT)
 *
 * http.timeout                  int     Per-request timeout, seconds.                     (GATEWAY_HTTP_TIMEOUT = 30)
 * http.retries                  int     Retries for transient failures (408/429/5xx).     (GATEWAY_HTTP_RETRIES = 2)
 * http.retry_base_delay_ms      int     Base backoff in ms, doubled per retry.            (GATEWAY_HTTP_RETRY_BASE_MS = 200)
 * http.logging                  bool    Log HTTP request/response metadata — no bodies.   (GATEWAY_HTTP_LOGGING = false)
 * http.rate_limit               bool    Token-bucket throttle for outbound requests.      (GATEWAY_HTTP_RATE_LIMIT = false)
 * http.rate_limit_max_requests  int     Bucket size / requests per window.                (GATEWAY_HTTP_RATE_LIMIT_MAX = 10)
 * http.rate_limit_per_seconds   int     Refill window length, seconds.                    (GATEWAY_HTTP_RATE_LIMIT_PER = 1)
 *
 * commands.reconcile            bool    Register the gateway:reconcile:{X} commands.       (GATEWAY_RECONCILE_COMMANDS = true)
 *
 * events.enabled                bool    Wrap drivers to emit payment domain events.        (GATEWAY_EVENTS = true)
 * events.log                    bool    Attach the redaction-safe audit-logging listener.  (GATEWAY_EVENTS_LOG = false)
 *
 * logging.operations            bool    Wrap drivers in a LoggingGateway (per-call logs).  (GATEWAY_LOG_OPERATIONS = false)
 * logging.channel               string  Log channel; null → dedicated daily hyprpay log.   (GATEWAY_LOG_CHANNEL)
 * logging.days                  int     Retention for the daily hyprpay log.               (GATEWAY_LOG_DAYS = 14)
 * logging.level                 string  Minimum level for the hyprpay channel.             (GATEWAY_LOG_LEVEL = debug)
 *
 * gateways.{key}                array   Per-gateway credentials — merchant id, secret, host, locale, currency.
 */

The blocks, and what each one governs

BlockWhat it governs
defaultGateway used when make() is called with no name
gatewaysPer-gateway credentials, plus the extra bag
httpTimeout, retries, rate limiting, wire logging
eventsDomain-event dispatch, and the built-in audit listener
loggingOperation logging channel and level
commandsThe gateway:reconcile:{gateway} Artisan commands
dashboardThe activity store and its UI

Everything is env-driven, so nothing is committed

Each key reads through env(), so credentials live in the environment and the published config file stays safe to commit. That also means php artisan config:cache is safe — but a cached config ignores later .env edits, which is the usual cause of a key change that appears to do nothing.

Reconciliation commands

commands.reconcile registers one Artisan command per gateway (gateway:reconcile:cybersource and friends), which fetch the authoritative status of one or more transaction ids straight from the gateway. Turn it off if your application ships its own reconciliation tooling and you would rather not expose a second path to the same data.

Cross-cutting

AI docs & MCP

docs/guides/ai/ — machine-consumable SDK reference, plus a developer MCP in mcp/

A 100%-coverage, machine-consumable reference for AI assistants lives under docs/guides/ai/: every class in the package, plus the full operation contract, request/result DTOs, value objects, enums, exceptions, events, ports, all ten gateways, and the config surface. Load it as context when an AI is helping you use the SDK — class-index.md lists every class so nothing is left undocumented.

The package also ships a developer MCP server in mcp/ — read-only Model Context Protocol tools that reflect the live classes so a coding agent can explore the SDK and generate correct integrations: get_sdk_overview, list_gateways, get_operation_details, get_class_details, get_code_template, and search. A project-scoped .mcp.json registers it as php mcp/server.php, so it is always exact — the support matrix and code templates come straight from the source.

Start at the AI docs index, the complete class index, or the developer MCP guide.

What the MCP server exposes

ToolWhat it returns
get_sdk_overviewThe package, its gateways, and this tool list
list_gatewaysEvery driver and the exact operations it implements
get_operation_detailsAn operation's request DTO, result, and supporting gateways
get_class_detailsFull reflection of any class, interface, enum, or trait
get_code_templateA ready-to-adapt snippet for a gateway + operation
searchFind types by name or purpose across the package
get_gateway_gotchasReal-world pitfalls reflection cannot show

The first six reflect the live package, so they cannot drift from the code. The seventh is curated: header quirks, account entitlements, ordering constraints, and the failure modes that only show up against the real gateway — the things a DTO signature will never tell an agent. CyberSource alone carries more than thirty.

Why the gotchas exist

Reflection shows an agent that timeoutVoid() takes a TimeoutVoidRequest. It cannot show that the call is impossible unless a merchantTransactionId was set on the original request hours earlier, that a report download is keyed by the end of the period rather than the report id, or that creating a webhook before its signing key leaves it unable to sign anything. Those are the mistakes that cost a day, and they are what get_gateway_gotchas is for.

What's new