Olanzo Docs

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:

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> or X-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> where v1 = 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_id and treat a 409 as 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:

  1. Stand up Adobe Commerce async eventing and subscribe to the order / invoice / shipment / refund / customer events (Integration guide, Steps 1–2).
  2. Deploy the adapter: map → sign → POST (Step 3). Verify your signing against the worked example below before sending real traffic.
  3. Add the scheduled cart-abandonment emitter for cart.abandoned (Step 4) — the only piece with no native Commerce event, and the longest-lead item.
  4. Point the adapter at your sandbox endpoint, send the sample events, and confirm 202 plus passing signatures (Step 5).
  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_id and 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 event field 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:

flowchart TD A["Adobe Commerce events<br/>(order, invoice, shipment, refund, customer)"] -->|"async · Adobe I/O Events"| C B["Scheduled cart-abandonment scan<br/>(queries quote/cart data on a cron)"] --> C C["Adapter<br/>maps to normalized envelope · signs HMAC · POSTs async with retry"] C -->|"HTTPS POST · signed · async"| D["Olanzo webhook endpoint"] D -->|"202 Accepted (fast ack)"| E["Internal routing"] E --> F["Email send"]

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.

sequenceDiagram actor Shopper participant AC as Adobe Commerce participant AD as Adapter participant OL as Olanzo Shopper->>AC: Places order AC-->>Shopper: Order placed (no wait on Olanzo) AC-)AD: order placed event (async, Adobe I/O Events) AD->>AD: Map to envelope · sign (HMAC + timestamp) AD->>OL: POST /v1/commerce (order.created) OL->>OL: Verify signature · check event_id OL-->>AD: 202 Accepted OL-)Shopper: Order confirmation email (sent asynchronously)

2. Abandoned cart with the out-of-order guard

A late cart.abandoned must never email a shopper who already bought.

sequenceDiagram participant SC as Scheduled scan participant AD as Adapter participant OL as Olanzo SC->>SC: Detect cart idle · email captured · no order SC-)AD: cart.abandoned AD->>OL: POST /v1/commerce (cart.abandoned) OL->>OL: Guard — any order.created recorded for this cart/customer? alt No order · marketing consent = true OL-->>AD: 202 Accepted OL-)OL: Send recovery email (after suppression-window check) else Order already exists, or no marketing consent OL-->>AD: 202 Accepted OL->>OL: Suppress — do not send end

3. Delivery retry and idempotency (at-least-once)

A duplicate delivery is normal; the sender treats 409 as success.

sequenceDiagram participant AD as Adapter participant OL as Olanzo AD->>OL: POST event (event_id = evt_001) Note over OL: Processed, but the 202 is lost in transit OL--xAD: (no response received) AD->>AD: Backoff, then retry AD->>OL: POST event (event_id = evt_001) again OL->>OL: event_id already seen OL-->>AD: 409 Conflict (duplicate) Note over AD: Treat 409 as success — already delivered

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>, or
  • X-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.email and 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 on event_id and returns 409 for a duplicate; the sender should treat 409 as success, not an error to keep retrying.
  • No ordering guarantee. Events may arrive out of order. Do not assume order.created arrives after cart.abandoned, or that invoice.created follows order.created on the wire. The processing rules (and the out-of-order guard) are designed for this.
  • Idempotency retention. Olanzo remembers each event_id for 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 429 with a Retry-After header.
  • 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, or 429, 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.
  • 4xx other than 429 are 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": true inside metadata to 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/health returns 200 with { "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) or magento (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.created
  • order.updated
  • invoice.created
  • shipment.created
  • refund.created
  • customer.created
  • customer.updated

order.created is used everywhere an order coming into existence matters (both as a transactional trigger and to cancel cart recovery). There is intentionally no separate order.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.updated and checkout.started create or refresh cart-activity state.
  • checkout.payment_started refines it (e.g. enables payment-method-specific copy); optional.
  • cart.abandoned is the final recovery trigger.
  • order.created cancels any pending recovery for that cart.id or 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).

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.abandoned and other marketing-class emails, do not send unless consent.marketing is true; if false or 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 consent object.

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 the X-Webhook-Schema-Version header.
  • 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 replayed event_id returns 409.
  • Confirm the out-of-order guard: an order.created suppresses a subsequent cart.abandoned for the same cart.
  • Use metadata.test = true to 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.abandoned is not native; it requires the scheduled scan or an extension (Step 4).
  • checkout objectstep and payment_method_selected are 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:

  1. Create a Shopify app (GraphQL Admin API) and subscribe to the order / fulfillment / refund / customer webhook topics (Integration guide, Steps 1–2).
  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.
  3. 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.
  4. Point the adapter at your sandbox endpoint, send sample events, and confirm 202 plus passing signatures (Step 5).
  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-Id on the direct path, your event_id on 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 event field 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:

