# Storlio Commerce API v1

Connect a website or server application to one Storlio store. Both directions are supported: your application writes products/stock to Storlio, and consumes Storlio reads and signed webhook events. Storlio does not automatically crawl or rewrite your website. Your developer must implement that side of the connection. This release does not include a WordPress plugin.

## Getting started

The store owner opens **Store settings → API → New connection**, selects permissions, optionally supplies a public HTTPS webhook URL, and copies the API key and signing secret. Secrets are displayed once and must be stored on the server, never in browser JavaScript. Keys expire after the selected period, at most one year. Replacing a key immediately invalidates its predecessor. Replacing a signing secret does not replace the API key. Revocation disables access, checkout links and webhook delivery; imported products remain.

Base URL: `https://backend.storlio.ng/api/commerce/v1` (use the API base URL displayed in your Storlio workspace for other environments).

Headers:

```http
Authorization: Bearer stc_YOUR_SERVER_KEY
Accept: application/json
Content-Type: application/json
Idempotency-Key: 415ce0b0-1710-4986-b29d-beb08ff882fa
```

All writes require a unique 8–120 character `Idempotency-Key` containing letters, numbers, hyphens or underscores. Retry an identical request with the same key after a timeout. Successful responses are retained and replayed without applying the mutation again. A reused key with different method, path or body returns 409. After reconciling a version conflict, use a **new** key for the changed request. Do not intentionally delete local idempotency records while a request may still retry.

## Permissions and endpoints

| Method | Path | Permission | Purpose |
| --- | --- | --- | --- |
| GET | `/products` | `products:read` | Paginated export, including unpublished products |
| GET | `/products/{id}` | `products:read` | Product and current version |
| POST | `/products` | `products:write` | Create, import, link or upsert by external ID |
| PATCH | `/products/{id}` | `products:write` | Update selected fields, with current version |
| GET | `/inventory/{productId}` | `inventory:read` | Stock, variation keys and inventory version |
| PUT | `/inventory/{productId}` | `inventory:write` | Replace reconciled stock with current version |
| POST | `/checkout-links` | `checkout:write` | Create a hosted Storlio checkout handoff |
| GET | `/orders` | `orders:read` | Paginated store order summaries |
| GET | `/orders/{id}` | `orders:read` | Store-scoped order summary |

Keys cannot access ordinary Storlio account/admin APIs, another store, payments, payouts or order payment-status writes. Errors use `{ "message": "...", "errors": { ... } }` where field validation applies: 401 invalid/revoked/expired key, 403 missing permission or protected moderation, 404 missing/foreign resource, 409 version/mapping/idempotency conflict, 422 invalid data, 429 rate limit. The initial limit is 120 requests/minute per caller IP; honor 429 and Retry-After. Product, inventory and order versions are not interchangeable.

## Authentication and credentials

Every connection is owned by one store. Switch to the correct store, then open its Settings > API. Different stores need different connections and credentials; you cannot select another store by adding a store ID to an API request.

| Credential | What it does | What you can see later |
| --- | --- | --- |
| API key | Authenticates requests FROM your server TO Storlio using the Authorization bearer header | Only an identifying prefix; the full key is shown once |
| Webhook signing secret | Verifies messages FROM Storlio TO your webhook receiver | The full secret is shown once; it cannot be revealed later |

Both values are secrets. Keep them in server environment variables or a secret manager, never in browser code, public repositories, URLs or screenshots. Do not enter real credentials into the public documentation. Downloads contain placeholders only.

The owner can inspect a connection's name, permissions, masked API key, expiry and last-used date in settings. If a credential is lost, choose Replace API key or Replace signing secret. API key replacement immediately invalidates the old API key. Signing-secret replacement affects new webhook signatures; coordinate the receiver change first. Neither replacement renews the connection's expiry. Revocation stops API access and webhook delivery, invalidates checkout links and leaves products in the store.

## Troubleshooting and safe retries

| Status | Meaning | Next step |
| --- | --- | --- |
| 401 | Missing, replaced, expired or revoked key | Check the server key and connection status in this store's settings |
| 403 | Missing permission or protected product moderation | Ask the owner to review permissions; do not attempt to bypass moderation |
| 404 | Resource missing or belonging to another store | Check that you are using the correct store's connection and resource IDs |
| 409 | Stock/product version changed, mapping conflict, another write in progress or reused key with different details | Read the message. Reconcile stale state before sending a changed request with a new key. If only another write is in progress, retry the unchanged request with its original key |
| 422 | Invalid request fields | Read the errors object and correct the fields |
| 429 | Rate limit reached | Wait for Retry-After and reduce request frequency |
| 5xx / timeout | Result may be unknown | Retry the identical write with the original Idempotency-Key, then reconcile against current state |

