Skip to content
Lapis
Esc
navigateopen⌘Jpreview
On this page

Ecommerce & Shopify

Send order_created, checkout_started, and items_added for online stores, with the Shopify webhook pattern.

This is the playbook for online stores: which events to send, how to build stable event IDs from your order system, and the specific patterns for Shopify and custom checkouts.

Event priority

Implement these in order. Each row is useful on its own, so you can stop after any of them.

Priority Event Fires when Why it matters
1 order_created Payment confirmed The conversion your ads optimize toward. Gives you revenue attribution per campaign and ad.
2 checkout_started Checkout begins Diagnoses your funnel. High clicks plus high checkouts but low orders points to payment or shipping friction, not ad quality.
3 items_added Add to cart An earlier intent signal. Useful when order volume is too low to compare ads directly.
4 contents_viewed Product detail view Only worth it at high traffic volumes. page_viewed already comes free from the browser pixel.

order_created, the one that matters

Send it server-side, after payment is confirmed, from your order webhook or payment success handler. Never rely on the thank-you page alone, because ad blockers, closed tabs, and prefetches all corrupt browser-only purchase tracking.

{
  "event_id": "order_created:4211",
  "event_type": "order_created",
  "lapis_click_id": "<from the lapis_sid cookie, stored on the order>",
  "value": 64.98,
  "currency": "USD",
  "properties": {
    "order_number": "4211",
    "item_count": 2
  }
}

Event ID convention: order_created:<your_order_id>. Your order ID is already unique and stable, so retries and duplicate webhooks deduplicate for free.

Refunds and cancellations: do not send negative events. Lapis has no reversal event, and neither does OpenAI. Track refund rate separately in your own reporting; the ad-level signal is still valid.

Subscriptions sold as products (subscribe-and-save): use order_created for the initial purchase. Only use subscription_created when the subscription itself is the product (see the SaaS guide).

Shopify

The reliable pattern is cart attribute, then order note attribute, then webhook. Shopify carries cart attributes onto the order automatically, which solves click-ID persistence without touching your checkout (which Shopify locks down anyway).

1. Theme: capture the click ID into a cart attribute

Add this to layout/theme.liquid, below the Lapis pixel snippet:

<script>
(function () {
  function getLapisClickId() {
    var cookie = document.cookie.split("; ").find(function (entry) {
      return entry.indexOf("lapis_sid=") === 0;
    });
    if (!cookie) return null;
    try {
      var session = JSON.parse(
        decodeURIComponent(cookie.split("=").slice(1).join("="))
      );
      return session.sid || null;
    } catch (e) { return null; }
  }

  var clickId = getLapisClickId();
  if (!clickId) return; // not a tracked ad visit, so never invent one

  fetch("/cart/update.js", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ attributes: { lapis_click_id: clickId } }),
  }).catch(function () {});
})();
</script>

2. Backend: send the conversion from the orders/create webhook

// POST /webhooks/shopify/orders-create
app.post("/webhooks/shopify/orders-create", async (req, res) => {
  res.sendStatus(200); // ack Shopify first, because it retries on slow responses

  const order = req.body;
  const attrs = Object.fromEntries(
    (order.note_attributes || []).map((a) => [a.name, a.value])
  );
  if (!attrs.lapis_click_id) return; // organic order, so skip

  await fetch("https://api.trylapis.com/api/v1/pixel/server-events", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.LAPIS_SERVER_EVENT_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      event_id: `order_created:${order.id}`,
      event_type: "order_created",
      lapis_click_id: attrs.lapis_click_id,
      value: parseFloat(order.total_price),
      currency: order.currency || "USD",
      properties: { order_number: String(order.order_number) },
    }),
  });
});

Tip: Shopify retries webhooks aggressively. Because the event ID is derived from order.id, every retry deduplicates and you never double-count.

checkout_started on Shopify

Use the checkouts/create webhook with event_id: "checkout_started:" + checkout.token. The cart attribute is already present on the checkout object.

Custom checkout / headless

Same shape, different plumbing:

  1. Read the lapis_sid cookie when the checkout session is created, and store the sid on your checkout or order record.
  2. On payment success (the Stripe webhook payment_intent.succeeded, or your equivalent), send order_created with an event_id derived from your order primary key.
  3. For checkout_started, fire when your checkout session is created, keyed by the session ID.

Cross-domain checkouts (store on shop.example.com, checkout on a payment provider domain): pass the click ID into the checkout-session metadata when you create it (for example, Stripe’s metadata.lapis_click_id) and read it back in the webhook.

What good looks like

After a week of live traffic, the funnel for a single ad group should look roughly like this. Read it left to right: each stage is the count that reached that step.

sessions → page_views → items_added → checkout_started → order_created
  1,000  →    2,400   →     140     →       90         →      31

A big drop between checkout_started and order_created is a checkout problem, not an ads problem. No items_added at all but healthy orders means your theme snippet is not firing. Check that it sits below the pixel script and that you republished the site.

Was this page helpful?