sparkcrm/spark-sdk 问题修复 & 功能扩展

解决BUG、新增功能、兼容多环境部署,快速响应你的开发需求

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

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 AuthException on 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 SparkCheckout on 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, sub1sub5, 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 returned session_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-ForX-Real-IPCF-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 (not Strict) 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_number stored) → POST /checkout/orders/payment
  • No leadPOST /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 that order_number for you — but it does not check one exists. If the funnel state was lost (a fresh client with no resume() / init()), it sends a null order and the API rejects the call. Run it on the same funnel where createOrder() 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() then createOrder()): the order already exists, so spark_order is 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 — so spark_order is appended here too. This costs one extra POST /checkout/leads call 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/orders and never creates a lead; it therefore does not append spark_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.

  • Affiliateaffiliate, affiliate_id, affId, affid, aff_id, aff, ref, a (first present wins)
  • Sub-affiliates sub1sub5sub{n}, sub_{n}, c{n}, subaff{n}, sub_affiliate_id[_{n}]
  • UTMs (utm_source/medium/campaign/term/content) pass through unchanged
  • cohort passes through unchanged (a first-class Spark field, not a custom field)
  • Everything elsecustom_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 via Config(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 0112, 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

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-06-18

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固