Olanzo Developer Documentation
Welcome to the Olanzo docs. Olanzo is a SINPE Móvil payment and messaging platform for Costa Rican merchants. These docs cover two things:
- API reference — the OpenAPI 3 reference for Olanzo's public HTTP APIs (Authenticate, CRM, Email, SMS, Payments, Terminal, SoundBox, E-commerce checkout). Open it from API reference in the top navigation.
- Commerce event integrations — how an ecommerce platform sends order, fulfillment/shipment, refund, customer, and abandoned-cart events to Olanzo so Olanzo can send the matching transactional and recovery emails. That is what the rest of these guides describe.
One contract, every platform
Every supported ecommerce platform sends the same normalized event envelope to one Olanzo endpoint, secured the same way. Only the sender side — how each platform produces the events — differs. So you learn the contract once in the Commerce events overview, then read the short platform-specific guide for the store you are integrating:
- Adobe Commerce / Magento
- Shopify
- WooCommerce
- Olanzo Checkout — Olanzo's own hosted checkout (expired / canceled / declined)
Environments
Use the environment that matches your credentials and tenant — Production for live data, Sandbox for staging — exactly as in the API reference. Olanzo issues your endpoint URL, credentials, and signing secret at onboarding.
Commerce events overview
This is the platform-independent contract that every integration sends to Olanzo. Read this once; then open your platform's guide for how that store produces the events.
It enables two things: transactional emails (order, invoice/fulfillment/shipment, refund, customer lifecycle) and abandoned-cart / abandoned-checkout recovery emails.
A related family — payment reminders for SINPE Requests and Payment Links — is driven by a due_date rather than a drop-off, and is scheduled by Olanzo. Olanzo can deliver any of these over Olanzo App push, WhatsApp, SMS, or email, chosen by the recipient identifiers and per-channel consent the event carries.
Endpoint
POST https://{olanzo-events-endpoint}/v1/commerce
The exact endpoint URL, credentials, and signing secret are issued per merchant at onboarding. A health check is available at GET https://{olanzo-events-endpoint}/v1/commerce/health.
Security
Every request must use HTTPS, authenticate, and be signed:
- Auth (required):
Authorization: Bearer <token>orX-Webhook-Key: <shared-secret>. - Signature (required): an HMAC-SHA256 of the raw body with the Olanzo signing secret, sent as
X-Webhook-Signature: t=<unix_ts>,v1=<hex_hmac_sha256>wherev1 = HMAC_SHA256(secret, "<t>.<raw_body>"). Olanzo recomputes and constant-time compares, and rejects if|now - t| > 5 minutes(replay window).
A static token alone is forgeable if it leaks, and the payload carries
customer.email. Idempotency (event_id) is not a substitute for signing — both are required.
Signature example (worked)
Given secret whsec_test_5f3a9c2e8b1d4f76, timestamp t = 1748599200, and raw body (201 bytes):
{"schema_version":"1.0","event":"order.created","event_id":"evt_1234567890","event_time":"2026-05-30T10:00:00Z","source":"adobe-commerce","store_id":"acme-cr","customer":{"email":"customer@example.com"}}
the header is:
X-Webhook-Signature: t=1748599200,v1=812b3f87e860af4499451cf51e67e3991d6673daf100a097203445be27c9860c
If your code reproduces that v1, your signing is correct.
Delivery semantics
- Asynchronous, non-blocking — delivery must never block a shopper or admin action; an Olanzo outage must never affect the store.
- At-least-once — duplicates are normal; deduplicate on
event_idand treat a409as success. - No ordering guarantee — design for out-of-order arrival (the processing rules and the out-of-order guard handle it).
Normalized envelope
The sender maps store data into this envelope and includes only the objects relevant to the event.
{
"schema_version": "1.0",
"event": "order.created",
"event_id": "evt_1234567890",
"event_time": "2026-05-30T10:00:00Z",
"source": "adobe-commerce",
"store_id": "acme-cr",
"site": "https://store.example.com",
"customer": { "id": "98765", "email": "customer@example.com", "first_name": "Ana", "last_name": "Lopez" },
"cart": { "id": "quote_123456", "currency": "CRC", "subtotal": 249900.00, "grand_total": 274900.00, "item_count": 2, "cart_url": "https://store.example.com/checkout/cart/" },
"order": { "id": "100048231", "order_number": "1001", "status": "processing", "created_at": "2026-05-30T10:00:00Z" },
"items": [ { "sku": "SKU-0001", "name": "Sample Product 1", "qty": 1, "price": 249900.00 } ],
"consent": { "transactional": true, "marketing": true },
"metadata": { "channel": "web", "locale": "es_CR", "test": false }
}
Required top-level fields are schema_version, event, event_id (unique idempotency key), event_time (ISO 8601 UTC), source, and store_id. customer.email is required for any event that results in an email. Amounts are JSON numbers in major currency units; currency is ISO 4217.
Event names
| Group | Events |
|---|---|
| Abandoned cart / checkout | cart.updated, checkout.started, checkout.payment_started (optional), cart.abandoned / checkout.abandoned, order.created (cancels recovery) |
| Transactional | order.created, order.updated, invoice.created, fulfillment.created, fulfillment.updated, shipment.created, shipment.updated, refund.created, customer.created, customer.updated |
source is one of adobe-commerce (alias magento), shopify, woocommerce, or olanzo-checkout.
Response codes
| Code | Meaning |
|---|---|
202 Accepted |
Validated and queued. Default success. |
200 OK |
Accepted and processed synchronously (rare). |
400 |
Malformed / non-JSON payload. |
401 / 403 |
Authentication or signature verification failed. |
409 |
Duplicate event_id (treat as success). |
422 |
Failed contract validation. |
429 |
Rate limited; see Retry-After. |
500 |
Transient server error; retry. |
Errors use a consistent body: { "error": { "code": "validation_failed", "message": "...", "details": [...] } }.
Choose your platform
The sender side differs per platform. Open the guide for the store you are integrating:
| Platform | Native signing | Abandonment source |
|---|---|---|
| Adobe Commerce / Magento | none (adapter signs) | scheduled quote scan (custom / extension) |
| Shopify | X-Shopify-Hmac-Sha256 |
Shopify Flow trigger (native) |
| WooCommerce | X-WC-Webhook-Signature |
recovery plugin / custom capture |
| Olanzo Checkout | internal (or Olanzo HMAC) | checkout drop-off — expired / canceled / declined |
Adobe Commerce / Magento → Olanzo Event Webhook Integration Specification
Version: 1.1 (generalized integration specification)
Date: 2026-05-30
Applies to: any merchant running Adobe Commerce or Magento Open Source (PaaS or Adobe Commerce as a Cloud Service)
Receiver: Olanzo messaging / email platform
This document defines how an Adobe Commerce / Magento store integrates with Olanzo so that Olanzo can receive abandoned-cart and transactional ecommerce events and send the corresponding emails. It is platform-specific (Adobe Commerce / Magento) but not merchant-specific — every merchant on this platform integrates the same way.
Audience and purpose
This spec is written for a merchant's engineering team and their Adobe Commerce implementation partner. It tells them:
- exactly what Olanzo expects to receive (the contract), and
- exactly how to make their Adobe Commerce store send it (the integration guide).
It enables two things once integrated: abandoned-cart recovery emails and transactional emails (order, invoice, shipment, refund, and customer lifecycle), driven by events from the store.
Is this a "webhook"? (terminology)
Yes — from Olanzo's side this is a webhook: Olanzo exposes a single HTTPS endpoint that receives event POSTs.
The only subtlety is on the Adobe Commerce side, where two differently-purposed features share the word "webhook":
| Adobe feature | What it is | Use it here? |
|---|---|---|
| Commerce Webhooks (Admin → System → Webhooks) | Synchronous, blocking calls made inside a Commerce process to compute or validate a value and write the result back. | No. This would put Olanzo in the checkout/admin critical path. |
| Adobe I/O Events (Adobe Commerce eventing) | Asynchronous event delivery. Adobe POSTs events out of band to a registered webhook URL (or a runtime action / journal). | Yes. This is the mechanism. |
So: it is a webhook, delivered asynchronously via Adobe I/O Events — never via the synchronous Commerce Webhooks module.
Quick start
If you only read one section, read this.
You are building: a small adapter that listens to your Adobe Commerce events, reshapes each into Olanzo's JSON envelope, signs it, and POSTs it asynchronously to one Olanzo URL.
From Olanzo (at onboarding) you receive: your endpoint URL, an auth token (or key), an HMAC signing secret, your store_id, and separate sandbox credentials.
Minimal path to a working integration:
- Stand up Adobe Commerce async eventing and subscribe to the order / invoice / shipment / refund / customer events (Integration guide, Steps 1–2).
- Deploy the adapter: map → sign → POST (Step 3). Verify your signing against the worked example below before sending real traffic.
- Add the scheduled cart-abandonment emitter for
cart.abandoned(Step 4) — the only piece with no native Commerce event, and the longest-lead item. - Point the adapter at your sandbox endpoint, send the sample events, and confirm
202plus passing signatures (Step 5). - Switch to the production endpoint and monitor (Step 6).
The three rules that prevent most issues:
- Deliver asynchronously — never block a shopper or admin action on Olanzo.
- Sign every request and include a fresh timestamp.
- Treat delivery as at-least-once — send a stable
event_idand expect Olanzo to deduplicate.
A copy-pasteable request is in Signature example (worked).
Scope and coverage
The integration covers both, on one endpoint:
- Abandoned-cart recovery, covering all abandonment scenarios:
- shoppers who fill the basket and never start checkout (the largest bucket), and
- shoppers who start checkout with any payment method (card, SINPE, etc.) and never complete the order.
- Transactional emails: order, invoice, shipment, refund, and customer lifecycle confirmations.
The mechanism that makes the abandonment coverage payment-method-agnostic is cart.abandoned, produced by a scheduled scan of the store's cart (quote) data — it does not depend on which payment method the shopper chose, or on whether they ever reached the payment step.
Core design principles
- Normalized event names. The payload uses platform-neutral names (
order.created,cart.abandoned, …), not raw Adobe Commerce method or extension names. One contract serves Adobe Commerce today and other platforms later. - One endpoint, internal routing. A single ingestion endpoint; Olanzo branches on the
eventfield internally. - Asynchronous and non-blocking. Delivery must never sit in the critical path of a store operation. An Olanzo outage must never affect the merchant's site.
- Secure by default. TLS, mandatory authentication, mandatory request signing, replay protection.
- Idempotent. Every event carries a unique
event_id; Olanzo deduplicates on it.
Architecture overview
The recommended integration shape places a thin adapter between Adobe Commerce and Olanzo. The adapter subscribes to Commerce events asynchronously, maps them into Olanzo's normalized envelope, signs them with the merchant's Olanzo-issued secret, and POSTs them to Olanzo — all out of band:
The adapter can be implemented as an Adobe App Builder runtime action (recommended; no core-code changes, works on SaaS) or as a custom Commerce module. The scheduled cart-abandonment scan lives alongside it (see Source mapping).
Alternative (lighter setup, less control): the merchant points Adobe I/O Events directly at Olanzo's webhook URL with no adapter. In that case Olanzo receives Adobe's native event payloads and verifies Adobe I/O Events' own signatures, rather than the normalized envelope and Olanzo's HMAC. This is acceptable but loses one consistent wire contract across merchants; the adapter pattern is preferred and is assumed below unless noted.
Sequence diagrams
1. Transactional happy path (order confirmation)
The storefront responds to the shopper immediately; email delivery is fully decoupled.
2. Abandoned cart with the out-of-order guard
A late cart.abandoned must never email a shopper who already bought.
3. Delivery retry and idempotency (at-least-once)
A duplicate delivery is normal; the sender treats 409 as success.
The contract Olanzo receives (normative)
Regardless of how the merchant produces events, Olanzo requires the following on the wire.
Endpoint
POST https://{olanzo-events-endpoint}/v1/commerce
The exact endpoint URL is issued by Olanzo per merchant during onboarding (along with credentials and the signing secret).
Transport
HTTPS/TLS is required. Plain HTTP is rejected.
Authentication — required
Every request authenticates with the credential Olanzo issues at onboarding:
Authorization: Bearer <token>, orX-Webhook-Key: <shared-secret>
Request signing — required
Every request includes an HMAC-SHA256 signature of the raw body, computed with the Olanzo-issued signing secret:
X-Webhook-Signature: t=<unix_timestamp>,v1=<hex_hmac_sha256>
where v1 = HMAC_SHA256(secret, "<t>.<raw_body>").
Olanzo verifies by recomputing the HMAC and constant-time comparing it, and rejects the request if |now − t| > 5 minutes (replay / freshness window).
// Olanzo-side verification (illustrative)
function verify(req, secret) {
const { t, v1 } = parseSignature(req.header("X-Webhook-Signature"));
if (Math.abs(now() - t) > 300) return reject(401); // replay window
const expected = hmacSHA256(secret, `${t}.${req.rawBody}`);
if (!constantTimeEqual(expected, v1)) return reject(401); // forgery
return ok();
}
A static token alone is forgeable if it leaks; because the payload carries
customer.emailand names, the signature ties each request to the secret and a fresh timestamp. Idempotency (event_id) is not a substitute for signing — it only stops an exact duplicate Olanzo has already seen, not a forged event with a new id. Both are required.
Signature example (worked)
Sign the exact raw bytes of the request body — compute the signature over precisely what you send (whitespace included), before any reserialization. The signature is lowercase hex. t is the signing time (Unix seconds) and is independent of event_time.
Given:
- signing secret:
whsec_test_5f3a9c2e8b1d4f76 - timestamp
t:1748599200 - raw body (exactly these 201 bytes):
{"schema_version":"1.0","event":"order.created","event_id":"evt_demo_001","event_time":"2026-05-30T10:00:00Z","source":"adobe-commerce","store_id":"acme-cr","customer":{"email":"customer@example.com"}}
The string to sign is t + . + raw body, and the resulting header is:
X-Webhook-Signature: t=1748599200,v1=812b3f87e860af4499451cf51e67e3991d6673daf100a097203445be27c9860c
If your code produces that exact v1 from those inputs, your signing is correct.
Sign (Node.js):
const crypto = require("crypto");
const t = Math.floor(Date.now() / 1000);
const v1 = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const signatureHeader = `t=${t},v1=${v1}`;
Sign (PHP):
$t = time();
$v1 = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
$signatureHeader = "t={$t},v1={$v1}";
Full request (cURL):
curl -X POST "https://{olanzo-events-endpoint}/v1/commerce" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-H "X-Webhook-Schema-Version: 1.0" \
-H "X-Webhook-Signature: t=1748599200,v1=812b3f87e860af4499451cf51e67e3991d6673daf100a097203445be27c9860c" \
--data '{"schema_version":"1.0","event":"order.created","event_id":"evt_demo_001","event_time":"2026-05-30T10:00:00Z","source":"adobe-commerce","store_id":"acme-cr","customer":{"email":"customer@example.com"}}'
(Use a current timestamp in production — the fixed value above is only so the example reproduces. Against the real sandbox, the secret differs and a stale t will fail the freshness window.)
Asynchronous / non-blocking — required of the sender
The sender MUST deliver out of band. If Olanzo is slow, errors, or is unreachable, the store's storefront, checkout, and admin must be unaffected, and events must queue and retry rather than block or fail a shopper action.
Idempotency
Every event carries a unique event_id. Olanzo persists seen ids and rejects duplicates with 409.
Delivery semantics
- At-least-once. A successfully processed event may still be re-delivered (e.g. if a retry fires before the sender records the
202). Olanzo deduplicates onevent_idand returns409for a duplicate; the sender should treat409as success, not an error to keep retrying. - No ordering guarantee. Events may arrive out of order. Do not assume
order.createdarrives aftercart.abandoned, or thatinvoice.createdfollowsorder.createdon the wire. The processing rules (and the out-of-order guard) are designed for this. - Idempotency retention. Olanzo remembers each
event_idfor a deduplication window (default: 7 days — confirm at onboarding). Retries complete well within this window; a re-send after it may be treated as new.
Limits
Values below are defaults; the exact figures for your account are confirmed at onboarding.
- Rate limit: ~50 requests/second per merchant (short bursts allowed). On exceed, Olanzo returns
429with aRetry-Afterheader. - Payload size: ≤ 256 KB per request.
- Request timeout: Olanzo acknowledges within ~5 seconds; senders should use a request timeout of ~10 seconds before treating a call as failed and retrying.
Required headers
| Header | Value |
|---|---|
Content-Type |
application/json |
Authorization or X-Webhook-Key |
Bearer <token> / <shared-secret> |
X-Webhook-Signature |
t=<unix_ts>,v1=<hex_hmac_sha256> |
X-Webhook-Schema-Version |
e.g. 1.0 |
Optional headers
| Header | Value |
|---|---|
X-Source-System |
adobe-commerce |
X-Store-Id |
<store_code> |
Response codes
| Code | Meaning |
|---|---|
202 Accepted |
Validated and queued for asynchronous processing. Default success. |
200 OK |
Accepted and processed synchronously (rare). |
400 Bad Request |
Malformed / non-JSON payload. |
401 / 403 |
Authentication or signature verification failed. |
409 Conflict |
Duplicate event_id (idempotency). |
422 Unprocessable Entity |
Well-formed JSON but failed contract validation. |
429 Too Many Requests |
Rate limited; back off and retry. |
500 Internal Server Error |
Genuine processing failure; retry. |
Error responses
Error responses use a consistent JSON body:
{
"error": {
"code": "validation_failed",
"message": "Missing required field: customer.email",
"details": ["customer.email is required for events that send an email"]
}
}
| HTTP | error.code |
When |
|---|---|---|
| 400 | invalid_json |
Body is not valid JSON. |
| 401 | unauthorized |
Missing/invalid credential, or signature verification failed. |
| 403 | forbidden |
Credential valid but not permitted for this store_id. |
| 409 | duplicate_event |
event_id already processed (treat as success). |
| 422 | validation_failed |
Valid JSON, failed contract validation; details lists the problems. |
| 429 | rate_limited |
Rate limit exceeded; see Retry-After. |
| 500 | internal_error |
Transient Olanzo error; retry. |
Retry, backoff, and dead-letter (sender obligation)
- Retry on timeout, connection failure,
5xx, or429, with exponential backoff (suggested ~1m, 5m, 30m, 2h, 6h) for up to ~24 hours. - After the final attempt, move the event to a dead-letter queue for inspection; never drop silently.
4xxother than429are permanent failures and are not retried.- Retries are safe because of the stable
event_id.
Endpoint verification handshake
Before live traffic, Olanzo confirms ownership of the endpoint. Olanzo sends a verification request and the endpoint must echo the challenge.
Request from Olanzo:
{ "type": "endpoint.verification", "challenge": "c_8f2a...random" }
Expected response (HTTP 200, within 10 seconds):
{ "challenge": "c_8f2a...random" }
A type: "endpoint.verification" request never carries a business event and never triggers an email. (If you use the direct Adobe I/O Events delivery alternative, Adobe issues its own challenge to the webhook URL instead; answer per Adobe's webhook verification.)
Sandbox and test mode
- Olanzo issues a separate sandbox endpoint and credentials at onboarding. Build and validate against sandbox before pointing at production.
- Set
"test": trueinsidemetadatato mark an event as a test: Olanzo validates, verifies the signature, deduplicates, and returns the normal status code, but does not send a real email (it routes to a sink). Use this to exercise production safely. - A health check is available:
GET https://{olanzo-events-endpoint}/v1/commerce/healthreturns200with{ "status": "ok" }.
Data formats and conventions
| Concern | Convention |
|---|---|
| Encoding | UTF-8 throughout. |
| Content type | application/json. |
| Timestamps | ISO 8601 in UTC with a Z suffix, e.g. 2026-05-30T10:00:00Z. Second precision; milliseconds optional. |
| Money amounts | JSON numbers in major currency units with up to 2 decimal places (e.g. 249900.00 means ₡249,900.00 — not céntimos/minor units). Parse as decimal, not binary float. |
| Currency | ISO 4217 code, e.g. CRC, USD. |
event_id |
Opaque string, ≤ 255 chars, globally unique per event, stable across retries. Prefixing (e.g. evt_…) is recommended. |
store_id |
The identifier agreed at onboarding; stable per merchant. |
| Booleans | JSON true / false (never strings). |
| Null / absent | Omit objects and fields that do not apply to an event. Optional scalars may be null. Receivers ignore unknown fields. |
Field enumerations
source:adobe-commerce(canonical) ormagento(alias).checkout.step:cart·shipping·payment·review.checkout.payment_method_selected:card·sinpe·other·null(extensible; unknown values are accepted and passed through).order.status: the store's own order status string (e.g.pending,processing,complete,canceled) — passed through, not an Olanzo-defined set.
Event model
Source
source value |
Origin |
|---|---|
adobe-commerce |
Canonical value for the Adobe Commerce / Magento store. |
magento |
Accepted alias; normalized to adobe-commerce by Olanzo. |
Abandoned-cart event group
cart.updated— items added or changed.checkout.started— shopper entered the checkout funnel (payment-method-independent).checkout.payment_started— (optional) shopper began a specific payment method but did not complete.cart.abandoned— the workhorse trigger; produced by the scheduled abandonment scan when a cart with a captured email has had no activity for a configured period and no order exists. Catches basket-only abandonment and any-payment-method checkout abandonment.order.created— an order now exists; cancels any pending recovery for that cart/customer.
Transactional event group
order.createdorder.updatedinvoice.createdshipment.createdrefund.createdcustomer.createdcustomer.updated
order.createdis used everywhere an order coming into existence matters (both as a transactional trigger and to cancel cart recovery). There is intentionally no separateorder.placed.
Normalized JSON envelope
The sender maps store data into this envelope, including only the objects relevant to the event (e.g. an invoice event includes invoice and order; a shipment event includes shipment and order).
{
"schema_version": "1.0",
"event": "order.created",
"event_id": "evt_1234567890",
"event_time": "2026-05-30T10:00:00Z",
"source": "adobe-commerce",
"store_id": "acme-cr",
"site": "https://store.example.com",
"customer": {
"id": "98765",
"email": "customer@example.com",
"first_name": "Ana",
"last_name": "Lopez"
},
"cart": {
"id": "quote_123456",
"currency": "CRC",
"subtotal": 249900.00,
"grand_total": 274900.00,
"item_count": 2,
"cart_url": "https://store.example.com/checkout/cart/"
},
"checkout": {
"step": "payment",
"payment_method_selected": "card",
"payment_started": false
},
"order": {
"id": "100048231",
"increment_id": "00010048231",
"status": "processing",
"created_at": "2026-05-30T10:00:00Z"
},
"invoice": {
"id": "inv_778899",
"increment_id": "INV-000778899"
},
"shipment": {
"id": "shp_445566",
"tracking_number": "TRACK123456",
"carrier": "DHL"
},
"refund": {
"id": "cm_112233",
"amount": 274900.00,
"currency": "CRC"
},
"items": [
{
"sku": "SKU-0001",
"name": "Sample Product 1",
"qty": 1,
"price": 249900.00,
"product_url": "https://store.example.com/p/sample-product-1",
"image_url": "https://store.example.com/media/catalog/product/s/k/sku-0001.jpg"
}
],
"consent": {
"transactional": true,
"marketing": true,
"source": "checkout_opt_in",
"captured_at": "2026-05-30T09:30:00Z"
},
"metadata": {
"channel": "web",
"locale": "es_CR",
"test": false
}
}
Required top-level fields
| Field | Type | Required | Description |
|---|---|---|---|
schema_version |
string | Yes | Contract version, e.g. 1.0. |
event |
string | Yes | Normalized event name. |
event_id |
string | Yes | Unique idempotency key. |
event_time |
string | Yes | ISO 8601 UTC timestamp. |
source |
string | Yes | adobe-commerce (or magento alias). |
store_id |
string | Yes | Store or brand identifier (issued/agreed at onboarding). |
items |
array | Conditional | Required for cart and order email use cases. |
Object reference
| JSON path | Required | Notes |
|---|---|---|
customer.email |
Yes for any event that sends an email | Recipient targeting. If absent, no email is sent. |
cart.id |
Yes for cart flows | Abandoned-cart state tracking. |
cart.currency |
Yes for cart flows | Amount formatting. |
checkout.step |
Optional | One of cart, shipping, payment, review. |
checkout.payment_method_selected |
Optional | e.g. card, sinpe, other, null; lets Olanzo tailor copy. |
checkout.payment_started |
Optional | Boolean. |
order.id or order.increment_id |
Yes for order / invoice / shipment / refund flows | Order-linked communications. |
invoice.id |
Yes for invoice events | Invoice-linked emails. |
shipment.id |
Yes for shipment events | Shipment-linked emails. |
refund.id |
Yes for refund events | Refund-linked emails. |
consent.transactional |
Recommended | Whether the contact accepts service/transactional email. |
consent.marketing |
Yes for cart.abandoned and other marketing-class emails |
Whether the contact opted in to marketing. |
metadata.test |
Optional | true marks a test event (validated and acknowledged, no real email). |
Event-specific payload examples
Each example shows the minimum useful payload; all follow the envelope above.
cart.abandoned (cross-payment-method workhorse)
{
"schema_version": "1.0",
"event": "cart.abandoned",
"event_id": "evt_abandoned_001",
"event_time": "2026-05-30T10:15:00Z",
"source": "adobe-commerce",
"store_id": "acme-cr",
"customer": { "email": "customer@example.com", "first_name": "Ana", "last_name": "Lopez" },
"cart": {
"id": "quote_123456",
"currency": "CRC",
"subtotal": 249900.00,
"grand_total": 274900.00,
"item_count": 2,
"cart_url": "https://store.example.com/checkout/cart/"
},
"checkout": { "step": "payment", "payment_method_selected": "card", "payment_started": false },
"items": [
{ "sku": "SKU-0001", "name": "Sample Product 1", "qty": 1, "price": 249900.00 }
],
"consent": { "transactional": true, "marketing": true },
"metadata": { "abandoned_after_minutes": 60, "detected_by": "scheduled_quote_scan" }
}
checkout.started
{
"schema_version": "1.0",
"event": "checkout.started",
"event_id": "evt_checkout_001",
"event_time": "2026-05-30T09:41:05Z",
"source": "adobe-commerce",
"store_id": "acme-cr",
"customer": { "email": "customer@example.com", "first_name": "Ana" },
"cart": { "id": "quote_123456", "currency": "CRC", "grand_total": 274900.00 },
"checkout": { "step": "shipping", "payment_method_selected": null, "payment_started": false },
"items": [
{ "sku": "SKU-0001", "name": "Sample Product 1", "qty": 1, "price": 249900.00 }
]
}
order.created
{
"schema_version": "1.0",
"event": "order.created",
"event_id": "evt_order_001",
"event_time": "2026-05-30T10:02:27Z",
"source": "adobe-commerce",
"store_id": "acme-cr",
"customer": { "email": "customer@example.com", "first_name": "Ana", "last_name": "Lopez" },
"order": { "id": "100048231", "increment_id": "00010048231", "status": "processing", "created_at": "2026-05-30T10:02:27Z" },
"cart": { "id": "quote_123456", "currency": "CRC", "subtotal": 249900.00, "grand_total": 274900.00 },
"items": [
{ "sku": "SKU-0001", "name": "Sample Product 1", "qty": 1, "price": 249900.00 }
]
}
invoice.created
{
"schema_version": "1.0",
"event": "invoice.created",
"event_id": "evt_invoice_001",
"event_time": "2026-05-30T10:05:00Z",
"source": "adobe-commerce",
"store_id": "acme-cr",
"customer": { "email": "customer@example.com" },
"order": { "id": "100048231", "increment_id": "00010048231", "status": "processing" },
"invoice": { "id": "inv_778899", "increment_id": "INV-000778899" },
"items": [
{ "sku": "SKU-0001", "name": "Sample Product 1", "qty": 1, "price": 249900.00 }
]
}
shipment.created
{
"schema_version": "1.0",
"event": "shipment.created",
"event_id": "evt_shipment_001",
"event_time": "2026-05-31T08:15:00Z",
"source": "adobe-commerce",
"store_id": "acme-cr",
"customer": { "email": "customer@example.com" },
"order": { "id": "100048231", "increment_id": "00010048231", "status": "complete" },
"shipment": { "id": "shp_445566", "tracking_number": "TRACK123456", "carrier": "DHL" },
"items": [
{ "sku": "SKU-0001", "name": "Sample Product 1", "qty": 1 }
]
}
refund.created
{
"schema_version": "1.0",
"event": "refund.created",
"event_id": "evt_refund_001",
"event_time": "2026-06-02T11:30:00Z",
"source": "adobe-commerce",
"store_id": "acme-cr",
"customer": { "email": "customer@example.com" },
"order": { "id": "100048231", "increment_id": "00010048231" },
"refund": { "id": "cm_112233", "amount": 274900.00, "currency": "CRC" }
}
customer.created
{
"schema_version": "1.0",
"event": "customer.created",
"event_id": "evt_customer_001",
"event_time": "2026-05-29T16:20:00Z",
"source": "adobe-commerce",
"store_id": "acme-cr",
"customer": { "id": "98765", "email": "customer@example.com", "first_name": "Ana", "last_name": "Lopez" },
"consent": { "transactional": true, "marketing": false },
"metadata": { "locale": "es_CR" }
}
Processing rules by event family
Abandoned cart
cart.updatedandcheckout.startedcreate or refresh cart-activity state.checkout.payment_startedrefines it (e.g. enables payment-method-specific copy); optional.cart.abandonedis the final recovery trigger.order.createdcancels any pending recovery for thatcart.idor customer.
Out-of-order / late-delivery guard (required). Because delivery is asynchronous, a cart.abandoned event can arrive after the purchase actually completed. Before sending any recovery email, Olanzo re-checks that no order.created exists for that cart/customer and does not send if one does.
Additional safeguards: a suppression window (no more than one recovery email per contact within a configurable window, e.g. 24h) and a max-age (ignore cart.abandoned whose event_time is older than a configurable threshold).
Transactional emails
order.created→ order confirmation.invoice.created→ invoice / payment confirmation.shipment.created→ shipping / dispatch.refund.created→ refund confirmation.customer.created→ welcome / activation (when enabled).
Consent and compliance
Operational guidance, not legal advice. Confirm with whoever owns data protection.
- Transactional vs marketing differ. Order/invoice/shipment/refund confirmations are service messages. Abandoned-cart reminders and welcome emails are generally treated as marketing.
- Olanzo behavior: for
cart.abandonedand other marketing-class emails, do not send unlessconsent.marketingistrue; iffalseor absent, suppress and log. Transactional emails send regardless of marketing consent, subject to suppression rules. - No recipient → no email. Many abandoned carts (guest, or shopper left before entering an email) have no
customer.email. Olanzo skips and logs these without error. Expect a meaningful share of abandonments to be unaddressable. - Jurisdiction. Merchants on Olanzo are subject to applicable data-protection law (in Costa Rica, Ley 8968 / Prodhab) for marketing email and personal data in the payload. The merchant is the source of truth for opt-in status, conveyed via the
consentobject.
Validation rules
Olanzo will:
- Reject non-JSON payloads (400).
- Verify authentication and signature before processing (401).
- Require schema_version, event, event_id, event_time, source, store_id (422 if missing).
- Require customer.email for any event that sends an email (otherwise skip — no recipient).
- Require cart fields for cart events and order fields for order-related events.
- Enforce idempotency on event_id (409 on duplicates).
- Ignore unknown additional fields safely (forward compatibility).
Schema evolution and compatibility
- The contract is versioned with
schema_version(major.minor) and echoed in theX-Webhook-Schema-Versionheader. - Within a major version, changes are additive and backward-compatible — new optional fields and new event types only. Receivers MUST ignore unknown fields and SHOULD ignore unknown event types they do not handle.
- Breaking changes increment the major version, are announced ahead of time, and run with an overlap period so integrations can migrate.
- Practical rule for implementers: never hard-fail on an unexpected field or event type — log it and move on.
Integration guide (step by step)
This is the recommended adapter-based integration. Exact commands and admin paths differ slightly between Adobe Commerce PaaS (on-prem / Cloud) and Adobe Commerce as a Cloud Service (SaaS) — follow Adobe's current docs for the target edition.
Step 0 — Onboarding (Olanzo provides)
Olanzo issues the merchant:
- the endpoint URL (https://{olanzo-events-endpoint}/v1/commerce),
- an auth credential (Bearer token or X-Webhook-Key),
- an HMAC signing secret,
- an agreed store_id,
- separate sandbox endpoint and credentials.
Step 1 — Enable asynchronous eventing on Adobe Commerce
Install and enable Adobe Commerce eventing (this is the async path, not System → Webhooks):
composer require magento/commerce-eventing
bin/magento module:enable \
Magento_AdobeCommerceEventsClient \
Magento_AdobeCommerceEventsGenerator \
Magento_AdobeIoEventsClient \
Magento_AdobeCommerceOutOfProcessExtensibility
bin/magento setup:upgrade
bin/magento setup:di:compile
Create an Adobe Developer Console / App Builder project, then in the Commerce Admin configure Adobe I/O Events (Stores → Configuration → Adobe Services → Adobe I/O Events): paste the workspace configuration, set the Commerce instance ID, and create/select the event provider (Admin, or bin/magento events:create-event-provider).
Step 2 — Subscribe to the required Commerce events
Subscribe to the native events that back the contract — order placed, invoice created, shipment created, credit memo (refund) created, customer created/updated, and the cart/checkout activity events. Subscribe via the Admin ("Create event subscriptions from the Admin"), in a module, or on PaaS with bin/magento events:subscribe <event_name>. Group event types into as few registrations as possible (Adobe I/O Events allows a maximum of 30 registrations per IMS client-id).
Step 3 — Deploy the adapter
Deploy a thin adapter (an App Builder runtime action — recommended, no core changes — or a custom module) that:
- receives the subscribed Commerce events,
- maps each to the normalized envelope and event name in this spec,
- computes the X-Webhook-Signature HMAC with the Olanzo signing secret,
- POSTs asynchronously to the Olanzo endpoint with the retry/backoff policy above,
- treats a 409 response as success (already delivered).
Step 4 — Add the cart-abandonment emitter
cart.abandoned has no native event in Adobe Commerce — abandonment is the absence of activity, detected by scanning cart (quote) data on a schedule. Add one of:
- a scheduled job (cron) — custom, or part of the adapter module — that queries the quote table for carts with a captured email, no recent activity, and no order, then emits cart.abandoned; or
- an existing abandoned-cart extension/connector whose output the adapter consumes and forwards.
This is the part that requires development access to the store (the merchant or their implementation partner). Confirm ownership and timeline early — it is the longest-lead item.
Step 5 — Verify the endpoint and test in sandbox
- Complete the endpoint verification handshake (echo Olanzo's challenge).
- Confirm your signing reproduces the worked signature example.
- Point the adapter at the sandbox endpoint and trigger sample events on a staging store; confirm Olanzo returns
202. - Confirm a tampered body is rejected with
401, and a replayedevent_idreturns409. - Confirm the out-of-order guard: an
order.createdsuppresses a subsequentcart.abandonedfor the same cart. - Use
metadata.test = trueto validate end to end without sending real emails. - Use Adobe's event debug tracing to confirm delivery and inspect payloads.
Step 6 — Go live and monitor
Switch to the production endpoint and enable subscriptions. Monitor delivery success, retry rates, and the dead-letter queue; watch the 30-registration limit.
Source mapping notes
- Transactional events (
order.created,invoice.created,shipment.created,refund.created,customer.created,customer.updated) map cleanly to native Adobe Commerce events delivered via Adobe I/O Events. cart.abandonedis not native; it requires the scheduled scan or an extension (Step 4).checkoutobject —stepandpayment_method_selectedare populated from the store's checkout/quote data so Olanzo can tailor copy and so abandonment is covered across all payment methods.- Do not use the synchronous Commerce Webhooks module (System → Webhooks) for any of this.
Internal routing (Olanzo side)
Even with one public endpoint, Olanzo routes internally by event family: - cart events → cart-recovery logic, - order and invoice events → transactional messaging, - shipment and refund events → post-purchase messaging, - customer events → lifecycle automation.
Responsibilities summary
| Item | Owner |
|---|---|
Endpoint, credentials, signing secret, store_id, sandbox |
Olanzo |
| Signature verification, idempotency, routing, email send | Olanzo |
| Adobe I/O Events setup and event subscriptions | Merchant / implementation partner |
| Adapter (mapping, signing, async POST, retry) | Merchant / implementation partner |
| Cart-abandonment emitter (scheduled scan or extension) | Merchant / implementation partner |
| Consent / opt-in source of truth | Merchant |
Glossary
- Adapter — the thin sender component (App Builder runtime action or custom module) that maps Commerce events to this contract, signs them, and POSTs them.
- Adobe I/O Events — Adobe's asynchronous eventing system; delivers Commerce events out of band to a webhook URL, runtime action, or journal.
- App Builder / runtime action — Adobe's serverless platform and its functions; a runtime action can host the adapter without changing Commerce core.
- Commerce Webhooks — Adobe's synchronous hook module (System → Webhooks). Not used here.
- Event provider / event registration — Adobe I/O Events constructs identifying the event source and the subscription (which events go where).
- Idempotency key — the
event_id; lets the receiver discard duplicate deliveries. - Quote — Magento's internal term for a shopping cart; the table the abandonment scan reads.
- Dead-letter queue — where the sender parks events that have exhausted their retries, for inspection.
Open setup decisions
Product values for Olanzo to confirm and publish (the spec uses defaults until then):
- rate limit, payload-size cap, and idempotency retention window (currently 50 req/s, 256 KB, 7 days);
- whether a post-202 delivery-status view (dashboard or status callback) is offered, so a merchant can confirm an email was actually sent rather than just accepted.
Per-merchant choices: 1. Adapter form — App Builder runtime action (recommended, SaaS-friendly) vs custom module. 2. Cart-abandonment source — custom scheduled scan vs existing extension/connector. 3. Commerce edition — PaaS vs SaaS, which changes some setup commands and options. 4. Direct-delivery alternative — whether any merchant will skip the adapter and have Olanzo accept Adobe-native payloads with Adobe I/O signature verification instead of the normalized envelope.
Shopify → Olanzo Event Webhook Integration Specification
Version: 1.0 (generalized integration specification)
Date: 2026-05-30
Applies to: any merchant on Shopify (including Shopify Plus)
Receiver: Olanzo messaging / email platform
This document defines how a Shopify store integrates with Olanzo so that Olanzo can receive abandoned-checkout and transactional ecommerce events and send the corresponding emails. It is platform-specific (Shopify) but not merchant-specific — every Shopify merchant integrates the same way. It is the Shopify counterpart to the Adobe Commerce / Magento integration spec and shares the same Olanzo-side contract.
Audience and purpose
This spec is written for a merchant's engineering team and their Shopify development partner. It tells them:
- exactly what Olanzo expects to receive (the contract), and
- exactly how to make their Shopify store send it (the integration guide).
It enables abandoned-checkout recovery emails and transactional emails (order, fulfillment, refund, and customer lifecycle), driven by events from the store.
Is this a "webhook"? (terminology)
Yes — and on Shopify this is simpler than on some platforms. Shopify webhooks are asynchronous HTTPS POSTs that Shopify sends to your endpoint when an event occurs, and Shopify signs every one natively with an HMAC in the X-Shopify-Hmac-Sha256 header. There is no synchronous/blocking variant to avoid.
What you choose is the shape of the integration:
| Shape | What Olanzo receives | Signature Olanzo verifies | Recommended? |
|---|---|---|---|
| Adapter (a thin app/middleware in between) | Olanzo's normalized envelope | Olanzo's HMAC (X-Webhook-Signature) |
Yes — one consistent contract across Shopify, Adobe Commerce, and future platforms. |
| Direct delivery (Shopify → Olanzo) | Shopify's native payload per topic | Shopify's HMAC (X-Shopify-Hmac-Sha256) |
Lighter setup, but loses the shared contract and still needs Flow/API for abandonment. |
The adapter pattern is assumed below unless noted. The direct alternative is covered where it differs.
Quick start
If you only read one section, read this.
You are building: a small adapter that receives your Shopify webhooks (and a Shopify Flow trigger for abandonment), reshapes each into Olanzo's JSON envelope, signs it, and POSTs it asynchronously to one Olanzo URL.
From Olanzo (at onboarding) you receive: your endpoint URL, an auth token (or key), an HMAC signing secret, your store_id, and separate sandbox credentials.
Minimal path to a working integration:
- Create a Shopify app (GraphQL Admin API) and subscribe to the order / fulfillment / refund / customer webhook topics (Integration guide, Steps 1–2).
- Deploy the adapter: verify Shopify's HMAC → map to Olanzo's envelope → sign with the Olanzo secret → POST async (Step 3). Verify your signing against the worked example below before sending real traffic.
- Set up abandonment — easiest is the Shopify Flow "Customer abandons checkout" trigger, which fires natively after an inactivity window; have it reach the adapter (Step 4). Unlike some platforms, you do not have to build the abandonment detector yourself.
- Point the adapter at your sandbox endpoint, send sample events, and confirm
202plus passing signatures (Step 5). - Switch to production, and — important on Shopify — add a reconciliation job and subscription monitor so a transient outage can't silently disconnect you (Step 6).
The rules that prevent most issues:
- Respond to Shopify within 5 seconds (any non-2xx is a failure) — verify, enqueue, ack; process afterwards.
- Sign every request to Olanzo and include a fresh timestamp; on the direct path, verify Shopify's HMAC over the raw body before parsing.
- Treat delivery as at-least-once — dedupe (Shopify's
X-Shopify-Webhook-Idon the direct path, yourevent_idon the adapter path). - Watch your subscriptions: Shopify silently removes a webhook subscription after sustained delivery failures.
A copy-pasteable request is in Signature example (worked).
Scope and coverage
The integration covers both, on one endpoint:
- Abandoned-checkout recovery, covering checkouts abandoned with any payment method. On Shopify, a checkout is "abandoned" once the shopper has entered contact information but not completed the order — so shoppers who reach checkout and leave (regardless of payment method) are covered. Shoppers who only fill a cart and never enter an email have no recipient and cannot be emailed (true on every platform).
- Transactional emails: order, fulfillment, refund, and customer lifecycle confirmations.
Shopify's abandonment determination (via Flow or the Abandoned Checkouts API) is payment-method-agnostic — it is simply a checkout that did not become an order within the window.
Core design principles
- Normalized event names. The payload uses platform-neutral names (
order.created,checkout.abandoned, …), not raw Shopify topic names. One contract serves Shopify today and other platforms later. - One endpoint, internal routing. A single ingestion endpoint; Olanzo branches on the
eventfield internally. - Asynchronous and non-blocking. Delivery must never block a shopper action. Respond fast and process out of band.
- Secure by default. TLS, mandatory authentication, mandatory request signing, replay protection.
- Idempotent. Every event carries a unique
event_id; Olanzo deduplicates on it.
Architecture overview
The recommended shape places a thin adapter between Shopify and Olanzo. The adapter receives Shopify webhooks and the Flow abandonment signal, maps them into Olanzo's normalized envelope, signs them with the merchant's Olanzo-issued secret, and POSTs them to Olanzo — all out of band:
The adapter is a small app or serverless service. The abandonment signal can reach it from a Shopify Flow "Send HTTP request" action on the abandonment trigger, or from a scheduled poll of the Abandoned Checkouts Admin API.
Alternative (lighter setup, less control): point Shopify webhooks directly at Olanzo. Olanzo then receives Shopify's native payloads per topic and verifies the X-Shopify-Hmac-Sha256 signature. This is acceptable but loses one consistent wire contract, requires Olanzo to understand Shopify's per-topic schemas, and still needs Flow or the API for abandonment.
Sequence diagrams
1. Transactional happy path (order confirmation)
The storefront responds to the shopper immediately; email delivery is fully decoupled.
2. Abandoned checkout via Shopify Flow
Shopify itself decides abandonment after a configurable window; the Flow trigger does the detecting.
3. Delivery retry and idempotency (at-least-once)
A duplicate delivery is normal; the sender treats 409 as success.
Separately, watch the Shopify → adapter leg: Shopify retries a failed delivery only ~8 times over ~4 hours, then drops the event, and after sustained failures removes the subscription entirely. Reconciliation and subscription monitoring (Step 6) are the safety net.
The contract Olanzo receives (normative)
Regardless of how the merchant produces events, Olanzo requires the following on the wire (adapter path).
Endpoint
POST https://{olanzo-events-endpoint}/v1/commerce
The exact endpoint URL is issued by Olanzo per merchant during onboarding (along with credentials and the signing secret).
Transport
HTTPS/TLS is required. Plain HTTP is rejected.
Authentication — required
Every request authenticates with the credential Olanzo issues at onboarding:
Authorization: Bearer <token>, orX-Webhook-Key: <shared-secret>
Request signing — required (adapter path)
Every request includes an HMAC-SHA256 signature of the raw body, computed with the Olanzo-issued signing secret:
X-Webhook-Signature: t=<unix_timestamp>,v1=<hex_hmac_sha256>
where v1 = HMAC_SHA256(secret, "<t>.<raw_body>").
Olanzo verifies by recomputing the HMAC and constant-time comparing it, and rejects the request if |now − t| > 5 minutes (replay / freshness window).
Note the difference from Shopify's own signature: Shopify uses a base64 HMAC over just the raw body (no timestamp) in
X-Shopify-Hmac-Sha256. The adapter verifies Shopify's signature on the way in, then produces Olanzo'sX-Webhook-Signature(hex, timestamped) on the way out. Idempotency (event_id) is not a substitute for signing.
Signature example (worked)
Sign the exact raw bytes of the request body — compute the signature over precisely what you send (whitespace included), before any reserialization. The signature is lowercase hex. t is the signing time (Unix seconds) and is independent of event_time.
Given:
- signing secret:
whsec_test_5f3a9c2e8b1d4f76 - timestamp
t:1748599200 - raw body (exactly these 203 bytes):
{"schema_version":"1.0","event":"order.created","event_id":"evt_demo_100001","event_time":"2026-05-30T10:00:00Z","source":"shopify","store_id":"demo-store-us","customer":{"email":"customer@example.com"}}
The string to sign is t + . + raw body, and the resulting header is:
X-Webhook-Signature: t=1748599200,v1=652d0f01f75000fbf7a759402d260b1fa62a1df72b588089605781e1c9a47eea
If your code produces that exact v1 from those inputs, your signing is correct.
Sign (Node.js):
const crypto = require("crypto");
const t = Math.floor(Date.now() / 1000);
const v1 = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const signatureHeader = `t=${t},v1=${v1}`;
Full request (cURL):
curl -X POST "https://{olanzo-events-endpoint}/v1/commerce" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-H "X-Webhook-Schema-Version: 1.0" \
-H "X-Webhook-Signature: t=1748599200,v1=652d0f01f75000fbf7a759402d260b1fa62a1df72b588089605781e1c9a47eea" \
--data '{"schema_version":"1.0","event":"order.created","event_id":"evt_demo_100001","event_time":"2026-05-30T10:00:00Z","source":"shopify","store_id":"demo-store-us","customer":{"email":"customer@example.com"}}'
(Use a current timestamp in production — the fixed value above is only so the example reproduces.)
Verifying Shopify's native HMAC (direct-delivery path only). Shopify signs with a base64 HMAC over the raw body using the app's client secret. Verify before JSON parsing:
const digest = crypto.createHmac("sha256", shopifyClientSecret).update(rawBody).digest("base64");
const ok = crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(req.header("X-Shopify-Hmac-Sha256")));
Worked Shopify example — secret shpss_test_a1b2c3d4e5f6, raw body {"id":820982911946154508,"email":"customer@example.com","total_price":"139.99"} → X-Shopify-Hmac-Sha256: wQaWg/tAMMaz351XDf06hPw64iziEyiu6UH3BpUBMd4=.
Asynchronous / non-blocking — required
Process out of band. On Shopify, the practical constraint is the 5-second response window: verify the signature, enqueue the event, return 2xx, and do the email work afterwards. Never render or send email inside the request handler.
Idempotency
Every event carries a unique event_id. Olanzo persists seen ids and rejects duplicates with 409. On the direct path, derive event_id from Shopify's X-Shopify-Webhook-Id (stable across Shopify's retries).
Delivery semantics
- At-least-once. Duplicates are normal; dedupe on
event_idand treat409as success. - No ordering guarantee. Events may arrive out of order (e.g.
order.createdbefore a delayedcheckout.abandoned). The processing rules and the out-of-order guard handle this. - Idempotency retention. Olanzo remembers each
event_idfor a deduplication window (default: 7 days — confirm at onboarding).
Limits
Values below are defaults; the exact figures for your account are confirmed at onboarding.
- Rate limit: ~50 requests/second per merchant (short bursts allowed). On exceed, Olanzo returns
429with aRetry-Afterheader. - Payload size: ≤ 256 KB per request.
- Response timing (Olanzo): Olanzo acknowledges within ~5 seconds; the adapter should use a ~10-second request timeout before retrying. Note Shopify's own timeout to the adapter is a hard 5 seconds.
Required headers
| Header | Value |
|---|---|
Content-Type |
application/json |
Authorization or X-Webhook-Key |
Bearer <token> / <shared-secret> |
X-Webhook-Signature |
t=<unix_ts>,v1=<hex_hmac_sha256> |
X-Webhook-Schema-Version |
e.g. 1.0 |
Optional headers
| Header | Value |
|---|---|
X-Source-System |
shopify |
X-Store-Id |
<store_domain or store_id> |
Response codes
| Code | Meaning |
|---|---|
202 Accepted |
Validated and queued for asynchronous processing. Default success. |
200 OK |
Accepted and processed synchronously (rare). |
400 Bad Request |
Malformed / non-JSON payload. |
401 / 403 |
Authentication or signature verification failed. |
409 Conflict |
Duplicate event_id (idempotency). |
422 Unprocessable Entity |
Well-formed JSON but failed contract validation. |
429 Too Many Requests |
Rate limited; back off and retry. |
500 Internal Server Error |
Genuine processing failure; retry. |
Error responses
Error responses use a consistent JSON body:
{
"error": {
"code": "validation_failed",
"message": "Missing required field: customer.email",
"details": ["customer.email is required for events that send an email"]
}
}
| HTTP | error.code |
When |
|---|---|---|
| 400 | invalid_json |
Body is not valid JSON. |
| 401 | unauthorized |
Missing/invalid credential, or signature verification failed. |
| 403 | forbidden |
Credential valid but not permitted for this store_id. |
| 409 | duplicate_event |
event_id already processed (treat as success). |
| 422 | validation_failed |
Valid JSON, failed contract validation; details lists the problems. |
| 429 | rate_limited |
Rate limit exceeded; see Retry-After. |
| 500 | internal_error |
Transient Olanzo error; retry. |
Retry, backoff, and dead-letter
Two legs, two policies:
- Adapter → Olanzo (you own this): retry on timeout, connection failure,
5xx, or429, with exponential backoff (~1m, 5m, 30m, 2h, 6h) for up to ~24 hours; dead-letter after the final attempt;4xxother than429are permanent. Retries are safe because of the stableevent_id. - Shopify → adapter (Shopify owns this): Shopify retries a failed delivery about 8 times over ~4 hours with exponential backoff (policy as of the September 2024 update; older "19 retries / 48 hours" figures are outdated), each attempt with a 5-second timeout. After sustained failures Shopify removes the subscription. Mitigate with the reconciliation job and subscription monitor in Step 6.
Endpoint verification handshake
Before live traffic, Olanzo confirms ownership of the endpoint. Olanzo sends a verification request and the endpoint must echo the challenge.
Request from Olanzo:
{ "type": "endpoint.verification", "challenge": "c_8f2a...random" }
Expected response (HTTP 200, within 10 seconds):
{ "challenge": "c_8f2a...random" }
A type: "endpoint.verification" request never carries a business event and never triggers an email.
Sandbox and test mode
- Olanzo issues a separate sandbox endpoint and credentials at onboarding. Build and validate against sandbox first. Shopify development stores are ideal for end-to-end testing.
- Set
"test": trueinsidemetadatato mark an event as a test: Olanzo validates, verifies the signature, deduplicates, and returns the normal status code, but does not send a real email (it routes to a sink). - A health check is available:
GET https://{olanzo-events-endpoint}/v1/commerce/healthreturns200with{ "status": "ok" }.
Data formats and conventions
| Concern | Convention |
|---|---|
| Encoding | UTF-8 throughout. |
| Content type | application/json. |
| Timestamps | ISO 8601 in UTC with a Z suffix, e.g. 2026-05-30T10:00:00Z. |
| Money amounts | JSON numbers in major currency units with up to 2 decimal places (e.g. 139.99 means USD 139.99 — not minor units). Parse as decimal, not binary float. |
| Currency | ISO 4217 code, e.g. USD, CRC. |
event_id |
Opaque string, ≤ 255 chars, globally unique per event, stable across retries. On the direct path, derive from X-Shopify-Webhook-Id. |
store_id |
The identifier agreed at onboarding (often the *.myshopify.com domain or a brand id); stable per merchant. |
| Booleans | JSON true / false (never strings). |
| Null / absent | Omit objects and fields that do not apply to an event. Optional scalars may be null. Receivers ignore unknown fields. |
Field enumerations
source:shopify.order.status: the store's order status (e.g.open,closed,cancelled) — passed through.order.financial_status: Shopify's financial status (e.g.pending,authorized,paid,partially_refunded,refunded,voided) — passed through.order.fulfillment_status:null|partial|fulfilled|restocked— passed through.checkout.step(optional):contact·shipping·payment·review.
Event model
Source
source value |
Origin |
|---|---|
shopify |
Canonical value for the Shopify store. |
Abandoned-checkout event group
Covers checkouts abandoned under any payment method:
checkout.created— a checkout was started.checkout.updated— checkout details changed.checkout.abandoned— the recovery trigger. On Shopify this is emitted natively via the Shopify Flow "Customer abandons checkout" trigger (or derived from the Abandoned Checkouts Admin API). If the sender cannot emit a finalcheckout.abandoned, the receiver determines abandonment after a configured inactivity window using thecheckout.*signals.order.created— an order now exists; cancels any pending recovery for that checkout/customer.
Transactional event group
order.createdorder.updatedfulfillment.createdfulfillment.updatedrefund.createdcustomer.createdcustomer.updated
order.createdis used both as a transactional trigger and to cancel checkout recovery.
Normalized JSON envelope
The sender maps Shopify data into this envelope, including only the objects relevant to the event (a fulfillment event includes fulfillment and order; a refund event includes refund and order).
{
"schema_version": "1.0",
"event": "order.created",
"event_id": "evt_demo_100001",
"event_time": "2026-05-30T10:00:00Z",
"source": "shopify",
"store_id": "demo-store-us",
"site": "https://demo-store.myshopify.com",
"customer": {
"id": "cust_10001",
"email": "customer@example.com",
"first_name": "Alice",
"last_name": "Johnson",
"phone": "+15555550123"
},
"checkout": {
"id": "chk_90001",
"token": "tok_checkout_90001",
"currency": "USD",
"subtotal": 129.99,
"grand_total": 139.99,
"item_count": 2,
"checkout_url": "https://demo-store.myshopify.com/checkouts/chk_90001"
},
"order": {
"id": "ord_70001",
"order_number": "1001",
"status": "open",
"financial_status": "paid",
"fulfillment_status": "unfulfilled",
"created_at": "2026-05-30T10:00:00Z"
},
"fulfillment": {
"id": "ful_40001",
"tracking_number": "1Z999AA10123456784",
"tracking_company": "UPS",
"tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784"
},
"refund": {
"id": "ref_30001",
"amount": 139.99,
"currency": "USD"
},
"items": [
{
"sku": "TSHIRT-BLK-M",
"name": "Classic T-Shirt",
"qty": 1,
"price": 39.99,
"product_url": "https://demo-store.myshopify.com/products/classic-t-shirt",
"image_url": "https://cdn.example.com/images/classic-t-shirt-black.jpg"
}
],
"consent": {
"email_marketing": true,
"sms_marketing": false,
"source": "checkout_opt_in",
"captured_at": "2026-05-30T09:30:00Z"
},
"metadata": {
"channel": "online_store",
"locale": "en-US",
"test": false
}
}
Required top-level fields
| Field | Type | Required | Description |
|---|---|---|---|
schema_version |
string | Yes | Contract version, e.g. 1.0. |
event |
string | Yes | Normalized event name. |
event_id |
string | Yes | Unique idempotency key. |
event_time |
string | Yes | ISO 8601 UTC timestamp. |
source |
string | Yes | Fixed value shopify. |
store_id |
string | Yes | Store or brand identifier (agreed at onboarding). |
items |
array | Conditional | Required for checkout and order email use cases. |
Object reference
| JSON path | Required | Notes |
|---|---|---|
customer.email |
Yes for any event that sends an email | Recipient targeting. If absent, no email is sent. |
checkout.id |
Yes for checkout flows | Abandoned-checkout state tracking. |
checkout.currency |
Yes for checkout flows | Amount formatting. |
checkout.step |
Optional | One of contact, shipping, payment, review. |
order.id or order.order_number |
Yes for order / fulfillment / refund flows | Order-linked communications. |
fulfillment.id |
Yes for fulfillment events | Shipping notifications. |
refund.id |
Yes for refund events | Refund-linked emails. |
consent.email_marketing |
Yes for checkout.abandoned and other marketing-class emails |
Whether the contact opted in to email marketing. |
consent.sms_marketing |
Optional | Reserved for future SMS use. |
metadata.test |
Optional | true marks a test event (validated and acknowledged, no real email). |
Event-specific payload examples
Each example shows the minimum useful payload; all follow the envelope above.
checkout.abandoned (the recovery trigger)
{
"schema_version": "1.0",
"event": "checkout.abandoned",
"event_id": "evt_checkout_10003",
"event_time": "2026-05-30T10:05:00Z",
"source": "shopify",
"store_id": "demo-store-us",
"customer": { "email": "customer@example.com", "first_name": "Alice", "last_name": "Johnson" },
"checkout": {
"id": "chk_90001",
"token": "tok_checkout_90001",
"currency": "USD",
"subtotal": 129.99,
"grand_total": 139.99,
"item_count": 2,
"checkout_url": "https://demo-store.myshopify.com/checkouts/chk_90001"
},
"items": [
{ "sku": "TSHIRT-BLK-M", "name": "Classic T-Shirt", "qty": 1, "price": 39.99 },
{ "sku": "JEANS-BLU-32", "name": "Slim Fit Jeans", "qty": 1, "price": 90.00 }
],
"consent": { "email_marketing": true, "sms_marketing": false },
"metadata": { "abandoned_after_minutes": 600, "detected_by": "shopify_flow" }
}
checkout.created
{
"schema_version": "1.0",
"event": "checkout.created",
"event_id": "evt_checkout_10001",
"event_time": "2026-05-30T09:15:00Z",
"source": "shopify",
"store_id": "demo-store-us",
"customer": { "email": "customer@example.com", "first_name": "Alice" },
"checkout": {
"id": "chk_90001",
"token": "tok_checkout_90001",
"currency": "USD",
"subtotal": 129.99,
"grand_total": 139.99,
"item_count": 2,
"checkout_url": "https://demo-store.myshopify.com/checkouts/chk_90001"
},
"items": [
{ "sku": "TSHIRT-BLK-M", "name": "Classic T-Shirt", "qty": 1, "price": 39.99 }
]
}
order.created
{
"schema_version": "1.0",
"event": "order.created",
"event_id": "evt_order_10001",
"event_time": "2026-05-30T10:00:00Z",
"source": "shopify",
"store_id": "demo-store-us",
"customer": { "email": "customer@example.com", "first_name": "Alice", "last_name": "Johnson" },
"order": {
"id": "ord_70001",
"order_number": "1001",
"status": "open",
"financial_status": "paid",
"fulfillment_status": "unfulfilled",
"created_at": "2026-05-30T10:00:00Z"
},
"checkout": { "id": "chk_90001", "currency": "USD", "subtotal": 129.99, "grand_total": 139.99 },
"items": [
{ "sku": "TSHIRT-BLK-M", "name": "Classic T-Shirt", "qty": 1, "price": 39.99 },
{ "sku": "JEANS-BLU-32", "name": "Slim Fit Jeans", "qty": 1, "price": 90.00 }
]
}
fulfillment.created
{
"schema_version": "1.0",
"event": "fulfillment.created",
"event_id": "evt_fulfillment_10001",
"event_time": "2026-05-31T08:00:00Z",
"source": "shopify",
"store_id": "demo-store-us",
"customer": { "email": "customer@example.com", "first_name": "Alice" },
"order": { "id": "ord_70001", "order_number": "1001", "fulfillment_status": "fulfilled" },
"fulfillment": {
"id": "ful_40001",
"tracking_number": "1Z999AA10123456784",
"tracking_company": "UPS",
"tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784"
},
"items": [
{ "sku": "TSHIRT-BLK-M", "name": "Classic T-Shirt", "qty": 1 }
]
}
refund.created
{
"schema_version": "1.0",
"event": "refund.created",
"event_id": "evt_refund_10001",
"event_time": "2026-06-02T14:30:00Z",
"source": "shopify",
"store_id": "demo-store-us",
"customer": { "email": "customer@example.com" },
"order": { "id": "ord_70001", "order_number": "1001" },
"refund": { "id": "ref_30001", "amount": 139.99, "currency": "USD" },
"metadata": { "reason": "customer_return" }
}
customer.created
{
"schema_version": "1.0",
"event": "customer.created",
"event_id": "evt_customer_10001",
"event_time": "2026-05-29T16:20:00Z",
"source": "shopify",
"store_id": "demo-store-us",
"customer": { "id": "cust_10001", "email": "customer@example.com", "first_name": "Alice", "last_name": "Johnson", "phone": "+15555550123" },
"consent": { "email_marketing": true, "sms_marketing": false },
"metadata": { "locale": "en-US", "customer_tags": ["newsletter", "vip-prospect"] }
}
Processing rules by event family
Abandoned checkout
checkout.createdandcheckout.updatedcreate or refresh checkout-activity state.checkout.abandonedis the final recovery trigger (emitted by Flow/API, or inferred by the receiver after an inactivity window).order.createdcancels any pending recovery for thatcheckout.idor customer.
Out-of-order / late-delivery guard (required). Because delivery is asynchronous, a checkout.abandoned event can arrive after the purchase completed. Before sending any recovery email, Olanzo re-checks that no order.created exists for that checkout/customer and does not send if one does.
Additional safeguards: a suppression window (no more than one recovery email per contact within a configurable window, e.g. 24h) and a max-age (ignore checkout.abandoned whose event_time is older than a configurable threshold).
Transactional emails
order.created→ order confirmation.order.updated→ status-dependent flows when configured.fulfillment.created→ shipment confirmation.fulfillment.updated→ tracking-update flows.refund.created→ refund confirmation.customer.created→ welcome / lifecycle flows (when enabled).
Consent and compliance
Operational guidance, not legal advice. Confirm with whoever owns data protection.
- Transactional vs marketing differ. Order/fulfillment/refund confirmations are service messages. Abandoned-checkout reminders and welcome emails are generally treated as marketing.
- Olanzo behavior: for
checkout.abandonedand other marketing-class emails, do not send unlessconsent.email_marketingistrue; iffalseor absent, suppress and log. Transactional emails send regardless of marketing consent, subject to suppression rules. (Shopify's own Flow abandonment automation likewise sends only to marketing-subscribed customers by default.) - No recipient → no email. A checkout with no captured email, or a cart abandoned before contact info, has no recipient. Olanzo skips and logs without error.
- Shopify protected customer data. Customer PII in Shopify webhooks and the Abandoned Checkouts API is protected customer data; the app receiving it must be approved for protected customer data access in the Shopify Partner Dashboard. If the integration is delivered as a Shopify app, it must also implement the mandatory compliance webhooks (
customers/data_request,customers/redact,shop/redact) andapp/uninstalled. - Jurisdiction. Merchants on Olanzo are subject to applicable data-protection law (in Costa Rica, Ley 8968 / Prodhab). The merchant is the source of truth for opt-in status, conveyed via the
consentobject.
Validation rules
Olanzo will:
- Reject non-JSON payloads (400).
- Verify authentication and signature before processing (401).
- Require schema_version, event, event_id, event_time, source, store_id (422 if missing).
- Require customer.email for any event that sends an email (otherwise skip — no recipient).
- Require checkout fields for checkout events and order fields for order-related events.
- Enforce idempotency on event_id (409 on duplicates).
- Ignore unknown additional fields safely (forward compatibility).
Schema evolution and compatibility
- The contract is versioned with
schema_version(major.minor) and echoed in theX-Webhook-Schema-Versionheader. - Within a major version, changes are additive and backward-compatible — new optional fields and event types only. Receivers MUST ignore unknown fields and SHOULD ignore unknown event types they do not handle.
- Breaking changes increment the major version, are announced ahead of time, and run with an overlap period.
- Shopify's own API versioning is separate. Shopify releases a new API version every 3 months and supports each for at least 12 months. Pin the adapter's webhook
api_version; if a version sunsets, Shopify falls forward to the oldest supported version and the native payload shape may change — the adapter absorbs that so Olanzo's contract stays stable.
Integration guide (step by step)
This is the recommended adapter-based integration.
Step 0 — Onboarding (Olanzo provides)
Olanzo issues the merchant: the endpoint URL (https://{olanzo-events-endpoint}/v1/commerce), an auth credential, an HMAC signing secret, an agreed store_id, and separate sandbox endpoint and credentials.
Step 1 — Create a Shopify app and access
Create a Shopify app (custom or public). New public apps must use the GraphQL Admin API (required since April 1, 2025). Request the scopes you need (e.g. read_orders, read_fulfillments, read_customers) and apply for protected customer data access, which is required to receive customer PII.
Step 2 — Subscribe to webhook topics
Subscribe to the native topics that back the contract and map them to normalized events:
| Shopify topic | Normalized event |
|---|---|
orders/create |
order.created |
orders/updated |
order.updated |
fulfillments/create |
fulfillment.created |
fulfillments/update |
fulfillment.updated |
refunds/create |
refund.created |
customers/create |
customer.created |
customers/update |
customer.updated |
checkouts/create |
checkout.created |
checkouts/update |
checkout.updated |
Create subscriptions via the GraphQL webhookSubscriptionCreate mutation (or the app config). Point them at the adapter's URL (or, on the direct path, at Olanzo's URL).
Step 3 — Deploy the adapter
Deploy a thin adapter (a small app or serverless service) that:
- verifies Shopify's X-Shopify-Hmac-Sha256 over the raw body before parsing,
- responds to Shopify within 5 seconds (verify → enqueue → 2xx),
- maps each topic to the normalized envelope and event name in this spec,
- computes the X-Webhook-Signature HMAC with the Olanzo signing secret,
- POSTs asynchronously to the Olanzo endpoint with the retry/backoff policy above,
- treats a 409 from Olanzo as success.
Step 4 — Set up abandonment (no detector to build)
Choose one (in order of least effort):
- Shopify Flow — use the "Customer abandons checkout" trigger with a "Send HTTP request" action that posts checkout.abandoned to the adapter. Shopify decides abandonment after a configurable window (default ~10 hours) and sends to marketing-subscribed customers by default. Confirm Flow is enabled for the merchant's plan.
- Abandoned Checkouts Admin API — a scheduled job queries abandoned checkouts (requires read_orders + protected customer data) and emits checkout.abandoned.
- Custom timer — subscribe to checkouts/create/checkouts/update, start a timer, and check the Orders API for a matching order; emit checkout.abandoned if none. (Heaviest; use only if Flow/API are unavailable.)
Unlike Adobe Commerce, you do not need to build the abandonment detector from scratch — Shopify already determines abandonment.
Step 5 — Verify the endpoint and test in sandbox
- Complete Olanzo's endpoint verification handshake (echo the challenge).
- Confirm your signing reproduces the worked signature example.
- Point the adapter at the sandbox endpoint and trigger sample events on a Shopify development store; confirm Olanzo returns
202. - Confirm a tampered body is rejected with
401, and a replayedevent_idreturns409. - Confirm the out-of-order guard: an
order.createdsuppresses a subsequentcheckout.abandonedfor the same checkout. - Use
metadata.test = trueto validate end to end without sending real emails.
Step 6 — Go live, then protect against silent failure
Switch to production. Because Shopify silently removes subscriptions after sustained failures, add: - a reconciliation job that periodically polls the Admin API (orders, etc.) to catch anything missed during an outage, - a subscription monitor that checks your webhook subscriptions daily and re-registers any that disappeared, - a dead-letter queue for events the adapter could not deliver to Olanzo.
Source mapping notes
- Transactional events map directly from native Shopify topics (Step 2 table) and are delivered by Shopify asynchronously, signed with
X-Shopify-Hmac-Sha256. checkout.abandonedhas no native webhook topic; it comes from Shopify Flow's abandonment trigger, the Abandoned Checkouts Admin API, or a custom timer (Step 4).- Direct-delivery path: if Shopify posts straight to Olanzo, Olanzo verifies
X-Shopify-Hmac-Sha256(base64, raw body) and consumes Shopify-native payloads;event_idis derived fromX-Shopify-Webhook-Id.
Internal routing (Olanzo side)
Even with one public endpoint, Olanzo routes internally by event family: - checkout events → cart-recovery logic, - order and refund events → transactional messaging, - fulfillment events → post-purchase shipping logic, - customer events → lifecycle automation.
Responsibilities summary
| Item | Owner |
|---|---|
Endpoint, credentials, signing secret, store_id, sandbox |
Olanzo |
| Signature verification, idempotency, routing, email send | Olanzo |
| Shopify app, scopes, protected-customer-data approval, webhook subscriptions | Merchant / development partner |
| Adapter (verify Shopify HMAC, map, sign, async POST, retry) | Merchant / development partner |
| Abandonment source (Flow trigger / Abandoned Checkouts API / timer) | Merchant / development partner |
| Reconciliation job + subscription monitoring | Merchant / development partner |
| Consent / opt-in source of truth | Merchant |
Glossary
- Adapter — the thin component that verifies Shopify's HMAC, maps Shopify events to this contract, signs with the Olanzo secret, and POSTs them.
- Webhook topic — Shopify's name for an event type you subscribe to (e.g.
orders/create). - Shopify Flow — Shopify's automation tool; its "Customer abandons checkout" trigger and "Send HTTP request" action can emit
checkout.abandoned. - Abandoned Checkouts API — Admin API resource listing checkouts that added contact info but did not complete; requires protected customer data access.
X-Shopify-Hmac-Sha256— Shopify's base64 HMAC signature over the raw webhook body, signed with the app's client secret.X-Shopify-Webhook-Id— a per-delivery identifier stable across Shopify's retries; use it for idempotency on the direct path.- Protected customer data — Shopify's access tier for customer PII; apps must be approved to receive it.
- Idempotency key — the
event_id; lets Olanzo discard duplicate deliveries. - Dead-letter queue — where the adapter parks events that exhausted their retries to Olanzo.
Open setup decisions
Product values for Olanzo to confirm and publish (the spec uses defaults until then):
- rate limit, payload-size cap, and idempotency retention window (currently 50 req/s, 256 KB, 7 days);
- whether a post-202 delivery-status view (dashboard or status callback) is offered, so a merchant can confirm an email was actually sent rather than just accepted.
Per-merchant choices: 1. Abandonment source — Shopify Flow trigger (recommended) vs Abandoned Checkouts API poll vs custom timer. 2. Integration shape — adapter with the normalized envelope (recommended) vs direct Shopify delivery with native HMAC. 3. Delivery as a Shopify app — which entails protected-customer-data approval and the mandatory compliance webhooks. 4. Reconciliation cadence — how often to poll the Admin API and check subscription health, given Shopify's silent subscription removal.
WooCommerce → Olanzo Event Webhook Integration Specification
Version: 1.0 (generalized integration specification)
Date: 2026-05-30
Applies to: any merchant on WooCommerce (WordPress)
Receiver: Olanzo messaging / email platform
This document defines how a WooCommerce store integrates with Olanzo so that Olanzo can receive abandoned-cart and transactional ecommerce events and send the corresponding emails. It is platform-specific (WooCommerce) but not merchant-specific — every WooCommerce merchant integrates the same way. It is the WooCommerce counterpart to the Shopify and Adobe Commerce integration specs and shares the same Olanzo-side contract.
Audience and purpose
This spec is written for a merchant's engineering team and their WordPress/WooCommerce development partner. It tells them:
- exactly what Olanzo expects to receive (the contract), and
- exactly how to make their WooCommerce store send it (the integration guide).
It enables abandoned-cart recovery emails and transactional emails (order, shipment, refund, and customer lifecycle), driven by events from the store.
Is this a "webhook"? (terminology)
Yes — and WooCommerce, like Shopify, sends asynchronous HTTPS POSTs and signs them natively with a base64 HMAC in the X-WC-Webhook-Signature header. There is no synchronous/blocking variant to avoid.
Two WooCommerce realities shape the design, though, and both are covered below:
- WooCommerce's built-in webhooks (WooCommerce → Settings → Advanced → Webhooks) only cleanly cover
order.*andcustomer.*. Cart/checkout activity, refunds-as-events, shipments, and abandoned cart are not plain built-in topics — they come from WordPress/WooCommerce action hooks, custom topics, or plugins. - WooCommerce's built-in webhook delivery is unreliable: it does not retry a failed delivery, and it automatically disables a webhook after five consecutive failures — silently, with no notification and no auto-re-enable.
Because of these two facts, the recommended shape is not the built-in webhooks UI but a small in-store adapter plugin that hooks WooCommerce's action hooks directly and delivers to Olanzo through Action Scheduler with its own retry. That gives full event coverage and reliable delivery.
| Shape | What Olanzo receives | Signature | Coverage & reliability | Recommended? |
|---|---|---|---|---|
| In-store adapter plugin (action hooks + Action Scheduler) | Olanzo's normalized envelope | Olanzo's HMAC (X-Webhook-Signature) |
Full coverage; you control retry | Yes |
| Built-in webhooks → external middleware | Olanzo's normalized envelope (middleware maps) | Olanzo's HMAC out; middleware verifies X-WC-Webhook-Signature in |
order/customer native; rest via Action/Custom topics; inherits built-in's weak retry | Workable with a buffer |
| Direct delivery (built-in webhooks → Olanzo) | WooCommerce-native payload | Olanzo verifies X-WC-Webhook-Signature |
order/customer only; no normalized envelope; weak retry | Not recommended |
The adapter plugin is assumed below unless noted.
Quick start
If you only read one section, read this.
You are building: a small WooCommerce/WordPress adapter plugin that hooks the relevant store events, reshapes each into Olanzo's JSON envelope, signs it, and POSTs it to Olanzo through Action Scheduler (async, with retry).
From Olanzo (at onboarding) you receive: your endpoint URL, an auth token (or key), an HMAC signing secret, your store_id, and separate sandbox credentials.
Minimal path to a working integration:
- Install/deploy the adapter plugin and hook WooCommerce's order and customer actions (Integration guide, Steps 1–2).
- In the adapter, map each event to Olanzo's envelope, sign with the Olanzo secret, and enqueue an Action Scheduler job that POSTs to Olanzo with retry (Step 3). Verify your signing against the worked example below first.
- Add an abandoned-cart source — WooCommerce has no native abandonment, so use a recovery plugin that exposes a hook/webhook (or custom capture), and emit
cart.abandonedfrom it (Step 4). This is the longest-lead, plugin-dependent item. - Point the adapter at your sandbox endpoint, fire sample events, and confirm
202plus passing signatures (Step 5). - Go live, and — important on WooCommerce — add a reconciliation job, a webhook/Action-Scheduler health monitor, and a real system cron so a transient failure can't silently disable delivery (Step 6).
The rules that prevent most issues:
- Sign every request to Olanzo and include a fresh timestamp; on the direct/core path, verify
X-WC-Webhook-Signatureover the raw body before parsing. - Deliver asynchronously with your own retry (Action Scheduler) — do not rely on WooCommerce's built-in webhook retry, because there isn't one.
- Treat delivery as at-least-once — send a stable
event_idand expect Olanzo to deduplicate. - Watch your delivery health: WooCommerce silently disables a built-in webhook after 5 consecutive failures, and Action Scheduler depends on WP-Cron.
A copy-pasteable request is in Signature example (worked).
Scope and coverage
The integration covers both, on one endpoint:
- Abandoned-cart recovery, covering carts/checkouts abandoned under any payment method. WooCommerce core has no abandoned-cart concept, so abandonment is provided by a recovery plugin (or custom capture) that detects it after an inactivity window. A shopper who never enters an email has no recipient and cannot be emailed (true on every platform).
- Transactional emails: order, shipment, refund, and customer lifecycle confirmations.
Abandonment detection is payment-method-agnostic — a cart/checkout that did not become a paid order within the window.
Core design principles
- Normalized event names. The payload uses platform-neutral names (
order.created,cart.abandoned, …), not raw WooCommerce topics or WordPress hook names. One contract serves WooCommerce today and other platforms later. - One endpoint, internal routing. A single ingestion endpoint; Olanzo branches on the
eventfield internally. - Asynchronous and non-blocking. Delivery must never block a shopper action or a WordPress request. Hook → enqueue → return; deliver in the background.
- Secure by default. TLS, mandatory authentication, mandatory request signing, replay protection.
- Idempotent. Every event carries a unique
event_id; Olanzo deduplicates on it.
Architecture overview
The recommended shape is an in-store adapter plugin that hooks WooCommerce/WordPress actions, maps them into Olanzo's normalized envelope, signs them with the merchant's Olanzo-issued secret, and POSTs them to Olanzo via Action Scheduler — all out of band:
Why a plugin rather than the built-in webhooks UI: the built-in webhooks only emit order.*/customer.* cleanly and have no retry plus silent auto-disable after 5 failures. An adapter plugin can hook any event (including refund, shipment-plugin, and cart hooks) and uses Action Scheduler to deliver with its own retry/backoff, so a transient Olanzo outage neither drops events nor disables anything.
Alternatives: route WooCommerce's built-in webhooks to an external middleware that maps and re-signs (you still inherit the built-in's weak retry, so put a buffer/reconciliation in front), or have WooCommerce post directly to Olanzo (Olanzo verifies X-WC-Webhook-Signature and consumes WooCommerce-native payloads — but this only covers order/customer and offers no normalized envelope).
Sequence diagrams
1. Transactional happy path (order confirmation)
The storefront responds to the shopper immediately; email delivery is fully decoupled.
2. Abandoned cart via a recovery plugin
WooCommerce has no native abandonment; a plugin does the detecting after a window.
3. Delivery retry and idempotency (at-least-once)
The adapter owns retry (Action Scheduler); a duplicate is normal and 409 means success.
If you use WooCommerce's built-in webhooks instead of the adapter's Action Scheduler delivery, remember there is no retry and the webhook is auto-disabled after 5 consecutive failures — see Step 6 for the required safety net.
The contract Olanzo receives (normative)
Regardless of how the merchant produces events, Olanzo requires the following on the wire (adapter path).
Endpoint
POST https://{olanzo-events-endpoint}/v1/commerce
The exact endpoint URL is issued by Olanzo per merchant during onboarding (along with credentials and the signing secret).
Transport
HTTPS/TLS is required. Plain HTTP is rejected.
Authentication — required
Every request authenticates with the credential Olanzo issues at onboarding:
Authorization: Bearer <token>, orX-Webhook-Key: <shared-secret>
Request signing — required (adapter path)
Every request includes an HMAC-SHA256 signature of the raw body, computed with the Olanzo-issued signing secret:
X-Webhook-Signature: t=<unix_timestamp>,v1=<hex_hmac_sha256>
where v1 = HMAC_SHA256(secret, "<t>.<raw_body>").
Olanzo verifies by recomputing the HMAC and constant-time comparing it, and rejects the request if |now − t| > 5 minutes (replay / freshness window).
Note the difference from WooCommerce's own signature: WooCommerce uses a base64 HMAC over just the raw body (no timestamp) in
X-WC-Webhook-Signature, keyed by the webhook's secret. On the direct/core path the adapter/Olanzo verifies that; on the adapter path the plugin produces Olanzo'sX-Webhook-Signature(hex, timestamped). Idempotency (event_id) is not a substitute for signing.
Signature example (worked)
Sign the exact raw bytes of the request body — before any reserialization, exactly what you send (whitespace included). The signature is lowercase hex. t is the signing time (Unix seconds), independent of event_time.
Given:
- signing secret:
whsec_test_5f3a9c2e8b1d4f76 - timestamp
t:1748599200 - raw body (exactly these 213 bytes):
{"schema_version":"1.0","event":"order.created","event_id":"evt_demo_wc_100001","event_time":"2026-05-30T10:00:00Z","source":"woocommerce","store_id":"example-store-us","customer":{"email":"customer@example.com"}}
The string to sign is t + . + raw body, and the resulting header is:
X-Webhook-Signature: t=1748599200,v1=15c262999a4305f7733cc263b3948c1ed0a795b79a086392b0804d369b338795
If your code produces that exact v1 from those inputs, your signing is correct.
Sign (PHP — natural for a WooCommerce plugin):
$t = time();
$v1 = hash_hmac('sha256', $t . '.' . $rawBody, $olanzoSigningSecret);
$signatureHeader = "t={$t},v1={$v1}";
Full request (cURL):
curl -X POST "https://{olanzo-events-endpoint}/v1/commerce" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-H "X-Webhook-Schema-Version: 1.0" \
-H "X-Webhook-Signature: t=1748599200,v1=15c262999a4305f7733cc263b3948c1ed0a795b79a086392b0804d369b338795" \
--data '{"schema_version":"1.0","event":"order.created","event_id":"evt_demo_wc_100001","event_time":"2026-05-30T10:00:00Z","source":"woocommerce","store_id":"example-store-us","customer":{"email":"customer@example.com"}}'
(Use a current timestamp in production — the fixed value above is only so the example reproduces.)
Verifying WooCommerce's native HMAC (direct/core path only). WooCommerce signs with a base64 HMAC over the raw body using the webhook's secret. Verify before JSON parsing:
$digest = base64_encode(hash_hmac('sha256', $rawBody, $webhookSecret, true));
$ok = hash_equals($digest, $_SERVER['HTTP_X_WC_WEBHOOK_SIGNATURE']);
Worked WooCommerce example — secret wc_whsec_test_9a8b7c6d, raw body {"id":70001,"status":"processing","total":"139.99"} → X-WC-Webhook-Signature: dRXpdhl8qiM2YaT4/TYM3t7CZQh4XYqrKBXuOdeqF80=.
Asynchronous / non-blocking — required
Deliver out of band. In the adapter plugin, hook the event, build and enqueue the job, and return immediately; Action Scheduler performs the POST in the background. Never POST to Olanzo synchronously inside a checkout or admin request.
Idempotency
Every event carries a unique event_id. Olanzo persists seen ids and rejects duplicates with 409. The adapter generates a stable event_id per logical event (e.g. derived from resource id + event type). On the direct/core path, derive it from the resource id and topic — note X-WC-Webhook-Delivery-ID changes per attempt and WooCommerce does not retry, so it is not a stable dedup key on its own.
Delivery semantics
- At-least-once. Duplicates are possible; dedupe on
event_idand treat409as success. - No ordering guarantee. Events may arrive out of order (e.g.
order.createdbefore a delayedcart.abandoned). The processing rules and the out-of-order guard handle this. - Idempotency retention. Olanzo remembers each
event_idfor a deduplication window (default: 7 days — confirm at onboarding).
Limits
Values below are defaults; the exact figures for your account are confirmed at onboarding.
- Rate limit: ~50 requests/second per merchant (short bursts allowed). On exceed, Olanzo returns
429with aRetry-Afterheader. - Payload size: ≤ 256 KB per request.
- Response timing: Olanzo acknowledges within ~5 seconds; the adapter should use a ~10-second request timeout before retrying via Action Scheduler.
Required headers
| Header | Value |
|---|---|
Content-Type |
application/json |
Authorization or X-Webhook-Key |
Bearer <token> / <shared-secret> |
X-Webhook-Signature |
t=<unix_ts>,v1=<hex_hmac_sha256> |
X-Webhook-Schema-Version |
e.g. 1.0 |
Optional headers
| Header | Value |
|---|---|
X-Source-System |
woocommerce |
X-Store-Id |
<store_domain or store_id> |
Response codes
| Code | Meaning |
|---|---|
202 Accepted |
Validated and queued for asynchronous processing. Default success. |
200 OK |
Accepted and processed synchronously (rare). |
400 Bad Request |
Malformed / non-JSON payload. |
401 / 403 |
Authentication or signature verification failed. |
409 Conflict |
Duplicate event_id (idempotency). |
422 Unprocessable Entity |
Well-formed JSON but failed contract validation. |
429 Too Many Requests |
Rate limited; back off and retry. |
500 Internal Server Error |
Genuine processing failure; retry. |
Error responses
Error responses use a consistent JSON body:
{
"error": {
"code": "validation_failed",
"message": "Missing required field: customer.email",
"details": ["customer.email is required for events that send an email"]
}
}
| HTTP | error.code |
When |
|---|---|---|
| 400 | invalid_json |
Body is not valid JSON. |
| 401 | unauthorized |
Missing/invalid credential, or signature verification failed. |
| 403 | forbidden |
Credential valid but not permitted for this store_id. |
| 409 | duplicate_event |
event_id already processed (treat as success). |
| 422 | validation_failed |
Valid JSON, failed contract validation; details lists the problems. |
| 429 | rate_limited |
Rate limit exceeded; see Retry-After. |
| 500 | internal_error |
Transient Olanzo error; retry. |
Retry, backoff, and dead-letter
- Adapter → Olanzo (you own this): the adapter delivers through Action Scheduler and retries on timeout, connection failure,
5xx, or429with exponential backoff (~1m, 5m, 30m, 2h, 6h) for up to ~24 hours; dead-letter after the final attempt;4xxother than429are permanent. Retries are safe because of the stableevent_id. - WooCommerce built-in delivery (avoid relying on it): WooCommerce does not retry a failed delivery, and auto-disables a webhook after five consecutive failures (any non-2xx/301/302), silently, with no auto-re-enable. This is the main reason the adapter owns delivery. If you do use built-in webhooks, mitigate with a buffer/gateway and the reconciliation + status monitoring in Step 6.
Endpoint verification handshake
Before live traffic, Olanzo confirms ownership of the endpoint. Olanzo sends a verification request and the endpoint must echo the challenge.
Request from Olanzo:
{ "type": "endpoint.verification", "challenge": "c_8f2a...random" }
Expected response (HTTP 200, within 10 seconds):
{ "challenge": "c_8f2a...random" }
A type: "endpoint.verification" request never carries a business event and never triggers an email.
Sandbox and test mode
- Olanzo issues a separate sandbox endpoint and credentials at onboarding. Build and validate against sandbox first (a staging WordPress/WooCommerce site is ideal).
- Set
"test": trueinsidemetadatato mark an event as a test: Olanzo validates, verifies the signature, deduplicates, and returns the normal status code, but does not send a real email (it routes to a sink). - A health check is available:
GET https://{olanzo-events-endpoint}/v1/commerce/healthreturns200with{ "status": "ok" }.
Data formats and conventions
| Concern | Convention |
|---|---|
| Encoding | UTF-8 throughout. |
| Content type | application/json. |
| Timestamps | ISO 8601 in UTC with a Z suffix, e.g. 2026-05-30T10:00:00Z. |
| Money amounts | JSON numbers in major currency units with up to 2 decimal places (e.g. 139.99 means USD 139.99). WooCommerce often exposes amounts as strings ("139.99"); the adapter converts them to numbers. Parse as decimal, not binary float. |
| Currency | ISO 4217 code, e.g. USD, CRC. |
event_id |
Opaque string, ≤ 255 chars, globally unique per event, stable across retries. Adapter-generated (e.g. resource id + event type). |
store_id |
The identifier agreed at onboarding (often the store domain or a brand id); stable per merchant. |
| Booleans | JSON true / false (never strings). |
| Null / absent | Omit objects and fields that do not apply to an event. Optional scalars may be null. Receivers ignore unknown fields. |
Field enumerations
source:woocommerce.order.status: the native WooCommerce order status —pending·processing·on-hold·completed·cancelled·refunded·failed. This is the primary order-state field for WooCommerce.order.financial_statusandorder.fulfillment_status: optional, derived fields (not native WooCommerce concepts). Include them only if your adapter computes them for cross-platform consistency; otherwise rely onorder.status.checkout.step(optional):cart·details·payment·review.
Event model
Source
source value |
Origin |
|---|---|
woocommerce |
Canonical value for the WooCommerce store. |
Abandoned-cart event group
Covers carts/checkouts abandoned under any payment method:
cart.updated— cart contents changed (from a cart hook / capture).checkout.started— shopper entered the checkout funnel (from a checkout hook / capture).cart.abandoned— the recovery trigger. WooCommerce has no native abandonment; this is emitted by a recovery plugin (or custom capture) after an inactivity window. If the sender cannot emit a finalcart.abandoned, the receiver determines abandonment after a configured inactivity window using thecart.*signals.order.created— an order now exists; cancels any pending recovery for that cart/customer.
Transactional event group
order.createdorder.updatedshipment.createdshipment.updatedrefund.createdcustomer.createdcustomer.updated
order.createdis used both as a transactional trigger and to cancel cart recovery.
Normalized JSON envelope
The sender maps WooCommerce data into this envelope, including only the objects relevant to the event (a shipment event includes shipment and order; a refund event includes refund and order).
{
"schema_version": "1.0",
"event": "order.created",
"event_id": "evt_demo_wc_100001",
"event_time": "2026-05-30T10:00:00Z",
"source": "woocommerce",
"store_id": "example-store-us",
"site": "https://shop.example-store.com",
"customer": {
"id": "cust_10001",
"email": "customer@example.com",
"first_name": "Alice",
"last_name": "Johnson",
"phone": "+15555550123"
},
"cart": {
"id": "cart_90001",
"currency": "USD",
"subtotal": 129.99,
"grand_total": 139.99,
"item_count": 2,
"cart_url": "https://shop.example-store.com/cart/"
},
"order": {
"id": "ord_70001",
"order_number": "1001",
"status": "processing",
"created_at": "2026-05-30T10:00:00Z"
},
"shipment": {
"id": "shp_40001",
"tracking_number": "1Z999AA10123456784",
"tracking_company": "UPS",
"tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784"
},
"refund": {
"id": "ref_30001",
"amount": 139.99,
"currency": "USD"
},
"items": [
{
"sku": "TSHIRT-BLK-M",
"name": "Classic T-Shirt",
"qty": 1,
"price": 39.99,
"product_url": "https://shop.example-store.com/product/classic-t-shirt/",
"image_url": "https://cdn.example.com/images/classic-t-shirt-black.jpg"
}
],
"consent": {
"email_marketing": true,
"sms_marketing": false,
"source": "checkout_opt_in",
"captured_at": "2026-05-30T09:30:00Z"
},
"metadata": {
"channel": "online_store",
"locale": "en-US",
"test": false
}
}
Required top-level fields
| Field | Type | Required | Description |
|---|---|---|---|
schema_version |
string | Yes | Contract version, e.g. 1.0. |
event |
string | Yes | Normalized event name. |
event_id |
string | Yes | Unique idempotency key. |
event_time |
string | Yes | ISO 8601 UTC timestamp. |
source |
string | Yes | Fixed value woocommerce. |
store_id |
string | Yes | Store or brand identifier (agreed at onboarding). |
items |
array | Conditional | Required for cart and order email use cases. |
Object reference
| JSON path | Required | Notes |
|---|---|---|
customer.email |
Yes for any event that sends an email | Recipient targeting. If absent, no email is sent. |
cart.id |
Yes for cart flows | Abandoned-cart state tracking. |
cart.currency |
Yes for cart flows | Amount formatting. |
checkout.step |
Optional | One of cart, details, payment, review. |
order.id or order.order_number |
Yes for order / shipment / refund flows | Order-linked communications. |
order.status |
Recommended for order events | Native WooCommerce status. |
shipment.id |
Yes for shipment events | Shipping notifications (from a tracking plugin). |
refund.id |
Yes for refund events | Refund-linked emails. |
consent.email_marketing |
Yes for cart.abandoned and other marketing-class emails |
Whether the contact opted in to email marketing. |
consent.sms_marketing |
Optional | Reserved for future SMS use. |
metadata.test |
Optional | true marks a test event (validated and acknowledged, no real email). |
Event-specific payload examples
Each example shows the minimum useful payload; all follow the envelope above.
cart.abandoned (the recovery trigger)
{
"schema_version": "1.0",
"event": "cart.abandoned",
"event_id": "evt_cart_10002",
"event_time": "2026-05-30T10:05:00Z",
"source": "woocommerce",
"store_id": "example-store-us",
"customer": { "email": "customer@example.com", "first_name": "Alice", "last_name": "Johnson" },
"cart": {
"id": "cart_90001",
"currency": "USD",
"subtotal": 129.99,
"grand_total": 139.99,
"item_count": 2,
"cart_url": "https://shop.example-store.com/cart/"
},
"items": [
{ "sku": "TSHIRT-BLK-M", "name": "Classic T-Shirt", "qty": 1, "price": 39.99 },
{ "sku": "JEANS-BLU-32", "name": "Slim Fit Jeans", "qty": 1, "price": 90.00 }
],
"consent": { "email_marketing": true, "sms_marketing": false },
"metadata": { "abandoned_after_minutes": 30, "detected_by": "cart_recovery_plugin" }
}
checkout.started
{
"schema_version": "1.0",
"event": "checkout.started",
"event_id": "evt_checkout_10001",
"event_time": "2026-05-30T09:28:00Z",
"source": "woocommerce",
"store_id": "example-store-us",
"customer": { "email": "customer@example.com", "first_name": "Alice" },
"cart": { "id": "cart_90001", "currency": "USD", "grand_total": 139.99, "cart_url": "https://shop.example-store.com/checkout/" },
"items": [
{ "sku": "TSHIRT-BLK-M", "name": "Classic T-Shirt", "qty": 1, "price": 39.99 }
]
}
order.created
{
"schema_version": "1.0",
"event": "order.created",
"event_id": "evt_order_10001",
"event_time": "2026-05-30T10:00:00Z",
"source": "woocommerce",
"store_id": "example-store-us",
"customer": { "email": "customer@example.com", "first_name": "Alice", "last_name": "Johnson" },
"order": { "id": "ord_70001", "order_number": "1001", "status": "processing", "created_at": "2026-05-30T10:00:00Z" },
"cart": { "id": "cart_90001", "currency": "USD", "subtotal": 129.99, "grand_total": 139.99 },
"items": [
{ "sku": "TSHIRT-BLK-M", "name": "Classic T-Shirt", "qty": 1, "price": 39.99 },
{ "sku": "JEANS-BLU-32", "name": "Slim Fit Jeans", "qty": 1, "price": 90.00 }
]
}
order.updated
{
"schema_version": "1.0",
"event": "order.updated",
"event_id": "evt_order_10002",
"event_time": "2026-05-30T10:12:00Z",
"source": "woocommerce",
"store_id": "example-store-us",
"customer": { "email": "customer@example.com" },
"order": { "id": "ord_70001", "order_number": "1001", "status": "completed" },
"metadata": { "change_reason": "status_update" }
}
shipment.created
{
"schema_version": "1.0",
"event": "shipment.created",
"event_id": "evt_shipment_10001",
"event_time": "2026-05-31T08:00:00Z",
"source": "woocommerce",
"store_id": "example-store-us",
"customer": { "email": "customer@example.com", "first_name": "Alice" },
"order": { "id": "ord_70001", "order_number": "1001", "status": "completed" },
"shipment": {
"id": "shp_40001",
"tracking_number": "1Z999AA10123456784",
"tracking_company": "UPS",
"tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784"
},
"items": [
{ "sku": "TSHIRT-BLK-M", "name": "Classic T-Shirt", "qty": 1 }
]
}
refund.created
{
"schema_version": "1.0",
"event": "refund.created",
"event_id": "evt_refund_10001",
"event_time": "2026-06-02T14:30:00Z",
"source": "woocommerce",
"store_id": "example-store-us",
"customer": { "email": "customer@example.com" },
"order": { "id": "ord_70001", "order_number": "1001" },
"refund": { "id": "ref_30001", "amount": 139.99, "currency": "USD" },
"metadata": { "reason": "customer_return" }
}
customer.created
{
"schema_version": "1.0",
"event": "customer.created",
"event_id": "evt_customer_10001",
"event_time": "2026-05-29T16:20:00Z",
"source": "woocommerce",
"store_id": "example-store-us",
"customer": { "id": "cust_10001", "email": "customer@example.com", "first_name": "Alice", "last_name": "Johnson", "phone": "+15555550123" },
"consent": { "email_marketing": true, "sms_marketing": false },
"metadata": { "locale": "en-US", "customer_tags": ["newsletter", "vip-prospect"] }
}
Processing rules by event family
Abandoned cart
cart.updatedandcheckout.startedcreate or refresh cart-activity state.cart.abandonedis the final recovery trigger (from the recovery plugin, or inferred by the receiver after an inactivity window).order.createdcancels any pending recovery for thatcart.idor customer.
Out-of-order / late-delivery guard (required). Because delivery is asynchronous, a cart.abandoned event can arrive after the purchase completed. Before sending any recovery email, Olanzo re-checks that no order.created exists for that cart/customer and does not send if one does.
Additional safeguards: a suppression window (no more than one recovery email per contact within a configurable window, e.g. 24h) and a max-age (ignore cart.abandoned whose event_time is older than a configurable threshold).
Transactional emails
order.created→ order confirmation.order.updated→ status-dependent flows when configured.shipment.created→ shipment confirmation.shipment.updated→ tracking-update flows.refund.created→ refund confirmation.customer.created→ welcome / lifecycle flows (when enabled).
Consent and compliance
Operational guidance, not legal advice. Confirm with whoever owns data protection.
- Transactional vs marketing differ. Order/shipment/refund confirmations are service messages. Abandoned-cart reminders and welcome emails are generally treated as marketing.
- Olanzo behavior: for
cart.abandonedand other marketing-class emails, do not send unlessconsent.email_marketingistrue; iffalseor absent, suppress and log. Transactional emails send regardless of marketing consent, subject to suppression rules. - No recipient → no email. A cart abandoned before an email was captured has no recipient. Olanzo skips and logs without error.
- Consent source. WooCommerce/WordPress (and the recovery plugin) are the source of truth for opt-in; the adapter conveys it via the
consentobject. Marketing-consent capture at checkout in WooCommerce typically requires a plugin or custom field. - Jurisdiction. Merchants on Olanzo are subject to applicable data-protection law (in Costa Rica, Ley 8968 / Prodhab). The merchant is the source of truth for opt-in status.
Validation rules
Olanzo will:
- Reject non-JSON payloads (400).
- Verify authentication and signature before processing (401).
- Require schema_version, event, event_id, event_time, source, store_id (422 if missing).
- Require customer.email for any event that sends an email (otherwise skip — no recipient).
- Require cart fields for cart events and order fields for order-related events.
- Enforce idempotency on event_id (409 on duplicates).
- Ignore unknown additional fields safely (forward compatibility).
Schema evolution and compatibility
- The contract is versioned with
schema_version(major.minor) and echoed in theX-Webhook-Schema-Versionheader. - Within a major version, changes are additive and backward-compatible — new optional fields and event types only. Receivers MUST ignore unknown fields and SHOULD ignore unknown event types they do not handle.
- Breaking changes increment the major version, are announced ahead of time, and run with an overlap period.
- WooCommerce/plugin versioning is separate. WooCommerce's built-in webhooks have their own API version (use WP REST API Integration v3 for new built-in webhooks); the adapter absorbs any WooCommerce-side payload changes so Olanzo's contract stays stable.
Integration guide (step by step)
This is the recommended adapter-plugin integration.
Step 0 — Onboarding (Olanzo provides)
Olanzo issues the merchant: the endpoint URL (https://{olanzo-events-endpoint}/v1/commerce), an auth credential, an HMAC signing secret, an agreed store_id, and separate sandbox endpoint and credentials.
Step 1 — Build or install the adapter plugin
Create a small WooCommerce/WordPress plugin (or mu-plugin). It will hook store events, sign with the Olanzo secret, and deliver via Action Scheduler (bundled with WooCommerce). Keep the Olanzo secret in wp-config.php/secrets, not in the database in plaintext.
Step 2 — Hook the store events
Bind to the relevant action hooks and map them to normalized events:
| WooCommerce / WordPress hook (typical) | Normalized event |
|---|---|
woocommerce_new_order / woocommerce_checkout_order_processed |
order.created |
woocommerce_order_status_changed |
order.updated |
woocommerce_order_refunded / woocommerce_refund_created |
refund.created |
woocommerce_created_customer |
customer.created |
woocommerce_update_customer / profile_update |
customer.updated |
| shipment/tracking plugin hook | shipment.created / shipment.updated |
| cart-recovery plugin hook | cart.abandoned |
| cart/checkout capture hook | cart.updated / checkout.started |
(If you instead use WooCommerce's built-in webhooks: order.* and customer.* are standard topics; for the rest, add Action webhooks bound to the hooks above, or register Custom topics via the woocommerce_webhook_topic_hooks filter — but you still inherit built-in delivery's weak retry, so prefer the plugin.)
Step 3 — Map, sign, and enqueue
In each hook handler: build the normalized envelope, convert WooCommerce string amounts to numbers, generate a stable event_id, compute the X-Webhook-Signature HMAC with the Olanzo secret, and enqueue an Action Scheduler job that POSTs to Olanzo. The job retries on failure (above) and treats a 409 as success. Do the network call in the job, never in the request that fired the hook.
Step 4 — Add the abandoned-cart source (no native support)
WooCommerce has no native abandonment. Choose one:
- A cart-recovery plugin that exposes a hook or webhook (e.g. a recovery/automation plugin). Hook its abandonment event and emit cart.abandoned. This is the least custom work.
- Custom capture + timer. Capture cart + email (e.g. on the checkout email field / woocommerce_checkout_update_order_review), persist it, and run a scheduled check (Action Scheduler / WP-Cron) that emits cart.abandoned for carts with an email, no recent activity, and no order.
Either way, email must be captured for a recovery email to be possible, and detection runs on a schedule — see the WP-Cron note in Step 6.
Step 5 — Verify the endpoint and test on staging
- Complete Olanzo's endpoint verification handshake (echo the challenge).
- Confirm your signing reproduces the worked signature example.
- Point the adapter at the sandbox endpoint and fire sample events on a staging store; confirm Olanzo returns
202. - Confirm a tampered body is rejected with
401, and a replayedevent_idreturns409. - Confirm the out-of-order guard: an
order.createdsuppresses a subsequentcart.abandonedfor the same cart. - Use
metadata.test = trueto validate end to end without sending real emails.
Step 6 — Go live, then protect against silent failure
Switch to production and add:
- a real system cron for WordPress (disable WP_CRON pseudo-cron and run wp-cron.php from the server crontab) so Action Scheduler and any abandonment timer run on time, even on low-traffic sites;
- a delivery/queue health monitor (watch Action Scheduler failures and, if you use built-in webhooks, check their status — WooCommerce silently disables a webhook after 5 consecutive failures);
- a reconciliation job that periodically reads orders via the WooCommerce REST API to catch anything missed during an outage;
- a dead-letter store for events the adapter could not deliver to Olanzo.
Source mapping notes
order.*andcustomer.*are the only events with clean built-in WooCommerce webhook topics; the adapter can also derive them from action hooks.refund.created,shipment.*,cart.*,checkout.started,cart.abandonedare not plain built-in topics. They come from WordPress/WooCommerce action hooks, Custom webhook topics (woocommerce_webhook_topic_hooks), or plugins (shipment/tracking plugin, cart-recovery plugin).- Native signing (direct/core path): WooCommerce signs with
X-WC-Webhook-Signature(base64 HMAC-SHA256 over the raw body, keyed by the webhook secret). The adapter verifies this when fronting built-in webhooks, then re-signs with Olanzo's scheme. - Built-in delivery is fire-once with auto-disable after 5 failures — the adapter's Action Scheduler delivery exists precisely to avoid that.
Internal routing (Olanzo side)
Even with one public endpoint, Olanzo routes internally by event family: - cart events → cart-recovery logic, - order and refund events → transactional messaging, - shipment events → post-purchase shipping logic, - customer events → lifecycle automation.
Responsibilities summary
| Item | Owner |
|---|---|
Endpoint, credentials, signing secret, store_id, sandbox |
Olanzo |
| Signature verification, idempotency, routing, email send | Olanzo |
| Adapter plugin (hooks, mapping, signing, Action Scheduler delivery, retry) | Merchant / development partner |
| Abandoned-cart source (recovery plugin or custom capture + timer) | Merchant / development partner |
| Cart/email capture and marketing-consent capture | Merchant / development partner |
| Real system cron, delivery health monitor, reconciliation job | Merchant / development partner |
| Consent / opt-in source of truth | Merchant |
Glossary
- Adapter plugin — the in-store WooCommerce/WordPress plugin that hooks store events, maps them to this contract, signs with the Olanzo secret, and delivers via Action Scheduler.
- Action hook — a WordPress/WooCommerce extension point (e.g.
woocommerce_new_order) the adapter binds to. - Action Scheduler — WooCommerce's bundled background-job system; used here for reliable, retrying delivery to Olanzo.
- Built-in webhook — WooCommerce's Settings → Advanced → Webhooks feature; limited topics, no retry, auto-disables after 5 failures.
- Custom topic — a webhook topic added via the
woocommerce_webhook_topic_hooksfilter to bind built-in webhooks to arbitrary hooks. X-WC-Webhook-Signature— WooCommerce's base64 HMAC signature over the raw body, keyed by the webhook secret.- WP-Cron — WordPress's traffic-triggered scheduler; unreliable on low-traffic sites unless replaced by a real system cron.
- Idempotency key — the
event_id; lets Olanzo discard duplicate deliveries. - Dead-letter store — where the adapter parks events that exhausted their retries to Olanzo.
Open setup decisions
Product values for Olanzo to confirm and publish (the spec uses defaults until then):
- rate limit, payload-size cap, and idempotency retention window (currently 50 req/s, 256 KB, 7 days);
- whether a post-202 delivery-status view (dashboard or status callback) is offered, so a merchant can confirm an email was actually sent rather than just accepted.
Per-merchant choices: 1. Delivery shape — in-store adapter plugin with Action Scheduler (recommended) vs built-in webhooks + external middleware vs direct delivery. 2. Abandoned-cart source — which recovery plugin (must expose a hook/webhook) vs custom capture + timer. 3. Cron — confirm a real system cron is configured so abandonment detection and Action Scheduler run on time. 4. Reconciliation cadence — how often to read the WooCommerce REST API and check delivery health, given fire-once delivery and auto-disable.
Olanzo Checkout → Commerce Events Integration Specification
Version: 1.0 (generalized integration specification)
Date: 2026-05-30
Applies to: Olanzo's own hosted checkout (checkout.olanzo.com)
Receiver: Olanzo messaging platform (Olanzo App push · WhatsApp · SMS · email)
This document defines how Olanzo's own hosted checkout feeds commerce events into Olanzo so Olanzo can send "come back" recovery messages (and transactional confirmations). It is the fourth integration alongside Adobe Commerce, Shopify, and WooCommerce, and uses the same normalized contract — the only difference is that the sender is Olanzo itself, not an external store. Because there is no third party, this is the lowest-dependency integration of the four and a good candidate to ship first.
Audience and purpose
This spec is for the Olanzo engineering team working on the hosted checkout and the commerce-events pipeline. It defines which checkout outcomes produce events, what those events look like, and how recovery messaging behaves.
It enables abandoned-checkout recovery for the three non-success outcomes of a SINPE checkout — expired, canceled, and declined — plus the usual transactional confirmation on success.
The three triggers, and how they map to the SINPE lifecycle
A hosted-checkout session moves through Verify → Confirm → Pay, then resolves to one terminal status. The SINPE transaction lifecycle is Pending → Matched | Expired | Declined, with a customer-initiated Cancel action available on the checkout page.
| Outcome | What happened | Recovery? | Message intent |
|---|---|---|---|
| Matched (Success) | Payment settled | No — sends confirmation instead (order.created) |
Transactional |
| Expired | Session timed out with no match | Yes | Marketing — "you left, come back" |
| Canceled | Customer pressed Cancel | Optional (often suppress) | Marketing — soft "changed your mind?" |
| Declined | Payment attempted but rejected | Yes | Often transactional — "payment didn't go through, try again" |
All three recoverable outcomes are emitted as one event — checkout.abandoned — carrying metadata.reason (expired / canceled / declined). This keeps the contract identical to the other platforms (Olanzo's routing already understands checkout.abandoned) while letting reason drive the message content and the send decision. Minting three separate event names would only fragment the contract; one event with a reason is the streamlined choice.
Declined is treated differently from expired/canceled. A declined payment is something the customer actively tried to do, so a "your payment didn't go through, try again" prompt is generally a transactional service message, whereas expired/canceled "come back" nudges are marketing. This affects consent (see Consent and compliance).
Quick start
If you only read one section, read this.
You are building: logic in the hosted-checkout backend that, when a session resolves to Expired, Canceled, or Declined, emits a checkout.abandoned event (with source: olanzo-checkout and the right reason) into the same commerce-events pipeline the receiver feeds — then lets Olanzo decide whether to send an SMS or email.
Minimal path:
- On each terminal drop-off status, build the normalized envelope with
source: olanzo-checkout,metadata.reason, the customer's phone (the verified identifier from Step 1), and acheckout.checkout_urlto resume (Integration guide, Steps 1–2). - Publish it internally to the commerce-events pipeline (no self-HTTP, no signing needed — it never leaves Olanzo's trust boundary). If your services are decoupled and prefer uniform ingress, POST it to
/v1/commercesigned, exactly like the external platforms (Step 3). - Let Olanzo's recovery logic apply the late-match guard, consent, and suppression before sending (Step 4).
- Test the three reasons in sandbox, including a late SINPE match that should suppress an already-fired expired recovery (Step 5).
- Go live (Step 6). There is no external adapter, webhook subscription, or third-party retry to manage.
The rules that matter most:
- Multi-channel delivery. Olanzo sends over the best available channel — Olanzo App push, WhatsApp, SMS, or email — chosen by what is on file (
app_user_id/phone/email) and per-channel consent. Phone is the most reliable identifier here (Step 1 verifies it), so coverage is high. - Re-check before sending. A SINPE payment can settle (Matched) after the UI showed Expired — never send a recovery for a session that ultimately matched.
- Reason drives intent. Expired/canceled = marketing (needs marketing consent); declined-retry = usually transactional.
A copy-pasteable signed request (for the HTTP path) is in Signature example (worked).
Scope and coverage
- Recovery for the three non-success SINPE outcomes — expired, canceled, declined — under one
checkout.abandonedevent. - Transactional confirmation on success (
order.created), which also cancels any pending recovery. - Channel: multi-channel. Recovery can go via Olanzo App push, WhatsApp, SMS, or email; Olanzo picks the best available channel by identifier + consent (a sensible default order: App push → WhatsApp → SMS → email). Because Step 1 (Verify) verifies the SINPE phone, Olanzo almost always has a phone, so coverage is high — unlike store integrations that depend on an email being captured.
- No recipient → no message. If neither phone nor email is on file, Olanzo skips and logs (rare here, since phone is verified early).
Core design principles
- Same normalized contract. One envelope, one set of event names,
source: olanzo-checkout. Learn it once (Commerce events overview); this guide only covers the Olanzo-checkout specifics. - One event, three reasons.
checkout.abandoned+metadata.reason. - Asynchronous and non-blocking. Emitting the event must never block the checkout UI or the SINPE settlement path.
- Idempotent. Each event carries a unique
event_id; Olanzo deduplicates on it. - Internal trust boundary. Sender and receiver are both Olanzo, so the primary path needs no cross-service HMAC.
Architecture overview
Because Olanzo owns both ends, the hosted-checkout backend publishes the event internally to the same commerce-events pipeline that the /v1/commerce receiver feeds:
Primary path — internal event. The checkout backend emits onto the internal pipeline directly. No self-HTTP call, no HMAC signing, lowest latency.
Alternative — signed HTTP. If Olanzo's services are decoupled and you prefer every source (including this one) to arrive over the same HTTP ingress, POST to https://{olanzo-events-endpoint}/v1/commerce with source: olanzo-checkout, signed exactly like the external platforms. Use this only if the uniform ingress is worth the signing overhead.
Sequence diagrams
1. Expired → recovery (with the late-match guard)
2. Late SINPE match suppresses an already-fired recovery
SINPE settlement can arrive after the UI gave up; recovery must not fire on a sale that completed.
3. Declined → retry prompt
The contract (normative)
Olanzo-checkout uses the same contract as the other platforms. The reference details (endpoint, auth, signing, headers, response codes, error envelope, delivery semantics, limits, sandbox) live in the Commerce events overview and apply unchanged on the HTTP path. The notes below are the Olanzo-checkout specifics.
Path choice and signing
- Internal path (primary): the event is published onto the internal pipeline; no
X-Webhook-Signatureis required because the event never crosses an untrusted boundary. Idempotency (event_id) still applies. - Signed HTTP path (alternative): identical to the external platforms —
Authorization: Bearer …(orX-Webhook-Key) andX-Webhook-Signature: t=<unix_ts>,v1=<hex_hmac_sha256>over the raw body, with the 5-minute replay window.
Signature example (worked)
For the signed HTTP path only. Sign the exact raw bytes of the body; the signature is lowercase hex.
Given secret whsec_test_5f3a9c2e8b1d4f76, timestamp t = 1748599200, and raw body (218 bytes):
{"schema_version":"1.0","event":"checkout.abandoned","event_id":"evt_chk_oz_10001","event_time":"2026-05-30T10:05:00Z","source":"olanzo-checkout","store_id":"vikingo-carwash-escazu","customer":{"phone":"+50688887777"}}
the header is:
X-Webhook-Signature: t=1748599200,v1=ddb49434ade8e8eed67ca6450b8ff9bc54feb404d387ffb4f40573c69539257e
Idempotency and delivery
- Each event carries a unique
event_id(derive it from the session id + reason so retries are stable). Olanzo deduplicates and treats a409as success on the HTTP path. - At-least-once and no ordering guarantee apply, which is exactly why the late-match guard exists.
Data formats and conventions
Same as the shared contract: UTF-8, application/json, ISO 8601 UTC timestamps, ISO 4217 currency (typically CRC), and amounts as JSON numbers in major units (e.g. 274900.00 = ₡274,900.00). Phone numbers are E.164 (Costa Rica +506 + 8 digits).
Field enumerations
source:olanzo-checkout.metadata.reason:expired|canceled|declined(required on Olanzo-checkoutcheckout.abandoned).metadata.decline_reason(optional, fordeclined): e.g.insufficient_funds·rejected_by_bank·timeout·other.order.status(onorder.created): the settled state (e.g.matched/paid).
Event model
| Event | When | Notes |
|---|---|---|
checkout.abandoned |
Session resolves to Expired, Canceled, or Declined | Carries metadata.reason; the recovery trigger. |
order.created |
Session matched (Success) | Transactional confirmation; cancels any pending recovery for the session. |
checkout.started (optional) |
Customer reaches checkout | Lets Olanzo know a session is in flight; not required if you only emit terminal events. |
source is fixed to olanzo-checkout.
Event-specific payload examples
checkout.abandoned — reason = expired (SMS recovery)
{
"schema_version": "1.0",
"event": "checkout.abandoned",
"event_id": "evt_chk_oz_10001",
"event_time": "2026-05-30T10:05:00Z",
"source": "olanzo-checkout",
"store_id": "vikingo-carwash-escazu",
"customer": { "phone": "+50688887777", "first_name": "Ana" },
"checkout": { "id": "cs_5f3a9c2e", "currency": "CRC", "grand_total": 274900.00, "item_count": 2, "checkout_url": "https://checkout.olanzo.com/resume/cs_5f3a9c2e" },
"order": { "order_number": "ORD-12345" },
"items": [ { "name": "Lavado Premium", "qty": 1, "price": 274900.00 } ],
"consent": { "sms_marketing": true, "email_marketing": false },
"metadata": { "reason": "expired", "channel": "ecommerce", "locale": "es_CR", "test": false }
}
checkout.abandoned — reason = declined (transactional retry)
{
"schema_version": "1.0",
"event": "checkout.abandoned",
"event_id": "evt_chk_oz_10002",
"event_time": "2026-05-30T10:06:00Z",
"source": "olanzo-checkout",
"store_id": "vikingo-carwash-escazu",
"customer": { "phone": "+50688887777", "first_name": "Ana" },
"checkout": { "id": "cs_5f3a9c2e", "currency": "CRC", "grand_total": 274900.00, "checkout_url": "https://checkout.olanzo.com/resume/cs_5f3a9c2e" },
"order": { "order_number": "ORD-12345" },
"consent": { "transactional": true },
"metadata": { "reason": "declined", "decline_reason": "insufficient_funds", "channel": "ecommerce", "locale": "es_CR" }
}
checkout.abandoned — reason = canceled (soft re-engagement)
{
"schema_version": "1.0",
"event": "checkout.abandoned",
"event_id": "evt_chk_oz_10003",
"event_time": "2026-05-30T10:07:00Z",
"source": "olanzo-checkout",
"store_id": "vikingo-carwash-escazu",
"customer": { "phone": "+50688887777" },
"checkout": { "id": "cs_5f3a9c2e", "currency": "CRC", "grand_total": 274900.00, "checkout_url": "https://checkout.olanzo.com/resume/cs_5f3a9c2e" },
"consent": { "sms_marketing": true },
"metadata": { "reason": "canceled", "channel": "ecommerce" }
}
order.created — success (cancels recovery)
{
"schema_version": "1.0",
"event": "order.created",
"event_id": "evt_ord_oz_10001",
"event_time": "2026-05-30T10:04:30Z",
"source": "olanzo-checkout",
"store_id": "vikingo-carwash-escazu",
"customer": { "phone": "+50688887777", "first_name": "Ana" },
"order": { "order_number": "ORD-12345", "status": "matched", "created_at": "2026-05-30T10:04:30Z" },
"checkout": { "id": "cs_5f3a9c2e", "currency": "CRC", "grand_total": 274900.00 },
"metadata": { "channel": "ecommerce", "locale": "es_CR" }
}
Processing rules
Recovery by reason
reason=expired→ marketing "come back" message (best available channel), subject to the guard, consent, and suppression below.reason=declined→ transactional retry prompt ("payment didn't go through, try again") with the resume link;decline_reasonmay tailor the copy.reason=canceled→ soft re-engagement; default to suppress unless the merchant has opted into canceled-cart nudges (a deliberate cancel is weak intent).
Late-match guard (required, SINPE-specific)
SINPE settlement is asynchronous: a session shown as Expired (or even Declined) can resolve to Matched shortly after. Hold each recovery briefly (e.g. a few minutes) and, before sending, re-check that no order.created / Matched status exists for that session or order. If it does, cancel the recovery. An order.created always cancels a pending recovery for the same session/order.
Suppression and max-age
- Send at most one recovery per customer per session within a configurable window.
- Ignore a
checkout.abandonedwhoseevent_timeis older than a configurable threshold.
Consent and compliance
Operational guidance, not legal advice.
- Each channel requires consent. App push, WhatsApp, SMS, and email each carry their own opt-in considerations; the event carries the available identifiers and per-channel consent flags (
sms_marketing,email_marketing,whatsapp_marketing,push_marketing), and Olanzo applies the right gate per channel. - Reason changes the class. Expired/canceled "come back" messages are marketing — send only with the matching marketing consent. A declined-payment retry prompt is generally transactional (it concerns the customer's own attempted purchase) and can be sent under transactional consent; confirm this classification with whoever owns compliance.
- Jurisdiction. Costa Rica's Ley 8968 / Prodhab governs personal-data and marketing messaging. The checkout's consent capture (at Verify/Confirm) is the source of truth and is conveyed in the
consentobject. - Quiet hours / frequency. Apply any SMS quiet-hours and per-customer frequency caps Olanzo enforces platform-wide.
Validation rules
Olanzo will:
- Require schema_version, event, event_id, event_time, source, store_id.
- Require metadata.reason on checkout.abandoned from olanzo-checkout.
- Require at least one recipient — customer.phone or customer.email — for any event that sends a message.
- On the HTTP path, verify auth and signature before processing; enforce idempotency on event_id.
- Ignore unknown fields (forward compatibility).
Schema evolution and compatibility
Same policy as the shared contract: versioned with schema_version (echoed in X-Webhook-Schema-Version on the HTTP path); additive, backward-compatible changes within a major version; breaking changes bump the major version with an overlap period. New decline_reason values may be added over time — treat unknown values as other.
Integration guide (Olanzo-internal)
This is short by design — there is no external store, adapter, or third-party webhook.
Step 1 — Detect the terminal outcome
In the hosted-checkout backend, hook the points where a session resolves to Matched, Expired, Declined, or where the customer presses Cancel. (Confirm how the backend records a customer cancel — as its own status, or folded into expired/abandoned.)
Step 2 — Build the envelope
Map the outcome to the event: Matched → order.created; Expired/Canceled/Declined → checkout.abandoned with metadata.reason. Include customer.phone (and email if present), the session id as checkout.id (cs_…), the ORD-… number as order.order_number, a resume link as checkout.checkout_url, and decline_reason for declines. Generate a stable event_id from session id + reason.
Step 3 — Emit it
Publish internally onto the commerce-events pipeline (primary), or POST it signed to /v1/commerce (alternative). Do this off the critical path so it never blocks checkout or settlement.
Step 4 — Let recovery logic decide
Olanzo applies the late-match guard, consent (per reason), suppression, and quiet hours, then sends SMS (primary) or email.
Step 5 — Test in sandbox
Exercise all three reasons. Critically, test a late SINPE match after an expired event and confirm the recovery is canceled. Use metadata.test = true to validate end to end without sending real messages.
Step 6 — Go live
No external dependencies to monitor. Keep an eye on recovery send/suppress rates and the late-match cancel rate to confirm the guard is doing its job.
Responsibilities summary
| Item | Owner |
|---|---|
| Endpoint, signing secret (HTTP path), sandbox | Olanzo platform |
| Receiver validation, idempotency, recovery logic, late-match guard, consent, SMS/email send | Olanzo platform |
| Emitting events from the hosted checkout (detect outcome, map reason, publish) | Olanzo checkout team |
| Consent capture at checkout (phone/email, marketing vs transactional) | Olanzo checkout team |
| Reason → message-class policy (declined = transactional?) and canceled opt-in | Olanzo (product + compliance) |
Glossary
- SINPE Móvil — Costa Rica's interbank mobile-transfer protocol; the checkout's payment method.
- Matched / Expired / Declined — the SINPE transaction terminal statuses (Matched = settled/success).
- Cancel — customer-initiated abandonment via the checkout's cancel action.
cs_…— the hosted-checkout session reference (shown on the Expired receipt).ORD-…— the order number shown on every checkout screen.- Late-match guard — holding a recovery briefly and cancelling it if the session settles after the UI gave up.
- Reason —
metadata.reason(expired/canceled/declined); drives message content and class. - Internal path — emitting the event onto Olanzo's own pipeline without a signed HTTP call.
Open setup decisions
- Channel preference order — the default is App push → WhatsApp → SMS → email; confirm the order and any per-channel rules (WhatsApp opt-in/templates, app-push availability detection, SMS quiet hours), and whether any reason should prefer a specific channel.
- Is "canceled" a distinct backend status or folded into expired/abandoned? This affects whether
reason=canceledis ever emitted. - Declined = transactional or marketing? Confirm the classification, since it changes consent gating.
- Resume vs new session — does
checkout_urlresume the samecs_…session, or start a fresh checkout? Pick one and keep it consistent. - Late-match hold window — how long to hold a recovery before sending, to absorb late SINPE settlement.
Olanzo Payment Requests & Payment Links → Reminder Events Specification
Version: 0.9 (design-stage specification)
Date: 2026-05-30
Applies to: Olanzo SINPE Requests (live; optional due date) and Payment Links (planned; SINPE + card brands via a third-party processor)
Receiver: Olanzo messaging platform (Olanzo App push · WhatsApp · SMS · email)
This document defines how Olanzo's own collection products — SINPE Requests and Payment Links — feed events into Olanzo so Olanzo can send due-date payment reminders (a lightweight invoicing / accounts-receivable flow). It is a companion to the four commerce-event integrations and reuses the same normalized envelope and delivery rails.
Design-stage. SINPE Requests exist today and already carry an optional
due_date; the "use it like an invoice with reminders" behavior and Payment Links are planned. Treat the statuses, fields, reminder cadence, and the third-party card processor below as a proposed model to confirm, not shipped behavior. Open questions are collected at the end.
How this differs from abandonment recovery
This is a different family of events, and the difference drives the whole design.
| Abandonment recovery (carts/checkouts) | Payment reminders (this spec) | |
|---|---|---|
| What it is | The customer started and didn't finish | A request to pay with a status and a deadline |
| Trigger | A real-time drop-off event | Time relative to a due_date while still unpaid |
| Who schedules the message | Sent soon after the event | Olanzo's reminder engine schedules nudges from the due date |
| Line items | Usually present | Often absent — an amount-only "you owe ₡X" is normal |
| Message intent | "Come back and finish" (marketing) | "Payment due / overdue" for an obligation the customer accepted (usually transactional) |
So the source does not tell Olanzo when to send. The source declares the request exists, here is the amount, the due date, and it is unpaid; Olanzo schedules and sends the reminders, and cancels them the moment the request is paid, canceled, or expired.
Quick start
You are building: logic in the SINPE-Request / Payment-Link backend that emits a payment_request.created event (with amount, optional due_date, and status: unpaid) when a request is issued, an updated/paid/canceled/expired event when its state changes, and then lets Olanzo's reminder engine do the rest.
Minimal path:
- On issue, emit
payment_request.createdwithsource=olanzo-sinpe-requestorolanzo-payment-link, thepayment_requestobject (amount, currency, status, optionaldue_date,methods,pay_urlfor links), and whatever recipient identifiers you have (app user / phone / email) (Integration guide, Steps 1–2). - Publish it internally to the commerce-events pipeline — no signing needed (it stays inside Olanzo). Signed
POST /v1/commerceis the alternative (Step 3). - Emit
payment_request.paid(orcanceled/expired) when the state changes, so Olanzo can cancel pending reminders (Step 4). - Olanzo's reminder engine schedules nudges from the
due_datewhilestatus = unpaid, choosing the best available channel and respecting consent and quiet hours (Step 5).
The rules that matter most:
- No
due_date→ no reminders (at most a single "you have a payment request" notification on creation). Reminders are a due-date feature. - Paid/canceled/expired cancels everything pending — never chase a settled or dead request.
- Reminders for an accepted obligation are usually transactional, not marketing — confirm the classification (see Consent).
- Line items are optional — remind about the amount and reference when there are none.
A signed request (HTTP path) is in Signature example (worked).
Scope and coverage
- SINPE Requests — a merchant sends, from the back office, a SINPE request as a mobile notification to a person who has the Olanzo user app; the recipient taps and pays. Natural channel: Olanzo App push (the recipient is an app user). Optional
due_date. - Payment Links (planned) — a shareable URL that collects payment, starting with SINPE and adding Visa / Mastercard / Amex through a third-party processor. Because it is a link, it suits any channel (SMS / WhatsApp / email). Optional
due_date. - With or without line items — both products may carry
items, or just anamount+reference. Amount-only reminders are fully supported. - Multi-channel delivery — Olanzo App push, WhatsApp, SMS, email (see Delivery channels).
Delivery channels (shared across Olanzo-sent messages)
Olanzo is a messaging platform, so reminders (and the checkout recovery messages) can go over any of:
- Olanzo App push — for recipients who have the Olanzo user app (the natural channel for SINPE Requests).
- WhatsApp — for recipients reachable on WhatsApp (uses the phone).
- SMS — broad fallback (uses the phone).
- Email — when an email is on file.
The event carries the available recipient identifiers (customer.app_user_id, customer.phone, customer.email) and per-channel consent; Olanzo selects the channel by availability + consent + a configurable preference order (a sensible default: App push → WhatsApp → SMS → email). The source does not need to pick the channel — just supply identifiers and consent.
Core design principles
- Same normalized envelope, with a
payment_requestobject andpayment_request.*events. - Due-date-driven, scheduled by Olanzo — the source declares state; Olanzo schedules reminders.
- Terminal state cancels reminders —
paid/canceled/expiredstop the schedule. - Asynchronous, non-blocking, idempotent — emitting events must not block the collection flow; dedupe on
event_id. - Internal trust boundary — sender and receiver are both Olanzo; the primary path needs no cross-service HMAC.
Architecture overview
Primary path — internal event. The collection backend publishes onto the internal pipeline (no self-HTTP, no signing). Alternative — signed HTTP to POST /v1/commerce, exactly like the external platforms, if you prefer uniform ingress.
Sequence diagrams
1. Request created → scheduled reminders while unpaid
2. Paid before the reminder → reminders canceled
3. Overdue escalation
The contract (normative)
Payment-request events use the same contract as the commerce events (endpoint, auth, signing, headers, response codes, error envelope, delivery semantics, limits, sandbox — see the Commerce events overview). Notes specific to this family:
- Internal path (primary): no
X-Webhook-Signaturerequired (stays inside Olanzo). Idempotency onevent_idstill applies. - Signed HTTP path (alternative):
Authorization: Bearer …(orX-Webhook-Key) andX-Webhook-Signatureover the raw body with the 5-minute replay window.
Signature example (worked)
For the signed HTTP path only. Sign the exact raw bytes; signature is lowercase hex.
Given secret whsec_test_5f3a9c2e8b1d4f76, timestamp t = 1748599200, and raw body (302 bytes):
{"schema_version":"1.0","event":"payment_request.created","event_id":"evt_pr_10001","event_time":"2026-05-30T09:00:00Z","source":"olanzo-sinpe-request","store_id":"vikingo-carwash-escazu","payment_request":{"id":"pr_7a1c9d","amount":45000.00,"currency":"CRC","status":"unpaid","due_date":"2026-06-06"}}
the header is:
X-Webhook-Signature: t=1748599200,v1=26c67a808449cbde64f6e3203ecdb4139f4e291d4d3067d6d083eb76dbd66d06
Idempotency
Each event carries a unique event_id (derive it from the request id + state so retries are stable). Olanzo deduplicates and treats 409 as success on the HTTP path.
Data formats and conventions
Same as the shared contract: UTF-8, application/json, ISO 4217 currency (typically CRC), amounts as JSON numbers in major units. Two additions:
due_dateis a calendar date (YYYY-MM-DD) — a due date, not a timestamp. (If a precise cutoff time is ever needed, use a full ISO 8601 timestamp and confirm the timezone; Costa Rica is UTC−6.)- Recipient identifiers:
customer.app_user_id(Olanzo app user),customer.phone(E.164),customer.email. At least one is required to send.
Field enumerations
source:olanzo-sinpe-request|olanzo-payment-link.payment_request.status:unpaid|paid|canceled|expired(proposed — confirm the real status set; partial payments are an open question).payment_request.methods: any ofsinpe,visa,mastercard,amex(SINPE Requests are typically[sinpe]; Payment Links may include card brands).
Event model
| Event | When | Effect on reminders |
|---|---|---|
payment_request.created |
A request/link is issued | Schedule reminders if due_date present and status=unpaid |
payment_request.updated |
Amount, due date, or status changes | Reschedule accordingly |
payment_request.paid |
Settled | Cancel all pending reminders |
payment_request.canceled |
Voided by the merchant | Cancel all pending reminders |
payment_request.expired |
Link/request lapsed | Cancel all pending reminders |
source is olanzo-sinpe-request or olanzo-payment-link.
Normalized envelope
The payment_request object is the new piece; customer and items are optional.
{
"schema_version": "1.0",
"event": "payment_request.created",
"event_id": "evt_pr_10001",
"event_time": "2026-05-30T09:00:00Z",
"source": "olanzo-sinpe-request",
"store_id": "vikingo-carwash-escazu",
"customer": { "app_user_id": "appu_2231", "phone": "+50688887777", "first_name": "Ana" },
"payment_request": {
"id": "pr_7a1c9d",
"amount": 45000.00,
"currency": "CRC",
"status": "unpaid",
"due_date": "2026-06-06",
"methods": ["sinpe"],
"reference": "Mensualidad junio",
"memo": "Plan lavado mensual"
},
"items": [],
"consent": { "transactional": true },
"metadata": { "channel_hint": "app_push", "locale": "es_CR", "test": false }
}
Required top-level fields
schema_version, event, event_id, event_time, source, store_id, and payment_request (with at least id, amount, currency, status). At least one recipient identifier (customer.app_user_id, customer.phone, or customer.email) is required for Olanzo to send.
Event-specific payload examples
SINPE Request created (app push, with due date)
{
"schema_version": "1.0",
"event": "payment_request.created",
"event_id": "evt_pr_10001",
"event_time": "2026-05-30T09:00:00Z",
"source": "olanzo-sinpe-request",
"store_id": "vikingo-carwash-escazu",
"customer": { "app_user_id": "appu_2231", "phone": "+50688887777", "first_name": "Ana" },
"payment_request": { "id": "pr_7a1c9d", "amount": 45000.00, "currency": "CRC", "status": "unpaid", "due_date": "2026-06-06", "methods": ["sinpe"], "reference": "Mensualidad junio" },
"consent": { "transactional": true },
"metadata": { "channel_hint": "app_push", "locale": "es_CR" }
}
Payment Link created (URL, SINPE + cards, with due date)
{
"schema_version": "1.0",
"event": "payment_request.created",
"event_id": "evt_pl_20001",
"event_time": "2026-05-30T09:10:00Z",
"source": "olanzo-payment-link",
"store_id": "vikingo-carwash-escazu",
"customer": { "phone": "+50688887777", "email": "ana@example.com", "first_name": "Ana" },
"payment_request": {
"id": "pl_55f2",
"amount": 120000.00,
"currency": "CRC",
"status": "unpaid",
"due_date": "2026-06-10",
"methods": ["sinpe", "visa", "mastercard", "amex"],
"pay_url": "https://pay.olanzo.com/l/55f2ab",
"reference": "Factura 0098"
},
"items": [
{ "name": "Detallado completo", "qty": 1, "price": 120000.00 }
],
"consent": { "transactional": true, "sms_marketing": false },
"metadata": { "locale": "es_CR" }
}
Paid (cancels reminders)
{
"schema_version": "1.0",
"event": "payment_request.paid",
"event_id": "evt_pr_10001_paid",
"event_time": "2026-06-03T14:22:00Z",
"source": "olanzo-sinpe-request",
"store_id": "vikingo-carwash-escazu",
"customer": { "app_user_id": "appu_2231" },
"payment_request": { "id": "pr_7a1c9d", "amount": 45000.00, "currency": "CRC", "status": "paid" },
"metadata": { "locale": "es_CR" }
}
Reminder scheduling rules
Olanzo's reminder engine owns timing. A sensible default cadence (configurable per merchant):
- Pre-due: a reminder N days before
due_date(e.g. 3 days). - Due day: a reminder on the
due_date. - Overdue: one or more reminders after the due date on a set cadence (e.g. +1 day, +3 days), up to a maximum, then stop.
- No
due_date: no scheduled reminders — at most a single notification at creation that a request is waiting.
Always: stop on paid / canceled / expired; one message per channel per scheduled slot; respect quiet hours and per-recipient frequency caps; apply the out-of-order guard (re-check current status is still unpaid immediately before sending, since paid may have arrived late).
Consent and compliance
Operational guidance, not legal advice.
- Reminders for an accepted obligation are usually transactional. A SINPE request or payment link the customer is expected to pay is a service message about a specific obligation, so a due-date reminder is generally transactional rather than marketing — confirm this with whoever owns compliance, since it changes the consent gate.
- Per-channel consent. App push, WhatsApp, SMS, and email each have their own consent/opt-in considerations (e.g. WhatsApp template/opt-in rules). The event carries the available identifiers and consent flags; Olanzo applies the right gate per channel.
- Jurisdiction. Costa Rica's Ley 8968 / Prodhab applies. The product that issued the request is the source of truth for the recipient relationship and consent.
Validation rules
Olanzo will:
- Require schema_version, event, event_id, event_time, source, and a payment_request with id, amount, currency, status.
- Require at least one recipient identifier (app_user_id, phone, or email).
- Schedule reminders only when due_date is present and status = unpaid.
- Cancel reminders on paid / canceled / expired.
- On the HTTP path, verify auth and signature; enforce idempotency on event_id.
- Ignore unknown fields (forward compatibility).
Schema evolution and compatibility
Same policy as the shared contract (versioned via schema_version, additive within a major version). New status values, methods, and channels may be added over time; treat unknown enum values conservatively (an unknown status should not trigger sends).
Integration guide (Olanzo-internal)
Step 1 — Emit on issue
When a SINPE request or payment link is created, emit payment_request.created with the payment_request object (id, amount, currency, status, optional due_date, methods, pay_url for links, optional reference/memo) and the recipient identifiers you hold.
Step 2 — Choose the source and identifiers
source = olanzo-sinpe-request (App-user recipient → push) or olanzo-payment-link (URL → any channel). Include app_user_id and/or phone and/or email. Generate a stable event_id from request id + state.
Step 3 — Emit it
Publish internally (primary) or POST signed to /v1/commerce (alternative), off the critical path.
Step 4 — Emit state changes
Emit payment_request.updated on amount/due-date/status changes and payment_request.paid / .canceled / .expired on terminal states so Olanzo cancels pending reminders.
Step 5 — Let the reminder engine run
Olanzo schedules from due_date, selects the channel, applies consent and quiet hours, and stops on terminal state. Test the cadence and the paid-cancels-reminders path in sandbox with metadata.test = true.
Responsibilities summary
| Item | Owner |
|---|---|
| Reminder engine (scheduling, cancellation, channel selection), consent gates, sends | Olanzo platform |
Emitting payment_request.* events with status + due date + identifiers |
SINPE Request / Payment Link team |
| Payment Link third-party card integration | Olanzo (payments) |
| Reminder cadence policy and transactional classification | Olanzo (product + compliance) |
Glossary
- SINPE Request — a merchant-initiated SINPE payment request delivered as an Olanzo App notification; the recipient taps to pay.
- Payment Link (planned) — a shareable URL that collects SINPE and (via a third party) Visa/Mastercard/Amex.
payment_request— the normalized object representing either of the above: amount, currency, status, optional due date, methods, optional pay URL.- Reminder engine — the Olanzo component that schedules due-date reminders and cancels them on terminal state.
due_date— optional calendar date; its presence is what enables reminders.- Channel selection — Olanzo choosing App push / WhatsApp / SMS / email by availability, consent, and preference order.
Open setup decisions (design-stage — please confirm)
- Status set. Is
unpaid / paid / canceled / expiredright? Do SINPE Requests expire? Are partial payments possible (and should they reschedule rather than cancel)? - Default reminder cadence. Pre-due offset, due-day, overdue cadence, and the maximum number of reminders before stopping.
- Transactional vs marketing. Confirm due-date reminders are treated as transactional (affects consent across all channels).
- No-due-date behavior. Send a single "you have a request" notification on creation, or nothing until paid?
- Channel preference order and any channel-specific rules (e.g. WhatsApp opt-in/templates, app-push availability detection, SMS quiet hours).
- Payment Links specifics. The third-party card processor, whether links expire, and whether a paid link emits the same
payment_request.paid. - Relationship to
order.created. Should a paid request/link also produce anorder.created(for a receipt), or ispayment_request.paidsufficient?