PHP SDK
storehugs/integration is the open-source PHP client for the Store Hugs Integration API. It runs on PHP 8.2+ and sends requests with Guzzle 7.15.2+ or 8.0.3+, or with any PSR-18 HTTP client you pass it.
It has a client for each half of the API:
| Client | API | Key | Runs on |
|---|---|---|---|
StorefrontClient |
Storefront API: catalog, carts, checkout links, signed-in shoppers | Secret (sk_live_…), with the shopper's IP |
Your server |
AdminClient |
Admin API: products, stock, orders, customers, marketing, reviews, quotations, keys, webhooks | Admin (sa_live_…) |
Your server |
Both need the shop to be on the Pro or Business plan, with the Integration API turned on in the panel under Shop Settings → Integration API.
Install
composer require storehugs/integration
Guzzle comes with it (7.15.2+ or 8.0.3+, whichever your app already uses), so nothing else is needed. Laravel apps already have it. Earlier Guzzle releases have security advisories, and Composer refuses them by default. Classes are under the StoreHugs\Integration namespace.
Quick start
A cart and a checkout link for a shopper on your own storefront:
use StoreHugs\Integration\StorefrontClient;
$storefront = new StorefrontClient(
host: 'acme.storehugs.com',
key: getenv('STOREHUGS_SECRET_KEY'),
shopperIp: $request->ip(),
);
$products = $storefront->products->list(['search' => 'mug', 'perPage' => 12])['data'];
$cart = $storefront->createCart();
$session = $storefront->cart($cart['cartToken']);
$session->addItem($products[0]['id'], 2);
$link = $session->checkout(['returnPath' => '/thanks']);
// Redirect the shopper to $link['checkoutUrl'].
Every order changed since a date:
use StoreHugs\Integration\AdminClient;
$admin = new AdminClient(getenv('STOREHUGS_ADMIN_KEY'));
foreach ($admin->orders->all(['updatedSince' => '2026-11-01T00:00:00Z']) as $order) {
echo $order['orderNumber'], ' ', $order['status'], PHP_EOL;
}
Answers
Answers are arrays, decoded from the API's JSON. Their shapes are PHPStan types generated from the OpenAPI file, in StoreHugs\Integration\Schemas. Import the ones you need, so PHPStan and your editor know each key:
use StoreHugs\Integration\Schemas;
/**
* @phpstan-import-type Cart from Schemas
* @phpstan-import-type AdminProduct from Schemas
*/
final class CartController
{
/** @param Cart $cart */
private function itemCount(array $cart): int
{
return $cart['totalQuantity'];
}
}
Errors
A refusal throws StoreHugs\Integration\IntegrationApiException. Network errors that outlast the retries are thrown as the HTTP client's own PSR-18 exceptions (with Guzzle, GuzzleHttp\Exception\ConnectException).
| Member | Type | What it is |
|---|---|---|
status |
int |
The HTTP status, also getCode(). |
errorCode |
?string |
The API's code, such as invalid_api_key, insufficient_scope, edit_conflict, limit_reached or idempotency_key_reused. |
errors |
array<string, list<string>> |
Messages per field, on a 422. |
isValidationError() |
bool |
Whether it's a 422 with messages per field. |
getMessage() |
string |
The API's message. |
body |
mixed |
The decoded answer. |
response |
ResponseInterface |
The raw PSR-7 answer. |
use StoreHugs\Integration\IntegrationApiException;
try {
$admin->products->update(12, ['price' => 19.5], unmodifiedSince: $product['updatedAt']);
} catch (IntegrationApiException $error) {
if ($error->errorCode === 'edit_conflict') {
// Someone changed the product since you read it: read it again and retry.
} elseif ($error->isValidationError()) {
var_dump($error->errors); // ['price' => ['…']]
} else {
throw $error;
}
}
Retries and safe writes
- Every write is safe to retry. Each
POST,PUT,PATCHandDELETEgets anIdempotency-Key, kept across retries, so a retried write never runs twice. Write methods takeidempotencyKeylast: pass a string to keep one key across processes (for example, from a job id), orfalseto send none. - Retries: 429 and 503 answers and network errors are retried up to
maxRetriestimes (default 2). The client waits forRetry-After(at most 60 seconds), or backs off from half a second. - A write still running: when a retry reaches the server while the first attempt still runs (409
idempotency_in_progress), the client waits under the same key, for up toinProgressTimeoutseconds (default 30), and returns the first attempt's answer.
$admin->setInventory($items, idempotencyKey: "stock-sync-{$jobId}");
HTTP options
Both clients take an HttpOptions as http. By default the client makes its own Guzzle client, which gives up on a request after 30 seconds and on connecting after 10.
| Option | Type | Default | |
|---|---|---|---|
client |
?ClientInterface |
a Guzzle client | Any PSR-18 client. Its own timeouts then apply. |
requestFactory |
?RequestFactoryInterface |
Guzzle's | A PSR-17 request factory. |
streamFactory |
?StreamFactoryInterface |
Guzzle's | A PSR-17 stream factory. |
maxRetries |
int |
2 |
Retries after 429, 503 and network errors. |
inProgressTimeout |
float |
30.0 |
Seconds to wait while a retried write still runs. |
sleep |
?Closure(float): void |
usleep |
Waits the given seconds; replace it in tests. |
timeout |
float |
30.0 |
Seconds a request may take in all, for the default Guzzle client. |
connectTimeout |
float |
10.0 |
Seconds to wait for a connection, for the default Guzzle client. |
use StoreHugs\Integration\AdminClient;
use StoreHugs\Integration\Http\HttpOptions;
$admin = new AdminClient(getenv('STOREHUGS_ADMIN_KEY'), http: new HttpOptions(
timeout: 60,
maxRetries: 3,
));
To add logging or a proxy, pass your own Guzzle client with its middleware, or another PSR-18 client such as Symfony's Psr18Client, as client.
Pagination
- Storefront API lists are numbered pages:
['data' => …, 'links' => …, 'meta' => …], wheremetahascurrentPage,lastPage,perPageandtotal. PasspageandperPage. - Admin API lists are cursor pages, where
meta.nextCursoris the next page's cursor, ornullon the last. Each Admin resource haslist($query)for one page andall($query), a generator that fetches every page as you read:
foreach ($admin->customers->all(['limit' => 100]) as $customer) {
// …
}
Webhooks
Register endpoints in the panel or with $admin->webhooks->create(). Each delivery is signed with your endpoint's whsec_… secret. Check it with Webhooks::verifySignature, on the raw body exactly as received:
use StoreHugs\Integration\Webhooks;
// In a Laravel controller:
$genuine = Webhooks::verifySignature(
$request->getContent(),
$request->header(Webhooks::SIGNATURE_HEADER),
config('services.storehugs.webhook_secret'),
);
if (! $genuine) {
abort(400, 'Invalid signature');
}
$eventId = $request->header(Webhooks::EVENT_ID_HEADER);
// Skip an event id you've already handled: retries reuse it.
$event = json_decode($request->getContent(), true);
return response()->noContent();
Webhooks::verifySignature($rawBody, $signatureHeader, $secret, $toleranceSeconds = 300, $now = null)
Returns true when an HMAC-SHA256 of the delivery matches one of the header's v1 signatures (two while a replaced secret still signs) and the signed time is within $toleranceSeconds of now. $now is the current time in seconds, for tests.
An empty $secret throws InvalidArgumentException instead of answering. Anyone can sign with an empty key, so a missing setting (an unset environment variable read with getenv(), for example) fails loudly rather than letting forged deliveries through. A null from config() fails too, with a TypeError.
Header constants
Webhooks::EVENT_ID_HEADER (Storehugs-Event-Id), Webhooks::EVENT_TYPE_HEADER (Storehugs-Event-Type) and Webhooks::SIGNATURE_HEADER (Storehugs-Signature).
The Admin API reference lists every event and its data.
PKCE helpers
Shopper sign-in and app approval both use OAuth with S256 PKCE. The SDK makes the values for you:
Pkce::create()
Returns a PkcePair with verifier and challenge. Send the challenge with the redirect, keep the verifier in the session for the code exchange.
Pkce::state()
Returns a random state to send with the redirect and check on the way back.
See shopper sign-in and apps.