Skip to content
← ALL WRITING

2026-04-22 / 10 MIN READ

CAPI for subscription commerce without double counting

A tutorial on capi subscription commerce. How to fire new-acquisition and rebill events so Meta optimizes against the right DTC cohort, not rebills.

If you run a subscription DTC brand and every rebill fires as a Purchase event to Meta, your ad algorithm is optimizing against rebills. The budget goes to cohorts that generate lots of renewals, not cohorts that generate net-new customers.

This tutorial shows how to wire capi subscription commerce so Meta sees what you want it to see: one Purchase per new customer, one SubscriptionRebill per renewal, and a clean dedup boundary between them.

What we are building

By the end of this walkthrough you will have an event map that fires four distinct Meta events across the subscription lifecycle: Purchase for first-order acquisition, SubscriptionRebill for renewals, SubscriptionChange for upgrades and downgrades, and SubscriptionCancel for churn.

Each event carries a unique event_id namespace, so dedup against the browser pixel cannot collapse a rebill into a first-order event. Match quality holds per event type. And Meta's optimization surface gets a clean signal about which cohort it is optimizing against.

I wired this exact flow on a Shopify DTC brand during the Q2 2024 rebuild documented in the tracking gap engagement. The pattern generalizes to any Recharge-style subscription platform.

Prerequisites

You need five things in place before this tutorial will work.

Server-side CAPI live and verified. If you do not have a GTM server container firing clean Purchase events on first orders, start with the field guide to Meta CAPI and come back. This walkthrough assumes you already have match quality above 8 on your existing Purchase events.

Shopify Flow access, or a serverless endpoint you can point Shopify webhooks at. Either works. Flow is simpler for the common path; a serverless endpoint gives you more control if your logic gets custom.

Recharge webhook access. Same pattern applies to Bold, Stay.ai, or any subscription platform that fires its own rebill webhook. I will use Recharge's event names as the archetype.

An event schema contract file in your repo. Every event you fire needs a typed contract so schema drift cannot silently degrade match quality. I covered the pattern in the payload mismatch postmortem.

A test Meta Pixel ID or a Test Events code so you can verify without contaminating production.

Step 1: Map your subscription lifecycle to Meta events

The core mistake I see on subscription brands: Recharge fires its charge-created webhook, and the integration layer treats it as a Purchase. Same event name as the first order. Meta cannot tell the difference.

Here is the mapping that actually works.

Subscription lifecycle momentMeta eventEvent ID namespace
First order (customer subscribes)PurchaseSHA-256(order_id + "Purchase")
Rebill (recurring charge)SubscriptionRebill (custom)SHA-256(charge_id + "SubscriptionRebill")
Upgrade or downgradeSubscriptionChange (custom)SHA-256(subscription_id + change_timestamp)
CancellationSubscriptionCancel (custom)SHA-256(subscription_id + cancel_timestamp)

Custom events are configured in Meta Events Manager under the same pixel. They show up as distinct rows and can be used as custom conversions for optimization or reporting.

Step 2: Wire the Shopify Flow and Recharge webhook triggers

Two distinct trigger sources. One for first-order Purchase, one for rebill.

