# Connect a custom CMS (/guides/custom-cms)

For Shopify, use the [native integration](/second-purchase/shopify) and the guided **Second purchase** program. For another CMS or a custom store, your backend can synchronize contacts, send events and trigger custom workflows through Bitelio's existing API.

This guide is a custom integration, not a WooCommerce or PrestaShop plugin. It does not import an order ledger or catalog, enable Shopify's Thank You block, or populate the guided Second purchase program. Your CMS remains responsible for deciding which orders are paid, delivered, cancelled, refunded or repeat purchases.

## 1. Prepare your integration

Use a separate Bitelio project and synthetic contacts while building. Start with no active workflows or outgoing webhooks. The requests below write real contact and event records; event tracking has no dry-run flag, and a test-mode secret key does not put `/v1/track` into test mode.

Keep a durable queue or outbox in your backend. Verify incoming CMS webhooks, deduplicate order changes, process each customer's changes in order and record Bitelio's response IDs. Decide what counts as an eligible first purchase and a second purchase in your own store, including guest identity, subscriptions, test orders, cancellations and refunds.

| Operation                                     | Authentication                            | Purpose                                    |
| --------------------------------------------- | ----------------------------------------- | ------------------------------------------ |
| `POST /v1/contacts`, `PATCH /v1/contacts/:id` | Secret key (`sk_*`) with `contacts:edit`  | Maintain contact data and consent          |
| `POST /v1/track`                              | Public key (`pk_*`) from the same project | Record a custom event and notify workflows |

Run these calls from your server. Never put a secret key in browser code, a theme or a public repository. `/v1/track` accepts **only the public key**: using it on a server does not make it secret or prove that a purchase happened. Anyone holding that public key can submit events, so these events must not authorize refunds, rewards, access or other financial actions.

## 2. Register the customer without inventing consent

This example registers a new customer without marketing consent:

```bash
curl -X POST https://api.bitelio.com/v1/contacts \
  -H "Authorization: Bearer sk_YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "buyer@example.com",
    "subscribed": false,
    "data": {
      "cms_order_count": 1,
      "cms_repurchase_eligible": false
    }
  }'
```

The response is the contact itself, with its `id` at the top level: `201` for a new contact, `200` for an existing one. Save that ID against the customer in your CMS.

This endpoint upserts by email. Explicit `subscribed: false` also unsubscribes an existing contact, so use this exact example only for an initial customer without consent. For subsequent order updates, omit `subscribed` to preserve the customer's current choice. Send `subscribed: true` only after a genuine, recorded opt-in; a purchase is not permission to reverse an unsubscribe. See [consent imports](/guides/importing-consent) and [double opt-in](/recipes/double-opt-in).

## 3. Update eligibility, then record delivery

When your CMS has confirmed that the first qualifying order is delivered and there is no disqualifying incident, update the contact using the ID returned above:

```bash
curl -X PATCH https://api.bitelio.com/v1/contacts/CONTACT_ID \
  -H "Authorization: Bearer sk_YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data":{"cms_repurchase_eligible":true}}'
```

Then record one event for that delivery:

```bash
curl -X POST https://api.bitelio.com/v1/track \
  -H "Authorization: Bearer pk_YOUR_PUBLIC_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cms:first-delivered:order-1001:v1" \
  -d '{
    "event": "cms.first_order.delivered",
    "email": "buyer@example.com",
    "data": {"order_id":{"value":"order-1001","persistent":false}}
  }'
```

Send this only after the contact has been created successfully. Tracking can create a missing contact, and new contacts default to subscribed if you omit `subscribed`. Here omission preserves the existing contact's choice. The `persistent: false` wrapper keeps the order ID in this event without adding it to the contact's fields.

Use your own namespace, such as `cms.*`. Do not imitate system events under `shopify.*`, `pixel.*` or `email.*`; the public tracking API rejects them.