flowchart TD A["Shopify webhook topics<br/>(orders, fulfillments, refunds, customers, checkouts)"] --> C B["Shopify Flow — 'Customer abandons checkout' trigger<br/>(or Abandoned Checkouts Admin API)"] --> C C["Adapter<br/>verify Shopify HMAC · map to Olanzo envelope · sign · POST async with retry"] C -->|"HTTPS POST · signed · async"| D["Olanzo webhook endpoint"] D -->|"202 Accepted (fast ack)"| E["Internal routing"] E --> F["Email send"]

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.

sequenceDiagram actor Shopper participant SH as Shopify participant AD as Adapter participant OL as Olanzo Shopper->>SH: Completes order SH-->>Shopper: Order placed (no wait on the integration) SH-)AD: orders/create webhook (async, signed X-Shopify-Hmac-Sha256) AD->>AD: Verify Shopify HMAC (raw body) · map to envelope · sign with Olanzo secret AD->>OL: POST /v1/commerce (order.created) OL->>OL: Verify signature · check event_id OL-->>AD: 202 Accepted OL-)Shopper: Order confirmation email (sent asynchronously)

2. Abandoned checkout via Shopify Flow

Shopify itself decides abandonment after a configurable window; the Flow trigger does the detecting.

sequenceDiagram actor Shopper participant SH as Shopify + Flow participant AD as Adapter participant OL as Olanzo Shopper->>SH: Reaches checkout, enters email, leaves Note over SH: Flow "Customer abandons checkout" waits the configured window (~10h default) SH-)AD: checkout.abandoned (Flow HTTP request, or API poll) AD->>OL: POST /v1/commerce (checkout.abandoned) OL->>OL: Guard — any order.created recorded for this checkout/customer? alt No order · marketing consent = true OL-->>AD: 202 Accepted OL-)OL: Send recovery email (after suppression-window check) else Order already exists, or no marketing consent OL-->>AD: 202 Accepted OL->>OL: Suppress — do not send end

3. Delivery retry and idempotency (at-least-once)

A duplicate delivery is normal; the sender treats 409 as success.

sequenceDiagram participant AD as Adapter participant OL as Olanzo AD->>OL: POST event (event_id = evt_001) Note over OL: Processed, but the 202 is lost in transit OL--xAD: (no response received) AD->>AD: Backoff, then retry AD->>OL: POST event (event_id = evt_001) again OL->>OL: event_id already seen OL-->>AD: 409 Conflict (duplicate) Note over AD: Treat 409 as success — already delivered

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>, or
  • X-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's X-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_id and treat 409 as success.
  • No ordering guarantee. Events may arrive out of order (e.g. order.created before a delayed checkout.abandoned). The processing rules and the out-of-order guard handle this.
  • Idempotency retention. Olanzo remembers each event_id for 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 429 with a Retry-After header.
  • 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, or 429, with exponential backoff (~1m, 5m, 30m, 2h, 6h) for up to ~24 hours; dead-letter after the final attempt; 4xx other than 429 are permanent. Retries are safe because of the stable event_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": true inside metadata to 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/health returns 200 with { "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 final checkout.abandoned, the receiver determines abandonment after a configured inactivity window using the checkout.* signals.
  • order.created — an order now exists; cancels any pending recovery for that checkout/customer.

Transactional event group

  • order.created
  • order.updated
  • fulfillment.created
  • fulfillment.updated
  • refund.created
  • customer.created
  • customer.updated

