sparkcrm/spark-sdk
Composer 安装命令:
composer require sparkcrm/spark-sdk
包简介
PHP SDK for the SparkCRM DTC Checkout API (init, lead, order, upsell, complete, PayPal, refunds).
README 文档
README
A standalone PHP SDK for the SparkCRM DTC Checkout API. It takes you from
init() → lead → order → upsell → complete, plus tax quotes, decline salvage,
refunds, and the PayPal redirect/capture flow — without you ever touching the
raw HTTP API.
The base URL is fixed to https://api.sparkcrm.io.
- PHP 8.1+
- PSR-18 / PSR-17 HTTP (no Guzzle or Laravel hard dependency)
- A normalized result object (declines are results, not exceptions) and typed exceptions for real failures
- Tracking auto-capture + param normalization, card-expiry normalization, retry/backoff
- Pluggable state store that survives the PayPal redirect (the default keeps state in the PHP session — call
session_start()first)
Install
With Composer (recommended)
composer require sparkcrm/spark-sdk
The SDK speaks HTTP through any PSR-18 client. Unless your app already ships one, you must also install an implementation + PSR-7 factories — they are then auto-discovered (the first real API call fails without them):
composer require nyholm/psr7 symfony/http-client
# or: composer require php-http/guzzle7-adapter guzzlehttp/guzzle
Download a ready-to-use bundle (no Composer)
Don't use Composer? Each GitHub Release
ships a spark-sdk-<version>.zip with everything already included — the SDK, its
dependencies, and a working HTTP client. Download it, unzip it into your
project, and include the bundled autoloader:
require __DIR__.'/spark-sdk/vendor/autoload.php'; use SparkCheckout\SparkCheckout; $spark = new SparkCheckout($apiToken);
That's it — no Composer, no extra HTTP-client install.
From source without Packagist
Want to vendor a specific copy, or it isn't on Packagist yet? Point Composer at the repo, or clone it and let Composer build the autoloader:
# Option 1 — install straight from the Git repo composer config repositories.spark vcs https://github.com/Spark-Public-SDKs/php-sdk.git composer require sparkcrm/spark-sdk:dev-main # Option 2 — clone anywhere, resolve dependencies once, then include its autoloader git clone https://github.com/Spark-Public-SDKs/php-sdk.git cd php-sdk && composer install # creates vendor/ with the autoloader + dependencies
require '/path/to/spark-sdk/vendor/autoload.php';
Dropping
src/in on its own is not enough: the SDK needs a few PSR packages (psr/http-*,php-http/discovery,psr/log,psr/simple-cache) plus a PSR-18 client at runtime. The downloadable bundle and both Composer paths above all bring those along for you.
Before you start — read this once
Four things silently break a first integration. Get these right and the rest just works:
- API token. Pass your SparkCRM DTC API token to the constructor. It is sent as
a Bearer token; a missing/invalid one throws
AuthExceptionon the first call. - Start the session. The default store keeps funnel state in the PHP session, so
your app must call
session_start()before any SDK call — otherwise nothing persists between requests and the order is silently lost. (Or pick another state store.) - One fresh client per request. The SDK is stateless between requests: build a new
SparkCheckouton each HTTP request — it recalls the buyer's order from the store (session/cookie/cache), so later requests continue the same funnel automatically. - Production base URL. The base URL is fixed to
https://api.sparkcrm.io— calls charge real money. Use a test campaign / your gateway's test cards while wiring up.
Quick start
A checkout spans two separate HTTP requests — the landing-page load and the checkout submit. Build a fresh client in each; state carries over via the store.
use SparkCheckout\SparkCheckout; use SparkCheckout\Exception\SparkException; $apiToken = getenv('SPARK_CHECKOUT_TOKEN'); // your SparkCRM DTC API token // ── Request 1: initial landing-page load ────────────────────────────────── session_start(); // default store lives in $_SESSION $spark = new SparkCheckout($apiToken); // Opens a checkout session AND captures ip, user_agent, utm_*, affiliate, // sub1-5 and any other query params, then stores session_id + context so every // later call reuses it automatically. $spark->init(['campaign' => 1042]); // integer campaign id (required); currency defaults to USD // ── Request 2: checkout submit (a SEPARATE request, same browser/session) ── session_start(); $spark = new SparkCheckout($apiToken); // fresh client; state recalled from the session try { // Create the lead first. If you skip it, createOrder() below becomes a // FULL order and then ALSO requires customer.first_name + last_name. $spark->createLead([ 'customer' => ['email' => $email, 'first_name' => $first, 'last_name' => $last], 'shipping' => ['address' => '...', 'city' => '...', 'state' => 'CA', 'zip' => '90001', 'country' => 'US'], 'products' => [['offer_id' => 'OFF-1', 'quantity' => 1]], ]); // auto-uses the stored session + context $result = $spark->createOrder([ 'payment' => ['method' => 'card', 'card_number' => '...', 'card_exp' => '12/26', 'card_cvv' => '123'], 'billing' => ['address' => '...', 'zip' => '90001', 'country' => 'US'], ]); // a lead exists -> charges the existing order (/orders/payment) // Branch in this order — declines, redirects and duplicates are RESULTS, not exceptions: if ($result->needsRedirect()) { header('Location: '.$result->redirectUrl()); // PayPal etc. exit; } elseif ($result->isDeclined()) { echo 'Declined: '.$result->declineCode(); } elseif ($result->isApproved()) { $spark->processUpsell(['upsell_id' => 'UP-1', 'quantity' => 1]); // optional $spark->completeOrder(); // fires autoresponders/webhooks, then clears state echo 'Approved! Order '.$result->orderNumber(); } } catch (SparkException $exception) { // 401/403/404/422-field/429/402/5xx (see Errors) echo 'Checkout error ('.$exception->httpStatus().'): '.$exception->getMessage(); }
Need a live tax quote before charging? See Tax, subscriptions, refunds, search — it is never auto-called.
init() — call once on page load
init() does two things and stores the result for the rest of the funnel:
- Captures tracking + device context from the incoming request/URL:
ip_address,user_agent,utm_*,affiliate,sub1–sub5, and any other query params — all normalized automatically (see Param normalization). - Opens a session (
POST /checkout/sessions) with that context + the campaign id, storing the returnedsession_id.
Everything captured here is auto-merged into createLead() / createOrder() so
you never re-pass ip/user_agent/utm/affiliate.
campaign is an integer and is required. currency defaults to USD.
Browser requests
Pass the incoming PSR-7 ServerRequest so the SDK can read the query string,
client IP and User-Agent. If you don't pass one, it falls back to $_GET /
$_SERVER.
$spark = new SparkCheckout($apiToken, request: $psrServerRequest); $spark->init(['campaign' => 1042]);
Behind a proxy or load balancer
Out of the box the SDK reads the real customer IP from the forwarded headers
(X-Forwarded-For → X-Real-IP → CF-Connecting-IP), falling back to
REMOTE_ADDR for direct connections — so a checkout behind a CDN or load
balancer records the customer's IP, not the proxy's, with no configuration.
To harden against header spoofing in a known topology, list your trusted proxies explicitly; forwarded headers are then honored only when the connection comes from one of them, and the client is the right-most non-proxy hop:
use SparkCheckout\Config; // Hardened: only trust these proxies' forwarded headers. $config = new Config($apiToken, trustedProxies: ['10.0.0.0/8', '192.168.1.5']); // Strict: ignore forwarded headers entirely, use REMOTE_ADDR only. $config = new Config($apiToken, trustedProxies: []); // Override the header priority (defaults shown) — e.g. put CF-Connecting-IP first behind Cloudflare: $config = new Config($apiToken, trustedProxyHeaders: ['X-Forwarded-For', 'X-Real-IP', 'CF-Connecting-IP']); $spark = new SparkCheckout($apiToken, config: $config, request: $psrServerRequest);
Pure server-to-server
Callers with no browser request can pass ip_address / user_agent /
affiliate explicitly; auto-capture is then skipped.
$spark->init([ 'campaign' => 1042, 'ip_address' => $buyerIp, 'user_agent' => $buyerUserAgent, 'affiliate' => 'AFF-123', ]);
Choosing a state store
The SDK remembers the buyer context + session_id and the live
order_number / transaction_number between requests. Pick the store that
matches your topology:
| Store | Use when | Notes |
|---|---|---|
SessionStore (default) |
single server | the session cookie is the correlation key |
CacheStore (PSR-16) |
multi-server / load-balanced | keyed by a small opaque cookie (e.g. spark_ref); any node recalls state |
SignedCookieStore |
stateless nodes, no session/cache | authenticated-encrypted cookie (libsodium, requires ext-sodium); contents are confidential and tamper-evident |
ArrayStore |
pure server-to-server, one process | does not survive across requests |
LayeredStore |
want a fallback chain | e.g. session → cache → signed cookie |
use SparkCheckout\SparkCheckout; use SparkCheckout\Store\CacheStore; $store = new CacheStore($psr16Cache, $correlationToken); $spark = new SparkCheckout($apiToken, $store);
PayPal note: the correlation cookie is set
Secure; HttpOnly; SameSite=Lax.Lax(notStrict) is required so the cookie is still sent when the customer returns from the PayPal redirect (a cross-site top-level navigation).
With the default SessionStore, the correlation key is the PHP session cookie.
The SDK hardens it to Secure; HttpOnly; SameSite=Lax on construction (a no-op
once the session has started — construct the client before session_start()).
For local HTTP development, disable the Secure flag:
use SparkCheckout\Store\SessionStore; $spark = new SparkCheckout($apiToken, new SessionStore(secure: false)); // or leave the host's session config untouched: new SessionStore(configureCookie: false)
createOrder() — one smart method
createOrder() picks the endpoint based on SDK state:
- A lead exists (
order_numberstored) →POST /checkout/orders/payment - No lead →
POST /checkout/orders(full order)
The full-order path additionally requires customer.first_name +
customer.last_name and attaches ip / user_agent / utm / affiliate / products /
campaign to the call. After it returns, the state is identical for both paths,
so processUpsell() / completeOrder() are unchanged.
Need to force a full order even when a lead exists? Use createFullOrder().
Reading the result
A decline is a result, not an exception:
$result = $spark->createOrder([...]); $result->isApproved(); // 2xx + success, not declined/redirect (incl. "processing") $result->isDeclined(); // gateway decline or pre-gateway decline $result->declineCode(); // e.g. "05" $result->isDuplicate(); // full-order dedupe; ->orderNumber() is the existing order $result->needsRedirect(); // PayPal $result->redirectUrl(); $result->orderNumber(); $result->transactionNumber(); $result->message(); $result->raw(); // the untouched response body
Decline salvage
// Retry a declined MAIN charge (order_number auto-injected). // Omit 'payment' to retry the existing method, or pass a fresh card. if ($result->isDeclined()) { $result = $spark->reprocessPayment([ 'payment' => ['method' => 'card', 'card_number' => '...', 'card_cvv' => '...'], // optional: 'amount', 'gateway_id', 'affiliate', 'sub1'..'sub5' ]); } // Retry one declined upsell transaction. $upsell = $spark->processUpsell(['upsell_id' => 'UP-1', 'quantity' => 1]); if ($upsell->isDeclined()) { $retry = $spark->reprocessUpsell($upsell->transactionNumber()); if ($retry->inProgress()) { // another reprocess for this transaction is already running (409) } }
When the per-decline retry cap is reached
(max_reprocess_attempts_per_decline / max_upsell_declines), the API returns
a declined result, not an exception — ->isDeclined() is true and
->isCapped() tells you the cap (rather than the gateway) ended the retries, so
you can stop retrying and message the customer.
reprocessPayment()charges the order already in the store and injects thatorder_numberfor you — but it does not check one exists. If the funnel state was lost (a fresh client with noresume()/init()), it sends a null order and the API rejects the call. Run it on the same funnel wherecreateOrder()ran.
PayPal (redirect + capture)
The initial PayPal order is a two-phase flow; upsells are seamless.
// Phase 1 — create the order. return_url + cancel_url are required. $result = $spark->createOrder([ // The full-order path (and this no-prior-lead paypal_wallet path) require // customer first/last. On the lead path createLead() only required the email. 'customer' => ['email' => $email, 'first_name' => $first, 'last_name' => $last], 'products' => [['offer_id' => 'OFF-1', 'quantity' => 1]], 'payment' => [ 'method' => 'paypal_wallet', 'return_url' => 'https://shop.test/checkout/return', 'cancel_url' => 'https://shop.test/checkout/cancel', // optional: 'external_payment_id' => 1234, ], ]); if ($result->needsRedirect()) { header('Location: '.$result->redirectUrl()); exit; }
// Phase 2 — the customer returns to return_url (a new request). // The persistent store (cookie/cache) survived the redirect. $spark->resume(); // or: $spark->resume($_GET['spark_order'] ?? null); $capture = $spark->capture(); // POST /checkout/orders/capture (idempotent) $capture->isApproved(); $capture->payerEmail(); $capture->subscriptionCreated(); $capture->subscriptionNumber();
If the customer cancels instead, they come back to your cancel_url — the SDK
appends ?spark_canceled=1 to it so you can detect and clean up the abandoned
order. The order is simply left uncaptured.
// On your cancel_url page: if ($spark->wasCanceled()) { $spark->abandon(); // clears the stored funnel state so the next order starts clean }
How spark_order is appended
A PayPal return_url can only carry ?spark_order=<order> if the order exists
before the redirect. createOrder() guarantees that:
- Lead path (
createLead()thencreateOrder()): the order already exists, sospark_orderis appended directly. - Full-order path (no prior lead) with
paypal_wallet:createOrder()transparently creates a lead first to mint the order, then processes the PayPal payment on it — sospark_orderis appended here too. This costs one extraPOST /checkout/leadscall and creates a real lead (subject to the usual throttle/dedupe).
On top of the URL marker, the persistent store also carries the order across the
redirect via the first-party SameSite=Lax cookie, so resume() recovers it
even if the marker is lost.
createFullOrder()is the explicit escape hatch that always hits/checkout/ordersand never creates a lead; it therefore does not appendspark_order. Use it only when you don't need redirect-marker survival.
Tax, subscriptions, refunds, search
// Tax quote (explicit; createOrder never auto-calls it). $tax = $spark->calculateTax([ 'to_address' => ['country' => 'US', 'state' => 'CA', 'zip' => '90001'], 'line_items' => [['quantity' => 1, 'unit_price' => 49.00]], ]); $tax->enabled(); // false when the campaign has no tax provider (zeros, not an error) // Subscription status — by the current order, or an explicit customer number. $subscription = $spark->checkSubscription(); // uses the stored order $spark->checkSubscription('CUST-123456-78901'); // explicit $subscription->isActive(); $subscription->subscriptionNumbers(); // Refund a transaction. Omit `amount` for a full refund; pass isExternal: true // to record an external (admin-recorded, off-gateway) refund. $spark->refund('TXN-123456-789012', amount: 9.99, reason: 'customer request'); $spark->refund('TXN-123456-789012'); // full refund $spark->searchTransactions(['order_number' => 'ORD-1']); // transaction search // Search helpers (paginated). $orders = $spark->searchOrders(['customer_email' => 'a@b.c']); $orders->items(); $orders->pagination(); // total / per_page / current_page / last_page
Param normalization
init() reads every query param off the landing-page URL and maps it to what
Spark accepts. Matching is case-insensitive and ignores separators
(- _ space), so affId, aff_id, AFFID, Aff-Id all resolve to the same
target.
- Affiliate ←
affiliate, affiliate_id, affId, affid, aff_id, aff, ref, a(first present wins) - Sub-affiliates
sub1–sub5←sub{n}, sub_{n}, c{n}, subaff{n}, sub_affiliate_id[_{n}] - UTMs (
utm_source/medium/campaign/term/content) pass through unchanged cohortpasses through unchanged (a first-class Spark field, not a custom field)- Everything else →
custom_fields, capped to the API's limits (max 50 keys, keys sanitized to[a-zA-Z0-9_-]{1,64}, values truncated to 1000 chars). Pass a PSR-3 logger viaConfig(logger: $logger)to get a warning when keys are dropped over the cap.
Extend the alias map for edge cases via Config:
use SparkCheckout\Config; use SparkCheckout\Normalization\AliasMap; $config = new Config($apiToken, aliasMap: new AliasMap(['affiliate' => ['my_aff']])); $spark = new SparkCheckout($apiToken, config: $config);
Card expiry is normalized automatically: 12/26, 12/2026, or split
card_exp_month + card_exp_year all become a canonical card_exp of
MM/YYYY (set Config(splitCardExpiry: true) to emit the split fields instead).
Malformed input — an unparseable card_exp, a month outside 01–12, or a year
that isn't 2 or 4 digits — throws ValidationException locally, before any
request is sent (not a server-side 422, not a declined result).
payment.method is validated against the supported set
(card, onfile, external, paypal_wallet); an unknown method throws a
ValidationException before any request is sent.
Errors: results vs exceptions
| Outcome | How it surfaces |
|---|---|
| Decline (gateway or pre-gateway) | OrderResult::isDeclined() |
| Duplicate order | OrderResult::isDuplicate() |
| PayPal redirect needed | OrderResult::needsRedirect() |
| Upsell reprocess already running (409) | OrderResult::inProgress() |
| 401 invalid/missing token | AuthException |
| 403 token lacks ability | PermissionException |
| 404 not found | NotFoundException |
422 field validation (has an errors map) |
ValidationException (->errors()) |
| 422 processing failure (no field errors, e.g. a failed capture/refund) | a non-approved OrderResult — inspect ->message() (not thrown) |
| 429 rate limited (after retries) | RateLimitException (->retryAfterMs()) |
| 402 account past due | AccountSuspendedException |
| 5xx / transport (after retries) | ApiException |
All exceptions extend SparkCheckout\Exception\SparkException and expose
->httpStatus() and ->raw(). Requests are retried with exponential backoff on
429 and 5xx (honoring the X-RateLimit-Reset delay).
Test mode
Enable test mode to tag every charge/session request as a test. Whether a tagged request actually routes to a sandbox gateway is governed server-side by the campaign's gateway configuration — the SDK only marks the request.
use SparkCheckout\Config; use SparkCheckout\Testing\TestCard; $spark = new SparkCheckout($apiToken, config: new Config($apiToken, testMode: true)); $spark->isTestMode(); // true // Canonical Luhn-valid test cards + a ready-to-use payment block: $spark->createOrder(['payment' => TestCard::approved()]); // sandbox-approved card $spark->createOrder(['payment' => TestCard::declined()]); // sandbox-declined card $spark->createOrder(['payment' => TestCard::payment(TestCard::AMEX)]);
Laravel (optional)
The package ships an optional Laravel bridge. The service provider is auto-discovered — no manual registration needed. Set your token and (optionally) publish the config:
# .env SPARK_CHECKOUT_TOKEN=your-token php artisan vendor:publish --tag=spark-checkout-config # optional
Resolve the client from the container or use the Spark facade. The store is
backed by the Laravel cache, keyed per visitor (the session id when a session is
started, otherwise an opaque spark_ref cookie); bindings are scoped, so they
reset cleanly per request under Octane.
use SparkCheckout\Laravel\SparkCheckoutFacade as Spark; use SparkCheckout\SparkCheckout; // Facade Spark::init(['campaign' => 1042]); // or resolve directly app(SparkCheckout::class)->createLead([...]);
The Laravel bridge requires illuminate/contracts (already present in any
Laravel app); the core SDK has no framework dependency.
License
MIT.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 7
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-18