Example validation error: { "message": "The title field is required.", "errors": { "title": ["The title field is required."] } }.

A successful write can be replayed with the same key without applying it again. Replayed responses include Idempotent-Replayed: true. Always generate a new key for a new logical operation, including intentionally repeating an earlier change. Never reuse a key with a different path or body. Example response IDs, versions and dates in the guide are illustrative; read the real values before updating.

## Products: import and export

```json
{
  "externalId": "website-product-123",
  "title": "Blue shirt",
  "description": "A cotton shirt",
  "price": 15000,
  "currency": "NGN",
  "cover": { "kind": "image", "url": "https://shop.example/images/blue.jpg" },
  "visible": false,
  "variants": [{ "name": "Size", "values": ["M", "L"] }],
  "variantIds": { "website-variant-41": "Size:M", "website-variant-42": "Size:L" }
}
```

POST returns 201 for creation, or 200 when updating/linking. Response: `{ "data": { "id": "51", "externalId": "website-product-123", "version": 1, ... } }`.

- New products start unpublished unless `visible: true` is supplied. Public discovery and checkout still enforce Storlio's existing store/subscription and product policies. Product write permission cannot clear moderation or republish a moderated product.
- v1 creates/updates **standard physical products, NGN, HTTPS image media**. Digital, custom-order and preorder products can be exported but their specialised settings cannot be changed through these write endpoints. Do not convert them by changing arbitrary JSON fields.
- Required create/upsert fields: title, description, price, currency and cover. Optional fields: externalId, gallery (up to 12 images), tags, category, variants and variantIds. PATCH accepts a subset of supported fields. Shipping uses the store's configured origin; custom dispatch addresses are not writable through this version.
- `externalId` is permanent and unique within a connection. Store the returned Storlio product ID. Different websites need different connections. To match an existing Storlio product instead of making a copy, POST its `productId`, `externalId`, current `expectedVersion` and required product fields. Never automatically match by title.
- To update an existing product, supply `expectedVersion` from a fresh GET. A 409 means re-read, reconcile the difference and retry. Example PATCH: `{ "expectedVersion": 3, "price": 16000 }`. Do not blindly fetch a version and overwrite newer values with a stale website snapshot.
- Variation options use Storlio's existing format (`name`, `values`, optional `valueConfig`/`combinationConfig` prices and SKUs). Maximum three option groups and 200 combinations. External variation IDs map to the returned `variantKeys`. For multiple options keys follow configured order, e.g. `Color:Blue | Size:M`. Variation stock is **not writable** through product fields; use inventory permission. Set existing stock to zero before changing the variation structure, then supply the complete new variation stock.
- Export: `GET /products?perPage=30&after=51`. `nextCursor` is the next `after` value, or null at the end. Optional filters: `externalId`, `updatedSince` (ISO date-time). IDs are strings; `perPage` is 1–100. Retain `version` when applying changes from reads/webhooks.

## Inventory and sync ownership

Storlio's inventory is the shared count in this release. Your website mirrors that count and must reconcile sales and stock movements with Storlio. Do not run two independent stock masters. No automatic external stock reservation API is provided in v1, and periodic snapshot copying alone cannot guarantee against simultaneous sales on independent checkouts. Hosted links use Storlio's existing checkout and stock checks; a link is not a reservation or a payment confirmation.

GET `/inventory/51`:

```json
{
  "data": {
    "productId": "51", "inventoryId": "12", "version": 4,
    "tracked": true, "quantity": 8,
    "variantStock": { "Size:M": 5, "Size:L": 3 },
    "variantKeys": ["Size:M", "Size:L"]
  }
}
```

PUT `/inventory/51`:

```json
{ "expectedVersion": 4, "quantity": 7, "variantStock": { "Size:M": 4, "Size:L": 3 } }
```

Send every variation key, with quantities summing to `quantity`. A product without variations uses quantity alone. An untracked product returns `tracked: false`, quantity null, version 0; use expectedVersion 0 to establish tracking. Inventory IDs identify tracking lifetimes: if a product is changed to a non-stock-tracked type and back, fetch the new inventory ID/version rather than comparing against a deleted inventory record. Product changes may require refreshing inventory as well.

Updates lock the product and inventory rows and reject mismatched versions. Storlio order stock deductions increment the inventory version and emit inventory events. Confirm the returned stock, not your original request, after a retry. A product already using another built-in two-way stock connector cannot accept API stock writes until that sync is disconnected.

## Checkout links

POST `/checkout-links`:

```json
{ "items": [{ "productId": "51", "quantity": 2, "options": { "Size": "M" } }] }
```

Response 201:

```json
{ "url": "https://usestorlio.com/checkout/connect/OPAQUE_TOKEN", "expiresAt": "2026-10-21T13:00:00Z" }
```

