# Affiliatops API — integration guide for AI agents

> Affiliatops tracks partner (affiliate and referral) traffic and pays partners commissions on the orders, sign-ups and subscriptions they bring in. This file is a complete, self-contained reference for adding Affiliatops to an application. It is generated from the same OpenAPI document as the human docs, so it matches the live API.

- Base URL: `https://affiliatops.com`
- API version: 1.3.0
- OpenAPI 3.0 JSON: https://affiliatops.com/api/docs
- Interactive reference with a request console: https://affiliatops.com/docs

## How to use this guide

1. Pick the integration path for the business (next section).
2. Ask the user for the credentials that path needs (see Authentication). Never invent keys, and never put a secret key in browser code.
3. Implement the recipe, then work through the verification checklist.

Only the endpoints under **Integration endpoints** are meant to be called from the user's code. `/api/admin/*` and `/api/affiliate/*` power the Affiliatops dashboards and need a logged-in browser session. Don't use them for server-to-server integrations.

## Choose an integration path

| The user sells | In the browser | On the server | Recipe |
| --- | --- | --- | --- |
| Physical or digital goods (online store) | Tracker script + `Affiliatops.trackConversion()` on the order confirmation page | Optional: `POST /api/webhook/conversion` and `POST /api/webhook/refund` from the order/payment backend | 1 + 2 |
| Subscriptions billed with Stripe | Tracker script + `Affiliatops.trackSignup()` | Stripe webhook (configuration, no code) + Affiliatops metadata on Checkout Sessions | 1 + 3a |
| Subscriptions billed any other way (own billing, Xendit, Midtrans, Paddle, …) | Tracker script + `Affiliatops.trackSignup()` | `POST /api/saas/events` from the billing code | 1 + 3b |

Service businesses usually need no code: partners submit leads in their portal and the team records closed deals in the dashboard. If deals close in the user's own system, treat them like 3b (`signup`, then `payment_succeeded`).

The merchant's workspace type decides which dashboard they see: e-commerce workspaces use recipe 2, SaaS/Other workspaces use recipe 3.

## Authentication

| Credential | Format | Where the user finds it | Send it as | Used by | Browser-safe |
| --- | --- | --- | --- | --- | --- |
| Store public key | `pk_store_…` | Admin → Store integrations (e-commerce) or Admin → Integrations → Website & app (SaaS) | `X-API-Key` header, or the tracker's `data-api-key` | `/api/track/*` | Yes |
| Server secret key | `sk_store_…` | Shown **once** when a store integration (Admin → Store integrations) or a Website & app integration (Admin → Integrations) is created; Website & app keys can be rotated there | `Authorization: Bearer <key>` (or `X-API-Key`) | `POST /api/saas/events`, `/api/webhook/conversion`, `/api/webhook/refund` | **No** |
| Merchant API key | `aft_…` | Admin → API keys (shown once). Needs `write` scope for `/api/saas/events` | `Authorization: Bearer <key>` / `X-API-Key` | `/api/saas/events`, `/api/webhook/conversion`, `/api/webhook/refund` | **No** |
| Stripe signing secret | `whsec_…` | Stripe → Developers → Webhooks → the endpoint. Paste it into the Stripe integration in Admin → Integrations | Stripe signs each request (`Stripe-Signature`) | `/api/webhook/stripe/{integrationId}` | **No** |
| Webhook HMAC secret | any string | The `WEBHOOK_SECRET` environment variable of a self-hosted deployment | `X-Webhook-Signature: sha256=<hex HMAC-SHA256 of the raw body>` | `/api/webhook/conversion`, `/api/webhook/refund` (alternative to a secret key) | **No** |

Server endpoints reject the store public key with 401: it ships in browser code, so anyone can read it.

Keep credentials in environment variables, for example `AFFILIATOPS_API_URL=https://affiliatops.com`, `AFFILIATOPS_PUBLIC_KEY` (browser) and `AFFILIATOPS_SECRET_KEY` (server: an `sk_store_…` or `aft_…` key).

## Conventions

- **Money.** Stored and returned as integer minor units ×100 ("cents"): `amount_cents: 34900000` = Rp 349.000. Inputs that say `amount` take major units (`349000` = Rp 349.000, `19.99` = $19.99) and are converted with `Math.round(amount * 100)`. Check which one each field wants: mixing them up is the most common integration bug.
- **Currency** is a 3-letter ISO code. Workspaces default to `IDR`.
- **Timestamps** are ISO-8601 (UTC). `/api/saas/events` also accepts unix seconds.
- **Idempotency.** `/api/saas/events` and the Stripe webhook are idempotent per event `id`: replaying an id returns `status: "duplicate"` and changes nothing, so retries are safe. Conversions are recorded once per order id: `orderId` on `/api/track/conversion`, `external_id` on `/api/webhook/conversion`. A retry with the same id returns `duplicate: true` and the conversion already recorded. **Without an id, every call records a new sale and commission.** Refunds are safe to retry: already-reversed commissions are skipped.
- **Attribution.** The partner's referral code arrives as `?ref=CODE` (also `?referral=` / `?affiliate=`). The tracker keeps it in the first-party cookie `affiliatops_ref` for 30 days. The first referral code that resolves to an active partner owns a customer. Later codes never reassign them.
- **Unattributed traffic.** Events for customers no partner referred are acknowledged but ignored (`status: "ignored"`, `attributed: false`). That is expected, not an error.
- **Errors** are JSON: `{ "error": string }` or `{ "success": false, "message": string }` with a 4xx/5xx status. `/api/saas/events` returns `400` with `errors: [{ index, errors: string[] }]` and processes nothing when any event in the batch is invalid.
- **CORS** is open (`*`) on `/api/track/*` only. Call every other integration endpoint from a server.
- **Rate limits** apply to auth endpoints (3–5 requests/minute per IP). Batch analytics events (up to 50) and SaaS events (up to 100) per request.

## Recipe 1 — Capture the referral (every integration)

Add the tracker to every page of the marketing site **and** the app, using the same public key:

```html
<script src="https://affiliatops.com/scripts/affiliatops-tracker.js"
        data-api-key="pk_store_xxx"
        data-api-url="https://affiliatops.com"></script>
```

On load it reads `?ref=CODE`, stores it in the `affiliatops_ref` cookie, records the click (`POST /api/track/referral`) and sends page views to `/api/track/events`. Partner links look like `https://affiliatops.com/r/CODE?dest=https://shop.example.com/product`: that redirect adds `?ref=CODE` to the destination.

The tracker exposes `window.Affiliatops`:

| Method | Does |
| --- | --- |
| `getReferralCode()` | The stored referral code, or null. Pass it to your server or billing provider. |
| `trackConversion({ orderId, amount, currency, email, name, metadata })` | Records an order for the referring partner (`amount` in major units), then clears the stored code. Resolves `{ success: false, error: 'No referral code' }` without a request when the visitor wasn't referred. |
| `trackSignup({ customerId, email, name, metadata })` | Links a new account to the referring partner. Attribution only — never creates commissions. |
| `trackEvent(name, data)`, `trackProductView(product)`, `trackAddToCart(product, cart)`, `trackCheckoutStarted(cart)` | Optional analytics events. |
| `clearReferralCode()` | Forget the stored code. |

