Canonical Contract (v1)¶
The single, provider-agnostic contract between an external commerce system (e.g. Magento) and HKJ. Your connector translates your system's data to and from the canonical shapes below. HKJ never learns your system's internals; you never learn HKJ's. The same contract serves any platform — Magento, Shopify, WooCommerce, …
- Inbound (you → HKJ): catalog, stock, price, and order-status updates.
- Outbound (HKJ → you): orders the chatbot places, and cancellations.
Conventions
- Prices are integer fils —
1 JOD = 1000 fils(so5000= 5.000 JOD). If your system stores decimal JOD instead, send*_jod(e.g.price_jod: "5.000") and HKJ converts it for you. When both are sent, the explicit*_filsvalue wins. - All text is bilingual:
*_en/*_ar— and that includes category paths. - The contract uses natural keys only —
sku,external_id,barcode, category path strings. Never HKJ database IDs.
1. Keys & authentication¶
The two keys you'll get¶
The shop owner provisions your integration once (in the HKJ dashboard) and hands you two secrets. They protect opposite directions of traffic, so a full two-way integration uses both:
| Credential | Direction | What it does | How it travels | HKJ stores |
|---|---|---|---|---|
API key (ik_live_…) |
you → HKJ | authenticates your calls to us | you send it as the X-API-Key header on every request |
only its SHA-256 hash |
| Signing secret | HKJ → you | lets you verify our webhooks are genuine | never sent — both sides hold it; we sign with it, you recompute to compare | the secret itself (needed to sign) |
In short: the API key secures everything you send us (/events,
/bulk); the signing secret lets you trust everything we send you
(outbound order events). They are independent and rotate independently.
Both are shown exactly once at provisioning and cannot be retrieved afterwards. There is one API key and one signing secret per shop — not multiple.
If a credential is lost, rotate it
There is no "retrieve". The owner re-provisions with a rotate flag, which mints a fresh value and immediately invalidates the old one (auth matches on the new hash; the old key stops working at once):
rotate_key→ new API keyrotate_secret→ new signing secret
Rotating the signing secret will break your outbound verification until you update your stored copy, so coordinate the swap.
Disabling is a hard revoke
When the owner disables the integration in the dashboard, both the API key and the signing
secret are permanently deleted (not merely paused). The connector stops working at once —
inbound calls get 401. Re-enabling issues brand-new credentials (shown once); the old
ones never come back, so you'll update your connector with the new values.
Calling us: your API key¶
Send your API key in the X-API-Key header on every call:
A missing or unknown key returns 401. (A disabled integration has no key at all — see the
hard-revoke note above — so its old key also returns 401.)
Trusting our webhooks: the signature¶
Your outbound_url must be a public https:// endpoint — HKJ rejects non-https URLs and any
host that resolves to a private, loopback, or link-local address, and never follows redirects.
When HKJ POSTs to the outbound_url you registered, it signs the request so you can prove it
came from us and wasn't tampered with:
POST <your outbound_url>
Content-Type: application/json
X-Signature: <hex HMAC-SHA256(signing_secret, raw_request_body)>
X-Event-Id: order_placed:10231
To verify: recompute HMAC-SHA256(signing_secret, raw_bytes_as_received) and compare it to
X-Signature in constant time. The signature is over the exact bytes we sent — compute it on
the raw body before any JSON re-serialization. Also dedupe on X-Event-Id; we retry delivery on
non-2xx responses or network errors.
import crypto from "node:crypto";
const expected = crypto.createHmac("sha256", signingSecret).update(rawBody).digest("hex");
const sig = req.get("X-Signature") || "";
const ok = expected.length === sig.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
if (!ok) return res.sendStatus(401); // rawBody must be the unparsed body
How often you can call us¶
Inbound endpoints are rate-limited per API key. Exceeding a limit returns 429; back off and
retry (your event_id makes retries idempotent).
| Endpoint | Limit |
|---|---|
POST /events |
1200 requests / minute |
POST /bulk |
120 requests / hour |
Batching helps: /events accepts a JSON array of events in one request (each still counts as a
single request against the limit), and a full catalog belongs in /bulk, not thousands of /events.
2. Every message looks the same¶
Every event, in both directions, shares one envelope:
{
"event_id": "b2c1a9f4-7e3d-4c2a-9f10-8a1b2c3d4e5f",
"event_type": "product.upserted",
"occurred_at": "2026-06-20T12:00:00Z",
"shop": "pk_live_xxx",
"data": {}
}
| field | required | notes |
|---|---|---|
event_id |
✓ | A unique id you generate per event (a UUID). Your idempotency key — see below. |
event_type |
✓ | One of the types below. |
occurred_at |
ISO-8601 timestamp. | |
shop |
The shop public key (pk_live_…). |
|
data |
✓ | Event-specific payload. |
Why event_id, and who creates it¶
event_id is an idempotency key — a unique id for one event, used to make retries safe.
The rule is symmetric: whoever sends an event creates its event_id; the receiver dedupes on
it. So you create it on the events you send us, and we create it (e.g. order_placed:123)
on the orders we send you — which you dedupe via the X-Event-Id header.
Why the sender owns it: the sender is the one who retries. If your request times out, you don't know whether we got it — so you resend with the same id, and we apply it just once:
sequenceDiagram
participant You as Your connector
participant HKJ
You->>HKJ: POST /events (event_id: abc123)
Note over HKJ: applies it · remembers abc123
HKJ--xYou: response lost ✗ (timeout)
You->>HKJ: retry — same event_id: abc123
HKJ-->>You: "status": "duplicate" (nothing re-applied)
If you'd minted a new id on the retry, the "timed-out-but-actually-succeeded" call would apply
twice. Rule of thumb: one logical change = one event_id, reused on every retry; a fresh id
for the next change. (We dedupe on (shop, event_id).)
Don't have an id handy? It's optional.
If you omit event_id, we generate one and return it in the response. The call still works —
but a server-minted id is always unique, so retries won't be deduplicated. Send your own
stable id whenever you want safe retries.
The inbound endpoint accepts one envelope or a JSON array of envelopes (a batch). Each event is processed and committed independently — one bad event never fails the rest — and the response reports a per-event status:
{
"results": [
{ "event_id": "e1", "status": "applied" }
],
"summary": {
"received": 1,
"applied": 1,
"skipped": 0,
"duplicate": 0,
"error": 0
}
}
| status | meaning |
|---|---|
applied |
The event was processed. |
skipped |
No-op — e.g. a stock update for a product HKJ doesn't have yet. |
duplicate |
This event_id was already processed. |
error |
Validation failed; see the error field on the result. |
3. Sending us your catalog & updates¶
How to send one event¶
To send one event: generate an event_id, wrap your data in the envelope,
and POST it to /api/integration/events with your X-API-Key. The endpoint accepts a single
object or an array (a batch), and replies with a per-event results list.
$event = [
'event_id' => bin2hex(random_bytes(16)), // unique per event; reuse on retry
'event_type' => 'product.upserted',
'data' => ['sku' => 'HNY-500', 'title_en' => 'Honey', 'title_ar' => 'عسل',
'price_fils' => 5000, 'stock_qty' => 12],
];
$ch = curl_init('https://hkjshopping-production.up.railway.app/api/integration/events');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-API-Key: ik_live_xxx'],
CURLOPT_POSTFIELDS => json_encode($event),
CURLOPT_RETURNTRANSFER => true,
]);
$result = json_decode(curl_exec($ch), true); // { "results": [...], "summary": {...} }
import uuid, requests
event = {
"event_id": str(uuid.uuid4()), # unique per event; reuse on retry
"event_type": "product.upserted",
"data": {"sku": "HNY-500", "title_en": "Honey", "title_ar": "عسل",
"price_fils": 5000, "stock_qty": 12},
}
r = requests.post("https://hkjshopping-production.up.railway.app/api/integration/events",
json=event, headers={"X-API-Key": "ik_live_xxx"})
print(r.json()) # {"results": [...], "summary": {...}}
import { randomUUID } from "node:crypto";
const event = {
event_id: randomUUID(), // unique per event; reuse on retry
event_type: "product.upserted",
data: { sku: "HNY-500", title_en: "Honey", title_ar: "عسل",
price_fils: 5000, stock_qty: 12 },
};
const res = await fetch("https://hkjshopping-production.up.railway.app/api/integration/events", {
method: "POST",
headers: { "Content-Type": "application/json", "X-API-Key": "ik_live_xxx" },
body: JSON.stringify(event),
});
console.log(await res.json()); // { results: [...], summary: {...} }
To send several at once, post a JSON array of envelopes — each is applied independently and
reported in results, so one bad event never fails the rest.
How we match a product to yours¶
Every entity HKJ syncs has a stable identity that links it to the record in your system.
For products it's the field named below; for orders it's external_order_id
(§4). Get this right and everything else follows.
Each shop picks one product match key — sku (default), external_id, or barcode —
and HKJ finds the existing product by that field on every event. Whichever you pick, you must
send it under its canonical name (we don't rename identity fields):
external_id is the identity — send it on every product
external_id is your system's stable product id (Magento's entity_id, a shared UUID,
…). If the shop matches on external_id, an event without it is rejected
("Event is missing the match key 'external_id'") — never silently mismatched.
- Streaming: put it in the
external_idfield of everyproduct.upserted. Your connector maps your id →external_id; HKJ does not rename it. - Bulk file: name the column/tag
external_id(or map it once — see Column mapping). - It never changes for the life of the product.
skucan change;external_idshould not.
product.upserted¶
Full product state. Creates or updates; idempotent.
| field | type | notes |
|---|---|---|
sku |
string | required, unique per shop |
external_id |
string | your system's stable product id — the recommended match key. Send it on every product. |
barcode |
string | optional |
title_en, title_ar |
string | required |
desc_en, desc_ar |
string | optional |
price_fils |
int | optional — integer fils |
price_jod |
string | number | optional — decimal JOD, converted to fils (*_fils wins if both sent) |
discount_price_fils |
int | optional — integer fils |
discount_price_jod |
string | number | optional — decimal JOD alternative |
stock_qty |
int | optional |
track_stock |
bool | default true |
is_active |
bool | default true |
category_paths |
array | see Category paths below |
brand |
string | object | "Nike" or { "en": "Nike", "ar": "نايكي" } |
img_urls |
string[] | optional |
metadata |
object | free-form; surfaced keys are per-shop configurable |
{
"event_id": "e1",
"event_type": "product.upserted",
"data": {
"sku": "HNY-500",
"external_id": "MAG-8842",
"title_en": "Wildflower Honey",
"title_ar": "عسل زهور برية",
"price_fils": 5000,
"stock_qty": 12,
"brand": { "en": "BeeCo", "ar": "بي كو" },
"category_paths": ["Food > Honey"],
"metadata": { "origin": "Ajloun" }
}
}
Category paths¶
A product's categories are sent as path strings, not IDs. HKJ creates any missing nodes in the hierarchy. Two accepted shapes:
Validation
Each entry must resolve to at least one non-empty English segment. Empty strings,
blank segments, or an empty en array are rejected with an error result (the product is
not changed). Arabic names are optional — when omitted, the English name is reused.
product.deleted¶
Soft-deletes the product (is_active = false). An unknown product returns skipped.
stock.changed¶
Lightweight, high-frequency. Requires stock_qty. Updates existing products only and never
re-runs embeddings. An unknown product returns skipped — the next product.upserted will carry
current stock.
price.changed¶
Lightweight, existing-only, no re-embed. Omit a field to leave it unchanged; send null to
clear it. Accepts price_jod / discount_price_jod (decimal JOD) just like product.upserted.
{
"event_id": "e4",
"event_type": "price.changed",
"data": {
"sku": "HNY-500",
"price_fils": 5500,
"discount_price_fils": 4900
}
}
order.status_changed¶
Reflects an HKJ-placed order's fulfillment status back, so the shopper can track it in chat.
Reference the order by HKJ's order_ref (from order.placed) or your external_order_id.
The order's identity works like a product's: HKJ assigns order_ref on order.placed; you reply
with your own external_order_id, which HKJ stores once and accepts as a match key thereafter.
No mapping or per-shop config is needed for orders — this linkage is automatic.
{
"event_id": "e5",
"event_type": "order.status_changed",
"data": {
"external_order_id": "MAG-ORD-7741",
"order_ref": "HKJ-10231",
"status": "dispatched"
}
}
status is one of placed, preparing, dispatched, delivered, cancelled.
4. Receiving the orders we place¶
When the chatbot places an order, HKJ pushes it to you automatically — no polling. The moment
the order is placed, we POST order.placed to your outbound_url, signed. You create the order,
reply with your id, and send status back as you fulfill:
sequenceDiagram
participant Shopper
participant HKJ
participant You as Your connector
Shopper->>HKJ: places an order in chat
HKJ->>You: POST order.placed (order_ref HKJ-123, signed)
You->>You: verify signature · create order MAG-ORD-7
You-->>HKJ: 200 OK
You->>HKJ: order.status_changed (order_ref HKJ-123, external_order_id MAG-ORD-7)
Note over HKJ: links HKJ-123 ⇄ MAG-ORD-7
You->>HKJ: order.status_changed: dispatched, delivered…
HKJ->>Shopper: shows each new status in chat
When does an order get sent to you?
Automatically, whenever both are true: the integration is enabled, and an
outbound_url is set. If either is missing, the order is still placed in HKJ — it just
isn't pushed to you. Delivery is best-effort: a failure never blocks the order; we retry,
and failed deliveries are visible in the dashboard for one-click redrive.
order.placed¶
Create the order in your system. To link your order id to ours, send an
order.status_changed that references our order_ref and includes your
external_order_id — a first event with status: "placed" is perfect. HKJ stores the link on
first contact; after that you can reference the order by either id.
We read the status code, not the body
HKJ only checks the HTTP status code of your order.placed response (2xx = received) — it
does not read the response body. So your external_order_id must come back via an
order.status_changed event, not in the webhook's HTTP reply.
{
"event_id": "order_placed:10231",
"event_type": "order.placed",
"data": {
"order_ref": "HKJ-10231",
"customer": {
"name": "Sami",
"phone": "+962790000000",
"area": "Amman",
"channel": "whatsapp"
},
"items": [
{
"sku": "HNY-500",
"external_id": "MAG-8842",
"qty": 2,
"agreed_price_fils": 5000
}
],
"subtotal_fils": 10000,
"delivery_fee_fils": 1500,
"total_fils": 11500,
"payment_method": "cod"
}
}
| field | values |
|---|---|
payment_method |
cod (cash on delivery), visa_pos, cliq |
customer.channel |
whatsapp, telegram, web |
items[].agreed_price_fils |
the price the shopper agreed to, in fils (may differ from catalog price) |
order.cancelled¶
{
"event_id": "order_cancelled:10231",
"event_type": "order.cancelled",
"data": {
"order_ref": "HKJ-10231",
"external_order_id": "MAG-ORD-7741"
}
}
5. Loading your whole catalog at once¶
For the initial load or a periodic full re-sync, upload a file instead of streaming events.
Supported containers: canonical JSON array (default), CSV, and XML — each is just a
batch of product.upserted records, so every field from §3 applies.
Send the file as multipart file, or as the raw request body. Your shop has a configured
default format, used when you don't specify one — but you can override it on any single
upload with the format query parameter (json | csv | xml).
Limits
A single upload is capped at 25 MB and 50,000 rows (413 / 400 if exceeded).
For a larger catalog, split it into multiple uploads — each batch is processed and reported
independently, and product upserts are idempotent so overlap is safe.
An array of product objects (the data shape from product.upserted), so every field
from §3 applies — including bilingual category_paths, brand, and
price_jod. metadata is a nested object; img_urls is an array.
[
{
"sku": "HNY-500",
"external_id": "MAG-8842",
"title_en": "Wildflower Honey",
"title_ar": "عسل زهور برية",
"price_jod": "5.000",
"stock_qty": 12,
"brand": { "en": "BeeCo", "ar": "بي كو" },
"category_paths": [
{ "en": ["Food", "Honey"], "ar": ["طعام", "عسل"] }
],
"img_urls": [
"https://cdn.example/hny-500-a.jpg",
"https://cdn.example/hny-500-b.jpg"
],
"metadata": { "origin": "Ajloun", "weight_g": 500 }
}
]
One product per row. Brand is brand_en / brand_ar; categories are category_paths
(A > B, comma-separated for multiple) with an optional parallel category_paths_ar
column for Arabic names (paired by position). img_urls holds pipe-separated URLs
(|, not comma). Prices may be price_fils or price_jod.
Always include external_id (the identity column) unless the shop matches on sku.
Metadata is flexible: metadata.* columns fold into the metadata object, and any
column that isn't a canonical field is automatically stored as metadata — so you don't
have to prefix custom columns, nothing is silently dropped.
sku,external_id,title_en,title_ar,price_jod,category_paths,category_paths_ar,img_urls,brand_en,origin
HNY-500,MAG-8842,Wildflower Honey,عسل زهور برية,5.000,Food > Honey,طعام > عسل,https://cdn.example/a.jpg|https://cdn.example/b.jpg,BeeCo,Ajloun
(origin above isn't a canonical field, so it lands in metadata.origin automatically.)
Forward-fill (one image per row). Leave sku blank to continue the product above —
its img_urls and category_paths accumulate, scalars come from the lead row only:
One <product> per product. <metadata> children fold into the metadata object;
<img_urls> holds <url> children (or a single pipe-delimited text value). Categories use
<category_paths> with an optional parallel <category_paths_ar> for Arabic. As with CSV,
any non-canonical child tag is stored as metadata automatically.
<?xml version="1.0" encoding="UTF-8"?>
<products>
<product>
<sku>HNY-500</sku>
<external_id>MAG-8842</external_id>
<title_en>Wildflower Honey</title_en>
<title_ar>عسل زهور برية</title_ar>
<price_jod>5.000</price_jod>
<stock_qty>12</stock_qty>
<category_paths>Food > Honey</category_paths>
<category_paths_ar>طعام > عسل</category_paths_ar>
<brand_en>BeeCo</brand_en>
<brand_ar>بي كو</brand_ar>
<img_urls>
<url>https://cdn.example/a.jpg</url>
<url>https://cdn.example/b.jpg</url>
</img_urls>
<metadata>
<origin>Ajloun</origin>
<weight_g>500</weight_g>
</metadata>
</product>
</products>
Images, categories & metadata across formats¶
img_urls |
bilingual category_paths |
metadata |
|
|---|---|---|---|
| JSON | array of strings | { "en": [...], "ar": [...] } objects |
nested object |
| CSV | pipe-separated cell or forward-fill (blank-sku rows) |
category_paths + parallel category_paths_ar columns |
metadata.<key> columns or any non-canonical column |
| XML | <img_urls> with <url> children, or pipe text |
<category_paths> + parallel <category_paths_ar> |
<metadata> children or any non-canonical tag |
Column mapping
For bulk files whose headers/tags don't match the canonical names, the shop can configure
a column_map ({ canonical_field: source_column }) — e.g.
{ "title_en": "Product Name", "metadata.origin": "Country of Origin" }. It renames
descriptive attributes only; the identity field (external_id, sku, or barcode)
is matched by name, so send it under its canonical name rather than mapping it.
The upload returns a batch_id; processing is asynchronous. Poll
GET /api/integration/bulk/{batch_id} for progress:
{
"batch_id": 42,
"status": "done",
"total_rows": 1200,
"processed_rows": 1198,
"error_rows": 2,
"error_kind": null,
"error_summary": "1198 of 1200 rows imported; 2 skipped — see the errors below.",
"retriable": false,
"errors": [
{ "row": 17, "error": "'title_ar' is required and must be a non-empty string." }
]
}
Status moves pending → started → done (or failed). A few rows failing is
not a failure — the good rows import and the batch is done with error_rows > 0.
stateDiagram-v2
[*] --> pending: upload accepted
pending --> started: worker picks it up
started --> done: file processed (some rows may be skipped)
started --> failed: file unusable
failed --> pending: retry — system errors only
done --> [*]
When status is failed, error_kind tells you what to do:
error_kind |
meaning | retriable |
what to do |
|---|---|---|---|
invalid_data |
every row was rejected | false |
fix the rows listed in errors and upload again |
system |
a transient error on our side | true |
retry the same batch — POST .../batches/{id}/retry (dashboard), or re-upload |
A bad file never partially imports silently: rows that fail validation are reported per-row in
errors; prices and stock must be non-negative, sku and both titles are required.
6. Who's in charge of stock?¶
Your system remains the stock master. When the chatbot makes a sale, HKJ optimistically
decrements its local copy and relies on your stock.changed feed to reconcile. If a sale
can't be fulfilled, reject it via order.status_changed → cancelled.