Return the generated URL to the shopper. Maximum 30 distinct product/variation lines; combine duplicate lines first. All items must belong to this connection's store. Storlio validates the actual products, prices, stock and selected variations. Caller-supplied totals, payment status and redirect URLs are not accepted. The shopper reviews the handoff, then chooses delivery/payment in the existing checkout. Continuing replaces their current Storlio cart. One-hour links are bearer capabilities: don't log or publish them. They do not reveal customer details and are invalidated by key connection expiry/revocation. Availability and prices are revalidated on use; creating a link does not reserve stock, create an order or charge anyone.

## Orders

`GET /orders?perPage=30&before=123` returns `{ "data": [...], "nextCursor": "..." }`. Supply the cursor as `before` until it is null. A page scans a bounded number of orders and may contain fewer than perPage or no matching orders while still returning a nextCursor; continue following it. Order webhooks avoid repeated polling.

Order summaries include id, paymentStatus, deliveryStatus, currency, this store's items, this store's itemsTotal and timestamps. Multi-store order totals, other sellers' items, customer email/phone/address, payment credentials and delivery verification codes are excluded. This API does not create external orders, mark orders paid or change fulfilment status. Use Storlio's checkout for order creation and the seller dashboard for fulfilment.

## Webhooks: Storlio → your website

Enable the matching **read** permissions and configure an HTTPS webhook URL to receive:

- `product.created`, `product.updated`, `product.deleted`
- `inventory.updated`
- `order.created`, `order.updated` (order payment/delivery/items changes)

```json
{
  "id": "b8661f68-c66b-4f9d-adfe-fe5c8392c303",
  "type": "inventory.updated",
  "apiVersion": "v1",
  "storeId": "14",
  "originConnectionId": 3,
  "occurredAt": "2026-10-21T12:00:00Z",
  "data": { "productId": "51", "inventoryId": "12", "version": 5, "quantity": 7 }
}
```

`originConnectionId` identifies writes made with a connection key (null for ordinary Storlio changes). Use it to recognise your own events. An event is a notification of a state change, not an instruction to write that same state back: do not echo events into new writes. Product mapping and API-origin information are included after the mapping is persisted. Deleted products emit an ID and last version.

Verify these headers **before parsing or applying** a webhook:

```text
X-Storlio-Event: EVENT_UUID
X-Storlio-Timestamp: UNIX_SECONDS
X-Storlio-Signature: sha256=HEX_HMAC
```

The signed bytes are `timestamp + "." + raw_request_body`, HMAC-SHA256 with your signing secret. Compare signatures in constant time, reject a timestamp older than five minutes (allow modest clock skew), and deduplicate the event ID. Example Node receiver check:

```js
import { createHmac, timingSafeEqual } from 'node:crypto';
const expected = Buffer.from('sha256=' + createHmac('sha256', signingSecret)
  .update(timestamp + '.').update(rawBody).digest('hex'));
const received = Buffer.from(signatureHeader || '');
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300 ||
    received.length !== expected.length || !timingSafeEqual(received, expected)) {
  throw new Error('Invalid Storlio webhook');
}
```

Durably record/enqueue accepted events and respond with 2xx quickly. Delivery is at least once, and concurrent workers may deliver resources out of order. Deduplicate IDs, ignore older/already-applied product or inventory versions, use inventoryId with its version, and fetch current state when uncertain. Order events have no incrementing version: treat them as a signal to fetch the current order state. Expired/revoked connections and removed permissions stop delivery. Redirects are not followed; private/reserved IPs, credential-bearing URLs and custom ports are rejected, and DNS is validated/pinned for each attempt.

Failed delivery attempts back off exponentially (initial retry about one minute, cap six hours); after eight attempts the owner can retry from Webhooks. The integrations queue and minute scheduler must run. Changing the webhook URL cancels pending events rather than redirecting old payloads. Failed historical deliveries should only be retried to the intended endpoint. History and linked products are paginated in the seller workspace.

## Deployment and validation

Deploy backend and frontend together. Run `php artisan migrate --force`, then restart queue workers (`php artisan queue:restart`). Keep the existing integrations worker running, plus Laravel's scheduler (normally `php artisan schedule:run` every minute). The scheduler runs `commerce:deliver-webhooks`; that command can also be used to enqueue due retries. PHP cURL is required for webhook transport. A shared cache supporting atomic locks is required for API writes and webhook workers across all application servers. APP_KEY must remain stable so signing secrets can be decrypted.

Start with a staging store and a staging HTTPS receiver. Test imports, mapping existing products, variation stock, stale-version conflicts, identical retry replay, signature verification, failed delivery retry, key rotation, revoked/expired connections and checkout handoff. No DNS verification, WordPress plugin, custom website crawler or automated website database rewriting is included in this API release.