order.created is 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.created and checkout.updated create or refresh checkout-activity state.
  • checkout.abandoned is the final recovery trigger (emitted by Flow/API, or inferred by the receiver after an inactivity window).
  • order.created cancels any pending recovery for that checkout.id or 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).

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.abandoned and other marketing-class emails, do not send unless consent.email_marketing is true; if false or 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) and app/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 consent object.

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 the X-Webhook-Schema-Version header.
  • 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 replayed event_id returns 409.
  • Confirm the out-of-order guard: an order.created suppresses a subsequent checkout.abandoned for the same checkout.
  • Use metadata.test = true to 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.abandoned has 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_id is derived from X-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:

  1. WooCommerce's built-in webhooks (WooCommerce → Settings → Advanced → Webhooks) only cleanly cover order.* and customer.*. 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.
  2. 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:

  1. Install/deploy the adapter plugin and hook WooCommerce's order and customer actions (Integration guide, Steps 1–2).
  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.
  3. 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.abandoned from it (Step 4). This is the longest-lead, plugin-dependent item.
  4. Point the adapter at your sandbox endpoint, fire sample events, and confirm 202 plus passing signatures (Step 5).
  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-Signature over 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_id and 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 event field 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:

flowchart TD A["WooCommerce / WordPress action hooks<br/>(new order, status changed, refunded, customer created/updated)"] --> C B["Abandoned-cart recovery plugin<br/>(detects abandonment after a window; fires a hook)"] --> C C["Adapter plugin<br/>map to Olanzo envelope · sign HMAC · enqueue Action Scheduler job"] C -->|"HTTPS POST · signed · async · own retry"| D["Olanzo webhook endpoint"] D -->|"202 Accepted (fast ack)"| E["Internal routing"] E --> F["Email send"]

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.

sequenceDiagram actor Shopper participant WC as WooCommerce (adapter plugin) participant AS as Action Scheduler participant OL as Olanzo Shopper->>WC: Places order WC-->>Shopper: Order received (no wait on the integration) WC->>WC: woocommerce_new_order hook -> map to envelope -> sign WC->>AS: Enqueue delivery job AS->>OL: POST /v1/commerce (order.created) OL->>OL: Verify signature · check event_id OL-->>AS: 202 Accepted OL-)Shopper: Order confirmation email (sent asynchronously)

2. Abandoned cart via a recovery plugin

WooCommerce has no native abandonment; a plugin does the detecting after a window.

sequenceDiagram actor Shopper participant CP as Cart-recovery plugin participant AD as Adapter plugin + Action Scheduler participant OL as Olanzo Shopper->>CP: Adds items, enters email at checkout, leaves Note over CP: Plugin waits the configured window (WP-Cron driven) CP-)AD: Abandonment hook fires -> map to checkout/cart.abandoned AD->>OL: POST /v1/commerce (cart.abandoned) OL->>OL: Guard — any order.created recorded for this cart/customer? alt No order · marketing consent = true OL-->>AD: 202 Accepted OL-)OL: Send recovery email (after suppression-window check) else Order already exists, or no marketing consent OL-->>AD: 202 Accepted OL->>OL: Suppress — do not send end

3. Delivery retry and idempotency (at-least-once)

The adapter owns retry (Action Scheduler); a duplicate is normal and 409 means success.

sequenceDiagram participant AD as Adapter (Action Scheduler) participant OL as Olanzo AD->>OL: POST event (event_id = evt_001) Note over OL: Processed, but the 202 is lost in transit OL--xAD: (no response received) AD->>AD: Backoff, then retry (Action Scheduler) AD->>OL: POST event (event_id = evt_001) again OL->>OL: event_id already seen OL-->>AD: 409 Conflict (duplicate) Note over AD: Treat 409 as success — already delivered

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>, or
  • X-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's X-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_id and treat 409 as success.
  • No ordering guarantee. Events may arrive out of order (e.g. order.created before a delayed cart.abandoned). The processing rules and the out-of-order guard handle this.
  • Idempotency retention. Olanzo remembers each event_id for 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 429 with a Retry-After header.
  • 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, or 429 with exponential backoff (~1m, 5m, 30m, 2h, 6h) for up to ~24 hours; dead-letter after the final attempt; 4xx other than 429 are permanent. Retries are safe because of the stable event_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": true inside metadata to 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/health returns 200 with { "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_status and order.fulfillment_status: optional, derived fields (not native WooCommerce concepts). Include them only if your adapter computes them for cross-platform consistency; otherwise rely on order.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 final cart.abandoned, the receiver determines abandonment after a configured inactivity window using the cart.* signals.
  • order.created — an order now exists; cancels any pending recovery for that cart/customer.