In single-page apps, call these after the tracker has loaded. Guard with `window.Affiliatops?.…`: ad blockers can stop the script.

## Recipe 2 — E-commerce orders

**Browser (simplest).** On the order confirmation page:

```js
window.Affiliatops?.trackConversion({
  orderId: order.id,            // required for retry safety: repeats are recorded once
  amount: order.total,          // major units, e.g. 349000 for Rp 349.000
  currency: 'IDR',
  email: order.customerEmail,
  name: order.customerName,
});
```

**Server (more reliable).** When an order is paid, pass the referral code captured at checkout (read it with `Affiliatops.getReferralCode()` and store it on the order), and send the conversion with the order id as `external_id`. Retrying after a timeout or a 5xx is then safe:

```ts
await fetch(`${process.env.AFFILIATOPS_API_URL}/api/webhook/conversion`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${process.env.AFFILIATOPS_SECRET_KEY}`,   // sk_store_… or aft_…, never pk_…
  },
  body: JSON.stringify({
    event_type: 'PURCHASE',
    external_id: order.id,                          // repeats return duplicate: true
    amount_cents: Math.round(order.total * 100),   // minor units ×100
    currency: order.currency,
    customer_email: order.customerEmail,
    referral_code: order.referralCode,
    coupon_code: order.voucherCode,                 // optional: the code the buyer typed
  }),
});
```

**Coupon / voucher codes.** If buyers type a partner's voucher or referral code at checkout, send it as `coupon_code` (alias `code`) on `/api/webhook/conversion` or `/api/track/conversion`, and on the `signup` (or first) event to `/api/saas/events`. It resolves, within the business only, to the partner owning an active coupon with that code (Admin → Coupons), else to the partner with that referral code; trimmed and case-insensitive. A resolved code wins over the cookie/`referral_code` and is recorded as `attributionMethod: "coupon"`. On `/api/webhook/conversion` it needs a secret key, not signature auth.

Pick one of the two per order. Each recorded conversion creates a commission (from the merchant's default commission rule) that stays PENDING through the hold period. If both paths do report the same order with the same id, the second is recognized as a duplicate.

On a refund, call `POST /api/webhook/refund` with the server secret, the customer's `customer_email` and `order_id` (the same id you sent as `orderId` or `external_id`). Only that order's commission is reversed. For a partial refund, send `amount_cents` (minor units): the commission is reversed in proportion to the refunded share, and later partial refunds add up. Omit it for a full refund. Always send the provider's refund id as `external_id` so a retried refund is ignored. This works for orders tracked in the browser or on the server. Without `order_id`, every conversion recorded for that email is reversed in full, which is wrong for repeat customers, so always send it.

## Recipe 3a — SaaS billed with Stripe

1. Call `Affiliatops.trackSignup({ customerId: user.accountId, email: user.email })` after sign-up succeeds.
2. The user creates a Stripe integration in Admin → Integrations and copies its webhook URL (`https://affiliatops.com/api/webhook/stripe/<integrationId>`) into Stripe → Developers → Webhooks, enabling `checkout.session.completed`, `customer.created`, `customer.updated`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed` and `charge.refunded`. Then they paste the endpoint's signing secret (`whsec_…`) back into the integration.
3. In code, put the account id and referral code on the Checkout Session so every Stripe event carries them:

```ts
await stripe.checkout.sessions.create({
  mode: 'subscription',
  client_reference_id: user.accountId,
  customer_email: user.email,
  metadata: { affiliatops_ref: referralCode ?? '' },
  subscription_data: {
    metadata: { affiliatops_ref: referralCode ?? '', affiliatops_customer_id: user.accountId },
  },
  line_items: [{ price: priceId, quantity: 1 }],
  success_url, cancel_url,
});
```

`referralCode` comes from `Affiliatops.getReferralCode()` in the browser, sent to your server with the checkout request. If customers are created without Checkout, set the same metadata (`affiliatops_customer_id`, `affiliatops_ref`) on the Stripe customer or subscription.

Subscription payments (`invoice.paid`) create recurring commissions; `charge.refunded` reverses them. Don't also send the same payments to `/api/saas/events`.

## Recipe 3b — SaaS with any other billing system

1. Call `Affiliatops.trackSignup(...)` in the browser after sign-up (as in 3a), **or** send a `signup` event from the server with the `referral_code` your sign-up form captured.
2. From the billing code, send each lifecycle event to `POST /api/saas/events` with the server secret key. Use a stable `id` per event (your invoice or event id) so retries are no-ops, and always identify the customer by your own account id (`customer.id`).

```ts
// lib/affiliatops.ts — server only
type SaasEvent = Record<string, unknown> & { id: string; type: string };

export async function sendAffiliatopsEvents(events: SaasEvent[]) {
  const res = await fetch(`${process.env.AFFILIATOPS_API_URL}/api/saas/events`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.AFFILIATOPS_SECRET_KEY}`,
    },
    body: JSON.stringify({ events }),
  });
  const data = await res.json();
  if (res.status === 500) throw new Error('Affiliatops: some events failed; retry with the same ids');
  if (!res.ok) throw new Error(`Affiliatops: ${res.status} ${JSON.stringify(data.errors ?? data.error)}`);
  return data.results as { index: number; status: 'processed' | 'duplicate' | 'ignored'; message?: string }[];
}

