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:

PlaceholderMeaning
https://platform.leaforder.comThe LeafOrder middleware (“ABP”). Use the host given to you for your environment.
https://shop.example.comYour 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

LeafOrder platform

GET /api/order-pdf/products — Bearer API key

POST /api/app/webhook/product-event — HMAC-SHA256

GET product by SKU or GTIN

POST add to cart

GET /api/widget/product-pdfs

Product sync endpoint requests

Product event webhook

Widget product catalog lookup

Your e-commerce store

Shopper browser / PDF flipbook

You implement four store endpoints (one mandatory sync endpoint, plus popup endpoints) and call one ABP endpoint (webhook) plus embed one widget:

#DirectionWhatRequired?
1LeafOrder → your storeGET product list (sync)Yes
2your store → LeafOrderPOST product-event webhookStrongly recommended (keeps catalogs fresh)
3shopper browser → your storeGET product by SKU (catalog popup)Yes
4shopper browser → your storeGET product by GTIN (catalog popup)Only if you use GTIN/EAN/UPC
5shopper browser → your storePOST add to cart (catalog popup)Only if you want the “Add to cart” button
6product page → LeafOrderWidget 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

  1. E-commerce Connections → New Connection.
  2. E-commerce Type = Custom, Base URL = your store, e.g. https://shop.example.com (must be https://).
  3. 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).
  4. 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
  5. 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.

URLThe Product sync URL you configured
MethodGET
AuthAuthorization: Bearer {API_KEY} (header omitted if the key is empty)
Querypage (starts at 1), per_page (always 100; 1 for Test Connection). If your URL already contains ?, parameters are appended with &.
Success200 with JSON body below
FailureAny 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:

FieldTypeNotes
productIdintegerStable 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.
skustringThe code printed in the PDF. Trimmed.
gtinstringEAN/UPC/GTIN, optional.
namestring
pricenumberRegular price (default 0 if missing).
salePricenumber | null
currencystring | nullISO 4217, e.g. EUR.
inStockboolean
imageUrlstring | nullAbsolute URL.
descriptionstring | nullHTML allowed; it is stripped to text and truncated to 2000 chars.
  • A product with neither sku nor gtin is ignored.
  • Pagination: LeafOrder keeps requesting page=2,3,… until hasMore is false, 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.

URLProduct-by-SKU URL
MethodGET
AuthNone (the shopper’s own session cookies are sent: credentials: include).
Querysku=<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.
HeadersAccept: 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": "…" } ] }
FieldNotes
productIdRequired – integer; it is what is later sent to add-to-cart. Without it the “Add to cart” button is disabled.
name, sku, gtinShown in the popup.
priceTextAlready formatted for display (currency symbol, locale, discounts). LeafOrder does not format it.
shortDescription (or description)Plain text or short HTML.
imageUrlAbsolute URL.
urlProduct page; enables the “Go to product” button.
canAddToCartInformational.

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 Custom connections 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.

URLAdd-to-cart URL
MethodPOST
HeadersContent-Type: application/json
CookiesSent (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" }
  • message is shown to the shopper on failure.
  • cartQty (optional) is broadcast to the page as a browser event window.dispatchEvent(new CustomEvent('pdforder:cartUpdated', { detail: { cartQty, flyoutHtml } })) so you can refresh your mini-cart badge. flyoutHtml is an optional pre-rendered mini-cart fragment.
  • After success the popup shows a link to the cart page ({BaseUrl}/cart by 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:

  1. Answer the preflight OPTIONS request (add-to-cart sends Content-Type: application/json).
  2. Return Access-Control-Allow-Origin: <the exact requesting origin> – not *, because requests are credentialed.
  3. Return Access-Control-Allow-Credentials: true, Access-Control-Allow-Headers: Content-Type, Accept, Access-Control-Allow-Methods: GET, POST, OPTIONS.
  4. 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.

URLhttps://platform.leaforder.com/api/app/webhook/product-event
MethodPOST
AuthHMAC-SHA256 signature (no Bearer token)
Content-Typeapplication/json

Required headers

HeaderValue
X-OrderPdf-Sourcecustom (fixed – identifies the Custom platform)
X-OrderPdf-SignatureBase64( HMAC-SHA256( key = {WEBHOOK_SECRET} (UTF-8), message = raw request body bytes ) ) – Base64, not hex
X-OrderPdf-TimestampCurrent Unix time in seconds (UTC). Requests older/newer than ±5 minutes are rejected.
X-OrderPdf-NonceA 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>"
}
FieldNotes
eventTypeproduct.created, product.updated or product.deleted (unknown values are ignored).
productIdThe 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 / gtinAt least one is required, otherwise the event is ignored.
other fieldsSame meaning as in §3.1. On product.deleted only the identifiers matter.

Responses

StatusMeaning
200Accepted.
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.

URLhttps://platform.leaforder.com/api/widget/product-pdfs
MethodGET
AuthNone. The connection is identified by its non-secret GUID. It only reveals which catalogs/pages mention a product.
Queryconnection={CONNECTION_ID} (GUID, required) · product_id={productId} (integer > 0, required)
CORSAllowed 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…"
    }
  ]
}
  • pdfs is an empty array when the product is not in any catalog, the product is unknown, or an internal error occurs → render nothing.
  • pages are the 1-based page numbers where the product (or one of its family/variants) appears. Sorted ascending.
  • fileKey is the opaque identifier you pass to the viewer component (§5). thumbnailUrl is 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:

  1. order-pdf-widget.js finds every [data-order-pdf-widget] element.
  2. It calls lookupUrl&product_id=<data-product-id> (§4.2).
  3. If pdfs is empty → nothing is rendered (the container stays empty, no error).
  4. Otherwise it renders one card per PDF, each containing the <abp-pdf-viewer> web component (pdf-key = fileKey, start-page = first page).
  5. 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:

AttributePurpose
pdf-keyThe fileKey from the lookup (or a catalog view token).
start-page1-based page to open.
button-labelText of the open button.
current-product-idYour productId; in family mode the popup starts on this variant.
cart-urlURL of your cart page, used for the “view cart” link.
sku-labelLabel for the code (SKU, or auto to show EAN/GTIN for 8–14 digit codes).
languageUI 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

  1. Create the Custom connection; set Base URL, API Key, Webhook Secret, endpoint URLs.
  2. Implement §3.1 and verify with curl -H "Authorization: Bearer $KEY" "https://shop.example.com/api/order-pdf/products?page=1&per_page=1".
  3. Click Test Connection, then Sync Now – the message should read “N products synced successfully.”
  4. Implement §3.2 (+ §3.3/§3.4 if needed) with CORS (§3.5) and test from a browser on https://platform.leaforder.com.
  5. Implement the webhook sender (§4.1); verify by editing a product and checking that the catalog updates (“PDF update queued…” in the dashboard).
  6. Upload a PDF catalog, wait for status Ready, open the flipbook, click a SKU, add to cart.
  7. Add the widget snippet (§5) to the product page template and check a product that appears in the catalog.

7. Troubleshooting

SymptomLikely cause
Test Connection failsSync URL not reachable from the internet, wrong Bearer key, non-JSON body, or TLS certificate invalid.
Sync succeeds but 0 productsEvery product lacks both sku and gtin, or products is empty on page 1.
Webhook 403Signature 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” disabledproductId missing in the popup response.
Add to cart succeeds but cart is emptyCart cookie not sent cross-site: set SameSite=None; Secure and Access-Control-Allow-Credentials: true.
Widget never appearsProduct 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 linkedThe code is not selectable text in the PDF, or the SKU differs from the synced sku (see user guide, “Selectable text”).