Store Hugs Developers

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

$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

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.