# API

A RESTful API for managing your affiliate program: associates, referral links, clicks, and conversions.

## Authentication

Endpoints under `/api/*` are authenticated with **HTTP Basic Auth**, using your account's API key as the username and API secret as the password:

```
Authorization: Basic base64(API_KEY:API_SECRET)
```

With curl, pass `-u API_KEY:API_SECRET` and it will encode this for you. Your API key and secret are shown once when the account is created (or regenerated from the dashboard's API Credentials page).

Requests without valid credentials return `401`:

```json
{ "error": "Invalid or missing credentials" }
```

## Create an associate

Registers a new associate (affiliate) under your account.

```shell
curl -u API_KEY:API_SECRET \
  -X POST https://affiliately.io/api/associates \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Jane Doe",
    "email": "jane@example.com"
  }'
```

| Field   | Type   | Required | Notes                                    |
|---------|--------|----------|-------------------------------------------|
| `name`  | string | yes      |                                            |
| `email` | string | yes      |                                            |
| `status`| string | no       | `active` (default) or `inactive`          |

Response — `201 Created`:

```json
{
  "id": 16,
  "accountId": 23,
  "name": "Jane Doe",
  "email": "jane@example.com",
  "status": "active",
  "createdAt": "2026-07-07T20:07:16.754Z",
  "updatedAt": "2026-07-07T20:07:16.754Z"
}
```

## Create a link

Generates a trackable referral link for one of your associates. `code` is what shows up in the actual URL a customer clicks (`https://affiliately.io/r/CODE`) — omit it to get a random one, or supply your own vanity code.

```shell
curl -u API_KEY:API_SECRET \
  -X POST https://affiliately.io/api/links \
  -H "Content-Type: application/json" \
  -d '{
    "associateId": 17,
    "code": "SUMMER10",
    "discountPct": 20
  }'
```

| Field             | Type    | Required | Notes                                                              |
|-------------------|---------|----------|---------------------------------------------------------------------|
| `associateId`     | integer | yes      | Must belong to your account                                         |
| `code`            | string  | no       | Alphanumeric, 3–32 chars. Auto-generated if omitted. Must be unique |
| `discountPct`     | integer | no       | 0–100, default `0`. Share of the flat commission passed on as a customer discount instead of associate payout |
| `destinationUrl`  | string  | no       | Overrides the account's default destination URL for this link      |

Response — `201 Created`:

```json
{
  "id": 16,
  "associateId": 17,
  "accountId": 24,
  "code": "SUMMER10",
  "discountPct": 20,
  "createdAt": "2026-07-07T20:10:41.800Z",
  "updatedAt": "2026-07-07T20:10:41.800Z"
}
```

If `associateId` doesn't belong to your account (or doesn't exist), you get `400`:

```json
{ "error": "associateId does not reference an existing associate" }
```

## Test a referral link

`GET /r/:code` is the public, unauthenticated endpoint a customer's browser actually hits when they click a referral link. It logs a click, sets a cookie, and redirects to the destination URL. You can inspect all of this with curl.

By default curl does **not** follow redirects, so `-i` (include headers) is enough to see the raw `302` response — the `Set-Cookie` header and the `Location` it would have sent a browser to:

```shell
curl -i https://affiliately.io/r/SUMMER10
```

```
HTTP/1.1 302 Found
Set-Cookie: aff_token=7403952b-4b3e-40e5-aac0-11084b5cfcf1; Max-Age=7776000; Path=/; Expires=...; HttpOnly; SameSite=Lax
Location: https://your-destination.example.com/?aff_token=7403952b-4b3e-40e5-aac0-11084b5cfcf1
...
```

