Skip to content

PHP SDK

The official PHP client for Crossmart lets you manage your catalog and stock, publish products to your Shopify, eBay, Etsy and Google Merchant Center stores, set per-store prices and overrides, read orders and sales, and work with Crossmart’s AI writing tools from PHP applications and AI agents.

  • Typed end to end: every model, enum and operation is generated from the API’s OpenAPI document, and the SDK passes PHPStan at the maximum level.
  • Works with any PSR-18 HTTP client: Guzzle, Symfony HttpClient, or the one your framework already uses.
  • Automatic pagination, typed exceptions, retries with backoff that respect Retry-After, and webhook signature verification.

Requires PHP 8.1 or later.

  1. Clone the repository:

    Terminal window
    git clone https://github.com/crossmart-io/crossmart.git
  2. In your project’s composer.json, add the SDK’s folder as a path repository:

    {
    "repositories": [{ "type": "path", "url": "../crossmart/sdks/php" }]
    }
  3. Require it, together with a PSR-18 HTTP client if your project has none yet:

    Terminal window
    composer require crossmart/crossmart-php:@dev guzzlehttp/guzzle
    # or: composer require crossmart/crossmart-php:@dev symfony/http-client nyholm/psr7

Once it is published, installing will be:

Terminal window
composer require crossmart/crossmart-php

The SDK needs a PSR-18 HTTP client and PSR-17 factories. It finds the installed ones with php-http/discovery, or you pass your own (see HTTP client).

Create an API key in the Crossmart web app under Settings → API keys. The secret starts with cm_ and is shown only once. A key belongs to one workspace and has a role:

Role Can
Admin Everything below, plus manage stores, pricing rules, members and keys
Editor Read everything; create and edit products and stock; publish and remove listings; ask the AI to write
Approver Read everything; approve or reject AI suggestions
Viewer Read everything

Keys can’t be Owners, and a key never has more access than the member who created it. Custom roles work too. See Roles and permissions for each permission.

use Crossmart\Crossmart;
$client = new Crossmart(apiKey: 'cm_...');

Every setting can come from the environment instead ($_ENV, $_SERVER or getenv()):

Argument Environment variable Default
apiKey CROSSMART_API_KEY
accessToken CROSSMART_ACCESS_TOKEN
baseUrl CROSSMART_BASE_URL https://api.crossmart.io
workspaceId CROSSMART_WORKSPACE_ID the API key’s workspace

The API key is sent in the x-api-key header. With an API key you don’t need workspaceId: the client asks the API which workspace the key belongs to (GET /api/v1/api-key) the first time it needs it.

AI agents that connect to Crossmart through OAuth get an access token instead of an API key. Pass it as accessToken, together with workspaceId; the client sends it as Authorization: Bearer <token>.

A self-hosted Crossmart works the same way: set baseUrl to your API’s address, with or without /api/v1.

use Crossmart\Crossmart;
use Crossmart\Enum\OrderStatus;
use Crossmart\Enum\ProductStatus;
use Crossmart\Model\CreateProductDto;
use Crossmart\Model\CreateVariantDto;
use Crossmart\Resource\Filter\OrderFilters;
use Crossmart\Resource\Filter\ProductFilters;
$client = new Crossmart(); // reads CROSSMART_API_KEY
// List products, one page at a time...
$page = $client->products->list(new ProductFilters(status: ProductStatus::Active), limit: 20);
echo $page->total, " active products\n";
// ...or iterate over all of them; pages are fetched as you go.
foreach ($client->products->listAll(new ProductFilters(search: 'apron')) as $product) {
echo $product->id, ' ', $product->title, "\n";
}
// Create a product with one variant and opening stock.
$created = $client->products->create(new CreateProductDto(
title: 'Linen apron',
variants: [new CreateVariantDto(price: '24.00', sku: 'APR-NAT', quantity: 15)],
status: ProductStatus::Active,
));
// Publish it to a connected store.
$store = $client->stores->list()[0];
$listing = $client->listings->publish($created->id, $store->id);
// Read open orders across every store.
foreach ($client->orders->listAll(new OrderFilters(status: OrderStatus::Open)) as $order) {
echo $order->name, ' ', $order->storeName, ' ', $order->total, ' ', $order->currencyCode, "\n";
}

Request bodies accept either a generated model or an array in the API’s JSON shape (camelCase keys), which is handy for agents that build requests as JSON:

$client->products->create(['title' => 'Linen apron', 'variants' => [['price' => '24.00']]]);
Property Methods
products list, listAll, get, create, update (replaces the product; a variant without quantity keeps its stock), delete
inventory list, listAll, adjust, listMovements, listAllMovements
stores list, get, setPricing, previewPricing, sync, syncProgress, listProducts, listAllProducts, listListings, listAllListings
listings forProduct, editor, publish, retry, remove, setOverride, patchOverride, clearOverride, publishMany, removeMany
orders list, listAll, get
reports sales, inventory
ai status, usage, generateContent, draftContent
suggestions list, listAll, get, approve, reject, approveMany
workspaces get, update, currentApiKey

Models live in Crossmart\Model, enums in Crossmart\Enum, and list filters in Crossmart\Resource\Filter. Models are immutable, with fromArray(), toArray() and jsonSerialize().

The API may add enum values within a version. An enum field holds its case, such as ProductStatus::Active, or a plain string for a value newer than your SDK, so existing code keeps working: compare with === and give a match a default arm.

A few more examples:

use Crossmart\Enum\CompareAtMode;
use Crossmart\Enum\InventoryMovementReason;
use Crossmart\Enum\PriceRounding;
use Crossmart\Enum\SalesRange;
use Crossmart\Enum\StockLevel;
use Crossmart\Enum\SuggestionStatus;
use Crossmart\Model\ListingOverrideDto;
use Crossmart\Model\ListingOverridePatchDto;
use Crossmart\Model\PricingRuleDto;
use Crossmart\Resource\Filter\InventoryFilters;
use Crossmart\Resource\Filter\SuggestionFilters;
// Restock everything that runs low.
foreach ($client->inventory->listAll(new InventoryFilters(stockLevel: StockLevel::LowStock)) as $item) {
$client->inventory->adjust($item->inventoryItemId, 10, InventoryMovementReason::Received);
}
// Price one store 10% above the catalog, ending in .99. `null` removes the rule.
$client->stores->setPricing($storeId, new PricingRuleDto(
adjustmentPercent: '10',
fixedAmount: '0',
rounding: PriceRounding::Ending99,
floor: null,
compareAt: CompareAtMode::Keep,
));
// Show a different title in one store...
$client->listings->setOverride($productId, $storeId, new ListingOverrideDto(title: 'Linen apron | Free shipping'));
// ...then change only the stock cap, and revert the title to the product's. Arguments you leave
// out are not sent, so their overrides stay as they are; `null` clears an override.
$client->listings->patchOverride($productId, $storeId, new ListingOverridePatchDto(title: null, maxQuantity: 5));
// Sales of the last 7 days, bucketed in a time zone.
$report = $client->reports->sales(SalesRange::Last7Days, 'America/New_York');
// Ask the AI to write store-specific titles and descriptions, then review them.
$client->ai->generateContent($storeId, [$productId]);
foreach ($client->suggestions->listAll(new SuggestionFilters(status: SuggestionStatus::Pending)) as $suggestion) {
echo $suggestion->currentValue, ' -> ', $suggestion->proposedValue, "\n";
$client->suggestions->approve($suggestion->id);
}

The resources cover what sellers and agents do most. Every operation of the API, including members, roles, API keys, product views, activity, webhooks and channel setup, is also available through $client->api: one generated class per tag, with methods named after the operation.

$members = $client->api->members->membersControllerListV1($client->workspaceId());
$stats = $client->api->catalog->catalogStatsControllerGetV1($client->workspaceId());

File uploads (CSV product imports and image uploads, which use multipart/form-data) are not in the PHP SDK yet. Use the REST API directly for those.

List endpoints use cursors. list() returns one Page with items, nextCursor, total (how many match across all pages) and hasMore(). Pass nextCursor back as cursor to get the next page. listAll() does that for you and returns a lazy generator; it asks for the largest page the API serves (100), or pageSize.

$page = $client->orders->list(limit: 50);
while (true) {
foreach ($page->items as $order) {
// ...
}
if (!$page->hasMore()) {
break;
}
$page = $client->orders->list(cursor: $page->nextCursor, limit: 50);
}

Every error status throws an ApiException with getStatus(), getApiMessage(), getErrorCode(), getReasonPhrase(), getBody() and getHeaders(). Subclasses in Crossmart\Exception let you catch the cases you handle differently:

Exception Status When
BadRequestException 400 Validation failed; the message joins the API’s messages with "; "
AuthenticationException 401 The key or token is missing, wrong, revoked or expired
PlanLimitException 402 The plan doesn’t allow it; see getFeature(), getLimit(), getUsage()
PermissionDeniedException 403 The key’s role lacks the permission
NotFoundException 404 Not found, or not in the key’s workspace
ConflictException 409 Conflicts with the current state, such as a suggestion already reviewed
RateLimitException 429 Too many requests, after retries
ServerException 5xx The API or an upstream provider failed

ConnectionException means the API could not be reached, ConfigurationException means the client is missing credentials or a workspace, and InvalidPayloadException means a response (or a request array) did not match the API’s schema. All of them implement CrossmartExceptionInterface.

use Crossmart\Enum\EntitlementErrorCode;
use Crossmart\Exception\PlanLimitException;
try {
$client->listings->publish($productId, $storeId);
} catch (PlanLimitException $exception) {
if ($exception->getErrorCode() === EntitlementErrorCode::PlanLimitReached->value) {
printf(
"Your %s plan allows %d %s. Upgrade to add more.\n",
$exception->getPlan(),
$exception->getLimit(),
$exception->getFeature(),
);
}
}

Crossmart signs every webhook delivery with your endpoint’s signing secret (whsec_…). WebhookSignature::verify() checks the Crossmart-Signature header against the raw request body, refuses deliveries more than five minutes old or ahead, and returns the parsed WebhookEvent. It throws a WebhookSignatureException (with a getReason()) when the delivery can’t be trusted; answer it with 400.

Pass the body exactly as it arrived (php://input). A body that was decoded and encoded again does not match the signature.

use Crossmart\Exception\WebhookSignatureException;
use Crossmart\Webhook\WebhookEventType;
use Crossmart\Webhook\WebhookHeader;
use Crossmart\Webhook\WebhookSignature;
try {
$event = WebhookSignature::verify(
(string) file_get_contents('php://input'),
$_SERVER[WebhookHeader::Signature->serverKey()] ?? null, // HTTP_CROSSMART_SIGNATURE
(string) getenv('CROSSMART_WEBHOOK_SECRET'),
);
} catch (WebhookSignatureException $exception) {
http_response_code(400);
echo $exception->getMessage();
exit;
}
if ($event->eventType() === WebhookEventType::OrderCreated) {
// $event->data holds the order
}
// Ignore event types you don't handle; new ones may be added (eventType() returns null for them).
http_response_code(204);
  • During a secret rotation Crossmart signs with both the new and the old secret, and either one verifies, so you can switch secrets without dropping deliveries.
  • Change the five-minute window with the $toleranceSeconds argument, and pass $now (a Unix time) in tests.
  • Retries of a delivery carry the same $event->id; use it to skip events you already handled.
  • In a framework, read the raw body from the request object, such as $request->getContent() (Symfony, Laravel) or (string) $request->getBody() (PSR-7).

Idempotent requests (GET, PUT, DELETE) are retried twice by default after a 429, a 5xx or a network failure, with exponential backoff and jitter. When the API sends Retry-After, the client waits that long (at most 60 seconds). POST and PATCH requests are never retried, so nothing is created or published twice.

$client = new Crossmart(maxRetries: 5); // maxRetries: 0 turns retries off

The SDK talks HTTP through PSR-18 and builds requests with PSR-17 factories, instead of depending on Guzzle directly. It never conflicts with the Guzzle or Symfony version your application already pins, it reuses the client your framework has configured, and tests can swap in a mock client. Pass your own client to set timeouts, proxies or middleware:

use GuzzleHttp\Client;
$client = new Crossmart(
apiKey: 'cm_...',
httpClient: new Client(['timeout' => 30, 'connect_timeout' => 5]),
);

Without httpClient, requestFactory and streamFactory, the installed implementations are found with php-http/discovery.

The SDK lives in the Crossmart repository. Its models, enums and low-level API classes are generated from the API’s OpenAPI document by a small generator in the same folder, with no Java and no runtime dependencies, and a drift check fails when the committed code is out of date. See the SDK’s README there for how to regenerate it and run its checks.

AGPL-3.0, like the rest of Crossmart.