Skip to content

Developer notes

Item Value
Add-on id kv_braintree (scheme 3.0, priority 1000), CS-Cart / CS-Cart Ultimate / Multi-Vendor / Multi-Vendor Ultimate 4.17.2 to 4.21.x, PHP 7.4 to 8.3
Processor “Braintree (cards, PayPal)”, script app/addons/kv_braintree/payments/kv_braintree.php (start -> pay page; payment_notification.return / .cancel)
Storefront kv_braintree.pay (GET, pay key = HMAC of order id + placement time + e-mail with a key derived from the store crypt key; Cache-Control: no-store), kv_braintree.confirm (POST JSON, CSRF hash), kv_braintree.webhook (POST, c=<account>), kv_braintree.cron (secret key)
Credentials ?:kv_braintree_accounts: per company (0 = owner, vendor id in vendor mode) and mode: merchant id, public key, private key sealed with AES-256-GCM (Secret), merchant accounts (JSON)
Tables kv_braintree_transactions (attempts), kv_braintree_ops (captures, voids, refunds), kv_braintree_hooks (webhook digests), kv_braintree_log, kv_braintree_jobs, kv_braintree_accounts; transactions and ops are kept on uninstall (retention.json, kv retention)
Hooks change_order_status_post (capture / void / refund at status), prepare_checkout_payment_methods (hide when unusable), init_templater_post (Upgrade Synchronizer while pending)
Server API Braintree GraphQL API over cURL, no SDK bundled (Tygh\Addons\KvBraintree\Client\Api)
Client SDK braintree-web 3.147.0 from js.braintreegateway.com/web/3.147.0/js/: client, hosted-fields, three-d-secure, data-collector, paypal-checkout
Test hook $config['kv_braintree_base_url'] in config.local.php (loopback hosts only) points the client and the SDK at the mock

Drop-in overview (developer.paypal.com/braintree/docs/guides/drop-in/overview): “Starting October 1, 2026, the Drop-in SDK will be deprecated. No new features, improvements, or bug fixes will be released after this date. Payment processing will continue to be supported until October 1, 2027 … Action required: Migrate to the Braintree Javascript SDK”. Client SDK setup: the JavaScript SDK “can be initialized with one of two integrations: Drop-in, Hosted fields”. Hosted Fields overview: SAQ A eligible, “Create your own payment form using your existing styles and layout”, “Use alongside the PayPal Checkout component to accept PayPal”. So 1.0 uses Hosted Fields + the 3D Secure, PayPal Checkout and Data Collector components of the same SDK.

Why GraphQL without the PHP SDK (decision)

Section titled “Why GraphQL without the PHP SDK (decision)”

The task asked for the documented GraphQL or REST API on the server. GraphQL covers every operation we need (client token, charge, authorize, capture, void, refund, read-back, search). The official PHP SDK (braintree/braintree_php, MIT licence, composer.json “php”: “>=7.3.0”) would fit PHP 7.4 to 8.3, but is not bundled: no Composer dependency in the package, and a smaller code surface. Only its webhook signature algorithm is reproduced (see below), with the source cited.

Every call, header and field, with its source

Section titled “Every call, header and field, with its source”

Sources, all read 2026-11-07 (copies in the build workspace scratchpad/oc-braintree/apidocs/, shared with the OpenCart build):

  • GraphQL guides: https://developer.paypal.com/braintree/graphql/guides/ (making_api_calls, collecting_payment_information, transactions, payment_methods, search, node_query, testing, migrating) (“GQL ”)
  • GraphQL schema: https://github.com/braintree/graphql-api/blob/master/schema.graphql (“schema”)
  • Developer docs: https://developer.paypal.com/braintree/docs/ (guides/drop-in/overview, guides/hosted-fields/overview, guides/hosted-fields/setup-and-integration/javascript/v3, guides/client-sdk/setup/javascript/v3, guides/3d-secure/overview, guides/3d-secure/step-by-step-integration/javascript/v3, guides/3d-secure/applying-3ds-to-transactions-and-verifications/php, guides/paypal/checkout-with-paypal/javascript/v3, guides/premium-fraud-management-tools/client-side/javascript/v3, guides/webhooks/overview|create|parse/php, reference/general/webhooks/transaction|dispute, reference/general/currencies, reference/general/statuses) (“Docs ”)
  • JavaScript reference: https://braintree.github.io/braintree-web/3.147.0/ (PayPalCheckout.html, ThreeDSecure.html, HostedFields.html, module-braintree-web_data-collector.html) (“JS ref”)
  • braintree_php (MIT): lib/Braintree/WebhookNotificationGateway.php, Digest.php, WebhookTestingGateway.php (“SDK”)