To capture the cookie into a file (and reuse it in later requests, e.g. simulating a customer's browser before your backend reports a conversion), use `-c`:

```shell
curl -c cookies.txt -o /dev/null -w "status: %{http_code}\nredirected to: %{redirect_url}\n" \
  https://affiliately.io/r/SUMMER10
```

Two things worth knowing:

- **`-L` follows the redirect** instead of just showing it — useful to confirm the destination page actually loads, but combine it with `-i` if you still want to see the 302 and its headers along the way.
- **This isn't idempotent.** Every request logs a new `Click` and mints a fresh `aff_token`, exactly like a real visitor clicking the link — so repeated test runs create real tracking data, not just a preview.

## Clicks

`GET /r/:code` (above) is how a real visitor's browser generates a click. The `/api/clicks` endpoints are for managing that data directly — `POST` is a raw, unauthenticated way to log a click without an actual browser redirect (e.g. from a mobile app or server-side integration); `GET`/`DELETE` are authenticated and scoped to your own account's links.

**Create a click** — public, no auth required:

```shell
curl -X POST https://affiliately.io/api/clicks \
  -H "Content-Type: application/json" \
  -d '{ "linkId": 17 }'
```

| Field         | Type    | Required | Notes                                          |
|---------------|---------|----------|--------------------------------------------------|
| `linkId`      | integer | yes      |                                                    |
| `cookieToken` | string  | no       | UUID. Auto-generated if omitted                   |
| `ipAddress`   | string  | no       | Defaults to the requester's IP                    |
| `userAgent`   | string  | no       | Defaults to the request's `User-Agent` header     |

Response — `201 Created`:

```json
{
  "id": 42,
  "linkId": 17,
  "cookieToken": "600aaeb9-6115-4fbc-93d2-3ec216ec4a53",
  "ipAddress": "203.0.113.10",
  "userAgent": "curl/8.7.1",
  "createdAt": "2026-07-07T20:19:50.935Z"
}
```

**List clicks** — requires auth, filter by `linkId` and/or `cookieToken`:

```shell
curl -u API_KEY:API_SECRET "https://affiliately.io/api/clicks?linkId=17"
```

```json
[
  {
    "id": 42,
    "linkId": 17,
    "cookieToken": "600aaeb9-6115-4fbc-93d2-3ec216ec4a53",
    "ipAddress": "203.0.113.10",
    "userAgent": "curl/8.7.1",
    "createdAt": "2026-07-07T20:19:50.000Z",
    "deletedAt": null
  }
]
```

**Get one click** — requires auth:

```shell
curl -u API_KEY:API_SECRET https://affiliately.io/api/clicks/42
```

**Delete a click** — requires auth. Clicks use soft deletes, so this hides the row from the API rather than erasing it:

```shell
curl -u API_KEY:API_SECRET -X DELETE https://affiliately.io/api/clicks/42
```

Response — `204 No Content`.

## Conversions

Reported by your own backend when an order completes, using the `aff_token` cookie value your frontend picked up from the redirect (see [Test a referral link](#test-a-referral-link)). The API looks up the matching click, attributes it to the right associate/link, and computes the payout split — you never send amounts yourself.

**Report a conversion** — requires auth:

```shell
curl -u API_KEY:API_SECRET \
  -X POST https://affiliately.io/api/conversions \
  -H "Content-Type: application/json" \
  -d '{
    "cookieToken": "9d2a19ed-54d7-486d-ad19-7561eaff4e02",
    "orderReference": "ORDER-1042"
  }'
```

| Field            | Type   | Required | Notes                                             |
|------------------|--------|----------|-----------------------------------------------------|
| `cookieToken`    | string | yes      | The `aff_token` value from the referral redirect     |
| `orderReference` | string | yes      | Your own order/invoice id. Must be unique per account |

Response — `201 Created`. `commissionAmount` is your account's flat fee; `customerDiscount` and `associatePayout` are split according to the link's `discountPct`:

```json
{
  "id": 38,
  "accountId": 26,
  "linkId": 18,
  "cookieToken": "9d2a19ed-54d7-486d-ad19-7561eaff4e02",
  "orderReference": "ORDER-1042",
  "commissionAmount": 100,
  "associatePayout": 80,
  "customerDiscount": 20,
  "status": "pending",
  "createdAt": "2026-07-07T20:24:10.745Z",
  "updatedAt": "2026-07-07T20:24:10.745Z"
}
```

Error responses:

| Status | Cause                                                                              |
|--------|--------------------------------------------------------------------------------------|
| `400`  | No click found for that `cookieToken`                                                |
| `409`  | `orderReference` has already been reported for this account                          |
| `410`  | The click's attribution window has expired (past the account's `cookieDurationDays`) |

**List / get conversions** — requires auth, list filters by `linkId`, `status`, and/or `orderReference`:

```shell
curl -u API_KEY:API_SECRET "https://affiliately.io/api/conversions?status=pending"
curl -u API_KEY:API_SECRET "https://affiliately.io/api/conversions?orderReference=ORDER-1042"
curl -u API_KEY:API_SECRET https://affiliately.io/api/conversions/38
```

`orderReference` is how you go from your own order id to the conversion's internal `id` — useful before a `PATCH` (e.g. [canceling one during the hold period](#cancel-a-conversion-during-the-hold-period)), since that endpoint only accepts the `id`.

**Find conversions ready to be paid** — `readyForPayment=true` returns `pending`/`approved` conversions whose `payoutHoldDays` window has already elapsed (i.e. exactly the set you could successfully `PATCH` to `paid` right now, without doing that date math yourself):

```shell
curl -u API_KEY:API_SECRET "https://affiliately.io/api/conversions?readyForPayment=true"
```

This ignores `status` if both are passed together — `readyForPayment` already implies `pending` or `approved`.

**Update status** — requires auth. This is how you move a conversion through `pending` → `approved` → `paid`, or mark it `rejected`:

```shell
curl -u API_KEY:API_SECRET \
  -X PATCH https://affiliately.io/api/conversions/38 \
  -H "Content-Type: application/json" \
  -d '{ "status": "approved" }'
```

`approved` is purely informational — nothing in the API requires it. There's no enforced state machine beyond the two rules below, so a conversion can go straight from `pending` to `paid` once the hold period ends without ever passing through `approved`. In practice, you'd set `approved` whenever your own backend considers the sale verified (e.g. payment/fraud checks clear) — independent of the cancellation window — to distinguish "reviewed and legitimate, just waiting out the hold period" from "still unreviewed."

Two rules are enforced server-side, not left to the caller:

- **Payout hold period.** You can't mark a conversion `paid` until `payoutHoldDays` have passed since it was created — this is your account's cancellation/returns window:

  ```json
  { "error": "This conversion cannot be marked paid until its 14-day hold period ends (eligible at 2026-07-21T20:24:10.000Z)" }
  ```

- **Once paid, it's final.** Any further `PATCH` or `DELETE` on a `paid` conversion returns `409`:

  ```json
  { "error": "This conversion has already been paid and cannot be modified" }
  ```

**Delete a conversion** — requires auth, soft delete, blocked once `paid` (see above):

```shell
curl -u API_KEY:API_SECRET -X DELETE https://affiliately.io/api/conversions/38
```

### Cancel a conversion during the hold period

If a customer cancels before a conversion has been paid, look it up by your own order id, then mark it `rejected`:

```shell
curl -u API_KEY:API_SECRET "https://affiliately.io/api/conversions?orderReference=ORDER-1042"
```

```shell
curl -u API_KEY:API_SECRET \
  -X PATCH https://affiliately.io/api/conversions/38 \
  -H "Content-Type: application/json" \
  -d '{ "status": "rejected" }'
```

This works at any point before the conversion is `paid` — unlike `paid`, `rejected` isn't gated by `payoutHoldDays`, so it doesn't matter whether the cancellation happens on day 1 or day 13 of the window.