// After a successful charge
await sendAffiliatopsEvents([{
  id: invoice.id,
  type: 'payment_succeeded',
  customer: { id: account.id, email: account.email },
  subscription: { id: subscription.id },
  payment: { id: invoice.id, amount: invoice.total, currency: 'IDR' },   // amount in major units
}]);
```

| Billing moment | `type` | Include |
| --- | --- | --- |
| Account created | `signup` | `customer`, `referral_code` and/or `coupon_code` |
| Trial begins | `trial_started` | `customer`, `subscription` (with `plan`, `trial_ends_at`) |
| Subscription starts | `subscription_created` | `customer`, `subscription` (`id`, `status`, `plan`, `quantity`) |
| Upgrade, downgrade, quantity or status change | `subscription_updated` | `customer`, `subscription` |
| Subscription ends | `subscription_canceled` | `customer`, `subscription.id`, optional `cancel_reason` |
| Charge succeeds | `payment_succeeded` | `customer`, `subscription.id`, `payment` (`id`, `amount`, `currency`) |
| Charge fails | `payment_failed` | `customer`, `subscription.id`, `payment` |
| Refund | `refund` | `refund.payment_id` (the original `payment.id`), optional `amount` (omit = refund the rest) |

Recurring commissions are created on `payment_succeeded` according to the merchant's commission plan (set during onboarding; if none is saved, 15% of every payment for 12 months, held 30 days). Refunds reverse them proportionally.

## Verification checklist

1. Visit the site with `?ref=<a real partner code>` and confirm the `affiliatops_ref` cookie is set.
2. Trigger the conversion or sign-up and check the call's response: `success: true`; for SaaS events, `status: "processed"`.
3. `ignored` with `unattributed customer` means no referral code reached Affiliatops for that customer. Send `referral_code` (or the Stripe metadata) on the first event.
4. Send the same SaaS event, or the same order id, twice: the second response must say `duplicate`.
5. Check the merchant dashboard: Admin → Integrations → event log (SaaS) or Referrals/Commissions (e-commerce).
6. Use the "Try it" console at https://affiliatops.com/docs to send test requests with real keys.

## Common mistakes

- Sending `amount` (major units) where `amount_cents` (minor units ×100) is expected, or the reverse.
- Using the public key (`pk_store_…`) for `/api/saas/events` or `/api/webhook/*`: it returns 401. Use the server secret key.
- Putting `sk_store_…` or `aft_…` keys in client-side code.
- Omitting a stable `id` on SaaS events, or `orderId` / `external_id` on conversions, which makes retries double count.
- Handling both `invoice.paid` and `invoice.payment_succeeded` yourself — Affiliatops already listens to `invoice.paid` only.
- Calling `/api/admin/*` endpoints with an API key: they only accept a dashboard session cookie.

## Integration endpoints

Everything third-party code calls. Each entry is generated from the OpenAPI document.

### GET /r/{code}

**Referral short link redirect.**

Public referral link. Records attribution and 302-redirects the visitor to the destination, appending `ref` and `attr` query params and setting a first-party attribution cookie. Supports deep links via `dest`/`target`.

- Auth: None (public)
- Operation id: `referralRedirect` · Try it: https://affiliatops.com/docs#op-referralRedirect

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `code` | path | string | yes | The affiliate referral code. |
| `dest` | query | uri |  | Destination URL to deep-link to (alias: `target`). |

```bash
curl -X GET "https://affiliatops.com/r/<code>"
```

Responses:
- `302` Redirect to the destination with attribution attached

### POST /api/track/referral

**Track a referral click / page view.**

Records a referral click from your storefront. Authenticate with the store **public API key** in the `X-API-Key` header (`pk_store_…`). The referral code is resolved within the key’s merchant; inactive affiliates are rejected.

- Auth: header `X-API-Key` (ApiKeyAuth)
- Operation id: `trackReferral` · Try it: https://affiliatops.com/docs#op-trackReferral

Body: [`TrackReferralRequest`](#schema-trackreferralrequest)

```bash
curl -X POST "https://affiliatops.com/api/track/referral" \
  -H "X-API-Key: $AFFILIATOPS_PUBLIC_KEY" \
  -H "Content-Type: application/json" \
  -d '{"referralCode":"JANE-4F2A","url":"https://shop.example.com/product/42","referrer":"https://instagram.com/"}'
```

Responses:
- `200` Click tracked
- `400` Missing referral code
- `401` Missing or invalid API key
- `403` Affiliate is not active
- `404` Unknown referral code

### POST /api/track/conversion

**Track a conversion / sale.**

Records a sale attributed to a referral code or a code the buyer typed. Send `referralCode` (from the tracker cookie), `coupon_code` (alias `code`), or both. A code the buyer typed at checkout (`coupon_code`, alias `code`) resolves, within your business only, to the partner who owns an active partner coupon with that code, else to the partner whose referral code it is (trimmed, case-insensitive). When it resolves it wins over the cookie/referral code and the conversion records `attributionMethod: "coupon"`; when it does not, the referral code is used. Send `amount` in **major units** (e.g. `99.99`) — it is converted to `amountCents` server-side with `Math.round(amount * 100)`. Send `orderId`: a retry with the same `orderId` returns the conversion already recorded (`duplicate: true`) instead of counting the sale twice. Authenticate with `X-API-Key`.

- Auth: header `X-API-Key` (ApiKeyAuth)
- Operation id: `trackConversion` · Try it: https://affiliatops.com/docs#op-trackConversion

Body: [`TrackConversionRequest`](#schema-trackconversionrequest)

```bash
curl -X POST "https://affiliatops.com/api/track/conversion" \
  -H "X-API-Key: $AFFILIATOPS_PUBLIC_KEY" \
  -H "Content-Type: application/json" \
  -d '{"referralCode":"JANE-4F2A","customerEmail":"buyer@example.com","customerName":"A. Buyer","amount":99.99,"currency":"IDR","orderId":"ORD-1024"}'
```

Responses:
- `200` Conversion tracked, or `duplicate: true` when this `orderId` was already recorded
- `400` Missing referral code and coupon code
- `401` Missing or invalid API key
- `403` Affiliate is not active
- `404` Unknown referral code, or a coupon code that matches no active partner

### POST /api/track/events

**Ingest a batch of analytics events.**

Accepts up to **50** behavioral events per request (page views, product views, add-to-cart, purchase, etc.). Drives the live view and analytics dashboards. Raw emails inside `metadata` are redacted server-side. Authenticate with `X-API-Key`.

- Auth: header `X-API-Key` (ApiKeyAuth)
- Operation id: `trackEvents` · Try it: https://affiliatops.com/docs#op-trackEvents

Body: [`AnalyticsBatchRequest`](#schema-analyticsbatchrequest)

```bash
curl -X POST "https://affiliatops.com/api/track/events" \
  -H "X-API-Key: $AFFILIATOPS_PUBLIC_KEY" \
  -H "Content-Type: application/json" \
  -d '{"events":[{"visitorId":"v_8f3a","sessionId":"s_19c2","eventName":"page_view","pageUrl":"https://shop.example.com/","occurredAt":"2026-06-01T10:00:00.000Z","referralCode":"JANE-4F2A"},{"visitorId":"v_8f3a","sessionId":"s_19c2","eventName":"purchase","orderId":"ORD-1024","amountCents":250000,"currency":"IDR"}]}'
```

Responses:
- `200` Events accepted
- `400` Validation error (batch too large, missing ids, metadata > 8KB, …)
- `401` Missing or invalid API key

### POST /api/track/signup

**Attribute a sign-up to the referring partner (browser).**

Called by `Affiliatops.trackSignup()` on your sign-up page. Links the new account (`customerId` and/or `email`) to the partner in the referral cookie. Authenticated with the store **public** key, so it only records attribution — sign-up bounties and recurring commissions are created from server-verified events (`/api/saas/events` or Stripe). Repeated calls for the same account are idempotent.

- Auth: header `X-API-Key` (ApiKeyAuth)
- Operation id: `trackSignup` · Try it: https://affiliatops.com/docs#op-trackSignup

Body: [`TrackSignupRequest`](#schema-tracksignuprequest)

```bash
curl -X POST "https://affiliatops.com/api/track/signup" \
  -H "X-API-Key: $AFFILIATOPS_PUBLIC_KEY" \
  -H "Content-Type: application/json" \
  -d '{"referralCode":"JANE-4F2A","customerId":"acct_1042","email":"owner@example.com","name":"Owner Name"}'
```

Responses:
- `200` Processed. `attributed: false` when the referral code did not resolve to an active partner.
- `400` Missing referral code, or neither customerId nor email
- `401` Missing or invalid API key

### POST /api/saas/events

**Send SaaS lifecycle & billing events (server-to-server).**

Send one event, or `{ "events": [...] }` with up to **100** events processed in order. Event `type`: `signup`, `trial_started`, `subscription_created`, `subscription_updated`, `subscription_canceled`, `payment_succeeded`, `payment_failed`, `refund`. Amounts are **major units** in `amount` (converted with `Math.round(amount * 100)`) or integer `amount_cents`. Pass a stable `id` per event: replays with the same id are no-ops (`status: duplicate`). Customers are matched by `customer.id`, then `customer.billing_id`, then `customer.email`; the first code that resolves to an active partner owns the customer: `coupon_code` (alias `code`, the partner coupon or referral code the customer typed — it wins over `referral_code`), then `referral_code`. Codes are trimmed, case-insensitive and scoped to your business; a later code never reassigns an attributed customer. Events for customers with no partner attribution are acknowledged with `status: ignored`. Authenticate with the integration **secret** key (`sk_store_…`) or a merchant API key with `write` scope — public keys are rejected.

- Auth: `Authorization: Bearer` (ServerKeyAuth) **or** header `X-API-Key` (ServerKeyHeader)
- Operation id: `ingestSaasEvents` · Try it: https://affiliatops.com/docs#op-ingestSaasEvents

Body: a single [`SaasEvent`](#schema-saasevent) or `{ "events": [SaasEvent, …] }`.

```bash
curl -X POST "https://affiliatops.com/api/saas/events" \
  -H "Authorization: Bearer $AFFILIATOPS_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"id":"inv_2026_0412","type":"payment_succeeded","customer":{"id":"acct_1042","email":"owner@example.com"},"subscription":{"id":"sub_77"},"payment":{"id":"inv_2026_0412","amount":349000,"currency":"IDR"}}'
```

Responses:
- `200` All events processed
- `400` Validation failed — `errors: [{ index, errors: string[] }]`; nothing was processed
- `401` Missing/invalid server key, or a public key was used
- `500` One or more events failed (`status: error` in `results`). Retry them with the same `id`.

### POST /api/webhook/conversion

**Receive a conversion event (server-to-server).**

Server-side conversion ingestion for payment providers and backends. Authenticate with a **server secret** — the store integration secret (`sk_store_…`) or a merchant API key (`aft_…`), as `Authorization: Bearer` or `X-API-Key` — **or** an HMAC-SHA256 signature in `X-Webhook-Signature` (computed over the raw body using `WEBHOOK_SECRET`, optional `sha256=` prefix). Store public keys (`pk_…`) are rejected: they are visible in browser code. Send `external_id` (your order or invoice id): a retry with the same id returns the conversion already recorded (`duplicate: true`). A commission is created using the merchant’s default rule and held until it matures. A code the buyer typed at checkout (`coupon_code`, alias `code`) resolves, within your business only, to the partner who owns an active partner coupon with that code, else to the partner whose referral code it is (trimmed, case-insensitive). When it resolves it wins over `referral_code` and the conversion records `attributionMethod: "coupon"`; when it does not, `referral_code` is used. Coupon codes need a merchant-scoped secret key; with signature auth (platform-wide) they are not resolved.

- Auth: `Authorization: Bearer` (ServerKeyAuth) **or** header `X-API-Key` (ServerKeyHeader) **or** header `X-Webhook-Signature` (WebhookSignature)
- Operation id: `webhookConversion` · Try it: https://affiliatops.com/docs#op-webhookConversion

Body: [`ConversionWebhook`](#schema-conversionwebhook)

```bash
curl -X POST "https://affiliatops.com/api/webhook/conversion" \
  -H "Authorization: Bearer $AFFILIATOPS_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type":"PURCHASE","external_id":"ORD-1024","amount_cents":250000,"currency":"IDR","customer_email":"buyer@example.com","referral_code":"JANE-4F2A"}'
```

Responses:
- `200` Processed. `attributed: false` when no affiliate matched; `duplicate: true` when this `external_id` was already recorded.
- `400` Missing required fields
- `401` Missing/invalid server key or signature, or a store public key was used

### POST /api/webhook/refund

**Receive a refund / clawback event.**

Reverses the commission for a refunded order. Send `order_id` — the `external_id` you sent to `/api/webhook/conversion`, or the tracker’s `orderId` — to reverse only that order; it is matched within your merchant, so orders tracked in the browser are found too. Without `order_id`, every conversion recorded for `customer_email` is reversed (the response then carries a `warning`). `amount_cents` makes it a partial refund when one order matched: its commission is reversed in proportion to the refunded share, and several partial refunds add up to the whole commission. Omit it to refund what is left. PENDING commissions are reduced or cancelled (no balance impact); APPROVED ones are reduced or cancelled and taken off the balance; PAID ones stay paid for partial refunds, and the reversed part becomes a negative balance for the next payout. Send `external_id` (the provider’s refund id): a redelivered refund with the same id changes nothing. Authenticate like `/api/webhook/conversion`: a server secret (`sk_store_…` or `aft_…`) or `X-Webhook-Signature`. Store public keys are rejected.

- Auth: `Authorization: Bearer` (ServerKeyAuth) **or** header `X-API-Key` (ServerKeyHeader) **or** header `X-Webhook-Signature` (WebhookSignature)
- Operation id: `webhookRefund` · Try it: https://affiliatops.com/docs#op-webhookRefund

Body: [`RefundWebhook`](#schema-refundwebhook)

```bash
curl -X POST "https://affiliatops.com/api/webhook/refund" \
  -H "Authorization: Bearer $AFFILIATOPS_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"customer_email":"buyer@example.com","order_id":"ORD-1024","amount_cents":250000,"reason":"Customer refund","external_id":"rf_123"}'
```

Responses:
- `200` Refund processed: `reversed` count, `matchedBy` (`order_id` or `customer_email`), and a `warning` when no order_id was sent and several conversions matched
- `400` `customer_email` is required
- `401` Missing/invalid server key or signature, or a store public key was used

### POST /api/webhook/stripe/{integrationId}

**Stripe webhook endpoint (per Stripe integration).**

Point a Stripe webhook endpoint at this URL (shown on Admin → Integrations). Verified with the endpoint signing secret (`whsec_…`) saved on the integration via the `Stripe-Signature` header (5-minute tolerance). Handled events: `checkout.session.completed`, `customer.created`, `customer.updated`, `customer.subscription.created|updated|deleted`, `invoice.paid`, `invoice.payment_failed`, `charge.refunded`. Other events are acknowledged and ignored (`invoice.payment_succeeded` is ignored on purpose: it duplicates `invoice.paid`). The first verified event activates a draft integration.

Attribution comes from Stripe metadata: set `affiliatops_ref` (the partner referral code) and `affiliatops_customer_id` (your account id) on the Checkout Session, subscription or customer.

- Auth: header `Stripe-Signature` (StripeSignature)
- Operation id: `webhookStripe` · Try it: https://affiliatops.com/docs#op-webhookStripe

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `integrationId` | path | string | yes | The Stripe integration id. |

```bash
curl -X POST "https://affiliatops.com/api/webhook/stripe/<integrationId>" \
  -H "Stripe-Signature: t=$TIMESTAMP,v1=$SIGNATURE" \
  -H "Content-Type: application/json" \
  -d '{"id":"evt_test_invoice_paid","type":"invoice.paid","created":1767225600,"data":{"object":{"id":"in_test_0412","object":"invoice","customer":"cus_test_1042","customer_email":"owner@example.com","currency":"idr","amount_paid":34900000,"subscription":"sub_test_77","metadata":{"affiliatops_ref":"JANE-4F2A","affiliatops_customer_id":"acct_1042"}}}}'
```

Responses:
- `200` `{ received: true }` — with `ignored: true` for unhandled event types, otherwise per-event `results`
- `400` Invalid/missing signature, stale timestamp, or no signing secret configured
- `404` Unknown or disabled Stripe integration
- `500` Processing failed; Stripe will retry (idempotent by event id)

### POST /api/webhook/midtrans/{integrationId}

**Midtrans payment notification endpoint (per Midtrans integration).**

Set this URL as the Payment Notification URL in Midtrans (shown on Admin → Integrations). There is no header signature: the body’s `signature_key` must equal SHA512(order_id + status_code + gross_amount + ServerKey), using the server key saved on the integration. `transaction_status` `settlement`, or `capture` with `fraud_status: accept`, records a payment (externalPaymentId = `order_id`); `refund` / `partial_refund` reverses commission against that order using the cumulative `refund_amount`. `pending`, `expire`, `cancel`, `deny` and challenged captures are acknowledged and ignored. Idempotent on `order_id` + `transaction_status` (+ cumulative refund amount for refunds). `gross_amount` is a major-unit string like `349000.00`, stored as minor units ×100 (34 900 000).

Attribution: `custom_field1` = partner referral code (or `metadata.affiliatops_ref`), `custom_field2` = your user id (or `metadata.affiliatops_customer_id`), `custom_field3` = customer email. Customers already attributed via trackSignup/API are matched by id or email. The first verified notification activates a draft integration.

- Auth: None (public)
- Operation id: `webhookMidtrans` · Try it: https://affiliatops.com/docs#op-webhookMidtrans

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `integrationId` | path | string | yes | The Midtrans integration id. |

```bash
curl -X POST "https://affiliatops.com/api/webhook/midtrans/<integrationId>" \
  -H "Content-Type: application/json" \
  -d '{"transaction_time":"2026-09-24 10:15:00","transaction_status":"settlement","transaction_id":"0f1c3a9e-7b1d-4f6a-9d7e-1a2b3c4d5e6f","status_code":"200","signature_key":"<sha512 hex>","settlement_time":"2026-09-24 10:16:02","payment_type":"bank_transfer","order_id":"INV-2026-0412","gross_amount":"349000.00","currency":"IDR","fraud_status":"accept","custom_field1":"JANE-4F2A","custom_field2":"acct_1042","custom_field3":"owner@example.com"}'
```

Responses:
- `200` `{ received: true }` — with `ignored: true` for statuses that are not payments or refunds, otherwise per-event `results`
- `400` Invalid JSON, or no server key configured
- `401` Missing or wrong signature_key
- `404` Unknown or disabled Midtrans integration
- `500` Processing failed; Midtrans retries (idempotent)

### POST /api/webhook/xendit/{integrationId}

**Xendit callback endpoint (per Xendit integration).**

Point Xendit callbacks (Settings → Webhooks) at this URL (shown on Admin → Integrations). Verified by comparing the `x-callback-token` header with the callback verification token saved on the integration. Handled: invoice callbacks with status `PAID` or `SETTLED` (payment, externalPaymentId = invoice `id`; SETTLED after PAID is a no-op), `payment.succeeded` / `payment.capture` (Payments API, keyed by payment request id), `recurring.cycle.succeeded`, and `refund.succeeded` (reverses commission against `invoice_id` / `payment_request_id`). Everything else is acknowledged and ignored. Idempotent on the callback object’s `id`. Amounts are major units (IDR 349000) stored as minor units ×100.

Attribution: `metadata.affiliatops_ref` (partner referral code) and `metadata.affiliatops_customer_id` (your user id) on the invoice / payment request / recurring plan; `payer_email` also matches customers already attributed via trackSignup/API. The first verified callback activates a draft integration.

- Auth: None (public)
- Operation id: `webhookXendit` · Try it: https://affiliatops.com/docs#op-webhookXendit

| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `integrationId` | path | string | yes | The Xendit integration id. |
| `x-callback-token` | header | string | yes | Xendit webhook verification token. |

```bash
curl -X POST "https://affiliatops.com/api/webhook/xendit/<integrationId>" \
  -H "Content-Type: application/json" \
  -d '{"id":"65f1a2b3c4d5e6f7a8b9c0d1","external_id":"INV-2026-0412","status":"PAID","amount":349000,"paid_amount":349000,"currency":"IDR","payer_email":"owner@example.com","payment_method":"BANK_TRANSFER","paid_at":"2026-09-24T03:16:02.000Z","metadata":{"affiliatops_ref":"JANE-4F2A","affiliatops_customer_id":"acct_1042"}}'
```

Responses:
- `200` `{ received: true }` — with `ignored: true` for unhandled callbacks, otherwise per-event `results`
- `400` Invalid JSON, or no callback token configured
- `401` Missing or wrong x-callback-token
- `404` Unknown or disabled Xendit integration
- `500` Processing failed; Xendit retries (idempotent)

## Schemas

<a id="schema-trackreferralrequest"></a>
### TrackReferralRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `referralCode` | string | yes |  |
| `url` | uri |  | Page URL where the click happened. |
| `referrer` | string |  |  |
| `userAgent` | string |  |  |
| `timestamp` | date-time |  |  |

<a id="schema-trackreferralresponse"></a>
### TrackReferralResponse

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `success` | boolean |  |  |
| `message` | string |  |  |
| `affiliate` | object |  |  |
| `affiliate.name` | string |  |  |
| `affiliate.code` | string |  |  |
| `trackingContext` | object \| null |  |  |
| `trackingContext.merchantId` | string |  |  |
| `trackingContext.storeIntegrationId` | string |  |  |
| `trackingContext.platform` | string |  |  |

<a id="schema-trackconversionrequest"></a>
### TrackConversionRequest

Send referralCode, coupon_code (or code), or both.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `referralCode` | string |  | From the tracker cookie. |
| `coupon_code` | string |  | Partner coupon/voucher or referral code the buyer typed. Wins over referralCode when it resolves. |
| `code` | string |  | Alias of coupon_code. |
| `customerEmail` | email |  |  |
| `customerName` | string |  |  |
| `amount` | number |  | Major units (e.g. 99.99). Converted to amountCents server-side. |
| `currency` | string |  | Default `"IDR"`. |
| `orderId` | string |  | Your order id. A retry with the same orderId returns the conversion already recorded. |
| `url` | uri |  |  |
| `metadata` | object |  |  |
| `timestamp` | date-time |  |  |

<a id="schema-trackconversionresponse"></a>
### TrackConversionResponse

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `success` | boolean |  |  |
| `duplicate` | boolean |  | True when this orderId was already recorded; nothing new was created. |
| `message` | string |  |  |
| `conversion` | object |  |  |
| `conversion.id` | string |  |  |
| `conversion.amount` | number |  |  |
| `conversion.currency` | string |  |  |
| `affiliate` | object |  |  |
| `affiliate.name` | string |  |  |
| `affiliate.code` | string |  |  |
| `attributionMethod` | coupon \| referral_code |  |  |

<a id="schema-analyticsbatchrequest"></a>
### AnalyticsBatchRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `events` | array<AnalyticsEvent> | yes | Max 50 items. |

<a id="schema-analyticsevent"></a>
### AnalyticsEvent

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `visitorId` | string | yes |  |
| `sessionId` | string | yes |  |
| `eventName` | string | yes | e.g. page_view, product_view, add_to_cart, purchase |
| `occurredAt` | date-time |  |  |
| `pageUrl` | string |  |  |
| `pageTitle` | string |  |  |
| `referrer` | string |  |  |
| `productId` | string |  |  |
| `productName` | string |  |  |
| `orderId` | string |  |  |
| `amount` | number |  |  |
| `amountCents` | integer |  |  |
| `currency` | string |  |  |
| `referralCode` | string |  |  |
| `metadata` | object |  | Arbitrary JSON, max 8KB serialized. Raw emails are redacted. |

<a id="schema-tracksignuprequest"></a>
### TrackSignupRequest

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `referralCode` | string | yes | From the referral cookie (the tracker fills this in). |
| `customerId` | string |  | Your own user/account id. customerId or email is required. |
| `email` | email |  |  |
| `name` | string |  |  |
| `metadata` | object |  | Up to 4KB. |

<a id="schema-saasevent"></a>
### SaasEvent

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string |  | Idempotency key (e.g. your invoice or event id). |
| `type` | signup \| trial_started \| subscription_created \| subscription_updated \| subscription_canceled \| payment_succeeded \| payment_failed \| refund | yes |  |
| `occurred_at` | date-time |  | ISO-8601 or unix seconds. Defaults to now. |
| `referral_code` | string |  | Partner referral code, typically captured at sign-up. |
| `coupon_code` | string |  | Partner coupon/voucher or referral code the customer typed (alias `code`). Wins over `referral_code` when it resolves. |
| `customer` | object |  |  |
| `customer.id` | string |  | Your user/account id (preferred match key). |
| `customer.email` | email |  |  |
| `customer.name` | string |  |  |
| `customer.billing_id` | string |  | Billing-provider customer id. |
| `subscription` | object |  |  |
| `subscription.id` | string |  |  |
| `subscription.status` | trialing \| active \| past_due \| paused \| canceled |  |  |
| `subscription.plan` | object |  |  |
| `subscription.plan.id` | string |  | Price/plan id; plans are matched by id, else name. |
| `subscription.plan.name` | string |  |  |
| `subscription.plan.amount` | number |  |  |
| `subscription.plan.amount_cents` | integer |  |  |
| `subscription.plan.currency` | string |  |  |
| `subscription.plan.interval` | day \| week \| month \| year |  |  |
| `subscription.plan.interval_count` | integer |  |  |
| `subscription.quantity` | integer |  |  |
| `subscription.amount` | number |  | Total per billing interval. Defaults to plan amount × quantity. |
| `subscription.amount_cents` | integer |  |  |
| `subscription.currency` | string |  |  |
| `subscription.interval` | day \| week \| month \| year |  |  |
| `subscription.interval_count` | integer |  |  |
| `subscription.trial_ends_at` | date-time |  |  |
| `subscription.current_period_start` | date-time |  |  |
| `subscription.current_period_end` | date-time |  |  |
| `subscription.cancel_reason` | string |  |  |
| `payment` | object |  |  |
| `payment.id` | string |  | Payment/invoice id; refunds reference it. |
| `payment.amount` | number |  |  |
| `payment.amount_cents` | integer |  |  |
| `payment.currency` | string |  |  |
| `refund` | object |  |  |
| `refund.payment_id` | string |  |  |
| `refund.amount` | number |  | Defaults to the full remaining payment. |
| `refund.amount_cents` | integer |  |  |
| `refund.reason` | string |  |  |
| `metadata` | object |  | Up to 8KB. |

<a id="schema-saaseventresult"></a>
### SaasEventResult

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `index` | integer |  |  |
| `status` | processed \| duplicate \| ignored \| error |  |  |
| `eventId` | string |  |  |
| `customerId` | string \| null |  |  |
| `subscriptionId` | string \| null |  |  |
| `affiliateId` | string \| null |  |  |
| `commissionIds` | array<string> |  |  |
| `message` | string |  |  |

<a id="schema-conversionwebhook"></a>
### ConversionWebhook

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `event_type` | SIGNUP \| PURCHASE \| TRIAL \| LEAD | yes |  |
| `external_id` | string |  | Your id for this conversion (order, invoice or lead id). A retry with the same id returns the conversion already recorded. |
| `amount_cents` | integer |  | Minor units (cents). |
| `currency` | string |  | Default `"IDR"`. |
| `customer_email` | email | yes |  |
| `referral_code` | string |  | Used to attribute the conversion. |
| `coupon_code` | string |  | Partner coupon/voucher or referral code the buyer typed (alias `code`). Wins over referral_code when it resolves; needs a merchant-scoped key. |
| `code` | string |  | Alias of coupon_code. |
| `attribution_key` | string |  |  |
| `event_metadata` | object |  |  |

<a id="schema-conversionwebhookresponse"></a>
### ConversionWebhookResponse

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `success` | boolean |  |  |
| `duplicate` | boolean |  | True when this external_id was already recorded; nothing new was created. |
| `message` | string |  |  |
| `attributed` | boolean |  |  |
| `attributionMethod` | coupon \| referral_code \| attribution_key \| none |  |  |
| `commission` | object |  |  |
| `conversion` | object |  |  |

<a id="schema-refundwebhook"></a>
### RefundWebhook

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `customer_email` | email | yes |  |
| `order_id` | string |  | The refunded order: the external_id sent to /api/webhook/conversion, or the tracker orderId. Reverses only that order. With signature auth, also send referral_code (or the email must match the order). |
| `referral_code` | string |  |  |
| `amount_cents` | integer |  | Refunded amount in minor units. Omit to refund whatever is left of the order. Applies when a single order matched. |
| `reason` | string |  |  |
| `external_id` | string |  | The payment provider’s refund id. Repeats with the same id are ignored, so retries are safe. |

## Dashboard endpoints (session cookie)

These power the Affiliatops admin and partner dashboards. They need the `auth-token` cookie from `POST /api/auth/login`, are scoped to the signed-in user’s merchant, and are not meant for server-to-server integrations. Full details: https://affiliatops.com/api/docs.

### Auth

Login, registration, OTP and session handling.

| Method | Path | Summary |
| --- | --- | --- |
| POST | `/api/auth/register` | Register a partner account or request a business workspace |
| POST | `/api/auth/login` | Log in with email & password |
| DELETE | `/api/auth/login` | Log out (alias) |
| POST | `/api/auth/logout` | Log out |
| GET | `/api/auth/me` | Get the current authenticated user |
| POST | `/api/auth/send-otp` | Send a one-time login code |
| POST | `/api/auth/verify-otp` | Verify a one-time login code |
| POST | `/api/auth/forgot-password` | Request a password reset email |
| POST | `/api/auth/reset-password` | Reset a password with a token |

### Affiliate

Partner portal endpoints, including the SaaS partner dashboard. Authenticated with the session cookie.

| Method | Path | Summary |
| --- | --- | --- |
| GET | `/api/affiliate/profile` | Get my profile, stats, referrals & commissions |
| PUT | `/api/affiliate/profile` | Update my profile & payout details |
| GET | `/api/affiliate/referrals` | List my referral leads |
| POST | `/api/affiliate/referrals` | Submit a new referral lead |
| GET | `/api/affiliate/payouts` | List my payout history |
| POST | `/api/affiliate/generate-code` | Create or fetch my referral code |
| GET | `/api/affiliate/resources` | List marketing resources |
| POST | `/api/affiliate/resources` | Increment a resource download counter |
| GET | `/api/affiliate/branding` | Get merchant branding (colours & logo) |
| GET | `/api/affiliate/analytics` | Get my analytics overview |
| GET | `/api/affiliate/workspace` | Get my program’s portal mode and commission terms |
| GET | `/api/affiliate/saas/overview` | Get my SaaS referral KPIs and commission trend |
| GET | `/api/affiliate/saas/customers` | List my referred SaaS customers |

### SaaS (Admin)

SaaS workspace: KPIs and metrics, referred customers and subscriptions, plans, the recurring commission plan, integrations and the billing-event ledger.

| Method | Path | Summary |
| --- | --- | --- |
| GET | `/api/admin/saas/overview` | SaaS dashboard KPIs |
| GET | `/api/admin/saas/metrics` | SaaS metrics: MRR movements, cohorts, churn |
| GET | `/api/admin/saas/customers` | List referred customers (subscriptions) |
| POST | `/api/admin/saas/customers` | Manually attribute a customer to a partner |
| GET | `/api/admin/saas/customers/{id}` | Get a referred customer |
| PATCH | `/api/admin/saas/customers/{id}` | Reassign (or clear) the credited partner |
| POST | `/api/admin/saas/customers/{id}/actions` | Record a manual payment, refund, subscription or cancellation |
| GET | `/api/admin/saas/events` | Billing-event ledger |
| GET | `/api/admin/saas/plans` | List plans |
| POST | `/api/admin/saas/plans` | Create a plan |
| PUT | `/api/admin/saas/plans/{id}` | Update a plan |
| DELETE | `/api/admin/saas/plans/{id}` | Delete or archive a plan |
| GET | `/api/admin/saas/commission-settings` | Get the recurring commission plan |
| PUT | `/api/admin/saas/commission-settings` | Update the recurring commission plan |
| GET | `/api/admin/saas/integrations` | List SaaS integrations |
| POST | `/api/admin/saas/integrations` | Create a Stripe, Midtrans, Xendit or website & app integration |
| PATCH | `/api/admin/saas/integrations/{id}` | Update a SaaS integration |

### Affiliates (Admin)

Manage partner accounts and their status.

| Method | Path | Summary |
| --- | --- | --- |
| GET | `/api/admin/affiliates` | List affiliates |
| POST | `/api/admin/affiliates` | Create an affiliate |
| PATCH | `/api/admin/affiliates/{id}` | Change an affiliate’s status |
| DELETE | `/api/admin/affiliates/{id}` | Delete an affiliate |
| POST | `/api/admin/affiliates/batch` | Batch update affiliates |

### Referrals (Admin)

Review, approve and reject referral leads.

| Method | Path | Summary |
| --- | --- | --- |
| GET | `/api/admin/referrals` | List referral leads |
| POST | `/api/admin/referrals` | Batch approve / reject referrals |
| PUT | `/api/admin/referrals/{id}` | Approve or reject a referral |
| PATCH | `/api/admin/referrals/{id}` | Edit a referral lead |
| DELETE | `/api/admin/referrals/{id}` | Delete a referral |

### Commissions (Admin)

Commission ledger, maturation and commission rules.

| Method | Path | Summary |
| --- | --- | --- |
| GET | `/api/admin/commissions` | List commissions |
| PATCH | `/api/admin/commissions` | Update a commission (status, approval) |
| POST | `/api/admin/commissions/mature` | Mature held commissions |
| GET | `/api/admin/commission-rules` | List commission rules |
| POST | `/api/admin/commission-rules` | Create a commission rule |
| PUT | `/api/admin/commission-rules` | Update a commission rule |
| DELETE | `/api/admin/commission-rules` | Delete a commission rule |
| GET | `/api/admin/commission-setup` | Has the business set its commission rate? |

### Payouts (Admin)

Create, complete and automate affiliate payouts; invoices, transactions and refunds.

| Method | Path | Summary |
| --- | --- | --- |
| GET | `/api/admin/payouts` | List payouts |
| POST | `/api/admin/payouts` | Create a payout from approved commissions |
| PUT | `/api/admin/payouts` | Update payout status |
| DELETE | `/api/admin/payouts` | Delete a payout |
| GET | `/api/admin/payouts/auto` | Auto-payout config & eligibility |
| POST | `/api/admin/payouts/auto` | Run auto-payouts |
| GET | `/api/admin/transactions` | List transactions |
| POST | `/api/admin/transactions` | Record a transaction |
| PUT | `/api/admin/transactions` | Update a transaction |
| DELETE | `/api/admin/transactions` | Delete a transaction |
| GET | `/api/admin/refunds` | List refunds |
| POST | `/api/admin/refunds` | Refund a transaction |
| GET | `/api/admin/invoices` | List invoices |
| POST | `/api/admin/invoices` | Create an invoice |
| PUT | `/api/admin/invoices` | Update an invoice |
| DELETE | `/api/admin/invoices` | Delete a (non-paid) invoice |

### Programs (Admin)

Programs, program settings, partner groups and coupons.

| Method | Path | Summary |
| --- | --- | --- |
| GET | `/api/admin/programs` | List programs |
| POST | `/api/admin/programs` | Create a program |
| PUT | `/api/admin/programs` | Update a program |
| DELETE | `/api/admin/programs` | Delete a program |
| GET | `/api/admin/program-settings` | Get program settings |
| PUT | `/api/admin/program-settings` | Update program settings |
| GET | `/api/admin/settings` | Get settings & commission rules |
| POST | `/api/admin/settings` | Create / update / delete a commission rule |
| PUT | `/api/admin/settings` | Update settings (allow-listed fields) |
| GET | `/api/admin/partner-groups` | List partner groups |
| POST | `/api/admin/partner-groups` | Create a partner group |
| PUT | `/api/admin/partner-groups` | Update a partner group |
| DELETE | `/api/admin/partner-groups` | Delete a partner group |
| GET | `/api/admin/coupons` | List coupons |
| POST | `/api/admin/coupons` | Create a coupon |
| PUT | `/api/admin/coupons` | Update a coupon |
| DELETE | `/api/admin/coupons` | Delete a coupon |
| GET | `/api/admin/resources` | List marketing resources |
| POST | `/api/admin/resources` | Create a resource |
| PUT | `/api/admin/resources` | Update a resource |
| DELETE | `/api/admin/resources` | Delete a resource |

### Analytics (Admin)

Dashboards, detailed analytics, live sessions and reports.

| Method | Path | Summary |
| --- | --- | --- |
| GET | `/api/admin/dashboard` | Dashboard summary stats |
| GET | `/api/admin/analytics` | Analytics overview |
| GET | `/api/admin/detailed-analytics` | Detailed analytics |
| GET | `/api/admin/live-sessions` | Live visitor sessions |
| GET | `/api/admin/sessions` | Historical sessions |
| GET | `/api/admin/reports` | Generate a report |
| GET | `/api/admin/reports/cohort` | Affiliate cohort analysis |
| POST | `/api/admin/reports/email` | Email a report |
| GET | `/api/admin/saved-reports` | List saved reports |
| POST | `/api/admin/saved-reports` | Save a report config |
| PUT | `/api/admin/saved-reports` | Update a saved report |
| DELETE | `/api/admin/saved-reports` | Delete a saved report |
| GET | `/api/admin/scheduled-reports` | List scheduled reports |
| POST | `/api/admin/scheduled-reports` | Schedule a report |
| PUT | `/api/admin/scheduled-reports` | Update a scheduled report |
| DELETE | `/api/admin/scheduled-reports` | Delete a scheduled report |

### Integrations (Admin)

Store integrations, API keys, usage and outbound webhooks.

| Method | Path | Summary |
| --- | --- | --- |
| GET | `/api/admin/store-integrations` | List store integrations |
| POST | `/api/admin/store-integrations` | Connect a store |
| PUT | `/api/admin/store-integrations` | Update a store integration |
| DELETE | `/api/admin/store-integrations` | Delete a store integration |
| GET | `/api/admin/api-keys` | List API keys (masked) |
| POST | `/api/admin/api-keys` | Create an API key |
| PUT | `/api/admin/api-keys` | Update an API key |
| DELETE | `/api/admin/api-keys` | Revoke an API key |
| GET | `/api/admin/api-usage` | API usage analytics |
| POST | `/api/admin/integration/generate-key` | Generate legacy integration keys |
| GET | `/api/admin/integration` | Get legacy integration settings |
| PUT | `/api/admin/integration` | Update legacy integration settings |
| GET | `/api/admin/settings/integration` | Get my legacy integration settings |
| POST | `/api/admin/settings/integration` | Create or update legacy integration settings |
| DELETE | `/api/admin/settings/integration` | Delete my legacy integration settings |
| POST | `/api/admin/emails/test` | Send a test email from a template |
| GET | `/api/admin/webhooks` | List outbound webhooks |
| POST | `/api/admin/webhooks` | Create, test or trigger a webhook |
| PUT | `/api/admin/webhooks` | Update a webhook |
| DELETE | `/api/admin/webhooks` | Delete a webhook |
| GET | `/api/admin/emails` | List email templates |
| POST | `/api/admin/emails` | Create or update an email template |
| PUT | `/api/admin/emails` | Update an email template |
| DELETE | `/api/admin/emails` | Delete an email template |

### Team (Admin)

Merchant profile, workspace mode and onboarding, team members and merchant switching.

| Method | Path | Summary |
| --- | --- | --- |
| GET | `/api/admin/workspace` | Get the active workspace (business type and mode) |
| POST | `/api/admin/onboarding` | Complete (or re-run) workspace onboarding |
| GET | `/api/admin/merchant` | Get the current merchant |
| PUT | `/api/admin/merchant` | Update the merchant |
| GET | `/api/admin/merchant/members` | List merchant members |
| POST | `/api/admin/merchant/members` | Invite a member |
| PATCH | `/api/admin/merchant/members` | Update a member role/status |
| DELETE | `/api/admin/merchant/members` | Remove a member |
| GET | `/api/admin/merchants` | List merchants I belong to |
| POST | `/api/admin/merchants` | Create a new merchant |
| GET | `/api/admin/team` | List team members |
| POST | `/api/admin/team` | Invite a team member |
| PUT | `/api/admin/team` | Update a team member |
| DELETE | `/api/admin/team` | Remove a team member |
| GET | `/api/admin/profile` | Get my admin profile |
| PUT | `/api/admin/profile` | Update my admin profile |
| GET | `/api/admin/settings/profile` | Get my account settings profile |
| PUT | `/api/admin/settings/profile` | Update my name, email and picture |

### System

Health and status probes for uptime monitors and the deploy platform. Public.

| Method | Path | Summary |
| --- | --- | --- |
| GET | `/api/health` | Health check |

### Billing (Admin)

Your Affiliatops plan, billed through Stripe: checkout with the free trial, plan changes, cancellation, the Stripe customer portal, billing history, and the billing webhook. Needs STRIPE_SECRET_KEY (and STRIPE_WEBHOOK_SECRET for the webhook).

| Method | Path | Summary |
| --- | --- | --- |
| GET | `/api/admin/billing` | Get the workspace plan, usage, payment method and invoices |
| GET | `/api/admin/billing/status` | Get the plan status (database only) |
| POST | `/api/admin/billing/checkout` | Start Stripe Checkout for a plan |
| POST | `/api/admin/billing/change-plan` | Switch plan or billing period |
| POST | `/api/admin/billing/cancel` | Cancel the plan at the end of the period |
| POST | `/api/admin/billing/resume` | Undo a scheduled cancellation |
| POST | `/api/admin/billing/portal` | Open the Stripe customer portal |
| POST | `/api/billing/webhook` | Stripe webhook for Affiliatops plan billing |
