INTEGRATION GUIDE
LeafOrder — Custom E-commerce Integration Guide
Introduction
Audience: developers of an e-commerce platform that has no native LeafOrder plugin (native plugins exist for WooCommerce, Magento 2, NopCommerce and PrestaShop). This document is the complete contract between your platform and LeafOrder: which endpoints you must expose, which endpoints you call on LeafOrder, and how to embed the product-page widget.
Throughout this guide:
| Placeholder | Meaning |
|---|---|
https://platform.leaforder.com | The LeafOrder middleware (“ABP”). Use the host given to you for your environment. |
https://shop.example.com | Your store (the connection’s Base URL). |
{CONNECTION_ID} | The GUID of your connection, visible in the dashboard URL of the connection’s edit page. It is not secret. |
{API_KEY} | The secret you typed in the API Key field of the connection. |
{WEBHOOK_SECRET} | The secret you typed in the Webhook Secret field. |
1. Overview
You implement four store endpoints (one mandatory sync endpoint, plus popup endpoints) and call one ABP endpoint (webhook) plus embed one widget:
| # | Direction | What | Required? |
|---|---|---|---|
| 1 | LeafOrder → your store | GET product list (sync) | Yes |
| 2 | your store → LeafOrder | POST product-event webhook | Strongly recommended (keeps catalogs fresh) |
| 3 | shopper browser → your store | GET product by SKU (catalog popup) | Yes |
| 4 | shopper browser → your store | GET product by GTIN (catalog popup) | Only if you use GTIN/EAN/UPC |
| 5 | shopper browser → your store | POST add to cart (catalog popup) | Only if you want the “Add to cart” button |
| 6 | product page → LeafOrder | Widget lookup: “is this product in a catalog, on which pages?” | For the product-page widget |
All payloads are JSON, UTF-8. Property names are camelCase (matching is case-insensitive on the LeafOrder side). Numbers may be sent as JSON numbers or numeric strings.
2. One-time setup in the LeafOrder dashboard
- E-commerce Connections → New Connection.
- E-commerce Type =
Custom, Base URL = your store, e.g.https://shop.example.com(must behttps://). - Set API Key (a long random string; this is the Bearer token LeafOrder presents to your sync endpoint) and Webhook Secret (another long random string; used to sign webhooks you send).
- Fill the Custom platform endpoints. If left empty, LeafOrder falls back to these conventions based on the Base URL:
- Product sync URL — no default, required for sync, e.g.
https://shop.example.com/api/order-pdf/products - Product-by-SKU URL →
{BaseUrl}/order-pdf/product-by-sku - Product-by-GTIN URL →
{BaseUrl}/order-pdf/product-by-gtin - Add-to-cart URL →
{BaseUrl}/order-pdf/add-to-cart
- Product sync URL — no default, required for sync, e.g.
- Save, then Test Connection and Sync Now. A PDF catalog cannot be uploaded until at least one successful sync has imported products.
Security: the API Key and Webhook Secret are stored encrypted by LeafOrder and must never be placed in browser-side code of your store.
3. Endpoints your store must expose
3.1 Product list (sync) — GET — required
Called by LeafOrder server-to-server: on Sync Now, on the schedule you chose (every 6 h / daily / weekly), and by Test Connection.
| URL | The Product sync URL you configured |
| Method | GET |
| Auth | Authorization: Bearer {API_KEY} (header omitted if the key is empty) |
| Query | page (starts at 1), per_page (always 100; 1 for Test Connection). If your URL already contains ?, parameters are appended with &. |
| Success | 200 with JSON body below |
| Failure | Any non-2xx, timeout, invalid JSON → the sync is treated as failed and skipped (no products are deleted). |
Request example:
HTTP
GET /api/order-pdf/products?page=1&per_page=100 HTTP/1.1
Host: shop.example.com
Authorization: Bearer 7f3c1d0e-8b2a-4a8e-9c55-2d6f1b7f0a11
Accept: application/json
Response example:
JSON
{
"products": [
{
"productId": 1042,
"sku": "BES.7170.03",
"gtin": "8001234567890",
"name": "Cordless drill 18V",
"price": 129.90,
"salePrice": 109.90,
"currency": "EUR",
"inStock": true,
"imageUrl": "https://shop.example.com/media/1042.jpg",
"description": "<p>18V brushless drill with two batteries.</p>"
}
],
"hasMore": true
}
Field rules:
| Field | Type | Notes |
|---|---|---|
productId | integer | Stable internal id of the product. Never reuse or change it: it is the key used to match products across syncs/webhooks and by the widget lookup. Must be > 0 for the widget to work. |
sku | string | The code printed in the PDF. Trimmed. |
gtin | string | EAN/UPC/GTIN, optional. |
name | string | |
price | number | Regular price (default 0 if missing). |
salePrice | number | null | |
currency | string | null | ISO 4217, e.g. EUR. |
inStock | boolean | |
imageUrl | string | null | Absolute URL. |
description | string | null | HTML allowed; it is stripped to text and truncated to 2000 chars. |
- A product with neither
skunorgtinis ignored. - Pagination: LeafOrder keeps requesting
page=2,3,…untilhasMoreisfalse, or a page returns fewer than 100 products, or an empty list. Always return exactly 100 items on non-final pages. - Return all purchasable products. A product missing from a complete sync is treated as removed, and its links are cleaned from the PDFs.
3.2 Product by SKU — GET — required (feeds the catalog popup)
Called from the shopper’s browser when they click a SKU link inside the flipbook. Because it runs in the browser, it must be publicly reachable and must answer CORS (see §3.5). Do not return secrets.
| URL | Product-by-SKU URL |
| Method | GET |
| Auth | None (the shopper’s own session cookies are sent: credentials: include). |
| Query | sku=<code>. If the catalog is in “starts with” (family) mode the browser also sends family=1: return all variants whose SKU starts with the code. |
| Headers | Accept: application/json |
Request:
HTTP
GET /order-pdf/product-by-sku?sku=BES.7170.03 HTTP/1.1
Host: shop.example.com
Origin: https://platform.leaforder.com
Accept: application/json
Response — single product (exact mode):
JSON
{
"productId": 1042,
"name": "Cordless drill 18V",
"sku": "BES.7170.03",
"gtin": "8001234567890",
"priceText": "€ 129,90",
"shortDescription": "18V brushless drill with two batteries.",
"imageUrl": "https://shop.example.com/media/1042.jpg",
"url": "https://shop.example.com/p/cordless-drill-18v",
"canAddToCart": true
}
Response — list (family mode, or whenever several variants match; the popup shows a pager “1 of N”):
JSON
{ "products": [ { "productId": 1042, "name": "…", "sku": "BES.7170.03", "priceText": "…", "url": "…" },
{ "productId": 1043, "name": "…", "sku": "BES.7170.04", "priceText": "…", "url": "…" } ] }
| Field | Notes |
|---|---|
productId | Required – integer; it is what is later sent to add-to-cart. Without it the “Add to cart” button is disabled. |
name, sku, gtin | Shown in the popup. |
priceText | Already formatted for display (currency symbol, locale, discounts). LeafOrder does not format it. |
shortDescription (or description) | Plain text or short HTML. |
imageUrl | Absolute URL. |
url | Product page; enables the “Go to product” button. |
canAddToCart | Informational. |
Return 404 (or any non-2xx) when the SKU is unknown; the popup then shows “Product not found”. Matching should be case-insensitive.
3.3 Product by GTIN — GET — optional
Identical to §3.2 but with ?gtin=<code> (plus &family=1 in family mode). Only needed if your PDF contains EAN/UPC/GTIN codes.
3.4 Add to cart — POST — optional
Called from the shopper’s browser when they press Add to cart in the popup.
Unlike the other platforms, for
Customconnections the browser calls your endpoint directly (there is no LeafOrder proxy in the middle). The request carries the shopper’s own cookies, so the item lands in their cart. No API credentials are involved – never accept the sync Bearer key here.
| URL | Add-to-cart URL |
| Method | POST |
| Headers | Content-Type: application/json |
| Cookies | Sent (credentials: include) |
HTTP
POST /order-pdf/add-to-cart HTTP/1.1
Host: shop.example.com
Origin: https://platform.leaforder.com
Content-Type: application/json
{ "productId": 1042, "quantity": 2 }
Success response (200):
JSON
{ "success": true, "message": "Added to cart", "cartQty": 3 }
Failure response (use any non-2xx status or success:false):
JSON
{ "success": false, "message": "Out of stock" }
messageis shown to the shopper on failure.cartQty(optional) is broadcast to the page as a browser eventwindow.dispatchEvent(new CustomEvent('pdforder:cartUpdated', { detail: { cartQty, flyoutHtml } }))so you can refresh your mini-cart badge.flyoutHtmlis an optional pre-rendered mini-cart fragment.- After success the popup shows a link to the cart page (
{BaseUrl}/cartby default).
3.5 CORS and cookies (important)
The popup and widget run in a browser on either your product pages (https://shop.example.com) or the hosted flipbook on https://platform.leaforder.com/view/{token}. Calls to endpoints 3.2–3.4 are therefore cross-origin whenever the flipbook is opened from LeafOrder. Your endpoints must:
- Answer the preflight
OPTIONSrequest (add-to-cart sendsContent-Type: application/json). - Return
Access-Control-Allow-Origin: <the exact requesting origin>– not*, because requests are credentialed. - Return
Access-Control-Allow-Credentials: true,Access-Control-Allow-Headers: Content-Type, Accept,Access-Control-Allow-Methods: GET, POST, OPTIONS. - If the cart session cookie must be shared when the flipbook is hosted on platform.leaforder.com, issue it with
SameSite=None; Secure.
Allow at least https://platform.leaforder.com and your own store origin.
4. Calls your store makes to LeafOrder
4.1 Product-event webhook — POST — recommended
Send this whenever a product is created, updated or deleted in your store. LeafOrder then updates its product cache and automatically re-annotates the affected catalogs (new SKUs get linked, renamed/removed SKUs are cleaned up). Without webhooks, changes are picked up only by the next scheduled sync.
| URL | https://platform.leaforder.com/api/app/webhook/product-event |
| Method | POST |
| Auth | HMAC-SHA256 signature (no Bearer token) |
| Content-Type | application/json |
Required headers
| Header | Value |
|---|---|
X-OrderPdf-Source | custom (fixed – identifies the Custom platform) |
X-OrderPdf-Signature | Base64( HMAC-SHA256( key = {WEBHOOK_SECRET} (UTF-8), message = raw request body bytes ) ) – Base64, not hex |
X-OrderPdf-Timestamp | Current Unix time in seconds (UTC). Requests older/newer than ±5 minutes are rejected. |
X-OrderPdf-Nonce | A unique value per delivery (e.g. a UUID). A nonce seen in the last 24 h is rejected as a replay. Retries must use a new nonce (and timestamp). |
Body (sign the exact bytes you send; don’t re-serialize after signing):
JSON
{
"eventType": "product.updated",
"productId": 1042,
"sku": "BES.7170.03",
"gtin": "8001234567890",
"name": "Cordless drill 18V",
"price": 119.90,
"inStock": true,
"imageUrl": "https://shop.example.com/media/1042.jpg",
"description": "<p>18V brushless drill with two batteries.</p>"
}
| Field | Notes |
|---|---|
eventType | product.created, product.updated or product.deleted (unknown values are ignored). |
productId | The same stable id used in the sync. On update it is the key – so changing a SKU is just an update with the same productId and the new sku; the old SKU’s links are removed automatically. |
sku / gtin | At least one is required, otherwise the event is ignored. |
| other fields | Same meaning as in §3.1. On product.deleted only the identifiers matter. |
Responses
| Status | Meaning |
|---|---|
200 | Accepted. |
403 {"error":"…"} | Missing/unknown signature header, bad signature, expired timestamp, replayed nonce, or invalid JSON. Don’t retry unchanged – fix the cause. |
Example (bash):
BASH
BODY='{"eventType":"product.created","productId":1042,"sku":"BES.7170.03","name":"Cordless drill 18V","price":129.90,"inStock":true}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -binary | base64)
curl -X POST https://platform.leaforder.com/api/app/webhook/product-event
-H "Content-Type: application/json"
-H "X-OrderPdf-Source: custom"
-H "X-OrderPdf-Signature: $SIG"
-H "X-OrderPdf-Timestamp: $(date +%s)"
-H "X-OrderPdf-Nonce: $(uuidgen)"
-d "$BODY"
Example (Node.js):
JS
import crypto from 'node:crypto';
async function sendProductEvent(event) {
const body = JSON.stringify(event); // sign exactly what you send
const signature = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(body, 'utf8').digest('base64');
const res = await fetch('https://platform.leaforder.com/api/app/webhook/product-event', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-OrderPdf-Source': 'custom',
'X-OrderPdf-Signature': signature,
'X-OrderPdf-Timestamp': String(Math.floor(Date.now() / 1000)),
'X-OrderPdf-Nonce': crypto.randomUUID(),
},
body,
});
if (!res.ok) throw new Error(`order-pdf webhook failed: ${res.status}`);
}
Example (PHP):
PHP
$body = json_encode($event, JSON_UNESCAPED_SLASHES);
$sig = base64_encode(hash_hmac('sha256', $body, $webhookSecret, true));
$ch = curl_init('https://platform.leaforder.com/api/app/webhook/product-event');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-OrderPdf-Source: custom',
'X-OrderPdf-Signature: ' . $sig,
'X-OrderPdf-Timestamp: ' . time(),
'X-OrderPdf-Nonce: ' . bin2hex(random_bytes(16)),
],
]);
curl_exec($ch);
Implementation tips: fire webhooks asynchronously (queue + retry with exponential back-off), debounce bursts (e.g. bulk imports – a scheduled sync is better for thousands of changes), and keep the sync endpoint authoritative.
4.2 Widget lookup — “is this product in a catalog, on which pages?” — GET
Used on product pages. Given the current product’s productId, it returns the catalogs (PDFs) containing it and the page numbers. Call it from the browser (the widget does this for you – see §5) or from your server.
| URL | https://platform.leaforder.com/api/widget/product-pdfs |
| Method | GET |
| Auth | None. The connection is identified by its non-secret GUID. It only reveals which catalogs/pages mention a product. |
| Query | connection={CONNECTION_ID} (GUID, required) · product_id={productId} (integer > 0, required) |
| CORS | Allowed for origins equal to your connection’s Base URL origin (credentialed). Calls from other origins are denied. |
HTTP
GET /api/widget/product-pdfs?connection=3b1f2a4e-5c6d-4e7f-8a9b-0c1d2e3f4a5b&product_id=1042 HTTP/1.1
Host: platform.leaforder.com
Origin: https://shop.example.com
Response 200:
JSON
{
"pdfs": [
{
"fileName": "Spring 2026 Catalog",
"fileKey": "tenants/…/annotated/spring-2026.pdf",
"pages": [12, 13, 87],
"thumbnailUrl": "https://…presigned-thumbnail-url…"
}
]
}
pdfsis an empty array when the product is not in any catalog, the product is unknown, or an internal error occurs → render nothing.pagesare the 1-based page numbers where the product (or one of its family/variants) appears. Sorted ascending.fileKeyis the opaque identifier you pass to the viewer component (§5).thumbnailUrlis a short-lived signed URL: don’t cache it.400 { "error": "connection and product_id are required" }for missing/invalid parameters.- The lookup is based on LeafOrder’s synced product cache, so the product must have been synced (or sent by webhook) first.
Server-side alternatives (use your secret key)
If you prefer to resolve everything on your server, both calls authenticate with the header X-OrderPdf-Key: {API_KEY} (the plain key is accepted; keep it on the server). Never call these from the browser.
By SKU – GET https://platform.leaforder.com/api/plugin/product-catalogs/{sku}
JSON
[ { "catalogId": "…guid…", "catalogName": "Spring 2026 Catalog",
"viewToken": "…guid…", "pages": [12, 13, 87] } ]
Open the flipbook at https://platform.leaforder.com/view/{viewToken} (the first page can be passed to the viewer, see §5). 401 {"error":"Invalid or missing API key"} on bad key. An empty array means “not in any catalog”.
By productId (+ variants) – POST https://platform.leaforder.com/api/app/pdf-product-links/by-product
HTTP
POST /api/app/pdf-product-links/by-product HTTP/1.1
Host: platform.leaforder.com
X-OrderPdf-Key: 7f3c1d0e-8b2a-4a8e-9c55-2d6f1b7f0a11
Content-Type: application/json
{ "baseUrl": "https://shop.example.com", "productId": 1042, "associatedProductIds": [1043, 1044] }
associatedProductIds (optional) lets you include variants/children so a configurable product shows catalogs for any of its variants. Response shape: { "requestedProductId": 1042, "pdfs": [ { "pdfDocumentId", "fileName", "fileKey", "productId", "sku", "pages": [..], "thumbnailUrl" } ] }. 401 without the key header, an error for an invalid key.
Category pages – GET https://platform.leaforder.com/api/plugin/category-pdfs/{categoryId} (header X-OrderPdf-Key) returns the ready catalogs the merchant attached to a category ({ "pdfs": [ { "fileName", "fileKey", "thumbnailUrl", … } ] }), to show a catalog banner on category pages.
5. Embedding the widget on the product page
The widget shows, under the product description, one card per catalog that contains the product, with thumbnail, page numbers and a View PDF button that opens the flipbook at the first page where the product appears. Clicking SKUs inside the flipbook opens the popup served by your endpoints §3.2–3.4.
5.1 Snippet
Paste in your product page template, replacing 123 with the current product’s productId (the same id you return in §3.1):
HTML
<div class="order-pdf-widget" data-order-pdf-widget data-product-id="123"></div>
<script src="https://platform.leaforder.com/widgets/pdf-viewer-wc.js" defer></script>
<script>
window.orderPdfWidget = {
lookupUrl: "https://platform.leaforder.com/api/widget/product-pdfs?connection=3b1f2a4e-5c6d-4e7f-8a9b-0c1d2e3f4a5b"
};
</script>
<script src="https://platform.leaforder.com/widgets/order-pdf-widget.js" defer></script>
(The dashboard generates this snippet with your real {CONNECTION_ID} on the connection’s edit page: Product-page widget snippet.)
What happens:
order-pdf-widget.jsfinds every[data-order-pdf-widget]element.- It calls
lookupUrl&product_id=<data-product-id>(§4.2). - If
pdfsis empty → nothing is rendered (the container stays empty, no error). - Otherwise it renders one card per PDF, each containing the
<abp-pdf-viewer>web component (pdf-key = fileKey,start-page = first page). - On View PDF, the viewer asks
GET /api/widget/init?key={fileKey}for a time-limited signed PDF URL and for your endpoint URLs (product-by-sku, product-by-gtin, add-to-cart), then opens the flipbook.
Single-page-app / dynamic stores: the loader runs on DOMContentLoaded. If you inject the container after load, call it again by re-adding the loader script or by rendering the container server-side. Insert the snippet only on product pages (it is a no-op without the container).
5.2 Styling
The widget is plain DOM with BEM-style classes you can override in your CSS: .order-pdf-widget__inner, __card, __thumb, __meta, __name, __pages. Use <abp-pdf-viewer button-label="Browse the catalog"> customisations via the component attributes if you build the markup yourself:
| Attribute | Purpose |
|---|---|
pdf-key | The fileKey from the lookup (or a catalog view token). |
start-page | 1-based page to open. |
button-label | Text of the open button. |
current-product-id | Your productId; in family mode the popup starts on this variant. |
cart-url | URL of your cart page, used for the “view cart” link. |
sku-label | Label for the code (SKU, or auto to show EAN/GTIN for 8–14 digit codes). |
language | UI language (en default; fallback to English if missing). |
tour="off" | Disable the first-use guided tour. |
5.3 Mini-cart refresh
After a successful add-to-cart the page receives:
JS
window.addEventListener('pdforder:cartUpdated', e => {
const { cartQty, flyoutHtml } = e.detail; // either may be null
updateMiniCartBadge(cartQty);
});
5.4 Sharing the catalog directly
Every ready catalog is also available at https://platform.leaforder.com/view/{viewToken} (anonymous, no store page needed). The same popup endpoints are used, so §3.5 CORS rules apply.
6. End-to-end checklist
- Create the
Customconnection; set Base URL, API Key, Webhook Secret, endpoint URLs. - Implement §3.1 and verify with
curl -H "Authorization: Bearer $KEY" "https://shop.example.com/api/order-pdf/products?page=1&per_page=1". - Click Test Connection, then Sync Now – the message should read “N products synced successfully.”
- Implement §3.2 (+ §3.3/§3.4 if needed) with CORS (§3.5) and test from a browser on
https://platform.leaforder.com. - Implement the webhook sender (§4.1); verify by editing a product and checking that the catalog updates (“PDF update queued…” in the dashboard).
- Upload a PDF catalog, wait for status Ready, open the flipbook, click a SKU, add to cart.
- Add the widget snippet (§5) to the product page template and check a product that appears in the catalog.
7. Troubleshooting
| Symptom | Likely cause |
|---|---|
| Test Connection fails | Sync URL not reachable from the internet, wrong Bearer key, non-JSON body, or TLS certificate invalid. |
| Sync succeeds but 0 products | Every product lacks both sku and gtin, or products is empty on page 1. |
Webhook 403 | Signature computed on different bytes than sent, hex instead of Base64, secret mismatch with the dashboard, timestamp skew > 5 min, reused nonce, X-OrderPdf-Source missing (the request would be treated as a NopCommerce webhook). |
| Popup says “Product not found” | product-by-sku returns non-2xx/invalid JSON, or CORS blocks the response (check the browser console). |
| “Add to cart” disabled | productId missing in the popup response. |
| Add to cart succeeds but cart is empty | Cart cookie not sent cross-site: set SameSite=None; Secure and Access-Control-Allow-Credentials: true. |
| Widget never appears | Product not synced; data-product-id is not the synced productId; the page origin differs from the connection’s Base URL (CORS denied); the product is simply not in any catalog. |
| SKU in PDF not linked | The code is not selectable text in the PDF, or the SKU differs from the synced sku (see user guide, “Selectable text”). |
Need Help with the Integration?
Not comfortable handling the technical integration on your own? No problem! Our team is here to help you implement the solution and seamlessly integrate Leaf Order with your eCommerce platform. Contact us
