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.
Install
Section titled “Install”-
Clone the repository:
Terminal window git clone https://github.com/crossmart-io/crossmart.git -
In your project’s
composer.json, add the SDK’s folder as a path repository:{"repositories": [{ "type": "path", "url": "../crossmart/sdks/php" }]} -
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:
composer require crossmart/crossmart-phpThe 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).
Authentication
Section titled “Authentication”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.
Quick start
Section titled “Quick start”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']]]);Resources
Section titled “Resources”| 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);}Every other operation
Section titled “Every other operation”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.
Pagination
Section titled “Pagination”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);}Errors
Section titled “Errors”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(), ); }}Verify webhooks
Section titled “Verify webhooks”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
$toleranceSecondsargument, 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).
Retries
Section titled “Retries”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 offHTTP client
Section titled “HTTP client”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.
Contributing
Section titled “Contributing”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.
License
Section titled “License”AGPL-3.0, like the rest of Crossmart.