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
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.
| Event | Emitted by | Fires when |
|---|---|---|
CheckoutSessionCreated | createCheckoutSession | A checkout session is created for the customer to complete payment |
PaymentCharged | charge | A charge completes — read the result's success/status for the outcome |
WalletCharged | chargeWallet | A digital-wallet charge (Apple Pay / Google Pay) completes |
StoredCredentialCharged | chargeStoredCredential | A charge against a stored (vaulted) credential completes |
PaymentCaptured | capture | A capture of a previously authorized payment completes |
PaymentRefunded | refund | A refund of a settled payment completes |
PaymentVoided | void | An authorized-but-uncaptured payment is voided |
AuthorizationReversed | reverseAuthorization | An existing authorization is reversed and its held funds released |
InstrumentVaulted | vaultInstrument | A payment instrument is tokenized for later reuse |
WebhookReceived | verifyWebhook | An inbound webhook is verified and parsed |
PayerAuthenticationEciRejected | enrollPayerAuth / validatePayerAuth | A 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
| Block | What it governs |
|---|---|
default | Gateway used when make() is called with no name |
gateways | Per-gateway credentials, plus the extra bag |
http | Timeout, retries, rate limiting, wire logging |
events | Domain-event dispatch, and the built-in audit listener |
logging | Operation logging channel and level |
commands | The gateway:reconcile:{gateway} Artisan commands |
dashboard | The 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
| Tool | What it returns |
|---|---|
get_sdk_overview | The package, its gateways, and this tool list |
list_gateways | Every driver and the exact operations it implements |
get_operation_details | An operation's request DTO, result, and supporting gateways |
get_class_details | Full reflection of any class, interface, enum, or trait |
get_code_template | A ready-to-adapt snippet for a gateway + operation |
search | Find types by name or purpose across the package |
get_gateway_gotchas | Real-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.