> For the complete documentation index, see [llms.txt](https://docs.wandreferrals.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.wandreferrals.com/integrations-and-api-production-rollout-gate-pending/reference.md).

# API reference

> **Production status:** The public API rollout is not approved. The repository does not yet enforce a universal production kill switch for every API route, so deployment configuration alone must not be treated as a safety boundary. This reference documents the implemented preview surface for authorized validation only. Production use requires a server-side gate, verified denial evidence, review, and a separate rollout approval.

The Wand Referrals public REST API lets a merchant read and manage their own affiliates, programs, commissions, payouts, coupons, analytics, click journeys, and outbound webhooks — and report server-side conversions from a headless or non-Shopify checkout.

* **Base URL:** `https://wandreferrals.com`
* **All paths are under** `/api/v1/*`
* **Machine-readable spec:** [`openapi.yaml`](https://github.com/wand-referrals-app/wand-referrals/tree/codex/gitbook-docs-foundation/docs/api/openapi.yaml) (OpenAPI 3.1.0 — the canonical source)
* **Legacy quick-start HTML (no auth, partial and non-canonical):** `GET https://wandreferrals.com/api/v1/docs` (it omits `/events` and `/journey`; use this reference and `openapi.yaml` for the complete contract)
* **Source of truth:** the route code in `app/routes/api.v1.*.tsx` + `app/api-auth.server.ts`

This page is the human-readable companion to `openapi.yaml`. When the two ever disagree, the route code wins — file a `BACKLOG.md` row.

***

## Authentication

Every endpoint except `/api/v1/docs` requires an HTTP Bearer token:

```
Authorization: Bearer <api_key>
```

* Create, rotate, and revoke scoped API keys on the merchant admin **Developer** page. Each key is per shop and stored hashed at rest as an `ApiKey.keyHash` row by `app/models.api-keys.server.js`.
* The tenant (`shopId`) is **derived from the key server-side** — it is never accepted from the client. You cannot read or write another shop's data with your key.
* A missing or malformed `Authorization` header returns `401`. A key that does not resolve to a shop on a qualifying plan returns `403`.

Keys are matched by a SHA-256 hash in constant time (`app/api-auth.server.ts`). Treat the key like a password: it grants the key's configured scopes within your shop.

***

## Plans & access

API access (`API_ACCESS`) is a **Professional-plan** feature — see `app/plan-gates.js`. The four offered tiers are Free, Starter, Growth, and Professional. **Enterprise is a legacy, grandfathered tier that is no longer sold** (it survives only so existing subscriptions are not stranded).

Access level and rate limit are enforced in `app/api-auth.server.ts`:

| Plan                    | API access | Methods         | Rate limit    |
| ----------------------- | ---------- | --------------- | ------------- |
| Free / Starter / Growth | No         | —               | —             |
| Professional            | Yes        | Full read/write | 100 req/min   |
| Enterprise (legacy)     | Yes        | Full read/write | 1,000 req/min |

Professional is the sold API tier and supports every documented method. Grandfathered Enterprise shops retain full access and their existing higher rate limit for backward compatibility; Enterprise is not a required upgrade target.

***

## Rate limiting

A fixed 60-second window is enforced per API key, shared across all app instances (counters live in the `ApiRateLimit` table). Every response carries:

| Header                  | Meaning                                     |
| ----------------------- | ------------------------------------------- |
| `X-RateLimit-Limit`     | Max requests allowed in the current window. |
| `X-RateLimit-Remaining` | Requests remaining in the current window.   |
| `X-RateLimit-Reset`     | Seconds until the window resets.            |

Exceeding the quota returns `429` with the same headers. If the rate-limit store is briefly unreachable, the request is allowed (fail-open) rather than 500'd.

***

## Response conventions

* **List endpoints** return a paginated envelope:

  ```json
  { "data": [ ... ], "pagination": { "page": 1, "limit": 20, "total": 42, "totalPages": 3 } }
  ```

  Pagination params: `page` (1-based, default 1) and `limit` (1–100, default 20).
* **Single-entity mutations** return `{ "data": { ... } }`.
* **Action endpoints** return `{ "ok": true, "message": "..." }` (sometimes with an `id`).
* **Errors** return `{ "error": "human-readable message" }` with the appropriate status code.
* **Money is always integer cents** (`*Cents` fields). Never a float.
* Enum values mirror `prisma/schema.prisma`.

### Common status codes

| Code  | Meaning                                                                                      |
| ----- | -------------------------------------------------------------------------------------------- |
| `200` | OK                                                                                           |
| `201` | Created                                                                                      |
| `202` | A non-money click/page-view event was accepted after click-journey persistence succeeded     |
| `400` | Bad request — missing/invalid params or body                                                 |
| `401` | Missing or malformed `Authorization` header                                                  |
| `403` | Unknown/revoked credential, qualifying-plan gate, or API-key scope gate                      |
| `404` | Resource not found for this shop                                                             |
| `405` | Method not allowed (e.g. `GET /api/v1/events`)                                               |
| `409` | Conflict — duplicate resource, or a state that cannot be re-applied                          |
| `429` | Rate limit exceeded                                                                          |
| `503` | Required persistence or audit is unavailable; retry when the endpoint documents retry safety |

***

## Endpoints

Every path is relative to `https://wandreferrals.com`. "Program" is the merchant-facing name for the `Campaign` model; "commission"/"sale" is the `OrderCommission` model.

### Affiliates — `/api/v1/affiliates`

Source: `app/routes/api.v1.affiliates.tsx`.

| Method   | Purpose                                      | Plan          |
| -------- | -------------------------------------------- | ------------- |
| `GET`    | List affiliates (paginated, filterable)      | Professional+ |
| `POST`   | Create an affiliate                          | Professional+ |
| `PATCH`  | Update whitelisted affiliate fields (`?id=`) | Professional+ |
| `DELETE` | Archive (soft-delete) an affiliate (`?id=`)  | Professional+ |

**`GET` query params:** `page`, `limit`, `status` (`PENDING`/`ACTIVE`/`APPROVED`/`REJECTED`), `campaignId`, `q` (case-insensitive search over name/email/firstName/lastName).

**`POST` body:** requires `email`, plus either `name` or `firstName`; optional `lastName`, `campaignId`. A slug and an 8-character discount code (max 9 chars, never containing `0`/`o`/`O`) are generated automatically. New affiliates start `PENDING`. The optional program is tenant-verified, and creation plus its PII-minimized audit commit atomically. A uniqueness race returns `409`; an unavailable required audit returns `503` and rolls the create back. Success returns `201`.

**`PATCH`** takes the affiliate id as the `id` **query parameter** (not in the path). Whitelisted fields: `name`, `firstName`, `lastName`, `email`, `phone`, `website`, `status`, `campaignId`, `instagramHandle`, `tiktokHandle`, `youtubeHandle`, `homeCountry`, `homeState`, `homeCity`, `homePostalCode`, `homeAddress`. Send only the fields that change. Duplicate or stale writes return `409`; unavailable persistence or required audit recording returns `503`.

**`DELETE`** sets `deletedAt` (data is preserved but hidden); returns `{ ok, message }`. A stale concurrent archive returns `409`; unavailable persistence or required audit recording returns `503`.

```bash
# List active affiliates in a program
curl -s "https://wandreferrals.com/api/v1/affiliates?status=ACTIVE&campaignId=clcamp1" \
  -H "Authorization: Bearer $WAND_API_KEY"

# Create an affiliate (Professional+ key)
curl -s -X POST https://wandreferrals.com/api/v1/affiliates \
  -H "Authorization: Bearer $WAND_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Jane Doe","email":"jane@example.com","campaignId":"clcamp1"}'
```

### Programs — `/api/v1/programs`

Source: `app/routes/api.v1.programs.tsx`. A "program" is the `Campaign` model.

| Method  | Purpose                                    | Plan          |
| ------- | ------------------------------------------ | ------------- |
| `GET`   | List programs (each with `affiliateCount`) | Professional+ |
| `POST`  | Create a program                           | Professional+ |
| `PATCH` | Update whitelisted program fields (`?id=`) | Professional+ |

**`GET` query params:** `page`, `limit`, `status` (`DRAFT`/`PRIVATE`/`PUBLISH`).

**`POST` body:** requires `name`; optional `description`, `commissionType` (`PERCENT_OF_SALE`/`FLAT_PER_ORDER`/`FLAT_PER_ITEM`, default `PERCENT_OF_SALE`), `commissionPercent` (positive commission value, default 16), `cookieDurationDays` (default 30), `autoApproveAffiliate` (default false), `autoApproveOrder` (default false), and `status` (default `DRAFT`). For `PERCENT_OF_SALE`, `commissionPercent` is a percentage capped at 100 and is stored as percentage basis points. For either flat type, the same backward-compatible field carries an amount in shop-currency units and is stored as integer cents in `defaultCommissionBps`; persisted `commissionPercent` is zero. Values must fit the stored signed integer-cents range. The first program created for a shop becomes its default program.

**`PATCH`** (`?id=`): same whitelisted fields plus `lifetimeCommission`, `discountEnabled`, `discountType` (`PERCENT`/`FIXED`), `discountValue`. Commission values follow the same type-dependent percentage/flat rules as `POST`; partial input is validated against the persisted program and `defaultCommissionBps` is synchronized whenever the commission type or value changes. A concurrent program change returns `409`; an audit-persistence failure rolls the mutation back and returns `503`.

### Commissions — `/api/v1/commissions`

Source: `app/routes/api.v1.commissions.tsx`. Read-only.

| Method | Purpose                                        | Plan          |
| ------ | ---------------------------------------------- | ------------- |
| `GET`  | List order commissions (paginated, filterable) | Professional+ |

Commission amounts are computed only by the deterministic runtime engine (`app/models.commissions.runtime.server.js`) — they **cannot be written via this API**.

**Query params:** `page`, `limit`, `status` (see `CommissionStatus` below), `affiliateId`, `from`, `to` (ISO-8601, inclusive; filter on `happenedAt`).

Each row includes `commissionBaseCents`, `commissionRateBps`, `commissionAmountCents`, `taxWithheldCents`, the order money breakdown (`orderSubtotalCents`, `orderDiscountCents`, `orderShippingCents`, `orderTaxCents`), `trackingMethod`, `attributionMethod`, `orderStatus`, and `happenedAt`.

### Payouts — `/api/v1/payouts`

Source: `app/routes/api.v1.payouts.tsx`.

| Method  | Purpose                                    | Plan          |
| ------- | ------------------------------------------ | ------------- |
| `GET`   | List payout batches (with computed totals) | Professional+ |
| `POST`  | Create a batch from approved commissions   | Professional+ |
| `PATCH` | Mark a batch paid (`?id=&action=pay`)      | Professional+ |

**`GET` query params:** `page`, `limit`, `status` (`OPEN`/`PROCESSING`/`PAID`/`FAILED`). The read returns `503` if protected-customer-data access, the payout read, or the required completion audit cannot be recorded safely.

**`POST` body:** requires `periodStart` and `periodEnd` as ISO 8601 dates (`YYYY-MM-DD`) or timestamps, with `periodEnd` after `periodStart`. Both bounds are inclusive instants; a date-only value is parsed as UTC midnight. To include a whole UTC calendar month, for example, send `2026-03-01T00:00:00.000Z` through `2026-03-31T23:59:59.999Z`. A batch is assembled from **approved, eligible, un-batched** commissions whose `happenedAt` falls in that interval. Returns `404` if no such commissions exist. Returns `409` if eligible commissions are below the resolved minimum payout threshold, legacy threshold settings require reconciliation, the total falls below the threshold after a concurrent claim, eligible commissions contain mixed currencies, or a concurrent batch claims the candidates before this request can assign them.

**`PATCH`** (`?id=&action=pay`): marks the batch and every commission in it `PAID`. `action` must be `pay` (the only supported action). Idempotency-safe: re-marking an already-`PAID` batch returns `409`.

### Coupons — `/api/v1/coupons`

Source: `app/routes/api.v1.coupons.tsx`. A coupon is an affiliate's `discountCode` joined with its program's discount config.

| Method | Purpose                                        | Plan          |
| ------ | ---------------------------------------------- | ------------- |
| `GET`  | List affiliate coupons (paginated, filterable) | Professional+ |
| `POST` | Assign a coupon to an affiliate                | Professional+ |

**`GET` query params:** `page`, `limit`, `affiliateId`, `code` (case-insensitive substring), `status` (affiliate status).

**`POST` body:** requires `affiliateId`; optional `code`. If `code` is omitted, a random 8-char code is generated. A provided code must be ≤ 9 chars, must not contain `0`/`o`/`O`, and must be unique within the shop. The assignment conditionally claims the current tenant affiliate and commits with its required audit. Reassigning the same explicit code is a no-op. Duplicate or stale assignments return `409`; an unavailable required audit returns `503` and rolls back the code change.

### Analytics — `/api/v1/analytics`

Source: `app/routes/api.v1.analytics.tsx`.

| Method | Purpose                                              | Plan          |
| ------ | ---------------------------------------------------- | ------------- |
| `GET`  | Aggregated totals + daily breakdown + top affiliates | Professional+ |

**Query params:** `from`, `to` (ISO-8601; default last 30 days), `affiliateId`, `programId`.

Returns `totals` (orders, `revenueCents`, `commissionCents`, `approvedCommissionCents`, `pendingCommissionCents`, clicks, `activeAffiliates`, `conversionRate`), a `dailyBreakdown` array, and `topAffiliates` (top 10 by commission). Note: `page`/`limit` are accepted but the current implementation returns the full aggregation (up to 10,000 commissions) rather than paging it.

### Journey — `/api/v1/journey`

Source: `app/routes/api.v1.journey.tsx`. Ordered click-journey timeline from `ClickJourney`.

| Method | Purpose                                                 | Plan          |
| ------ | ------------------------------------------------------- | ------------- |
| `GET`  | Click-journey timeline for a session/affiliate/customer | Professional+ |

**Query params:** at least one of `sessionId`, `affiliateId`, or `customerEmail` is **required**; plus `from`, `to` (ISO-8601; default last 90 days), `limit` (1–500, default 100). Returns `{ journey: [...], total }`.

### Webhooks — `/api/v1/webhooks`

Source: `app/routes/api.v1.webhooks.tsx`. Zapier/Make-compatible outbound webhook subscriptions. See the full delivery + signature-verification guide in [`webhooks.md`](/integrations-and-api-production-rollout-gate-pending/webhooks.md).

| Method   | Purpose                                               | Plan          |
| -------- | ----------------------------------------------------- | ------------- |
| `GET`    | List subscriptions + available event types            | Professional+ |
| `POST`   | Subscribe an endpoint (or fire a test with `?test=1`) | Professional+ |
| `DELETE` | Unsubscribe by id (`?id=`)                            | Professional+ |

**`POST` body:** requires a public-HTTPS `url` (may also be sent as `hookUrl` or `target_url`). Optional `events` (array; empty = all), `secret` (signing secret, encrypted at rest), and `source` (origin label, default `api`). Subscription/removal conditionally claims the exact tenant settings blob and requires a URL/secret-free audit in the same transaction: concurrent settings changes return `409`, while unavailable persistence/audit returns `503`. With `?test=1`, a JSON body with a valid public-HTTPS `url` is still required and validated, although no subscription is created and the test is not dispatched directly to that URL. A generic `test` event is fired to existing subscriptions whose `events` array is empty or explicitly contains `test`. Although `test` is not advertised by `available_events`, the current subscription API accepts arbitrary filter strings, including `test`. Other filtered subscriptions do not receive it. The route still returns `{ ok: true, message: "Test event fired" }` when no subscription is eligible, so confirm receipt at an authorized temporary empty-filter or explicit-`test` endpoint.

### Events (server-side conversions) — `/api/v1/events`

Source: `app/routes/api.v1.events.tsx`. `POST`-only (`GET` returns `405` with `Allow: POST`).

| Method | Purpose                                             | Plan          |
| ------ | --------------------------------------------------- | ------------- |
| `POST` | Report a server-side conversion / click / page view | Professional+ |

Lets a headless storefront, server, or OMS report a conversion **without** a native Shopify `orders/paid` webhook. Conversions funnel into the **same deterministic commission runtime** (`processOrderPaidWebhookRuntime`) as Shopify orders — there is no parallel money path.

The body's `type` selects behavior:

* **`type: "conversion"`** (default) — money-bearing. **Requires an `Idempotency-Key` header** (a missing key returns `400`). The `externalOrderId` is namespaced server-side as `api:<externalOrderId>`, making the underlying commission upsert idempotent on `@@unique([shopId, shopifyOrderId])`. Returns `201`.
* **`type: "click"` / `type: "page_view"`** — non-money. The route returns `202` after click-journey persistence succeeds. An affiliate lookup or persistence failure returns `503` with a fixed, data-free diagnostic, and callers may retry that unavailable response.

**Conversion body:** requires `externalOrderId` (alias `orderId`). Provide either `amountCents` or `lineItems` (line-item total stands in for `amountCents`). Money fields are integer cents: `amountCents`, `discountCents`, `shippingCents`, `taxCents`. Attribution hints map to the same evidence the Shopify path uses:

| Field                          | Attribution evidence                       |
| ------------------------------ | ------------------------------------------ |
| `couponCode` / `affiliateCode` | COUPON                                     |
| `ref`                          | DIRECT\_LINK                               |
| `clickId`                      | DIRECT\_LINK (via `wr_ref` note attribute) |
| `customerEmail` / `customerId` | CONNECTED\_CUSTOMER                        |
| `lineItems`                    | CONNECTED\_PRODUCT                         |

`lineItems[].productId` and `lineItems[].variantId` must be the numeric Shopify ID strings used by order webhook payloads and Wand's stored product connections/rules (for example, `"1234567890"`). Do not send GraphQL GIDs such as `gid://shopify/Product/...`; they are not normalized by this adapter and will not match connected-product attribution or product commission rules.

Optional: `currency` (default `USD`), `shipping` (`country`/`state`/`city`/`postalCode` for geo eligibility), `occurredAt` (alias `processedAt`), `orderName`, `note`, `noteAttributes`.

**Idempotency (conversion only):**

* same key + same body → the cached response is replayed with an `Idempotent-Replayed: true` header, preserving the original status code;
* same key + different body → `409`;
* validation failures (`400`) are **not** cached, so a corrected retry with the same key succeeds.

The `201` response reflects the engine's decision: `attribution` (`affiliateId`, `method`, `confidence`, `reasonCode`) and `commission` (`eligible`, `affiliateId`, `amountCents`, `status`, `restrictionReasonCode`).

```bash
curl -s -X POST https://wandreferrals.com/api/v1/events \
  -H "Authorization: Bearer $WAND_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-9001-attempt-1" \
  -d '{
    "type":"conversion",
    "externalOrderId":"9001",
    "amountCents":12000,
    "currency":"USD",
    "couponCode":"JANE25",
    "customerEmail":"buyer@example.com",
    "shipping":{"country":"US","state":"CA","city":"San Francisco","postalCode":"94102"}
  }'
```

### Docs — `/api/v1/docs`

Source: `app/routes/api.v1.docs.tsx`. `GET` returns the HTML docs page. **No auth required.** Always `200`.

***

## Enum reference

Mirrored from `prisma/schema.prisma`.

| Enum                                                | Values                                                                                               |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `AffiliateStatus`                                   | `PENDING`, `ACTIVE`, `APPROVED`, `REJECTED`                                                          |
| `OrderCommissionStatus` (returned as `orderStatus`) | `REJECTED`, `PENDING`, `APPROVED`, `ACCEPTED`, `PAYABLE`, `PAID`, `REVERSED`, `CANCELED`, `REFUNDED` |
| `CampaignStatus` (program status)                   | `DRAFT`, `PRIVATE`, `PUBLISH`                                                                        |
| `PayoutBatchStatus`                                 | `OPEN`, `PROCESSING`, `PAID`, `FAILED`                                                               |
| `CommissionType`                                    | `PERCENT_OF_SALE`, `FLAT_PER_ORDER`, `FLAT_PER_ITEM`                                                 |
| `DiscountType`                                      | `PERCENT`, `FIXED`                                                                                   |

***

## Keeping this reference honest

This document and `openapi.yaml` must ship with any change to `app/routes/api.v1.*.tsx` or `app/api-auth.server.ts`. `openapi.yaml` is the canonical, machine-readable source; validate it with:

```bash
npx @redocly/cli lint docs/api/openapi.yaml
```

Maintainers validate this reference and the OpenAPI document through the repository's normal pull-request checks. The public documentation intentionally excludes internal maintenance policy.
