Connect your store to accounting, warehouse and in-house systems: read and create orders, sync products, stock and customers, and receive events in real time over webhooks.
A JSON REST API. Every request carries a token that belongs to exactly one store, and the store is decided by the token alone, so no token can reach another store's data. Fields are camelCase, dates are ISO 8601 in UTC, and money is a number in the order or store currency with a currency field beside it.
In the dashboard: Settings > API tokens, then New token. Tick only the scopes your integration needs and copy the token right away: it is shown once. Send it on every request:
Authorization: Bearer shf_xxxxxxxxxxxxxxxxxxxxxxxx| Scope | Allows |
|---|---|
catalog:read | View products, categories, brands, stock levels and prices. |
catalog:write | Create and change products, categories, prices and stock. |
orders:read | View orders, their statuses and the customer details on them. |
orders:write | Change order statuses and update order details. Does not include refunds. |
customers:read | View customers, contact details, addresses and order history. |
customers:write | Create and change customers and their details. |
A write scope includes read on the same surface. A token with no scopes can do nothing; a revoked or expired token is refused with 401 at once. A request missing a scope gets 403 with details.requiredScope.
https://shofniplus.com/api/v1The API also answers on your store's own domain under /api/v1. A first call:
curl "https://shofniplus.com/api/v1/orders?limit=5" \
-H "Authorization: Bearer $SHOFNI_TOKEN"120 requests per trailing minute per token. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Over the limit you get 429 with Retry-After in seconds. Request bodies are capped at 4 MB (413 above that).
HTTP/1.1 429 Too Many Requests
Retry-After: 12
{ "error": "rate_limited", "retryAfter": 12 }{
"error": {
"code": "validation_failed",
"message": "\"name\" is required.",
"details": { "field": "name" }
}
}| HTTP | code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing, invalid or revoked token. |
| 403 | forbidden | The token lacks the required scope. |
| 404 | not_found | Not found in this store. |
| 409 | conflict | Conflict, e.g. a duplicate SKU. |
| 422 | validation_failed | Invalid input; details.field names the field. |
| 429 | rate_limited | Rate limit exceeded. |
| 500 | internal_error | Our fault; retry later. |
Switch on code; message is for humans and may change.
Lists take ?page= and ?limit= (max 100, default 25), newest first, and answer:
{
"data": [ ... ],
"pagination": { "page": 1, "limit": 25, "total": 240, "totalPages": 10 }
}Common filters where they apply: created_after, created_before, updated_after (ISO 8601) and q for search. For incremental sync, pass updated_after with your last sync time. Each endpoint's own filters are listed in the reference below.
POST /orders accepts an Idempotency-Key header (up to 200 characters). The same key for the same store within 24 hours returns the first order instead of creating a second one, with 200 and Idempotent-Replayed: true (the first create is 201). Other create endpoints do not support it in v1: creating a product with an existing SKU is refused with 409.
Add an endpoint under Settings > API tokens > Webhooks or with POST /webhooks. It must be https on a public address (private addresses and non-default ports are refused), up to 10 per store. The signing secret is returned on create and rotation, and settings users can reveal it in the dashboard.
| Event | Scope | When |
|---|---|---|
order.created | orders:read | A new order from any channel: storefront, POS, tables, pre-orders, MCP or API. |
order.updated | orders:read | The order was edited: lines, prices, customer/address details, or a new POS round on a table order. |
order.status_changed | orders:read | The status moved. data.previousStatus holds the status it left. |
order.paid | orders:read | A successful capture was recorded in the payment ledger and the order is paid (gateway, POS, manual payment). |
order.cancelled | orders:read | The order entered a cancelled status and its stock was returned. |
customer.created | customers:read | A customer was created: dashboard, import, API, checkout, POS or storefront sign-up. |
customer.updated | customers:read | A customer's profile was edited. |
product.updated | catalog:read | A product was created or one of its fields changed (dashboard, bulk edit, import, API). |
inventory.updated | catalog:read | Stock moved through the stock ledger: manual adjustment, count, receiving, cancellation or return, API. |
Subscribing over the API needs orders:write plus each event's read scope (e.g. customers:read for customer events). data is the same shape the matching GET endpoint returns; inventory.updated carries the stock movement. Test an endpoint with a ping via POST /webhooks/{id}/test.
POST /webhooks/shofni HTTP/1.1
Content-Type: application/json
User-Agent: Shofni-Webhooks/1.0
X-Shofni-Event: order.status_changed
X-Shofni-Delivery: dlv_4c1e9a0b7f2d83e6a5b1c09d
X-Shofni-Attempt: 1
X-Shofni-Signature: t=1760870400,v1=8f3a...c21e
{
"id": "evt_0c7e0b9d3a1f4e58b2c6d1a9e4f7b3c2",
"type": "order.status_changed",
"createdAt": "2026-10-19T10:40:00.000Z",
"data": { "id": "482913", "status": { ... }, "previousStatus": "PENDING", ... }
}Signature: v1 = hex(HMAC-SHA256(secret, t + "." + rawBody)). Compute it over the raw body as received, compare in constant time, and reject timestamps older than 5 minutes. Answer any 2xx within 5 seconds; anything else is retried after 1m, 5m, 30m, 2h, 6h (6 attempts in all). Retries carry the same event id and X-Shofni-Delivery, so use it to de-duplicate. Redirects (3xx) are not followed.
import crypto from "node:crypto";
import express from "express";
import { localeAlternates } from "../seo";
const app = express();
const SECRET = process.env.SHOFNI_WEBHOOK_SECRET; // whsec_...
// Read the RAW body: the signature covers the exact bytes we sent.
app.post("/webhooks/shofni", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Shofni-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${req.body}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - t) <= 300;
const valid =
fresh &&
typeof parts.v1 === "string" &&
parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!valid) return res.status(400).send("invalid signature");
const event = JSON.parse(req.body.toString("utf8"));
// Retries reuse event.id: ignore an id you have already processed.
console.log(event.type, event.data.id);
res.sendStatus(200); // answer fast (5 s timeout); do heavy work async
});
app.listen(3000);<?php
$secret = getenv('SHOFNI_WEBHOOK_SECRET'); // whsec_...
$payload = file_get_contents('php://input'); // raw body
$header = $_SERVER['HTTP_X_SHOFNI_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $kv) {
[$k, $v] = array_pad(explode('=', trim($kv), 2), 2, '');
$parts[$k] = $v;
}
$t = (int) ($parts['t'] ?? 0);
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);
if (abs(time() - $t) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($payload, true);
// Retries reuse $event['id']: ignore an id you have already processed.
http_response_code(200);import hashlib, hmac, json, os, time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["SHOFNI_WEBHOOK_SECRET"].encode() # whsec_...
@app.post("/webhooks/shofni")
def shofni_webhook():
payload = request.get_data() # raw bytes
header = request.headers.get("X-Shofni-Signature", "")
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t = int(parts.get("t", "0"))
expected = hmac.new(SECRET, f"{t}.".encode() + payload, hashlib.sha256).hexdigest()
if abs(time.time() - t) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
event = json.loads(payload)
# Retries reuse event["id"]: ignore an id you have already processed.
return "", 200Generated from the OpenAPI document at /api/v1/openapi.json (import it into Postman or Insomnia).
/ordersList ordersNewest first. Held table baskets are excluded.
| Field | In | Type | Description |
|---|---|---|---|
page | query | integer | |
limit | query | integer | |
status | query | string | Comma-separated status codes of this store. Unknown codes: 422 with `details.allowed`. |
payment_status | query | string | Comma-separated: paid, unpaid, failed, authorized, refunded_partial, refunded_full, refund_failed. |
created_after | query | string | ISO 8601, inclusive. |
created_before | query | string | ISO 8601, exclusive. A date without a time covers that whole UTC day. |
updated_after | query | string | ISO 8601, inclusive. Any change to the order moves `updatedAt`. |
q | query | string | Order number, customer name, phone or email, tracking number, product/variant SKU. |
channel | query | string | Comma-separated: store, pos, app, whatsapp, phone, manual, external. |
200A page of orders.401Missing or invalid token.403Token lacks the required scope.422Bad filter.429Rate limited (120 requests per minute per token).curl "https://shofniplus.com/api/v1/orders" \
-H "Authorization: Bearer $SHOFNI_TOKEN"{
"data": [
{
"id": "483920",
"number": "483920",
"status": {
"code": "PENDING",
"group": "NEW",
"name": {
"ar": "جديد",
"en": "New"
},
"customerName": {
"ar": "تم استلام طلبك",
"en": "Order received"
}
},
"paymentStatus": "paid",
"paymentMethod": {
"code": "card",
"group": "card"
},
"currency": "SAR",
"total": 27,
"amountPaid": 27,
"channel": "external",
"source": "api",
"serviceType": "delivery",
"itemCount": 2,
"customer": {
"id": "clx1cust",
"name": "سارة أحمد",
"phone": "966551112233",
"email": "[email protected]"
},
"trackingNumber": "AWB123",
"createdAt": "2026-10-19T08:00:00.000Z",
"updatedAt": "2026-10-19T08:05:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 25,
"total": 1,
"totalPages": 1
}
}/ordersCreate an orderCreates a PENDING order priced by the store (catalogue, variant surcharges, add-ons, membership prices, tax), the same path as the register. Tracked stock is deducted with stock movements and returned if the order is later cancelled; the store owner gets the usual new-order alert; restaurants get a kitchen ticket. Cash on delivery is refused (403) for customers the store blocked from COD. `Idempotency-Key`: optional. The same key within 24 hours returns the first order with 200 and `Idempotent-Replayed: true` instead of creating another.
| Field | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string |
| Field | Type | Description |
|---|---|---|
items* | OrderCreateItem[] | |
customer* | object | Either `{ id }` of an existing customer of this store, or `{ phone, name?, email? }`: the phone is matched against the store's customers in every spelling; a new customer is created (source API) when none matches. Existing names/emails are never overwritten, only filled in when empty. |
serviceType | "delivery" | "pickup" | "dine_in" | Default: delivery when shippingAddress is given, else pickup. Delivery needs shippingAddress.street or shippingAddress.shortAddress. |
shippingAddress | object | |
paymentMethod | "cod" | "cash" | "card" | "bank_transfer" | |
paid | boolean | true records a successful capture for the full total on the payment ledger (gateway "api"). Not allowed with cod. No money is charged by this API. |
note | string | Customer-facing order note. |
staffNote | string | Internal staff note added to the order. |
channel | string | Origin label. STORE, POS, APP, WHATSAPP or PHONE is stored as the order channel; any other text is kept as a free-form label. `source` is always "api". |
200Idempotent replay of an earlier request with the same key.201Created.401Missing or invalid token.403COD blocked for this customer, or missing scope.409Could not allocate an order number; retry.422Invalid input, unknown product, not enough stock, or a unit price was sent.429Rate limited (120 requests per minute per token).curl -X POST "https://shofniplus.com/api/v1/orders" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"items": [
{
"productId": "clx1burger",
"quantity": 2,
"note": "بدون بصل"
},
{
"sku": "CAKE-01",
"quantity": 1
}
],
"customer": {
"name": "سارة أحمد",
"phone": "+966551112233",
"email": "[email protected]"
},
"serviceType": "delivery",
"shippingAddress": {
"city": "الرياض",
"district": "العليا",
"street": "شارع التحلية",
"building": "12",
"shortAddress": "RRRD2929",
"latitude": 24.7,
"longitude": 46.68
},
"paymentMethod": "card",
"paid": true,
"note": "اتصل قبل الوصول",
"staffNote": "VIP",
"channel": "erp-x"
}'/orders/{id}Get one order| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Order id or its visible number. `123456`, `#A1B2C3` (URL-encode `#` as `%23`) and the legacy six-character suffix are accepted. |
200The order.401Missing or invalid token.403Token lacks the required scope.404No such order in this store (ids of other stores read as not found).429Rate limited (120 requests per minute per token).curl "https://shofniplus.com/api/v1/orders/{id}" \
-H "Authorization: Bearer $SHOFNI_TOKEN"{
"data": {
"id": "483920",
"number": "483920",
"status": {
"code": "PENDING",
"group": "NEW",
"name": {
"ar": "جديد",
"en": "New"
},
"customerName": {
"ar": "تم استلام طلبك",
"en": "Order received"
}
},
"paymentStatus": "paid",
"paymentMethod": {
"code": "card",
"group": "card"
},
"currency": "SAR",
"channel": "external",
"source": "api",
"serviceType": "delivery",
"totals": {
"subtotal": 27,
"listSubtotal": 27,
"discountTotal": 0,
"couponDiscount": 0,
"offerDiscount": 0,
"discounts": [],
"shipping": 0,
"codFee": 0,
"fees": [],
"tax": 0,
"taxInclusive": false,
"total": 27,
"amountPaid": 27,
"amountRefunded": 0,
"amountDue": 0
},
"lineItems": [
{
"productId": "clx1burger",
"variantId": null,
"sku": "BRG-01",
"name": "برجر",
"nameEn": "Burger",
"options": [],
"variantLabel": null,
"addons": [],
"note": "بدون بصل",
"quantity": 2,
"unitPrice": 10,
"listUnitPrice": 10,
"discount": 0,
"tax": 0,
"lineTotal": 20
}
],
"customer": {
"id": "clx1cust",
"name": "سارة أحمد",
"phone": "+966 55 111 2233",
"email": "[email protected]"
},
"shippingAddress": {
"name": "سارة أحمد",
"phone": "966551112233",
"country": null,
"region": null,
"city": "الرياض",
"district": "العليا",
"street": "شارع التحلية",
"building": "12",
"unit": null,
"postalCode": null,
"shortAddress": "RRRD2929",
"additionalNumber": null,
"coordinates": {
"lat": 24.7,
"lng": 46.68
}
},
"shipping": {
"method": "HOME_DELIVERY",
"carrierCode": "aramex",
"carrierName": "Aramex",
"trackingNumber": "AWB123",
"trackingUrl": "https://www.aramex.com/track/results?ShipmentNumber=AWB123",
"labelStatus": "ISSUED",
"shipmentStatus": null
},
"notes": {
"customer": "اتصل قبل الوصول",
"staff": [
{
"id": "clx1note",
"body": "VIP",
"authorName": "API: ERP",
"createdAt": "2026-10-19T08:00:00.000Z"
}
]
},
"tags": [],
"createdAt": "2026-10-19T08:00:00.000Z",
"updatedAt": "2026-10-19T08:05:00.000Z"
}
}/orders/{id}/statusMove an order to another statusRuns the store's own status engine, exactly as the dashboard does: allowed transitions, required reasons, kitchen ticket, stock return on cancellation, coupon/offer release, return paperwork, customer notification templates, audit trail (actor = the token). Refunds are not performed.
| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Order id or its visible number. `123456`, `#A1B2C3` (URL-encode `#` as `%23`) and the legacy six-character suffix are accepted. |
| Field | Type | Description |
|---|---|---|
status* | string | A status code this store defines (see GET /order-statuses). |
reason | string | Required by some transitions (e.g. cancellation, return). |
notify | true | Only `true` (the default) is accepted: customer notifications follow the store's templates for the new status and cannot be suppressed through the API. |
200The updated order.401Missing or invalid token.403Token lacks the required scope.404No such order in this store (ids of other stores read as not found).409The order changed concurrently.422Unknown status, transition not allowed, reason required, or notify=false.429Rate limited (120 requests per minute per token).curl -X PATCH "https://shofniplus.com/api/v1/orders/{id}/status" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"status": "CANCELLED",
"reason": "طلب العميل"
}'/orders/{id}/notesAdd an internal staff note| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Order id or its visible number. `123456`, `#A1B2C3` (URL-encode `#` as `%23`) and the legacy six-character suffix are accepted. |
| Field | Type | Description |
|---|---|---|
body* | string | @name mentions an employee of this store. |
201Note added.401Missing or invalid token.403Token lacks the required scope.404No such order in this store (ids of other stores read as not found).422Empty note.429Rate limited (120 requests per minute per token).curl -X POST "https://shofniplus.com/api/v1/orders/{id}/notes" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"body": "تم التحقق من العنوان"
}'{
"data": {
"id": "clx1note",
"orderId": "483920",
"body": "تم التحقق من العنوان",
"authorName": "API: ERP",
"createdAt": "2026-10-19T08:10:00.000Z",
"unresolvedMentions": []
}
}/orders/{id}/shippingRecord the carrier and tracking numberRecords a waybill that already exists at the carrier (manual ISSUED label, or confirms a label REQUESTED from the same carrier). Writes the shipment and order event and runs the store's LABEL_ISSUED automation. `trackingUrl` is derived from the carrier and cannot be set (422 if sent). An order that already has a live waybill returns 409: reissuing is done from the dashboard.
| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Order id or its visible number. `123456`, `#A1B2C3` (URL-encode `#` as `%23`) and the legacy six-character suffix are accepted. |
| Field | Type | Description |
|---|---|---|
carrier* | string | Carrier code: fastcoo, redbox, aramex, smsa, naqel, spl, imile, oto, deliveryone, ... |
trackingNumber* | string | |
serviceCode | string |
200The updated order.401Missing or invalid token.403Token lacks the required scope.404No such order in this store (ids of other stores read as not found).409A live waybill already exists.422Unknown carrier or carrier switched off.429Rate limited (120 requests per minute per token).curl -X PATCH "https://shofniplus.com/api/v1/orders/{id}/shipping" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"carrier": "aramex",
"trackingNumber": "AWB123"
}'/customersList customersNewest registration first. Customers merged into another record are excluded. `addresses` is not included in list rows.
| Field | In | Type | Description |
|---|---|---|---|
page | query | integer | |
limit | query | integer | |
q | query | string | Name, email or phone (local `05...` and international forms both match). |
created_after | query | string | ISO 8601; compared with the registration date. |
updated_after | query | string | Not supported for customers (no modification timestamp is kept): 422. |
200A page of customers.401Missing or invalid token.403Token lacks the required scope.422Bad filter (including updated_after).429Rate limited (120 requests per minute per token).curl "https://shofniplus.com/api/v1/customers" \
-H "Authorization: Bearer $SHOFNI_TOKEN"{
"data": [
{
"id": "clx1cust",
"number": 42,
"name": "خالد سعيد",
"phone": "966559998877",
"email": "[email protected]",
"emailVerified": false,
"marketingOptIn": false,
"language": "ar",
"country": null,
"status": "ACTIVE",
"source": "API",
"tags": [
"wholesale"
],
"loyaltyPoints": 120,
"walletBalance": 15.5,
"orderCount": 3,
"totalSpent": {
"SAR": 840
},
"lastOrderAt": "2026-10-18T19:22:00.000Z",
"registeredAt": "2026-09-01T10:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 25,
"total": 1,
"totalPages": 1
}
}/customersCreate a customerIdempotency-Key is not used here: the phone number is unique per store, so a retried create answers 409 with `details.customerId`.
| Field | Type | Description |
|---|---|---|
phone* | string | E.164 (`+` and 8 to 15 digits; spaces and dashes are ignored). Stored in the store's canonical form. |
name | string | |
email | string | |
language | "ar" | "en" | |
tags | string[] | |
addresses | object[] |
201Created.401Missing or invalid token.403Token lacks the required scope.409This store already has the phone (any spelling) or the email.422Invalid input.429Rate limited (120 requests per minute per token).curl -X POST "https://shofniplus.com/api/v1/customers" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "خالد",
"phone": "+966559998877",
"email": "[email protected]",
"tags": [
"wholesale"
],
"addresses": [
{
"label": "المنزل",
"city": "جدة",
"street": "شارع فلسطين",
"shortAddress": "JEDA1234"
}
]
}'{
"data": {
"id": "clx1cust",
"number": 42,
"name": "خالد سعيد",
"phone": "966559998877",
"email": "[email protected]",
"emailVerified": false,
"marketingOptIn": false,
"language": "ar",
"country": null,
"status": "ACTIVE",
"source": "API",
"tags": [
"wholesale"
],
"loyaltyPoints": 120,
"walletBalance": 15.5,
"orderCount": 3,
"totalSpent": {
"SAR": 840
},
"lastOrderAt": "2026-10-18T19:22:00.000Z",
"registeredAt": "2026-09-01T10:00:00.000Z",
"addresses": [
{
"id": "clx1addr",
"label": "المنزل",
"name": null,
"phone": null,
"country": null,
"region": null,
"city": "جدة",
"district": null,
"street": "شارع فلسطين",
"building": null,
"unit": null,
"postalCode": null,
"shortAddress": "JEDA1234",
"coordinates": null,
"isDefaultShipping": true,
"isDefaultBilling": true
}
]
}
}/customers/{id}Get one customer| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Customer id. |
200The customer, with addresses.401Missing or invalid token.403Token lacks the required scope.404No such customer in this store.429Rate limited (120 requests per minute per token).curl "https://shofniplus.com/api/v1/customers/{id}" \
-H "Authorization: Bearer $SHOFNI_TOKEN"{
"data": {
"id": "clx1cust",
"number": 42,
"name": "خالد سعيد",
"phone": "966559998877",
"email": "[email protected]",
"emailVerified": false,
"marketingOptIn": false,
"language": "ar",
"country": null,
"status": "ACTIVE",
"source": "API",
"tags": [
"wholesale"
],
"loyaltyPoints": 120,
"walletBalance": 15.5,
"orderCount": 3,
"totalSpent": {
"SAR": 840
},
"lastOrderAt": "2026-10-18T19:22:00.000Z",
"registeredAt": "2026-09-01T10:00:00.000Z",
"addresses": [
{
"id": "clx1addr",
"label": "المنزل",
"name": null,
"phone": null,
"country": null,
"region": null,
"city": "جدة",
"district": null,
"street": "شارع فلسطين",
"building": null,
"unit": null,
"postalCode": null,
"shortAddress": "JEDA1234",
"coordinates": null,
"isDefaultShipping": true,
"isDefaultBilling": true
}
]
}
}/customers/{id}Update a customer| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Customer id. |
| Field | Type | Description |
|---|---|---|
name | string | null | |
email | string | null | Changing it resets emailVerified. |
phone | string | E.164. 409 if another customer of this store has it in any spelling. |
language | "ar" | "en" | null | |
tags | string[] | Replaces the current tag set. |
note | string | Appends one internal note to the customer (existing notes are not editable here). |
200The updated customer.401Missing or invalid token.403Token lacks the required scope.404No such customer in this store.409Phone/email belongs to another customer, or the record was merged.422Invalid input.429Rate limited (120 requests per minute per token).curl -X PATCH "https://shofniplus.com/api/v1/customers/{id}" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "خالد سعيد",
"tags": [
"vip"
],
"note": "عميل جملة"
}'{
"data": {
"id": "clx1cust",
"number": 42,
"name": "خالد سعيد",
"phone": "966559998877",
"email": "[email protected]",
"emailVerified": false,
"marketingOptIn": false,
"language": "ar",
"country": null,
"status": "ACTIVE",
"source": "API",
"tags": [
"wholesale"
],
"loyaltyPoints": 120,
"walletBalance": 15.5,
"orderCount": 3,
"totalSpent": {
"SAR": 840
},
"lastOrderAt": "2026-10-18T19:22:00.000Z",
"registeredAt": "2026-09-01T10:00:00.000Z",
"addresses": [
{
"id": "clx1addr",
"label": "المنزل",
"name": null,
"phone": null,
"country": null,
"region": null,
"city": "جدة",
"district": null,
"street": "شارع فلسطين",
"building": null,
"unit": null,
"postalCode": null,
"shortAddress": "JEDA1234",
"coordinates": null,
"isDefaultShipping": true,
"isDefaultBilling": true
}
]
}
}/customers/{id}/ordersList a customer's ordersNewest first, open table baskets excluded. Requires customers:read.
| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Customer id. |
page | query | integer | |
limit | query | integer |
200A page of orders.401Missing or invalid token.403Token lacks the required scope.404No such customer in this store.429Rate limited (120 requests per minute per token).curl "https://shofniplus.com/api/v1/customers/{id}/orders" \
-H "Authorization: Bearer $SHOFNI_TOKEN"/productsList productscatalog:readNewest first. Archived products are excluded unless `status=archived`. Each product includes its option groups and variants.
| Field | In | Type | Description |
|---|---|---|---|
q | query | string | Search names, descriptions, SKUs, barcodes and tags (Arabic or English). |
category_id | query | string | Products whose primary category is this id. |
brand_id | query | string | Products of this brand. |
sku | query | string | Exact SKU (case-insensitive) on the product or any of its variants. |
status | query | "active" | "scheduled" | "draft" | "archived" | Lifecycle state. |
in_stock | query | boolean | true: tracked with stock > 0, or untracked. false: tracked and at or below 0. |
updated_after | query | string | ISO 8601; products changed at or after this instant (use for incremental sync). |
created_after | query | string | ISO 8601. |
created_before | query | string | ISO 8601. |
page | query | integer | Page number, from 1. |
limit | query | integer | Page size (max 100). |
200OK401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).422Bad filter value.429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl "https://shofniplus.com/api/v1/products" \
-H "Authorization: Bearer $SHOFNI_TOKEN"/productsCreate a productcatalog:writeSlugs are generated from the names unless given. Idempotency-Key is not supported for products; a repeated create with the same `sku` is refused with 409.
| Field | Type | Description |
|---|---|---|
name* | string | Arabic name (primary; the canonical URL is built from it). |
nameEn | string | null | |
description | string | null | Short Arabic description. |
descriptionEn | string | null | |
longDescription | string | null | Full Arabic body; simple HTML, sanitised on save. |
longDescriptionEn | string | null | |
slug | string | null | Arabic URL segment. Generated from the name when omitted. Changing it moves the public URL. |
slugEn | string | null | English URL alias. |
sku | string | null | Unique within the store (products and variants, case-insensitive). |
barcode | string | null | Unique within the store; EAN/UPC check digit validated. |
price* | number | Base selling price in the store currency. |
compareAtPrice | number | null | Was-price. Must be higher than `price`; null clears it. |
cost | number | null | |
status | "active" | "draft" | "archived" | `active` publishes and switches the product on; `archived` retires it and switches it off. |
categoryId | string | null | Primary category. Send this or `categoryIds`, not both. |
categoryIds | string[] | First id is the primary category, the rest are additional categories. |
brandId | string | null | |
tags | string[] | |
images | string[] | Image URLs, first is the main image. Entries already on the product and this store's own media URLs are kept as-is; any other public https URL is downloaded into the store's media library (JPEG, PNG, WebP, GIF, AVIF; max 8 MB; private/internal addresses refused). Replaces the whole list. |
trackStock | boolean | Whether stock is counted. Untracked products are never out of stock. |
isFeatured | boolean | |
isHidden | boolean | Reachable by direct link but not listed. |
options | object[] | Option groups. Replaces all groups and regenerates the variant matrix; variants for kept combinations keep their id, SKU and stock. |
stock | integer | Opening stock (turns tracking on). Written to the stock ledger as PURCHASE. Ignored with a warning when `options` create variants. |
201Created401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).409Duplicate SKU, barcode or slug.422Invalid field (details.field), foreign category/brand id, or an image URL that could not be imported.429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl -X POST "https://shofniplus.com/api/v1/products" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "تيشيرت قطن",
"nameEn": "Cotton T-shirt",
"price": 89,
"compareAtPrice": 120,
"sku": "TSH-001",
"status": "active",
"categoryIds": [
"clc1cat0001"
],
"images": [
"https://cdn.example.com/tshirt-front.jpg"
],
"trackStock": true,
"stock": 25
}'{
"data": {
"id": "clx8p2k0f0001",
"name": "تيشيرت قطن",
"nameEn": "Cotton T-shirt",
"slug": "تيشيرت-قطن",
"slugEn": "cotton-t-shirt",
"url": "https://mystore.shofniplus.com/products/%D8%AA%D9%8A%D8%B4%D9%8A%D8%B1%D8%AA-%D9%82%D8%B7%D9%86",
"description": "قطن مصري 100%",
"descriptionEn": "100% Egyptian cotton",
"longDescription": null,
"longDescriptionEn": null,
"status": "active",
"isActive": true,
"isHidden": false,
"isFeatured": false,
"sku": "TSH-001",
"barcode": null,
"price": 89,
"compareAtPrice": 120,
"effectivePrice": 79,
"onSale": true,
"cost": 35,
"currency": "SAR",
"trackStock": true,
"stock": 14,
"images": [
"https://mystore.shofniplus.com/uploads/clt0abc/1729250000000-a1b2-front.webp"
],
"categoryId": "clc1cat0001",
"categoryIds": [
"clc1cat0001",
"clc1cat0007"
],
"brand": {
"id": "clb1brand01",
"name": "نسيج",
"nameEn": "Naseej"
},
"tags": [
"صيفي"
],
"options": [
{
"id": "clv1opt0001",
"name": "المقاس",
"nameEn": "Size",
"required": true,
"values": [
{
"code": "m",
"label": "M",
"labelEn": null,
"priceDelta": 0
},
{
"code": "l",
"label": "L",
"labelEn": null,
"priceDelta": 5
}
]
}
],
"variants": [
{
"id": "cmb_01hx9k2v7",
"productId": "clx8p2k0f0001",
"sku": "TSH-RED-M",
"barcode": "6281234567897",
"optionValues": [
{
"group": "اللون",
"groupEn": "Colour",
"value": "أحمر",
"valueEn": "Red"
},
{
"group": "المقاس",
"groupEn": "Size",
"value": "M",
"valueEn": null
}
],
"price": 89,
"priceInherited": true,
"compareAtPrice": null,
"currency": "SAR",
"stock": 14,
"trackStock": true,
"isEnabled": true,
"image": null,
"weight": null,
"weightUnit": null,
"createdAt": "2026-10-01T09:12:00.000Z",
"updatedAt": "2026-10-18T14:03:11.000Z"
}
],
"weight": {
"net": 0.25,
"gross": 0.3,
"unit": "KG"
},
"createdAt": "2026-10-01T09:12:00.000Z",
"updatedAt": "2026-10-18T14:03:11.000Z"
}
}/products/{id}Get a productcatalog:read| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Product id, Arabic or English slug, or SKU (product or variant). |
200OK401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).404No such product in this store.429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl "https://shofniplus.com/api/v1/products/{id}" \
-H "Authorization: Bearer $SHOFNI_TOKEN"{
"data": {
"id": "clx8p2k0f0001",
"name": "تيشيرت قطن",
"nameEn": "Cotton T-shirt",
"slug": "تيشيرت-قطن",
"slugEn": "cotton-t-shirt",
"url": "https://mystore.shofniplus.com/products/%D8%AA%D9%8A%D8%B4%D9%8A%D8%B1%D8%AA-%D9%82%D8%B7%D9%86",
"description": "قطن مصري 100%",
"descriptionEn": "100% Egyptian cotton",
"longDescription": null,
"longDescriptionEn": null,
"status": "active",
"isActive": true,
"isHidden": false,
"isFeatured": false,
"sku": "TSH-001",
"barcode": null,
"price": 89,
"compareAtPrice": 120,
"effectivePrice": 79,
"onSale": true,
"cost": 35,
"currency": "SAR",
"trackStock": true,
"stock": 14,
"images": [
"https://mystore.shofniplus.com/uploads/clt0abc/1729250000000-a1b2-front.webp"
],
"categoryId": "clc1cat0001",
"categoryIds": [
"clc1cat0001",
"clc1cat0007"
],
"brand": {
"id": "clb1brand01",
"name": "نسيج",
"nameEn": "Naseej"
},
"tags": [
"صيفي"
],
"options": [
{
"id": "clv1opt0001",
"name": "المقاس",
"nameEn": "Size",
"required": true,
"values": [
{
"code": "m",
"label": "M",
"labelEn": null,
"priceDelta": 0
},
{
"code": "l",
"label": "L",
"labelEn": null,
"priceDelta": 5
}
]
}
],
"variants": [
{
"id": "cmb_01hx9k2v7",
"productId": "clx8p2k0f0001",
"sku": "TSH-RED-M",
"barcode": "6281234567897",
"optionValues": [
{
"group": "اللون",
"groupEn": "Colour",
"value": "أحمر",
"valueEn": "Red"
},
{
"group": "المقاس",
"groupEn": "Size",
"value": "M",
"valueEn": null
}
],
"price": 89,
"priceInherited": true,
"compareAtPrice": null,
"currency": "SAR",
"stock": 14,
"trackStock": true,
"isEnabled": true,
"image": null,
"weight": null,
"weightUnit": null,
"createdAt": "2026-10-01T09:12:00.000Z",
"updatedAt": "2026-10-18T14:03:11.000Z"
}
],
"weight": {
"net": 0.25,
"gross": 0.3,
"unit": "KG"
},
"createdAt": "2026-10-01T09:12:00.000Z",
"updatedAt": "2026-10-18T14:03:11.000Z"
}
}/products/{id}Update a productcatalog:writeOnly the fields sent are written; null clears an optional field. Renaming keeps the URL. Price changes go to the price-change ledger and every save is a restorable revision. Stock is not set here: use PATCH /api/v1/inventory or PATCH /api/v1/variants/{id}.
| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Product id, Arabic or English slug, or SKU (product or variant). |
| Field | Type | Description |
|---|---|---|
name | string | Arabic name (primary; the canonical URL is built from it). |
nameEn | string | null | |
description | string | null | Short Arabic description. |
descriptionEn | string | null | |
longDescription | string | null | Full Arabic body; simple HTML, sanitised on save. |
longDescriptionEn | string | null | |
slug | string | null | Arabic URL segment. Generated from the name when omitted. Changing it moves the public URL. |
slugEn | string | null | English URL alias. |
sku | string | null | Unique within the store (products and variants, case-insensitive). |
barcode | string | null | Unique within the store; EAN/UPC check digit validated. |
price | number | Base selling price in the store currency. |
compareAtPrice | number | null | Was-price. Must be higher than `price`; null clears it. |
cost | number | null | |
status | "active" | "draft" | "archived" | `active` publishes and switches the product on; `archived` retires it and switches it off. |
categoryId | string | null | Primary category. Send this or `categoryIds`, not both. |
categoryIds | string[] | First id is the primary category, the rest are additional categories. |
brandId | string | null | |
tags | string[] | |
images | string[] | Image URLs, first is the main image. Entries already on the product and this store's own media URLs are kept as-is; any other public https URL is downloaded into the store's media library (JPEG, PNG, WebP, GIF, AVIF; max 8 MB; private/internal addresses refused). Replaces the whole list. |
trackStock | boolean | Whether stock is counted. Untracked products are never out of stock. |
isFeatured | boolean | |
isHidden | boolean | Reachable by direct link but not listed. |
options | object[] | Option groups. Replaces all groups and regenerates the variant matrix; variants for kept combinations keep their id, SKU and stock. |
priceChangeReason | string | Stored on the price-change ledger. |
200OK401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).404No such product.409Duplicate SKU, barcode or slug.422Invalid field.429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl -X PATCH "https://shofniplus.com/api/v1/products/{id}" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"price": 79,
"compareAtPrice": 99,
"priceChangeReason": "ERP price sync"
}'/products/{id}Archive a productcatalog:writeNever deletes. Sets status `archived` and switches the product off; orders and reports keep it. PATCH `status: active` restores it.
| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Product id, Arabic or English slug, or SKU (product or variant). |
200Archived401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).404No such product.429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl -X DELETE "https://shofniplus.com/api/v1/products/{id}" \
-H "Authorization: Bearer $SHOFNI_TOKEN"{
"data": {
"id": "clx8p2k0f0001",
"status": "archived",
"archived": true,
"deleted": false,
"alreadyArchived": false
}
}/products/{id}/variantsList a product's variantscatalog:readAll variants (max 500) in one page.
| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Product id, slug or SKU. |
200OK401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).404No such product.429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl "https://shofniplus.com/api/v1/products/{id}/variants" \
-H "Authorization: Bearer $SHOFNI_TOKEN"/variants/{id}Update a variantcatalog:write| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Variant id. |
| Field | Type | Description |
|---|---|---|
price | number | null | Null = inherit the product price. |
compareAtPrice | number | null | |
sku | string | null | |
barcode | string | null | |
stock | integer | New on-hand balance, written to the stock ledger (reason STOCKTAKE). |
trackStock | boolean | Send true to switch tracking on for an untracked product together with `stock`. |
200OK401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).404No such variant.409Duplicate SKU/barcode, or stock refused (`details.reason`: stock_not_tracked); other fields in the request were saved when `details.otherFieldsSaved` is true.422Invalid field.429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl -X PATCH "https://shofniplus.com/api/v1/variants/{id}" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"price": 95,
"sku": "TSH-RED-M",
"stock": 12
}'{
"data": {
"id": "cmb_01hx9k2v7",
"productId": "clx8p2k0f0001",
"sku": "TSH-RED-M",
"barcode": "6281234567897",
"optionValues": [
{
"group": "اللون",
"groupEn": "Colour",
"value": "أحمر",
"valueEn": "Red"
},
{
"group": "المقاس",
"groupEn": "Size",
"value": "M",
"valueEn": null
}
],
"price": 89,
"priceInherited": true,
"compareAtPrice": null,
"currency": "SAR",
"stock": 14,
"trackStock": true,
"isEnabled": true,
"image": null,
"weight": null,
"weightUnit": null,
"createdAt": "2026-10-01T09:12:00.000Z",
"updatedAt": "2026-10-18T14:03:11.000Z"
}
}/inventoryStock levelscatalog:readWith `sku`: the lines for those SKUs (unpaged) plus `notFound`. Without: every non-archived product, paged by product, one line per variant (or one per product without variants).
| Field | In | Type | Description |
|---|---|---|---|
sku | query | string | Comma-separated SKUs, max 200. |
page | query | integer | Page number, from 1. |
limit | query | integer | Page size (max 100). |
200OK401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).422Bad `sku` list.429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl "https://shofniplus.com/api/v1/inventory" \
-H "Authorization: Bearer $SHOFNI_TOKEN"{
"data": {
"items": [
{
"productId": "clx8p2k0f0001",
"variantId": "cmb_01hx9k2v7",
"sku": "TSH-RED-M",
"barcode": null,
"name": "تيشيرت قطن",
"nameEn": "Cotton T-shirt",
"optionValues": [
{
"group": "المقاس",
"value": "M"
}
],
"trackStock": true,
"stock": 14,
"branches": [
{
"branchId": "clbr01",
"branchName": "الرياض",
"branchCode": "RUH",
"onHand": 10,
"reserved": 1,
"available": 9
}
]
}
],
"notFound": [
"UNKNOWN-1"
]
}
}/inventoryBulk set / adjust stockcatalog:write1 to 200 items, each applied independently through the stock ledger (a StockMovement row per change, actor = the token). The response is 200 with a per-item result; check `ok` on each. Stock never goes below zero.
| Field | Type | Description |
|---|---|---|
items* | InventoryUpdateItem[] |
200Per-item results401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).422`items` missing, empty or over 200.429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl -X PATCH "https://shofniplus.com/api/v1/inventory" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"sku": "TSH-RED-M",
"quantity": 40,
"mode": "set",
"reason": "STOCKTAKE"
},
{
"sku": "TSH-RED-L",
"quantity": -2,
"mode": "adjust",
"reason": "DAMAGE",
"note": "ERP doc 8812"
},
{
"variantId": "cmb_01hx9k2v8",
"quantity": 5,
"mode": "set",
"branchId": "clbr01"
}
]
}'{
"data": {
"results": [
{
"index": 0,
"ok": true,
"sku": "TSH-RED-M",
"productId": "clx8p2k0f0001",
"variantId": "cmb_01hx9k2v7",
"branchId": null,
"before": 14,
"after": 40,
"changed": true
},
{
"index": 1,
"ok": false,
"error": {
"code": "not_found",
"message": "No product or variant has SKU \"TSH-RED-L\"."
}
}
],
"summary": {
"total": 2,
"succeeded": 1,
"failed": 1
}
}
}/categoriesList categoriescatalog:readIn the merchant's order. Rows carry `parentId`; a full listing rebuilds the tree.
| Field | In | Type | Description |
|---|---|---|---|
parent_id | query | string | Children of this category; `root` for top-level categories. |
include_archived | query | boolean | Include archived categories. |
with_counts | query | boolean | Add productCount (whole subtree). |
page | query | integer | Page number, from 1. |
limit | query | integer | Page size (max 100). |
200OK401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl "https://shofniplus.com/api/v1/categories" \
-H "Authorization: Bearer $SHOFNI_TOKEN"/categoriesCreate a categorycatalog:write| Field | Type | Description |
|---|---|---|
name* | string | Arabic name; required on create. |
nameEn | string | null | |
slug | string | null | |
slugEn | string | null | |
parentId | string | null | Null for top level. Refused if it would create a cycle or nest deeper than 4 levels. |
isHidden | boolean | |
description | string | null | |
descriptionEn | string | null | |
fullDescription | string | null | |
fullDescriptionEn | string | null | |
image | string | null | Image or icon URL. |
201Created401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).422Invalid field, unknown parent, cycle or too deep (details.reason).429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl -X POST "https://shofniplus.com/api/v1/categories" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "أحذية",
"nameEn": "Shoes",
"parentId": "clc1cat0001"
}'{
"data": {
"id": "clc1cat0001",
"name": "ملابس رجالية",
"nameEn": "Men's clothing",
"slug": "ملابس-رجالية",
"slugEn": "mens-clothing",
"parentId": null,
"sortOrder": 0,
"isHidden": false,
"isArchived": false,
"description": null,
"descriptionEn": null,
"fullDescription": null,
"fullDescriptionEn": null,
"image": null,
"createdAt": "2026-09-01T10:00:00.000Z"
}
}/categories/{id}Get a categorycatalog:read| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Category id. |
200OK401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).404No such category.429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl "https://shofniplus.com/api/v1/categories/{id}" \
-H "Authorization: Bearer $SHOFNI_TOKEN"{
"data": {
"id": "clc1cat0001",
"name": "ملابس رجالية",
"nameEn": "Men's clothing",
"slug": "ملابس-رجالية",
"slugEn": "mens-clothing",
"parentId": null,
"sortOrder": 0,
"isHidden": false,
"isArchived": false,
"description": null,
"descriptionEn": null,
"fullDescription": null,
"fullDescriptionEn": null,
"image": null,
"createdAt": "2026-09-01T10:00:00.000Z"
}
}/categories/{id}Update a categorycatalog:writeOnly the fields sent are written. Renaming keeps the URL. Moving (parentId) moves the whole subtree.
| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Category id. |
| Field | Type | Description |
|---|---|---|
name | string | Arabic name; required on create. |
nameEn | string | null | |
slug | string | null | |
slugEn | string | null | |
parentId | string | null | Null for top level. Refused if it would create a cycle or nest deeper than 4 levels. |
isHidden | boolean | |
description | string | null | |
descriptionEn | string | null | |
fullDescription | string | null | |
fullDescriptionEn | string | null | |
image | string | null | Image or icon URL. |
200OK401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).404No such category.422Invalid field.429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl -X PATCH "https://shofniplus.com/api/v1/categories/{id}" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"nameEn": "Footwear",
"isHidden": false
}'/brandsList brandscatalog:read| Field | In | Type | Description |
|---|---|---|---|
q | query | string | Name search (Arabic or English). |
include_archived | query | boolean | Include archived brands. |
page | query | integer | Page number, from 1. |
limit | query | integer | Page size (max 100). |
200OK401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl "https://shofniplus.com/api/v1/brands" \
-H "Authorization: Bearer $SHOFNI_TOKEN"/brands/{id}Get a brandcatalog:read| Field | In | Type | Description |
|---|---|---|---|
id* | path | string | Brand id or slug. |
200OK401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).404No such brand.429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl "https://shofniplus.com/api/v1/brands/{id}" \
-H "Authorization: Bearer $SHOFNI_TOKEN"/storeStore informationcatalog:readName, currency, country, timezone, languages, domains and public contact details of the token's store.
200OK401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl "https://shofniplus.com/api/v1/store" \
-H "Authorization: Bearer $SHOFNI_TOKEN"/order-statusesOrder status catalogueorders:readEvery status code orders can carry, with its group and Arabic/English names. Stores that never customised statuses get the built-in set.
200OK401Missing or invalid API token (`unauthorized`).403The token lacks the required scope (`forbidden`).429Rate limit exceeded (`rate_limited`, 120 requests per minute per token).curl "https://shofniplus.com/api/v1/order-statuses" \
-H "Authorization: Bearer $SHOFNI_TOKEN"/webhooksList webhook endpointsorders:readRequires `orders:read`. Secrets are never listed.
200All endpoints of the store.401Missing or invalid API token.403The token lacks the required scope.429More than 120 requests in a trailing minute with this token.curl "https://shofniplus.com/api/v1/webhooks" \
-H "Authorization: Bearer $SHOFNI_TOKEN"/webhooksCreate a webhook endpointorders:writeRequires `orders:write`, plus the read scope of every subscribed event: `order.created` (orders:read), `order.updated` (orders:read), `order.status_changed` (orders:read), `order.paid` (orders:read), `order.cancelled` (orders:read), `customer.created` (customers:read), `customer.updated` (customers:read), `product.updated` (catalog:read), `inventory.updated` (catalog:read). At most 10 endpoints per store. The URL is checked at save time and before every delivery: https only, default port, and every address the host resolves to must be public.
| Field | Type | Description |
|---|---|---|
url* | string | https only, default port, publicly routable host. |
events* | WebhookEventType[] | |
description | string | null | |
active | boolean |
201Created. `secret` is shown only now.401Missing or invalid API token.403The token lacks the required scope.409The request conflicts with the current state.422The request is malformed.429More than 120 requests in a trailing minute with this token.curl -X POST "https://shofniplus.com/api/v1/webhooks" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/shofni",
"events": [
"order.created",
"order.status_changed"
]
}'/webhooks/{id}Get a webhook endpoint with its recent deliveriesorders:readRequires `orders:read`. `recentDeliveries` holds the last 50 attempts, newest first.
| Field | In | Type | Description |
|---|---|---|---|
id* | path | string |
200The endpoint.401Missing or invalid API token.403The token lacks the required scope.404No such resource in this store.429More than 120 requests in a trailing minute with this token.curl "https://shofniplus.com/api/v1/webhooks/{id}" \
-H "Authorization: Bearer $SHOFNI_TOKEN"/webhooks/{id}Update a webhook endpoint or rotate its secretorders:writeRequires `orders:write` (and the read scope of any newly subscribed event).
| Field | In | Type | Description |
|---|---|---|---|
id* | path | string |
| Field | Type | Description |
|---|---|---|
url | string | |
events | WebhookEventType[] | |
description | string | null | |
active | boolean | |
rotateSecret | boolean | Generate a new signing secret; the response then includes `secret`. |
200Updated. Includes `secret` only when `rotateSecret` was true.401Missing or invalid API token.403The token lacks the required scope.404No such resource in this store.422The request is malformed.429More than 120 requests in a trailing minute with this token.curl -X PATCH "https://shofniplus.com/api/v1/webhooks/{id}" \
-H "Authorization: Bearer $SHOFNI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"active": false
}'/webhooks/{id}Delete a webhook endpointorders:writeRequires `orders:write`. Queued retries for it are dropped.
| Field | In | Type | Description |
|---|---|---|---|
id* | path | string |
200Deleted.401Missing or invalid API token.403The token lacks the required scope.404No such resource in this store.429More than 120 requests in a trailing minute with this token.curl -X DELETE "https://shofniplus.com/api/v1/webhooks/{id}" \
-H "Authorization: Bearer $SHOFNI_TOKEN"/webhooks/{id}/testSend a test `ping` eventorders:writeRequires `orders:write`. Sends a signed `ping` now and reports the result. Logged, never retried.
| Field | In | Type | Description |
|---|---|---|---|
id* | path | string |
200Attempt result (delivered or not).401Missing or invalid API token.403The token lacks the required scope.404No such resource in this store.429More than 120 requests in a trailing minute with this token.curl -X POST "https://shofniplus.com/api/v1/webhooks/{id}/test" \
-H "Authorization: Bearer $SHOFNI_TOKEN"