thayron / cjdropshipping-php
Framework-agnostic PHP SDK for the CJdropshipping API 2.0, with an optional Laravel bridge.
Requires
- php: ^8.2
- php-http/discovery: ^1.19
- psr/clock: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/simple-cache: ^2.0|^3.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
Suggests
- guzzlehttp/guzzle: PSR-18 HTTP client used automatically when installed.
- illuminate/support: Required for the Laravel service provider, facade and webhook middleware.
Provides
None
Conflicts
None
Replaces
None
README
A framework-agnostic PHP SDK for the CJdropshipping API 2.0, with an optional Laravel bridge.
Requirements
- PHP
^8.2 - A PSR-18 HTTP client and PSR-17 factories. If
guzzlehttp/guzzleis installed it is used automatically; otherwise any other PSR-18/PSR-17 implementation is discovered viaphp-http/discovery.
Installation
composer require thayron/cjdropshipping-php
If your project has no PSR-18 client yet, pull in Guzzle:
composer require guzzlehttp/guzzle
Quick start
use Thayron\CjDropshipping\CjClient; use Thayron\CjDropshipping\Config; use Thayron\CjDropshipping\Auth\Psr16TokenStore; $client = CjClient::create( new Config(apiKey: 'your-cj-api-key'), tokenStore: new Psr16TokenStore($cache), // any PSR-16 cache ); $categories = $client->products()->categories();
CjClient::create() discovers a PSR-18 client and PSR-17 factories for you if you don't pass any. Access and refresh tokens are obtained and renewed automatically — you never call the authentication endpoints yourself.
By default tokens are kept in an InMemoryTokenStore, which only lasts for the current process. Pass a Psr16TokenStore (backed by any PSR-16 cache) to persist tokens across requests — this matters because CJ's getAccessToken endpoint allows roughly one call per second per API key, and requesting a fresh token on every process is wasteful and can get you rate-limited.
Products
use Thayron\CjDropshipping\Criteria\ProductSearch; use Thayron\CjDropshipping\Enums\ProductFeature; use Thayron\CjDropshipping\Enums\ProductSort; use Thayron\CjDropshipping\Enums\SortDirection; // Categories (flattened 3-level tree) $categories = $client->products()->categories(); // Search $criteria = ProductSearch::make() ->keyword('phone case') ->category($categoryId) ->country('US') ->priceBetween('1.00', '20.00') ->orderBy(ProductSort::Price, SortDirection::Asc) ->withFeatures(ProductFeature::Description, ProductFeature::Video) ->page(1) ->perPage(50); $page = $client->products()->search($criteria); foreach ($page->items as $summary) { // $summary is a ProductSummary } // Iterate every page automatically (CJ caps listV2 at page 1000, 100 per page) foreach ($client->products()->searchAll($criteria) as $summary) { // ... } // Lookups $product = $client->products()->find($productId); $product = $client->products()->findBySku($productSku); $product = $client->products()->findByVariantSku($variantSku); // Variants $variants = $client->products()->variants($productId, countryCode: 'US'); $variant = $client->products()->variant($variantId); // Warehouses $warehouses = $client->products()->warehouses(); // My products (products you've already imported into your CJ account) use Thayron\CjDropshipping\Criteria\MyProductSearch; $myProducts = $client->products()->myProducts(MyProductSearch::make()->keyword('case')->perPage(50)); $client->products()->addToMyProducts($productId);
ProductSearch::page() accepts 1–1000 and perPage() 1–100; MyProductSearch::perPage() accepts 1–100. Both throw InvalidArgumentException outside those ranges.
Inventory
Verified against the live API: products()->find() returns a Product whose variants do not carry stock — CJ's product/query endpoint omits inventory entirely. To get stock levels, use one of:
// All warehouses for a product, plus a per-variant breakdown $inventory = $client->products()->inventoryByProduct($productId); $inventory->warehouses; // list<Inventory> for the product as a whole $inventory->forVariant($variantId); // list<Inventory> for one variant // Stock for a single variant, by variant id $inventory = $client->products()->inventoryByVariant($variantId); // Stock for a single variant or product, by SKU/SPU $inventory = $client->products()->inventoryBySku($sku); // Or fetch a variant with inventory included (enabled by default) $variant = $client->products()->variant($variantId); // $variant->inventories is populated
Each Inventory entry exposes countryCode, total, cjInventory, factoryInventory, verifiedWarehouse (1 = verified, 2 = unverified), warehouseId and warehouseName.
Data conventions
- Prices, weights and dimensions are decimal strings, never floats, to avoid precision loss (e.g.
$product->sellPrice,$variant->weight). - Weights are in grams, dimensions in millimeters, prices in USD.
- Every DTO (
Product,Variant,Inventory,ProductSummary,MyProduct,Warehouse,Paginated,ProductInventory,SubscribedProduct) exposes araw()method returning the original decoded response array, in case you need a field the DTO doesn't map.
Webhooks
Configuring topics
use Thayron\CjDropshipping\Criteria\WebhookSettings; $client->webhooks()->configure( WebhookSettings::make() ->product('https://example.com/webhooks/cj/product') ->stock('https://example.com/webhooks/cj/stock') ->order('https://example.com/webhooks/cj/order') ->logistics('https://example.com/webhooks/cj/logistics') );
Callback URLs must be public HTTPS URLs (an InvalidArgumentException is thrown otherwise). CJ's webhook/set requires all four topics (product, stock, order, logistics) in every call — any topic you don't enable()/set on the builder is sent as CANCEL, disabling it. Use ->cancel(WebhookTopic::Order) to explicitly disable a topic you had previously enabled.
Subscribing products
$result = $client->webhooks()->subscribeProducts([$productId1, $productId2, /* ... */]); $result->successProductIds; $result->failedProductIds; // Subscribe/unsubscribe your whole catalog $client->webhooks()->subscribeAll(); $client->webhooks()->subscribeAll(enabled: false); $client->webhooks()->unsubscribeProducts([$productId1, $productId2]); // List what's currently subscribed for a shop $page = $client->webhooks()->subscribedProducts($shopId, page: 1, perPage: 20);
Product ids are automatically de-duplicated and batched in groups of 100 per request — pass as many ids as you like.
Receiving webhooks
use Thayron\CjDropshipping\Webhooks\WebhookEvent; $event = WebhookEvent::fromRequest( rawBody: $rawRequestBody, // must be the exact raw body, not re-encoded JSON signature: $request->header('sign'), openId: $client->openId(), ); $event->messageId; // dedupe retries by this $event->type; // WebhookType enum, or null for an unrecognized type $event->typeName; // raw `type` from the payload, upper-cased, even when $type is null $event->messageType; // INSERT, UPDATE, DELETE, CANCEL, PAID, ... $event->params; // the payload's `params` array
- Always pass the raw request body to
fromRequest()/fromPayload()— re-serializing the parsed JSON will not match CJ's signature. - Respond with a 2xx status within 3 seconds, or CJ will retry the delivery.
- Deduplicate deliveries using
$event->messageId. - CJ may add new webhook types over time. An unrecognized
typedoes not throw:$event->typeisnulland$event->typeNamestill carries the raw value, so you can branch on it defensively.
$client->openId() returns the secret CJ uses to sign webhooks for this API key; it triggers a token request the first time it's called if none is cached yet.
Error handling
Every SDK exception extends Thayron\CjDropshipping\Exceptions\CjException (itself a RuntimeException), which exposes errorCode (CJ's numeric code, if any), requestId and response (the raw envelope).
| Exception | Meaning |
|---|---|
AuthenticationException |
Invalid API key, or a token request/refresh was rejected. |
InvalidTokenException |
An already-authenticated request's access token was rejected by CJ. |
RateLimitException |
CJ code 1600200, or HTTP 429. |
QuotaExceededException |
CJ codes 1600201, 16900500. |
ValidationException |
CJ validation error codes, or an invalid webhook payload. |
ServerException |
CJ codes 1600000/1600301, or HTTP 5xx. |
NotFoundException |
CJ "not found" error codes. |
ApiException |
Any other CJ error response. |
TransportException |
The HTTP client failed to reach the API, or the response wasn't valid JSON. |
InvalidSignatureException |
A webhook's sign header is missing or doesn't match. |
UnexpectedResponseException |
A stored access token couldn't be decoded. |
Retry behavior (configured via Config::$maxRetries / $retryBaseDelayMs, exponential backoff with jitter):
RateLimitExceptionandServerExceptionare retried for every HTTP method.TransportException(network-level failures) is retried only for GET requests, since retrying a POST that may have already been applied is unsafe.- An
InvalidTokenExceptionon an authenticated request triggers exactly one automatic token renewal and retry, transparently.
Escape hatch
Any endpoint not covered by products()/webhooks() — orders, logistics, disputes, shop, storage, tickets, settings, etc. — can be called directly:
$data = $client->request('GET', 'some/endpoint', query: ['foo' => 'bar']); $data = $client->request('POST', 'some/endpoint', body: ['foo' => 'bar']);
This goes through the same authenticated, retrying connector and returns the data field of CJ's response envelope.
Laravel
The service provider and CjDropshipping facade are auto-discovered — no manual registration needed.
Publish the config file:
php artisan vendor:publish --tag=cjdropshipping-config
Set these .env keys (see config/cjdropshipping.php):
CJ_API_KEY=your-cj-api-key CJ_BASE_URI=https://developers.cjdropshipping.com/api2.0/v1/ CJ_TIMEOUT=30 CJ_MAX_RETRIES=2 CJ_RETRY_BASE_DELAY_MS=1000 CJ_CACHE_STORE=
CJ_CACHE_STORE selects which of your app's cache stores holds the access token (leave empty for the default store). The bound CjClient uses a Psr16TokenStore on that store automatically.
Usage via the facade or dependency injection:
use Thayron\CjDropshipping\Laravel\Facades\CjDropshipping; use Thayron\CjDropshipping\CjClient; CjDropshipping::products()->categories(); // or public function __construct(private readonly CjClient $client) {}
To use your own PSR-18 client (custom middleware, testing, etc.), bind it before the package's provider resolves CjClient:
$this->app->bind(\Psr\Http\Client\ClientInterface::class, fn () => $myCustomClient);
Receiving webhooks
use Illuminate\Support\Facades\Route; use Thayron\CjDropshipping\Laravel\Middleware\VerifyCjWebhookSignature; Route::post('/webhooks/cj/product', function (\Illuminate\Http\Request $request) { $event = $request->attributes->get(VerifyCjWebhookSignature::EVENT_ATTRIBUTE); // handle $event (Thayron\CjDropshipping\Webhooks\WebhookEvent) return response()->noContent(); })->middleware(VerifyCjWebhookSignature::class);
The middleware verifies the sign header against the raw body, aborting with 401 on an invalid/missing signature or 422 on an invalid payload (missing type/messageId, or invalid JSON), and puts the parsed WebhookEvent on the request under VerifyCjWebhookSignature::EVENT_ATTRIBUTE.
Remember to exclude this route from CSRF verification (e.g. add it to $except in VerifyCsrfToken), since CJ posts to it without a CSRF token.
Testing
composer test # phpunit (unit + integration, excludes the "live" group) composer analyse # phpstan, level 8 composer format # pint
Live tests hit the real CJdropshipping API and are excluded by default. Run them explicitly with a real API key:
CJ_API_KEY=your-cj-api-key vendor/bin/phpunit --group live
Roadmap
Orders, payments, sandbox and logistics (freight calculation, tracking) are not implemented yet — planned for a future Phase B. Anything not yet covered by products()/webhooks() can still be called through the generic request() escape hatch.
License
MIT. See LICENSE.