Transactional event group

  • order.created
  • order.updated
  • shipment.created
  • shipment.updated
  • refund.created
  • customer.created
  • customer.updated

order.created is 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.updated and checkout.started create or refresh cart-activity state.
  • cart.abandoned is the final recovery trigger (from the recovery plugin, or inferred by the receiver after an inactivity window).
  • order.created cancels any pending recovery for that cart.id or 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).

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.abandoned and other marketing-class emails, do not send unless consent.email_marketing is true; if false or 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 consent object. 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 the X-Webhook-Schema-Version header.
  • 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 replayed event_id returns 409.
  • Confirm the out-of-order guard: an order.created suppresses a subsequent cart.abandoned for the same cart.
  • Use metadata.test = true to 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.* and customer.* 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.abandoned are 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_hooks filter 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 eventcheckout.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:

  1. 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 a checkout.checkout_url to resume (Integration guide, Steps 1–2).
  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/commerce signed, exactly like the external platforms (Step 3).
  3. Let Olanzo's recovery logic apply the late-match guard, consent, and suppression before sending (Step 4).
  4. Test the three reasons in sandbox, including a late SINPE match that should suppress an already-fired expired recovery (Step 5).
  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.abandoned event.
  • 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:

flowchart TD A["Hosted checkout backend<br/>(Verify → Confirm → Pay)"] --> B{Terminal status} B -->|Matched| C["order.created (confirmation)"] B -->|Expired| D["checkout.abandoned · reason=expired"] B -->|Canceled| E["checkout.abandoned · reason=canceled"] B -->|Declined| F["checkout.abandoned · reason=declined"] C --> G["Commerce-events pipeline"] D --> G E --> G F --> G G --> H["Recovery logic: late-match guard · consent · suppression"] H --> I["App push / WhatsApp / SMS / email"]

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)

sequenceDiagram actor Shopper participant CO as Checkout backend participant PL as Commerce-events pipeline participant OL as Olanzo recovery Shopper->>CO: Reaches Pay, waits, session times out CO->>PL: checkout.abandoned (reason=expired, phone, resume URL) PL->>OL: Deliver event OL->>OL: Guard — did this session later reach Matched? alt Not matched · marketing consent present OL-)Shopper: Message (best channel) — "Come back and finish your purchase" else Matched, or no consent OL->>OL: Suppress — do not send end

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.

sequenceDiagram participant CO as Checkout backend participant PL as Commerce-events pipeline participant OL as Olanzo recovery CO->>PL: checkout.abandoned (reason=expired) Note over OL: Recovery queued with a short hold (e.g. a few minutes) CO->>PL: order.created (SINPE matched late) PL->>OL: order.created for same session/order OL->>OL: Cancel the held recovery Note over OL: No "come back" sent — the order completed

3. Declined → retry prompt