When a second order, cancellation, refund or other exclusion occurs, set `cms_repurchase_eligible` to `false` before recording its event. Keep that state current while a workflow is waiting. Bitelio does not derive it from custom order payloads.

## 4. Build the custom workflow

In **Workflows**, create a disabled workflow with this sequence:

1. Trigger: `cms.first_order.delivered`.
2. Delay: your chosen interval after delivery.
3. Condition: current contact data `data.cms_repurchase_eligible` equals `true`.
4. Yes: send a reviewed **Marketing** template with a fixed link to your store. No: exit.

Keep re-entry disabled if the customer should enter this workflow only once. Check the current `data.*` field, not the original `event.*` snapshot. A `WAIT_FOR_EVENT` step only listens while it is waiting; it does not search past purchases. Marketing sends also check subscription and topic preferences.

Workflow editing requires `workflows:edit`; manually starting and cancelling executions require `workflows:publish`. Use the account's existing permissions. The custom workflow does not use the Retention program's execution switch, so that switch is not a sandbox for this integration.

To stop work already in progress, cancel its pending executions. Disabling the workflow only prevents new executions. A condition check and a later send are not an atomic order check, so an urgent cancellation should also cancel the pending execution from your backend or the dashboard.

## 5. Measure the second purchase you report

When the CMS confirms that this customer has made their **second** qualifying purchase, set eligibility to `false` and send the following event once. Do not send it for every later purchase:

```bash
curl -X POST https://api.bitelio.com/v1/track \
  -H "Authorization: Bearer pk_YOUR_PUBLIC_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cms:second-paid:order-1002:v1" \
  -d '{
    "event": "cms.second_order.paid",
    "email": "buyer@example.com",
    "data": {"order_id":{"value":"order-1002","persistent":false}}
  }'
```

In **Settings → Data → Conversion goals**, create a goal named exactly `cms.second_order.paid`, then attach it to the workflow. Leave the amount field empty to count conversions without money. Creating goals requires `settings:manage`, attaching them to workflows requires `workflows:edit`, and reading results requires `analytics:view`.

These are conversions reported by your integration, dated when Bitelio receives the event. Historical timestamps in the payload do not backdate them. Attribution credits a recent click or open; it does not establish that the message caused the purchase. If you add amount and currency fields, the reported value is **gross revenue**, without automatic refund reversal, costs or margin. With non-persistent wrappers, the goal paths must include `.value`, for example `amount.value` and `currency.value`.

A [conversion-goal holdout](/guides/holdouts) can compare sending against not sending without Shopify. It withholds eligible marketing across the **project**, not just this workflow, and needs an explicit measurement plan. Do not enable one merely to test this guide.

## 6. Validate and handle retries

* Test only in the isolated project, with synthetic contacts and no active outgoing webhooks. Keep workflows disabled to inspect stored events first. To test execution, use a temporary workflow with conditions and an exit step only, without email, SMS or webhook actions.
* Confirm a duplicate event request returns `409` with `IDEMPOTENCY_KEY_REUSED`, and store the IDs from the first successful response. Keys are scoped to the project and retained for 24 hours by default; they are not permanent order deduplication and do not replay the first response.
* Keep your own durable deduplication. A timeout or `5xx` may have happened after a write; do not generate a new key and blindly repeat it. Reconcile the contact/event history first. Respect `Retry-After` on `429`, use bounded retries and avoid unbounded contact imports.
* A `200` tracking response confirms the recorded event, not successful email delivery. Check workflow executions and email logs separately.
* Before enabling the real workflow, verify the sending domain, consent rules, billing limits and current eligibility updates. Review the message and activate only after the integration checks pass.

The separate dashboard endpoint `/events/track` takes `name` and contact IDs rather than this payload. It does not provide the tracking endpoint's idempotency or rate-limit contract; it is not a drop-in replacement.

See [event declarations](/guides/declaring-events), [idempotency](/guides/idempotency), [workflows](/concepts/workflows) and [conversion goals](/guides/conversion-goals) for the individual contracts.