What Source Used for / fields
Endpoints GQL making_api_calls, Endpoints: https://payments.sandbox.braintree-api.com/graphql, https://payments.braintree-api.com/graphql; “You SHOULD use the POST method” Config::TEST, Config::LIVE; every request is a POST (Client\Api::call())
Authorization GQL making_api_calls, API Keys: “you MUST Base64-encode it”, Authorization: Basic base64(public_key:private_key); “vaulting and charging payment methods, require API Key authentication” Client\Api::call()
Braintree-Version GQL making_api_calls: “You MUST provide a Braintree-Version header with a date in the format YYYY-MM-DD. We recommend using the date on which you begin integrating” Config::API_VERSION = '2026-11-07'
Content-Type, body GQL making_api_calls: “MUST be application/json”, body {query, variables} Client\Api::call()
Responses and errors GQL making_api_calls, Understanding Responses: always HTTP 200; errors[].extensions.errorClass one of AUTHENTICATION, AUTHORIZATION, INTERNAL, UNSUPPORTED_CLIENT, NOT_FOUND, NOT_IMPLEMENTED, RESOURCE_LIMIT, SERVICE_AVAILABILITY, VALIDATION; legacyCode; partial success possible; extensions.requestId INTERNAL / SERVICE_AVAILABILITY / RESOURCE_LIMIT and transport errors are retryable (unclear); VALIDATION, NOT_FOUND are final; AUTHENTICATION / AUTHORIZATION = key message. A field with data counts even if errors is present for another field
Timeout GQL making_api_calls, Timeouts: “We set a recommend setting a timeout of 60 seconds … If … less than 60 seconds … the customer will be charged, but you will not receive any notification … You will need to search for the transaction” cURL timeout 60 s; on any unclear charge the transaction is searched by orderId
Output encoding GQL making_api_calls, Important Security Requirement: “The Braintree API returns data exactly as it is provided … You are responsible for properly encoding” Braintree values are shown through Smarty templates; notifications are escaped with htmlspecialchars
query { ping } GQL making_api_calls, Your First Request: answer {"data":{"ping":"pong"}} Test connection
Merchant accounts schema Viewer.merchant: Merchant, Merchant.merchantAccounts(input, first, after): MerchantAccountConnection, MerchantAccount { id currencyCode isDefault status } Test connection stores the merchant accounts of the mode (Accounts::setMerchantAccounts()); Accounts::merchantAccountFor()
createClientToken GQL collecting_payment_information, Requesting a Client Token; schema CreateClientTokenInput.clientToken: ClientTokenInput { merchantAccountId }; Docs 3d-secure step-by-step: “The merchant account ID used to create the client token must match the merchant account ID used to create the subsequent transaction or verification” The pay page (kv_braintree.pay, PaymentService::prepare()) creates one per page view with the merchant account of the charge
Nonce = payment method id GQL collecting_payment_information: the nonce “is the ID of a single-use PaymentMethod … Send the string to your payment integration with our GraphQL API in order to charge” paymentMethodId of charge / authorize
Nonce read before the charge schema node(id), PaymentMethod.details: PaymentMethodDetails, CreditCardDetails { brandCode last4 threeDSecure { authentication { liabilityShifted liabilityShiftPossible } } } Client\Api::paymentMethod(); “Require liability shift” refuses liabilityShiftPossible = true without liabilityShifted = true, or no 3DS data
3DS required Docs applying-3ds (php): “When you create a transaction with a 3D Secure enriched single-use-token, the options.threeDSecure.required parameter defaults to true … We strongly recommend you allow this parameter to default to true” The extension never sends required: false; Braintree’s own default stays on, plus the server check above
chargePaymentMethod GQL transactions, Charging a Payment Method; schema ChargePaymentMethodInput { apiRequestKey paymentMethodId transaction: TransactionInput }, “A successful charge results in a transaction with the SUBMITTED_FOR_SETTLEMENT or SETTLING status”; schema: “Charge any payment method” Payment action “Charge at once” (the GraphQL equivalent of submitForSettlement: true); used for cards and PayPal nonces
authorizePaymentMethod GQL transactions, Using Separate Authorization and Capture: result AUTHORIZED Payment action “Authorize only”
TransactionInput schema: amount: Amount!, merchantAccountId, orderId, riskData: RiskDataInput { deviceData customerIp }, customerDetails: TransactionCustomerDetailsInput { email firstName lastName } Amount from the order (server side), the account of the order currency, our orderId, Data Collector device data, customer IP and contact
apiRequestKey schema (on Charge/Authorize/Capture/Void/Refund inputs): “If subsequent requests are made with the same apiRequestKey, and the first request resulted in a transaction being created (in any status), then the same transaction in its current status will be returned … must have the same input values … before 30 days” 32-hex key stored BEFORE the call (kv_braintree_transactions.request_key, kv_braintree_ops.request_key); a retry re-sends the identical input with the same key; resends stop after 24 h (RESEND_WINDOW)
Lookup after a timeout schema Query.transactions(input: TransactionSearchInput!, first, after), TransactionSearchInput.orderId: SearchTextInput { is } (search is deprecated: “Use the individual top-level search queries”) Client\Api::findByOrderRef(); our orderId is unique per attempt (Money::reference())
Read-back GQL node_query / transactions, Node Query; schema Transaction { id legacyId status orderId merchantAccountId amount { value currencyCode } paymentMethodSnapshot refunds statusHistory processorAuthorizationResponse } Client\Api::transaction()
Statuses schema enum PaymentStatus (AUTHORIZATION_EXPIRED, AUTHORIZED, AUTHORIZING, FAILED, GATEWAY_REJECTED, PROCESSOR_DECLINED, SETTLED, SETTLEMENT_CONFIRMED, SETTLEMENT_DECLINED, SETTLEMENT_PENDING, SETTLING, SUBMITTED_FOR_SETTLEMENT, VOIDED); Docs statuses Transition::derive(): AUTHORIZING and any unknown value stay pending (never cancelled); declines only from PROCESSOR_DECLINED / GATEWAY_REJECTED / FAILED
Decline reason schema TransactionAuthorizationProcessorResponse { legacyCode message }, GatewayRejectedEvent.gatewayRejectionReason Stored on the payment row (order tab, log); the customer sees a neutral text
captureTransaction GQL transactions; schema CaptureTransactionInput { apiRequestKey transactionId transaction: CaptureTransactionOptionsInput { amount } }; “You have a limited number of days to capture a transaction once it has been authorized” Capture once, full or a lower amount; cron warns after 5 days
Partial captures GQL transactions, Using Multiple Partial Capture: “This mutation is available for PayPal transactions” Not used in 1.0 (one capture per authorization)
voidTransaction GQL transactions, Voiding a Transaction: “If a payment has been authorized or submitted for settlement but has not yet completed settlement, you can void it”; schema VoidTransactionPayload { clientMutationId transaction } Void of AUTHORIZED / SUBMITTED_FOR_SETTLEMENT. The guide’s sample selects an errors field that the schema does not have; we follow the schema
refundTransaction GQL transactions, Refunding a Transaction: refundable “in either the SETTLING or SETTLED status”, “has not already been fully refunded”; “pass in an amount less than the original amount in order to partially refund”; “optionally specify an orderId to give the refund its own unique order ID”; schema RefundInput { amount orderId }, RefundTransactionPayload { refund } Full and partial refunds of SETTLING / SETTLED (and SETTLEMENT_CONFIRMED) payments; each refund gets the orderId <txn orderId>-r<op id> so a lost answer is matched in Transaction.refunds
Refunded amount schema Transaction.refunds: [Refund!], Refund { status amount orderId } Sum of refunds not in FAILED / GATEWAY_REJECTED / PROCESSOR_DECLINED / VOIDED / SETTLEMENT_DECLINED
Amounts and currencies schema scalar Amount (decimal string), MonetaryAmount { value currencyCode }; Docs currencies: supported list, zero-decimal currencies marked with an asterisk Money: ISO 4217 List One exponents (tooling/release-templates/cscart/iso4217), Braintree list in Money::CURRENCIES; LAK left out (Braintree zero-decimal vs ISO 2); parse of returned amounts is exact (no float)
Client SDK loading Docs client-sdk setup: https://js.braintreegateway.com/web/3.147.0/js/client.min.js and components Config::jsBase()
Hosted Fields Docs hosted-fields setup: braintree.hostedFields.create({client, styles, fields: {number, cvv, expirationDate}}), tokenize() -> payload.nonce, payload.details.bin js/addons/kv_braintree/pay.js + views/kv_braintree/pay.tpl
3D Secure 2 Docs 3d-secure step-by-step (Hosted Fields): load three-d-secure; threeDSecure.create({version: 2, client}) (“Version 1 is deprecated”); verifyCard({amount, nonce, bin, email, billingAddress, collectDeviceData: true, onLookupComplete(data, next)}) (“It is required to implement the callback onLookupComplete”); names “ASCII-printable characters required” Amount = the charge amount in the merchant account currency; billing fields sent only when ASCII
Device data Docs premium fraud management tools (client side): braintree.dataCollector.create({client}) -> deviceData, passed with the transaction TransactionInput.riskData.deviceData
PayPal Docs paypal checkout-with-paypal (JS v3): paypalCheckout.create({client}), loadPayPalSDK({currency, intent}), paypal.Buttons({fundingSource: paypal.FUNDING.PAYPAL, createOrder: createPayment({flow: 'checkout', amount, currency, intent}), onApprove: tokenizePayment(data)}); “If intent is used, the value must match in both loadPayPalSDK and createPayment”; JS ref PayPalCheckout intents authorize, order, capture intent capture for sale, authorize for authorize only; the nonce is charged with the same mutations as cards
Privacy notice Docs hosted-fields overview, Notice of Collection: the merchant “must include the below languages in your own privacy notice, which must be presented to the buyers at or before the point of collection” The text is shown under the payment form (kv_braintree.privacy_notice)
Webhook creation Docs webhooks create: Control Panel > gear > API > Webhooks > Create New Webhook; “a valid HTTPS path … publicly accessible” Destination URL shown on the Connection page (index.php?dispatch=kv_braintree.webhook&c=<account>); Config::webhookUrlOk()
Webhook POST Docs webhooks parse: “The post body contains two x-www-form-urlencoded parameters: bt_signature, bt_payload”; kinds; retries “every hour for up to 3 hours in sandbox, or up to 24 hours in production, until the webhook responds with a successful HTTPS response code (i.e. ‘2xx’) within 30 seconds” kv_braintree.webhook reads the raw $_POST fields; 200 when handled or not ours, 403 bad signature, 400 unreadable, 503 when the read-back failed
Webhook signature SDK WebhookNotificationGateway::parse(): payload must not match /[^A-Za-z0-9+=\/\n]/; signature pairs split on &, then ` , pair of our public key; valid if Digest::hexDigestSha1(privateKey, payload)or ofpayload . “\n”matches;Digest::_builtInHmacSha1 = hash_hmac(‘sha1’, message, sha1(key, true))`; secure compare
Webhook payload SDK: base64_decode(payload) -> XML <notification><timestamp/><kind/><subject>; WebhookTestingGateway sample XML for transaction_settled, transaction_settlement_declined, dispute_*, check Webhook::parse() with LIBXML_NONET, no DOCTYPE / ENTITY
Kinds Docs reference webhooks transaction: transaction_settled, transaction_settlement_declined (“Transaction webhooks are available for ACH and SEPA Direct Debit”); dispute: dispute_accepted, dispute_auto_accepted, dispute_disputed, dispute_expired, dispute_lost, dispute_opened, dispute_under_review, dispute_won; check (Control Panel “Check URL”) Disputes: recorded on the payment + log + optional order status; transaction kinds: read-back; card settlement is read back by the cron because card transactions get no settlement webhook
Test data GQL testing; Docs drop-in overview (“test card numbers (e.g. 4111111111111111)”) docs/installation.md
  • No sandbox account yet. Everything was proven against tests/mock-server/server.js, which follows the sources above. Run one real sandbox order before release to customers (owner item: free sandbox sign-up).
  • Captured amount after a lower capture. The docs say a capture may name an amount; they do not say in words that Transaction.amount then shows the captured amount. The extension reads amount of a settling transaction as the captured amount (mock does the same). Confirm in the sandbox.
  • voidTransaction errors field. The guide sample selects errors { field message code }; the schema has no such field, so it is not requested.
  • Webhook replay window. Braintree documents no maximum age; a redelivery of the same payload is acknowledged once (digest table), and the state always comes from a read-back.
  • LAK is excluded (decimals conflict). Withdrawn ISO codes on Braintree’s list (HRK, LTL, SLL, STD, ZMK, ZWD) have no ISO exponent and are refused.
  • Flow: fn_start_payment -> processor script -> PaymentService::initialize() checks that the order can be charged (active, account, merchant account, amount; Multi-Vendor vendor mode refuses parent orders) and redirects to the pay page. The pay page posts the nonce to kv_braintree.confirm -> PaymentService::pay() -> JSON with the return URL of the attempt; the return handler books it (fn_order_placement_routines) or, while the attempt is pending, empties the cart and the saved cart rows (?:user_session_products) and shows the order.
  • PaymentService::pay() (under the order lock): earlier attempts first (money held = paid, unclear = read back, still unclear = busy); amount from the order (context(): merchant account of the order’s secondary currency, else the default account converted with the store rate), refused with “changed” when it differs from the amount the page showed (3D Secure ran for that amount); the nonce is read back (node) and its type (CreditCardDetails / PayPalAccountDetails) decides card or PayPal, never the posted value; the card 3DS rule; the row with request_key and provider_ref (orderId) BEFORE the call. A second try with the same key only after a retryable GraphQL answer (HTTP 200: INTERNAL, SERVICE_AVAILABILITY, RESOURCE_LIMIT); after a transport error or HTTP 5xx the attempt stays pending (unclear = 1) and is looked up by orderId (sync(), cron) for 24 hours (UNCLEAR_WATCH), even after the 15 minute grace closed it as failed.
  • applyRemote() under the order lock and the payment lock (Idempotency::lock, counted per request): only open (or failed-but-unclear) attempts become paid; amount, merchant account and orderId must match what was sent and the order total must equal order_total stored at the start (no new currency conversion); otherwise flag = mismatch and the payment is voided or refunded after the locks are released (reverse(); a charge that is neither voidable nor refundable yet is queued). applyPaid() flags a second payment of a paid order (duplicate) or a payment of a cancelled / declined order (late). Final states never move back.
  • Operations (kv_braintree_ops): creating -> done / failed / unknown. Admin operations read the transaction back first. An error answer to a RESEND keeps the operation unknown; only a read-back (didHappen(): capture = settling status, void = VOIDED, refund = a live refund with our refund orderId) or the 24 hour window decides. A resend waits 60 seconds after the last try (the request timeout).
  • Multi-Vendor (owner mode): the parent order carries the payment (Transactions::payingOrderId()), every lookup resolves a child to its parent; Transactions::shareLeft() limits a child’s refund to its share (child total / parent total of the charged amount); a capture is for the whole payment (Braintree captures once) and leaves out the shares of vendor orders that are stopped at that moment (sharedCaptureAmount()); childrenReadyToCapture() waits until every vendor order reached the capture status (or a later paid status) or is stopped. Vendors in owner mode never move money (Access::mayMoveMoney()); global queue actions are admin only (vendor_multivendor.post.php).
  • Webhooks: the signature is tried with the keys of both modes of the account in the URL (c=); the payload digest is claimed first (INSERT IGNORE + ROW_COUNT()), so parallel or repeated deliveries apply once; a failed read-back releases the claim (503) unless a dispute was already recorded. The webhook path waits at most 5 s for a lock and 20 s for Braintree.
  • Log: keys, tokens, nonces, device data, e-mail, names and customer IP are masked (Log::mask()), structurally and inside JSON text.
  • Tests: php81 tests/unit.php and php83 tests/unit.php (no store); node tests/mock-server/server.js <port>; node tests/e2e/e2e.mjs (single store) and node tests/e2e/vendor.mjs (Multi-Vendor, vendor.php logins proven). Both e2e scripts refuse to run unless the store’s kv_braintree_base_url and --mock are loopback addresses.