sequenceDiagram actor Shopper participant CO as Checkout backend participant PL as Commerce-events pipeline participant OL as Olanzo recovery Shopper->>CO: Attempts payment, SINPE declines it CO->>PL: checkout.abandoned (reason=declined, decline_reason) PL->>OL: Deliver event OL->>OL: Treat as transactional retry prompt · suppression check OL-)Shopper: Message (best channel) — "Payment didn't go through, try again" + resume link

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-Signature is 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 … (or X-Webhook-Key) and X-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 a 409 as 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-checkout checkout.abandoned).
  • metadata.decline_reason (optional, for declined): e.g. insufficient_funds · rejected_by_bank · timeout · other.
  • order.status (on order.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_reason may 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.abandoned whose event_time is older than a configurable threshold.

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 consent object.
  • 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.
  • Reasonmetadata.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

  1. 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.
  2. Is "canceled" a distinct backend status or folded into expired/abandoned? This affects whether reason=canceled is ever emitted.
  3. Declined = transactional or marketing? Confirm the classification, since it changes consent gating.
  4. Resume vs new session — does checkout_url resume the same cs_… session, or start a fresh checkout? Pick one and keep it consistent.
  5. 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:

  1. On issue, emit payment_request.created with source = olanzo-sinpe-request or olanzo-payment-link, the payment_request object (amount, currency, status, optional due_date, methods, pay_url for links), and whatever recipient identifiers you have (app user / phone / email) (Integration guide, Steps 1–2).
  2. Publish it internally to the commerce-events pipeline — no signing needed (it stays inside Olanzo). Signed POST /v1/commerce is the alternative (Step 3).
  3. Emit payment_request.paid (or canceled / expired) when the state changes, so Olanzo can cancel pending reminders (Step 4).
  4. Olanzo's reminder engine schedules nudges from the due_date while status = 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 an amount + 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_request object and payment_request.* events.
  • Due-date-driven, scheduled by Olanzo — the source declares state; Olanzo schedules reminders.
  • Terminal state cancels reminderspaid / canceled / expired stop 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

flowchart TD A["SINPE Request / Payment Link backend"] --> B{Event} B -->|issued| C["payment_request.created (status=unpaid, due_date?)"] B -->|state change| D["payment_request.updated"] B -->|settled| E["payment_request.paid"] B -->|canceled / expired| F["payment_request.canceled / .expired"] C --> G["Commerce-events pipeline"] D --> G E --> G F --> G G --> H["Reminder engine: schedule from due_date while unpaid · cancel on terminal state"] H --> I["Channel selection: App push / WhatsApp / SMS / email · consent · quiet hours"] I --> J["Reminder sent"]

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

sequenceDiagram participant BO as Back office / link backend participant PL as Commerce-events pipeline participant RE as Olanzo reminder engine actor Payer BO->>PL: payment_request.created (amount, due_date, status=unpaid) PL->>RE: Deliver event RE->>RE: Schedule reminders from due_date (e.g. -3d, due day, +overdue) RE-)Payer: Reminder (best available channel) — "Payment due {date}"

2. Paid before the reminder → reminders canceled

sequenceDiagram participant BO as Back office / link backend participant PL as Commerce-events pipeline participant RE as Olanzo reminder engine BO->>PL: payment_request.created (due_date, unpaid) Note over RE: Reminders scheduled BO->>PL: payment_request.paid (same request id) PL->>RE: Deliver paid event RE->>RE: Cancel all pending reminders for this request Note over RE: No reminder sent — already paid

3. Overdue escalation

sequenceDiagram participant RE as Olanzo reminder engine actor Payer Note over RE: due_date passes, still unpaid RE-)Payer: Pre-due reminder (e.g. 3 days before) RE-)Payer: Due-today reminder RE-)Payer: Overdue reminder(s) per cadence, until paid/canceled/expired or max reached

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-Signature required (stays inside Olanzo). Idempotency on event_id still applies.
  • Signed HTTP path (alternative): Authorization: Bearer … (or X-Webhook-Key) and X-Webhook-Signature over 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_date is 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 of sinpe, 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" }
}
{
  "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).


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)

  1. Status set. Is unpaid / paid / canceled / expired right? Do SINPE Requests expire? Are partial payments possible (and should they reschedule rather than cancel)?
  2. Default reminder cadence. Pre-due offset, due-day, overdue cadence, and the maximum number of reminders before stopping.
  3. Transactional vs marketing. Confirm due-date reminders are treated as transactional (affects consent across all channels).
  4. No-due-date behavior. Send a single "you have a request" notification on creation, or nothing until paid?
  5. Channel preference order and any channel-specific rules (e.g. WhatsApp opt-in/templates, app-push availability detection, SMS quiet hours).
  6. Payment Links specifics. The third-party card processor, whether links expire, and whether a paid link emits the same payment_request.paid.
  7. Relationship to order.created. Should a paid request/link also produce an order.created (for a receipt), or is payment_request.paid sufficient?