E-Commerce API
Manage your product catalog, track orders, monitor cart activity, and analyze customer RFM segments.
All endpoints on this page are relative to the base URL: https://api-eu1.joryio.com/api - see API Overview.
Permissions
Reading catalog, cart and order data needs analytics:read. Creating or updating products, carts and orders needs ecommerce:write. Both are grantable on an API key under Settings → API Keys.
These write routes previously required integrations:write, which is not offered on API keys because it also governs connector OAuth and warehouse-sync configuration. They still accept it, so existing callers are unaffected.
ID Naming Convention
Joryio uses a clear naming convention for identifiers:
Your IDs (use these when creating/tracking data)
| Field | Description | Example |
|---|---|---|
productId | Your product identifier (SKU, product ID from your e-commerce platform) | "SKU-12345" |
orderId | Your order identifier (order number from your platform) | "ORD-2024-001" |
userId | Your user identifier | "user-123" |
Joryio IDs (returned in API responses)
| Field | Description | When to use |
|---|---|---|
id | Joryio's internal ID for products/orders | Returned in API responses, use for updates/deletes |
joryioUserId | Joryio's internal user ID (the 24-hex id returned in user API responses) | Alternative to userId when you want to reference by Joryio's ID |
Flexible User Identification
When creating orders or tracking events, you can identify users using either:
userId- Your user identifier (most common)joryioUserId- Joryio's internal user ID
// Using your user ID (recommended)
{ "orderId": "ORD-001", "userId": "user-123", ... }
// Using Joryio's internal user ID
{ "orderId": "ORD-001", "joryioUserId": "66a1f2c3d4e5f6a7b8c9d0e1", ... }
Authentication
All requests require JWT authentication:
Authorization: Bearer your_jwt_token
Content-Type: application/json
X-Workspace-Id: your_workspace_id
Product Catalog
Create Product
Create a new product in your catalog.
POST /catalog/products
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
productId | string | Yes | Your unique product identifier (SKU, product ID) |
name | string | Yes | Product name |
price | number | Yes | Product price |
description | string | No | Product description |
compareAtPrice | number | No | Original price (for discounts) |
currency | string | No | Currency code (default: USD) |
categories | string[] | No | Product categories |
tags | string[] | No | Product tags |
brand | string | No | Brand name |
imageUrl | string | No | Main product image URL |
url | string | No | Product page URL |
inStock | boolean | No | Stock availability (default: true) |
sku | string | No | Stock keeping unit |
variants | object[] | No | Product variants |
customFields | object | No | Custom attributes |
Example Request:
curl -X POST https://api-eu1.joryio.com/api/catalog/products \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"productId": "SKU-12345",
"name": "Classic Blue T-Shirt",
"price": 29.99,
"compareAtPrice": 39.99,
"currency": "USD",
"categories": ["Clothing", "T-Shirts"],
"tags": ["sale", "bestseller"],
"brand": "Acme Apparel",
"imageUrl": "https://example.com/images/blue-tshirt.jpg",
"url": "https://example.com/products/blue-tshirt",
"inStock": true,
"sku": "BTS-001-BL",
"variants": [
{
"id": "var-s",
"name": "Small",
"sku": "BTS-001-BL-S",
"price": 29.99,
"inStock": true,
"options": { "size": "S", "color": "Blue" }
},
{
"id": "var-m",
"name": "Medium",
"sku": "BTS-001-BL-M",
"price": 29.99,
"inStock": true,
"options": { "size": "M", "color": "Blue" }
}
]
}'
Response:
{
"id": "prod_abc123",
"productId": "SKU-12345",
"name": "Classic Blue T-Shirt",
"price": 29.99,
"createdAt": "2024-01-20T10:00:00.000Z"
}
Bulk Upsert Products (array body)
There is no separate bulk endpoint: POST /catalog/products accepts either a single product object or a bare JSON array of product objects (no wrapper object). The array form creates or updates up to 500 products in one request, upserted by productId.
POST /catalog/products
Request Body:
A JSON array (max 500 elements; an empty array or more than 500 returns 400). Each element follows the same format as the single-object form, including the optional per-product source field.
Every element receives full validation. An invalid element is reported in failed by its array index - it is never silently accepted - and the remaining valid elements are still upserted.
[
{ "productId": "SKU-001", "name": "Product 1", "price": 19.99 },
{ "productId": "SKU-002", "name": "Product 2", "price": 29.99, "source": "shopify" }
]
Response:
Unlike the object form (which returns the created product), the array form returns an aggregate summary:
{
"processed": 2,
"upserted": 2,
"failed": []
}
| Field | Description |
|---|---|
processed | Number of elements received in the request array |
upserted | Products actually created or updated |
failed | Per-element failures: index (position in the request array), productId / sku (when present on the element), reason |
List Products
Query products with filtering and pagination.
GET /catalog/products
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
search | string | Search by name, description, or SKU |
categories | string[] | Filter by categories |
tags | string[] | Filter by tags |
brand | string | Filter by brand |
inStock | boolean | Filter by stock status |
minPrice | number | Minimum price |
maxPrice | number | Maximum price |
limit | number | Results per page (default: 50) |
offset | number | Pagination offset |
sortBy | string | Sort field: name, price, createdAt, updatedAt |
sortOrder | string | asc or desc |
Example:
curl "https://api-eu1.joryio.com/api/catalog/products?categories=T-Shirts&inStock=true&limit=20" \
-H "Authorization: Bearer $TOKEN"
Fields filled by store integrations. Product responses also carry fields that a connected store (WooCommerce) fills and the create and update routes do not accept: gtin (GTIN, UPC, EAN or ISBN, or null), attributes (attribute name to its values, for example {"Material": ["Linen"]}, or null), variants[].gtin, and sourceModifiedAt (when the store last changed the product). brand and description (plain text) are filled the same way for synced products.
Get Categories
List all product categories.
GET /catalog/categories
Get Brands
List all product brands.
GET /catalog/brands
Catalogue Stats
Totals and catalogue health, counted over every product - never over a page.
GET /catalog/stats
Response:
{
"productCount": 8214,
"categoryCount": 37,
"brandCount": 12,
"outOfStockCount": 341,
"lowStockCount": 96,
"unclassifiedCount": 18,
"lowStockThreshold": 10
}
| Field | Description |
|---|---|
outOfStockCount | Products with quantity 0, or inStock: false when no quantity is tracked |
lowStockCount | Products with quantity between 1 and lowStockThreshold |
unclassifiedCount | Products missing a category or a brand |
lowStockThreshold | Units-remaining boundary lowStockCount was computed at |
Orders
Create Order
Track a new order.
POST /orders
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
orderId | string | Yes | Your order ID (order number from your platform) |
userId | string | Either/Or | Your customer user ID |
joryioUserId | string | Either/Or | Joryio's internal user ID (alternative to userId) |
total | number | Yes | Order total |
items | object[] | Yes | Line items |
status | string | No | Order status (default: pending) |
paymentStatus | string | No | paid or unpaid: whether the order has been paid. A Cash-on-delivery or bank-transfer order is processing and unpaid until the money arrives, and shows Awaiting payment on the Orders page instead of Paid. Leave it out and a processing / shipped / delivered order counts as paid (an existing order keeps the value it has). Revenue does not depend on it. See Paid or awaiting payment. |
currency | string | No | Currency code |
subtotal | number | No | Subtotal before discounts |
discount | number | No | Discount amount |
shipping | number | No | Shipping cost |
tax | number | No | Tax amount |
totalRefunded | number | No | Amount refunded so far, in the order currency (default 0). For a partial refund send the partial amount with status: partiallyRefunded; for a full refund set it to total with status: refunded. Refunds are netted out of attributed revenue. |
refunds | object[] | No | Every refund so far (up to 100): { "id": "RF-1", "amount": 29.99, "createdAt": "2026-06-03T09:00:00Z", "reason": "Wrong size", "items": [{ "productId": "SKU-1", "quantity": 1 }] } - only amount is required. Each new refund emits one Order Refunded event (by id; without one, by the refunded total rising) and a refund already reported emits nothing again. Sent without totalRefunded, their sum is the refunded total. A move to status: cancelled (or POST /orders/:id/cancel) emits one Order Cancelled. See Refund and cancellation events. |
couponCode | string | No | Applied coupon |
coupons | object[] | No | Every coupon code the order used (up to 50): { "code": "SUMMER10", "amount": 10.0, "type": "fixed_amount" }. amount (the discount the code gave) and type are optional. On upsert, coupons replaces the stored codes ([] clears them); leave it out to keep them. When both are sent, coupons wins over couponCode |
shippingAddress | object | No | Shipping address |
campaignId | string | No | Attribution campaign |
canvasId | string | No | Attribution canvas |
source | string | No | Order source (email, sms, direct) |
utmSource | string | No | UTM source |
utmMedium | string | No | UTM medium |
utmCampaign | string | No | UTM campaign |
salesChannel | string | No | Where the order was placed: online, pos, app, phone, marketplace or other (any letter case). Any other value is a 400. Left out, a new order is online and an existing one keeps its channel. See Store and sales channel |
storeId | string | No | Your own id for the store the order belongs to (a shop, a branch, a second website), up to 100 characters. The Store filter on the Orders page and the "Bought in store" segment condition match it |
storeName | string | No | The store's display name, up to 255 characters, shown with storeId |
Either userId or joryioUserId must be provided. Use userId with your own user identifiers (most common). Use joryioUserId when you have Joryio's internal user ID (the 24-hex id returned in user API responses).
Line Item Structure:
{
"productId": "prod_abc123",
"name": "Blue T-Shirt",
"sku": "BTS-001",
"quantity": 2,
"price": 29.99,
"total": 59.98,
"imageUrl": "https://example.com/image.jpg"
}
Example Request:
curl -X POST https://api-eu1.joryio.com/api/orders \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"orderId": "ORD-2024-001",
"userId": "user-123",
"total": 89.97,
"subtotal": 99.97,
"discount": 10.00,
"shipping": 5.99,
"tax": 4.99,
"currency": "USD",
"coupons": [
{ "code": "SAVE10", "amount": 10.00, "type": "fixed_amount" }
],
"items": [
{
"productId": "prod_abc123",
"name": "Blue T-Shirt",
"quantity": 2,
"price": 29.99,
"total": 59.98
},
{
"productId": "prod_def456",
"name": "Black Jeans",
"quantity": 1,
"price": 49.99,
"total": 49.99
}
],
"source": "email",
"salesChannel": "pos",
"storeId": "tlv-dizengoff",
"storeName": "Tel Aviv - Dizengoff",
"campaignId": "camp_xyz789"
}'
Store and sales channel
Every order carries where it was sold (salesChannel) and, when it names one, which store it belongs to (storeId). A merchant with a shop and a website, or with two stores, can then filter and split orders by each and build segments such as "bought in the shop in the last 90 days".
salesChannel | Meaning |
|---|---|
online | Your own website or web checkout (the default) |
pos | A point-of-sale register in a physical shop |
app | Your own mobile app |
phone | Entered by staff: phone, email or manual orders, drafts |
marketplace | A third-party marketplace |
other | Anything else |
The values are fixed and the same everywhere: in this API, in event properties (sales_channel), in segment conditions and in the Orders CSV export.
Connected stores fill both in for you. The store is the store connection itself; the channel comes from what the platform records:
| Store | Sales channel |
|---|---|
| Shopify | From the order's source_name: web, online_store, checkout and subscription orders are online; pos is pos; shopify_draft_order, iphone and android (orders staff create in the admin or the Shopify mobile app) are phone; anything else is other |
| WooCommerce | From created_via: checkout and store-api are online; admin is phone; a POS plugin is pos; rest-api and anything else is other |
| Magento | An order created in the admin (no shopper IP) is phone; every other order is online |
| PrestaShop | Always online - PrestaShop does not record where an order was placed |
An SDK Order Completed event may send sales_channel (and store_id, store_name). Without it, an order from an iOS or Android app is app and one from a website is online. Order Completed, Order Refunded and Order Cancelled events carry sales_channel and, when the order has a store, store_id, so a journey can trigger on them.
Fulfill Order
Mark an order as shipped.
POST /orders/:id/fulfill
Request Body:
{
"trackingNumber": "1Z999AA10123456784",
"carrier": "UPS"
}
Cancel Order
Cancel an order.
POST /orders/:id/cancel
Request Body:
{
"reason": "Customer requested cancellation"
}
Refund Order
Process a refund.
POST /orders/:id/refund
Request Body:
{
"refundAmount": 29.99,
"reason": "Product defective",
"partial": true
}
Upsert Orders (bulk)
POST /orders/upsert
The primary way a store that is not on Shopify, WooCommerce or Magento sends
its orders. Idempotent on orderId: sending the same order again updates it
rather than creating a duplicate, so a retry after a network failure is safe.
Request Body:
{
"orders": [
{
"orderId": "1001",
"userId": "user_123",
"total": 89.90,
"currency": "EUR",
"placedAt": "2026-08-27T10:15:00Z",
"status": "completed",
"items": [
{ "productId": "prod_abc123", "quantity": 2, "price": 29.95 }
]
}
]
}
Response:
{ "upserted": 1, "created": 1, "updated": 0 }
Send each order in the currency it was paid in. You do not convert anything:
Joryio converts total into the workspace's
reporting currency at the European Central Bank rate of the order's
placedAt date, and reports sum that converted amount.
Revenue Trend
GET /orders/revenue-trend?days=30&granularity=day
Net revenue over time (refunds and cancellations removed), for pulling into a
BI tool. granularity is day, week or month.
Response:
{
"currency": "EUR",
"points": [
{ "date": "2026-08-25", "revenue": 1240.50, "orders": 14 },
{ "date": "2026-08-26", "revenue": 980.00, "orders": 11 }
]
}
Order Statistics
Get order statistics for a date range.
GET /orders/stats?startDate=2024-01-01&endDate=2024-01-31
Response:
{
"totalOrders": 156,
"totalRevenue": 12450.50,
"averageOrderValue": 79.81,
"ordersByStatus": {
"pending": 5,
"processing": 12,
"shipped": 45,
"delivered": 90,
"cancelled": 4
}
}
Cart Tracking
Update Cart
Track or update a user's cart.
PUT /carts
Request Body:
{
"userId": "user_123",
"items": [
{
"productId": "prod_abc123",
"name": "Blue T-Shirt",
"price": 29.99,
"quantity": 2,
"total": 59.98,
"imageUrl": "https://example.com/image.jpg"
}
],
"value": 59.98,
"subtotal": 59.98,
"currency": "USD",
"checkoutUrl": "https://store.example.com/checkout?cart=abc123"
}
value is the cart total as you count it (it can include shipping and tax). subtotal is optional: the items subtotal, before shipping and tax - when it is left out it is computed from the items (each line's total, else price x quantity). The subtotal is the cart's value on E-Commerce → Carts, in the Cart value segment filter, in minValue and in totalAbandonedValue.
Add to Cart
Add an item to a user's cart.
POST /carts/add
Request Body:
{
"userId": "user_123",
"item": {
"productId": "prod_abc123",
"name": "Blue T-Shirt",
"price": 29.99,
"quantity": 1,
"total": 29.99
}
}
Get Abandoned Carts
List abandoned carts for recovery campaigns.
GET /carts/abandoned
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
minValue | number | Minimum cart value |
abandonedMinutesAgo | number | Max minutes since abandonment |
limit | number | Results per page |
offset | number | Pagination offset |
Response:
{
"data": [
{
"id": "cart_123",
"userId": "user_456",
"items": [...],
"value": 149.99,
"abandonedAt": "2024-01-20T15:30:00.000Z",
"checkoutUrl": "https://store.example.com/checkout?cart=abc"
}
],
"total": 42,
"hasMore": true
}
Cart Statistics
Get cart and abandonment statistics.
GET /carts/stats
Response:
{
"totalCarts": 1250,
"abandonedCarts": 312,
"recoveredCarts": 87,
"totalAbandonedValue": 45670.50,
"recoveryRate": 27.88
}
Remove from Cart
POST /carts/remove
{ "userId": "user_123", "productId": "prod_abc123" }
Clear Cart
POST /carts/clear
Empties the cart. Call this after a successful checkout so an abandoned-cart journey does not chase someone who has already bought.
{ "userId": "user_123" }
Mark Cart Abandoned
POST /carts/mark-abandoned
Marks the cart abandoned now, rather than waiting for the inactivity timer. Emits the event abandoned-cart journeys wait on.
{ "userId": "user_123" }
Mark Cart Recovered
POST /carts/recovered
Records that the cart converted, which stops any recovery journey the contact is in and attributes the order to it.
{ "userId": "user_123", "orderId": "1001" }
clear and recovered both end the cart, and they are not interchangeable.
recovered says the recovery worked and credits it; clear says the cart is
simply gone. Using clear after a purchase silently removes the evidence that
your recovery journey earned anything.
RFM Analysis
RFM (Recency, Frequency, Monetary) analysis segments customers based on purchase behavior.
Get User RFM Data
Get RFM scores and segment for a specific user.
GET /rfm/user/:userId
:userId is the internal user id or the external id your app identifies the person with; an unknown id returns 404.
Response:
{
"totalOrders": 12,
"totalSpent": 849.50,
"averageOrderValue": 70.79,
"firstOrderDate": "2023-06-15T10:00:00.000Z",
"lastOrderDate": "2024-01-18T14:30:00.000Z",
"daysSinceLastOrder": 2,
"hasActiveCart": false,
"rfmRecency": 5,
"rfmFrequency": 4,
"rfmMonetary": 4,
"rfmScore": "544",
"rfmSegment": "champions"
}
The RFM fields are null for a contact with no counted order (cancelled and refunded orders do not count).
Get RFM Distribution
Get customer distribution across RFM segments.
GET /rfm/distribution
Response:
{
"champions": { "count": 150, "totalValue": 125000 },
"loyal": { "count": 320, "totalValue": 89000 },
"potential_loyalists": { "count": 180, "totalValue": 32000 },
"new_customers": { "count": 450, "totalValue": 28000 },
"at_risk": { "count": 95, "totalValue": 42000 },
"lost": { "count": 280, "totalValue": 15000 },
"scoring": {
"computedAt": "2026-09-02T02:00:00.000Z",
"method": "quantile",
"buyers": 1475
}
}
Tiers are quintiles within this store (method: "quantile"): each of recency, frequency and net spend is ranked across the store's buyers and split into five equal groups, so a top tier means the top fifth of your customers, whatever the currency. A store with fewer than 25 buyers uses fixed thresholds instead (method: "fixed"). Scores are recomputed nightly and on every order; scoring.computedAt says when.
RFM Segments
| Segment | Description | Typical RFM Scores |
|---|---|---|
| Champions | Best customers, buy often, spend most | 555, 554, 545 |
| Loyal | Consistent customers | 444, 443, 434 |
| Potential Loyalists | Recent with average frequency | 433, 343, 333 |
| New Customers | Just made first purchase | 511, 512, 411 |
| Promising | Recent but low frequency | 422, 322, 312 |
| Needs Attention | Average, declining engagement | 332, 322, 233 |
| About to Sleep | Below average, at risk | 211, 212, 221 |
| At Risk | Were loyal, haven't bought recently | 144, 143, 244 |
| Can't Lose | Were best customers, now inactive | 155, 154, 255 |
| Hibernating | Low engagement, long inactive | 122, 121, 112 |
| Lost | Lowest scores, likely churned | 111 |
List RFM Segments
GET /rfm/segments
The RFM segments in this workspace with their current sizes, so an integration can target them without recomputing the scoring itself.
Response:
{
"segments": [
{ "segment": "champions", "users": 412, "avgRecencyDays": 8, "avgOrders": 6.2 },
{ "segment": "at_risk", "users": 1180, "avgRecencyDays": 96, "avgOrders": 2.1 }
],
"computedAt": "2026-08-27T02:10:00Z"
}
Shopify storefront tracking
Activates (or refreshes) the Joryio Web Pixel on the connected Shopify store - storefront and checkout events - and writes the settings the Joryio app embed reads. Once the pixel is active, the store's old tracking script (stores connected before October 2026) stops sending events - Joryio does not delete it; Shopify removes it when the app is uninstalled and stops loading it on March 1, 2027. Safe to call again. Requires integrations:write.
POST /api/integrations/shopify/storefront-tracking
Authorization: Bearer YOUR_API_KEY
{
"pixel": { "status": "active", "id": "gid://shopify/WebPixel/123", "error": null, "checkedAt": "2026-10-03T10:00:00.000Z" },
"missingScopes": [],
"legacyScriptTag": false,
"appEmbed": {
"configured": true,
"themeEditorUrl": "https://my-store.myshopify.com/admin/themes/current/editor?context=apps&activateAppId=<api-key>/joryio-embed"
}
}
pixel.status | Meaning |
|---|---|
active | The pixel is on the store and sending events |
needs_scopes | The store has not approved write_pixels and read_customer_events. Start the Shopify connection again (it asks the store owner to approve them); missingScopes lists them |
error | Shopify refused the pixel; pixel.error says why |
not_registered | Not set up yet |
GET /api/integrations/shopify returns the same object as storefrontTracking. See Shopify integration.
It also returns themeEmbed, the Popups on your store row: activateUrl opens the theme editor with the Joryio embed switched on (the store owner clicks Save; null when the server has no Shopify app configured), and status is on when the store's SDK has reported in during the last 7 days, otherwise not_reported. See Turn on popups.
"themeEmbed": {
"activateUrl": "https://my-store.myshopify.com/admin/themes/current/editor?context=apps&activateAppId=<api-key>/joryio-embed",
"status": "not_reported"
}
installUrl (also when no store is connected) is where Install Joryio from Shopify sends a merchant who has not installed Joryio yet: the Joryio app's Shopify App Store listing or install link, or null when the server has none configured (the dashboard then links to the install steps). Joryio never asks for a store's address - a store is installed from Shopify and linked. See Connecting your store.
Shopify app launch
When a merchant installs or opens the Joryio app from Shopify, Shopify adds a signed query (shop, hmac, timestamp, host) to the app's URL. Send that query unchanged; Joryio checks Shopify's signature and that the link is at most an hour old, and says where the store stands for the current workspace. It never connects anything. Requires integrations:read.
POST /api/integrations/shopify/app-launch
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{ "query": { "shop": "my-store.myshopify.com", "host": "YWRtaW4uc2hvcGlmeS5jb20vc3RvcmUvbXktc3RvcmU", "timestamp": "1760000000", "hmac": "<from Shopify>" } }
{ "status": "not_connected", "shopDomain": "my-store.myshopify.com" }
status | Meaning |
|---|---|
connected_here | The store is connected to this workspace |
connected_elsewhere | Connected to another workspace of this organization; workspaceId names it |
not_connected | Not connected in this organization - start the connection with POST /api/integrations/shopify/oauth/initiate |
A query that is unsigned, altered or older than an hour gets 400. See Shopify integration.
Store checkout re-subscribe setting
Turns the store connection's Let checkout opt-ins re-subscribe people who unsubscribed setting on or off. :platform is shopify, woocommerce, magento or prestashop. Off by default. When on, a checkout or customer-record opt-in from that store - guest checkouts included - dated after the contact's latest unsubscribe re-subscribes that contact. It never goes past a spam complaint, a hard bounce, an SMS STOP (only a double opt-in confirmation), an organization-wide block or another contact's opt-out. Each change is audit-logged. Requires settings:write.
PUT /api/integrations/shopify/consent-settings
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{ "checkoutResubscribe": true }
{ "checkoutResubscribe": true }
GET /api/integrations/<platform> returns the current value as checkoutResubscribe. See Store marketing consent.
Checkout opt-in safeguards (WooCommerce, Magento, PrestaShop)
The same route takes the Record consent immediately safeguards of a WooCommerce or Magento connection, and the same safeguards for PrestaShop's checkout newsletter box, which always reaches Joryio before the order (module 1.2.8 or later - see When a tick counts). :platform is woocommerce, magento or prestashop; Shopify gets 400. A checkout tick that arrives before an order counts at once only for a known buyer or subscriber; anyone else is held as pending until their order or the confirmation link - see When a tick counts. checkoutConsentConfirmEmail (default false) emails such a guest one confirmation link. checkoutConsentTickLimit (1-100, default 3) is how many ticks are accepted per shopper IP address, per checkout and per email address per hour; a tick over it is ignored. Send either or both, alone or with checkoutResubscribe. Each change is audit-logged. Requires settings:write.
PUT /api/integrations/magento/consent-settings
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{ "checkoutConsentConfirmEmail": true, "checkoutConsentTickLimit": 5 }
{ "checkoutConsentConfirmEmail": true, "checkoutConsentTickLimit": 5 }
GET /api/integrations/woocommerce, GET /api/integrations/magento and GET /api/integrations/prestashop return both values.
newShopperOptIns - Opt-ins from new shoppers (not yet buyers) - is hold (default) or immediate. It governs every opt-in that arrives without an order from someone Joryio does not know yet: a checkout tick sent before the order, and a registration, account-page, newsletter or customer-sync opt-in. hold keeps it pending until their order or the confirmation link; immediate counts it at once (less strict - any visitor can enter any email). Known buyers and existing subscribers count at once either way, an unsubscribe always applies at once, and checkoutConsentTickLimit stays in force. Same route and platforms, alone or with the other fields; each change is audit-logged; requires settings:write. GET /api/integrations/<platform> returns it as newShopperOptIns.
PUT /api/integrations/woocommerce/consent-settings
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{ "newShopperOptIns": "immediate" }
{ "newShopperOptIns": "immediate" }
GET /api/integrations/woocommerce and GET /api/integrations/magento also return storeCheckoutOptIn: the plugin's own checkout opt-in settings as it last reported them (WooCommerce plugin 1.6.16 or later, Magento extension 1.4.17 or later). They are read-only - set in the store, never through this API - and null until the plugin reports them. recordMode is order (only when the order is placed) or immediate (record consent immediately).
{ "storeCheckoutOptIn": { "pluginVersion": "1.6.16", "checkoutBox": true, "preTicked": false, "recordMode": "order", "reportedAt": "2026-10-27T10:00:00.000Z" } }
Visitor identity: recognise visitors by the email they type
Recognise visitors by the email they type - see WooCommerce and Shopify. On by default: an email typed at checkout or in a form identifies the visitor's browser as the contact with that email. Off (strict identity): a typed email never identifies the browser; carts, orders and form submissions still go to the email's contact.
GET /api/integrations/<platform> returns visitorIdentity: identifyByTypedEmail, source (default - nothing reported or set yet, which means on; plugin - reported by the WooCommerce plugin 1.6.22 or later, the Magento extension 1.4.21 or later or the PrestaShop module 1.2.10 or later; joryio - set in Joryio), pluginVersion and reportedAt. For WooCommerce, Magento and PrestaShop the setting lives in the store's plugin and is read-only here. For Shopify, set it with the route below; other platforms get 400. Each change is audit-logged. Requires settings:write.
PUT /api/integrations/shopify/visitor-identity
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{ "identifyByTypedEmail": false }
{ "visitorIdentity": { "identifyByTypedEmail": false, "source": "joryio", "pluginVersion": null, "reportedAt": "2026-11-01T10:00:00.000Z" } }
PrestaShop connection (early access)
Read and manage the workspace's PrestaShop connection (one PrestaShop shop per connection; see PrestaShop integration). GET returns { "connected": false, "platform": "prestashop" } when nothing is connected. The api-key route returns the jry_ps_ key you paste into the module (it needs integrations:write, like settings, sync and DELETE). GET also returns workspaceId, prestashopVersion and moduleVersion. sync takes type: full (default), delta (products, customers and orders changed since the last clean sync), products, customers, orders or carts (open carts of the last 24 hours), and returns at once while the sync runs. DELETE disconnects the shop and revokes the key.
GET /api/integrations/prestashop
Authorization: Bearer YOUR_API_KEY
{
"connected": true,
"status": "connected",
"platform": "prestashop",
"storeName": "My Shop",
"storeUrl": "https://shop.example.com",
"shopId": 1,
"workspaceId": "6f1c2d3e-...",
"prestashopVersion": "8.2.1",
"moduleVersion": "1.0.0",
"syncSettings": { "syncProducts": true, "syncOrders": true, "syncCustomers": true, "syncCarts": true, "historicalDays": 90 },
"checkoutResubscribe": false,
"syncStats": { "productCount": 120, "orderCount": 860, "customerCount": 540 },
"lastSyncAt": "2026-10-21T10:00:00.000Z",
"connectedAt": "2026-10-21T09:58:00.000Z"
}
connectedAt is when the store last connected (every reconnect moves it); GET /api/integrations/shopify, /woocommerce and /magento return it too. A connection made before it was recorded returns the row's creation time.
GET /api/integrations/prestashop/api-key
Authorization: Bearer YOUR_API_KEY
{ "apiKey": "jry_ps_...", "isNew": false }
PUT /api/integrations/prestashop/settings
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{ "syncCarts": true, "historicalDays": 90 }
POST /api/integrations/prestashop/sync
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{ "type": "full" }
DELETE /api/integrations/prestashop
Authorization: Bearer YOUR_API_KEY
Webhooks
E-commerce events emit webhooks that can trigger canvas flows:
| Event | Description |
|---|---|
ecommerce.order.created | New order placed |
ecommerce.order.fulfilled | Order shipped |
ecommerce.order.cancelled | Order cancelled |
ecommerce.order.refunded | Order refunded |
ecommerce.order.status_changed | Order status changed |
ecommerce.cart.abandoned | Cart marked as abandoned |
ecommerce.cart.recovered | Abandoned cart recovered |
ecommerce.cart.updated | Cart contents changed |
Lookup by Your IDs
Get Product by Your Product ID
Retrieve a product using your product identifier.
GET /catalog/products/by-product-id/:productId
An identifier with no matching record answers 404, not 200 with an empty body.
Example:
curl "https://api-eu1.joryio.com/api/catalog/products/by-product-id/SKU-12345" \
-H "Authorization: Bearer $TOKEN"
Get Order by Your Order ID
Retrieve an order using your order identifier.
GET /orders/by-order-id/:orderId
An identifier with no matching record answers 404, not 200 with an empty body.
Example:
curl "https://api-eu1.joryio.com/api/orders/by-order-id/ORD-2024-001" \
-H "Authorization: Bearer $TOKEN"
Error Responses
{
"statusCode": 404,
"message": "Product SKU-12345 not found",
"error": "Not Found"
}
| Status Code | Description |
|---|---|
| 400 | Invalid request body |
| 401 | Authentication required |
| 403 | Insufficient permissions |
| 404 | Resource not found |
| 409 | Conflict (duplicate external ID) |
| 500 | Internal server error |