first orderrebill
BrowserShopifyRechargeServerMetacheckoutorders/createpixel: PurchaseCAPI: Purchase (dedup'd)create rebill ordersubscription/chargedCAPI: SubscriptionRebill--- later ---
First-order flow fires pixel and CAPI Purchase; rebill flow has no browser fire, only a SubscriptionRebill from the server.

For first-order Purchase: subscribe to the Shopify orders/create webhook. Add a filter: only fire if the order tags or the line-item properties indicate this is NOT a Recharge rebill. Recharge marks rebilled orders with a specific tag or line-item property; check your Recharge admin for the exact string.

// Example filter in a serverless handler
export async function POST(req: Request) {
  const order = await req.json();
  const isRebill = order.tags?.includes("subscription") &&
                   order.line_items.some((li: any) => li.properties?.["_recharge_charge_id"]);

  if (isRebill) {
    return new Response("skip: handled by Recharge webhook", { status: 200 });
  }

  // Fire Purchase event to Meta CAPI
  await fireCapi({
    event_name: "Purchase",
    event_id: sha256(`${order.id}:Purchase`),
    event_source_url: order.landing_site ?? order.referring_site ?? "",
    user_data: hashUserData(order),
    custom_data: {
      currency: order.currency,
      value: parseFloat(order.total_price),
      content_ids: order.line_items.map((li: any) => li.product_id.toString()),
      content_type: "product",
    },
  });

  return new Response("ok", { status: 200 });
}

For rebill: subscribe to the Recharge subscription/charged webhook. Every firing is a rebill, by definition. Fire SubscriptionRebill as a custom event.

export async function POST(req: Request) {
  const charge = await req.json();

  await fireCapi({
    event_name: "SubscriptionRebill",
    event_id: sha256(`${charge.id}:SubscriptionRebill`),
    event_source_url: "", // rebills are not page-sourced
    user_data: hashUserData({
      email: charge.customer.email,
      phone: charge.customer.phone,
      external_id: charge.customer.shopify_customer_id?.toString(),
    }),
    custom_data: {
      currency: charge.currency,
      value: parseFloat(charge.total_price),
      subscription_id: charge.subscription_id?.toString(),
      charge_cycle: charge.orders_count ?? 1,
    },
  });

  return new Response("ok", { status: 200 });
}

Step 3: Generate event_id that prevents double-counting

The namespace is the whole trick. If your first-order and rebill events share an event_id namespace, Meta's dedup logic will collapse a rebill into the first order of the same customer.

Keep them separate by embedding the event name in the hash input.

function sha256(input: string): string {
  // pseudo-code: use your crypto library of choice
  return crypto.createHash("sha256").update(input).digest("hex");
}

// First order
const firstOrderId = sha256(`${order.id}:Purchase`);

// Rebill
const rebillId = sha256(`${charge.id}:SubscriptionRebill`);

// Change
const changeId = sha256(`${subscription.id}:${changeTimestamp}:SubscriptionChange`);

// Cancel
const cancelId = sha256(`${subscription.id}:${cancelTimestamp}:SubscriptionCancel`);

Every event_id is unique per event type and per customer action. Meta's dedup operates within an event_name, so cross-event collisions are safe; the namespace is belt and suspenders.

Step 4: Dedup against the browser pixel

The browser pixel fires Purchase on the thank-you page of the first order. The customer is on the site, the pixel fires, the server fires the same event with the same event_id, Meta dedups. Standard.

Rebills are different. The customer is not on the site when Recharge rebills them. There is no browser pixel fire. Your server is the only event source. This is by design; you do not want a browser pixel pretending a rebill happened on page load.

Make sure your browser pixel logic filters out subscription thank-you pages that appear post-rebill (for stores that email a receipt link back to the site). If the rebill customer lands on a thank-you page and the pixel fires Purchase, you just created a phantom first-order event.

Step 5: Verify in Meta Test Events

Open Meta Events Manager, go to Test Events, and paste your test_event_code into each CAPI call temporarily.

Fire a new subscription purchase through the test browser. You should see:

  • One Purchase event with user_data matched to the test customer
  • external_id present, email hashed, phone hashed
  • Match quality for this event: 8+

Fire a rebill (you can trigger this from Recharge's admin with a test charge). You should see:

  • One SubscriptionRebill event
  • No companion Purchase event
  • external_id present, match quality 8+
  • event_source_url empty (correct for server-only events)

Check match quality per event type. Meta reports match quality separately per event name. If Purchase sits at 9 and SubscriptionRebill sits at 5, the rebill path is missing PII. Add em, ph, and external_id to the rebill handler.

The CAPI implementation readiness checklist covers the full pre-cutover verification sequence. This step is the subscription-specific extension.

Common mistakes

Four things break for most subscription brands shipping this pattern.

Mistake one: firing Purchase on rebill. The Recharge webhook fires, the integration treats it as a Purchase, Meta counts it as new acquisition. The ad algorithm optimizes against the rebill cohort. Budget leaks toward creatives that happen to convert loyal renewers, not net-new customers. The fix is Step 1, but the mistake is often not the code, it is the event-name choice.

Mistake two: sharing event_id namespace. Your first-order event_id hashes order.id. Your rebill event_id hashes charge.id. If your Shopify order_id and Recharge charge_id collide by coincidence, Meta collapses them. Include the event name in the hash input. Cheap and safe.

Mistake three: missing external_id on rebill events. Match quality drops for SubscriptionRebill because the handler only passes hashed email, not external_id. Meta's matching confidence lowers, modeled conversions rise, data quality degrades. Pass the hashed Shopify customer ID on every rebill.

Mistake four: not filtering Recharge-sourced orders from the Shopify webhook. The Shopify orders/create webhook fires for both first orders and rebills (Recharge creates a real Shopify order for each rebill). If your first-order handler does not filter these out, every rebill fires both as a Purchase (from the Shopify webhook) and as a SubscriptionRebill (from the Recharge webhook). The Meta Purchase count triples inside a month.

What to try next

Once the basic flow is clean, three optional moves.

Define a custom conversion on SubscriptionRebill in Meta Events Manager. It becomes an optimization event you can use for retention campaigns targeting expiring cohorts.

Use SubscriptionRebill as the revenue source of truth for LTV dashboards. The rebill event carries charge_cycle (cycle number) and subscription_id, so you can rebuild LTV curves by acquisition cohort without touching Recharge's API.

Run a subscription-specific ad set where SubscriptionRebill events weight the audience. Meta will find customers with characteristics that correlate with rebilling, which is a different optimization target from net-new acquisition.

FAQ

Why not just fire Purchase for rebills and filter in Meta?

Because Meta's optimization algorithm uses the Purchase event as the signal, not whatever filter you apply downstream. If rebills fire as Purchase, the algorithm optimizes against them even if your reporting slices them out later. The separation has to happen at the event layer.

Do I need a custom event or can I use one of Meta's standard events for rebills?

Custom event is the right call. Meta's standard events (Purchase, Subscribe, StartTrial) are either wrong semantically (Subscribe fires at signup, not rebill) or conflict with your acquisition events. A custom SubscriptionRebill gives you a dedicated optimization and reporting surface.

What if my subscription platform does not expose a rebill webhook?

Most do (Recharge, Bold, Stay.ai). If yours does not, you can derive the rebill signal from the Shopify orders/create webhook by checking tags or line-item properties that your subscription app adds to rebilled orders. Slightly more fragile; prefer the native webhook if available.

How do I handle failed rebills?

Fire a separate SubscriptionFailed custom event from Recharge's charge/failed webhook. You can use this as a signal for win-back campaigns or for segmenting dunning audiences. Do not fire Purchase or SubscriptionRebill on failure.

Will this pattern break if I switch subscription apps?

The event names and ID namespace stay stable. Only the webhook sources change. Keep your event firing logic behind an internal function that the webhook handlers call; the handlers change per vendor, the event contract does not. This is the same pattern as a clean integration layer on any CAPI implementation.

Sources and specifics

  • Pattern wired on a Shopify + Recharge subscription brand during the Q2 2024 rebuild. See the tracking gap case study for production context.
  • Recharge webhook names (subscription/charged, charge/failed) are the 2026 API surface; same shape on Bold and Stay.ai with different event names.
  • SubscriptionRebill, SubscriptionChange, SubscriptionCancel are custom event names I use as archetypes; register them in Meta Events Manager before they will show up in the reporting surface.
  • The namespace-per-event_id pattern is drawn from the debugging postmortem on CAPI payload mismatch, where shared namespace was one of the three root causes.
  • For a scan of an existing subscription DTC's CAPI setup before wiring this, the CAPI Leak Report runs 14 checks including the subscription rebill path.

// related

DTC Stack Audit

If this resonated, the audit covers your tracking layer end-to-end. Server-side CAPI, dedup logic, and attribution gaps - all mapped to your stack.

>See what is covered