# REST API

> The affiliate API under /api/v1 – keys, limits, every endpoint with parameters and responses, pagination and error codes.

The REST API gives your own scripts and integrations access to the same data as the affiliate dashboard: programs, rules, products, links, sites, clicks, sales, balance and payouts. Every endpoint maps to a tool on the [MCP server](https://www.affilyflow.com/docs/en/affiliate/mcp) – the same functions sit behind both.

The API is for affiliates only. Stores have no API.

## Get started

1. Open **AI og API** in the dashboard menu (under Tjen penge – the address is `app.affilyflow.com/indstillinger/api`).
2. Create an API key and give it a name, e.g. "My site".
3. Copy the key right away. It is shown only once, and we store only a fingerprint of it.

Send the key in the `Authorization` header on every request:

```bash title="List your programs"
curl https://app.affilyflow.com/api/v1/programs \
  -H "Authorization: Bearer $AFFILYFLOW_API_KEY"
```

The header `X-API-Key: afk_…` also works. A key always starts with `afk_`.

> [!WARNING]
> The key gives access to your links and numbers. Keep it on the server as an environment variable – never in code that runs in the browser or in a public repository. Revoke a key under **AI og API** if it has been shared. You can have at most 10 active keys.

`GET https://app.affilyflow.com/api/v1` needs no key and returns a machine-readable overview of every endpoint, parameter and limit.

OAuth connections (claude.ai, ChatGPT, Claude Code) only work on the MCP server – not on the REST API.

## Conventions

| Topic | Rule |
|---|---|
| Base URL | `https://app.affilyflow.com/api/v1` |
| Format | JSON in and out. Send `Content-Type: application/json` with a body. |
| Amounts | Danish kroner (DKK) excluding VAT. Sales in other currencies are converted at a locked ECB rate; the original is in `original_currency` and `original_value`. |
| Dates | `from` and `to` as `YYYY-MM-DD` in Danish time (Europe/Copenhagen), both inclusive. Without them: the last 30 days. At most 366 days per request. |
| IDs | Programs, links, sites and sales have UUIDs. `click_id` is an integer. |
| Pagination | `limit` + `cursor`. The response has `total`, `has_more` and `next_cursor`. Pass `next_cursor` back as `cursor` with the same filters. |
| Errors | `{ "error": { "code": "…", "message": "…" } }` with an HTTP status. Codes are stable identifiers; messages are in Danish. |
| Language | Data labels such as `status_text` and notes are in Danish. |
| Cache | Responses are sent with `Cache-Control: no-store`. |

Rejected sales never count as sales or commission in the numbers.

## Limits

| Limit | Value | Error |
|---|---|---|
| Requests per minute per key | 120 | HTTP 429 `rate_limited` with `Retry-After: 60` |
| Write requests per minute per key | 60 (a batch request counts as one) | HTTP 429 `rate_limited` |
| New links per 24 hours per account | 500 (every new link on the account in the last 24 hours counts) | HTTP 429 `daily_link_limit` |
| Links per batch | 50 | HTTP 400 `invalid_input` |

A reused identical link does not count as a new link. Append `?sid=<sub-id>` (another sub-ID) or `?p=<path>` (another page at the store) to an existing link instead of creating many near-identical links.

## Endpoints

| Method | Path | Use | MCP tool |
|---|---|---|---|
| GET | `/account` | Your account, quota and dashboard links | `get_account` |
| GET | `/catalog` | Search all open programs | `search_programs` |
| GET | `/programs` | Programs you have joined | `list_programs` |
| GET | `/programs/{id}` | One program with rules and guidelines | `get_program` |
| GET, POST | `/programs/{id}/compliance` | Check a promotion against the rules | `check_compliance` |
| GET | `/products` | Search the stores' products | `search_products` |
| GET | `/websites` | Your registered sites | `list_websites` |
| POST | `/websites` | Add a site | `add_site` |
| PATCH | `/websites/{id}` | Edit a site | `update_site` |
| GET | `/links` | Your links | `list_links` |
| POST | `/links` | Create a link | `create_link` |
| POST | `/links/batch` | Up to 50 links in one request | `create_links_batch` |
| GET | `/links/{id}` | One link with numbers | `get_link` |
| PATCH | `/links/{id}` | Change name, landing page or site label | `update_link` |
| POST | `/links/{id}/archive` | Archive a link | `archive_link` |
| DELETE | `/links/{id}/archive` | Restore an archived link | `restore_link` |
| GET | `/stats` | Clicks, sales and commission for a period | `get_stats` |
| GET | `/sales` | Individual sales | `list_sales` |
| GET | `/clicks` | Individual clicks with page and source | `list_clicks` |
| GET | `/sites` | Measured sites | `get_sites` |
| GET | `/pages` | Pages on your sites | `get_pages` |
| GET | `/balance` | Balance and next payout | `get_balance` |
| GET | `/payouts` | Your payouts | `list_payouts` |

## Account

### GET /account

Returns `account` (AF number, display name and how stores see you, country, niche, number of sites, links and joined programs, currency and timezone), `access`, `quota` (links in the last 24 hours, remaining, limits and requests used in the current minute) and `dashboard` with the addresses of the dashboard pages.

## Programs

### GET /catalog

The full catalog of open programs – including programs you have not joined.

| Parameter | Type | Description |
|---|---|---|
| `query` | text | Free text matched against store name, category, description and audience. |
| `category` | text | Category (Danish label), e.g. `Bolig`. The response lists them in `categories`. |
| `joined` | `true` / `false` | Only joined or only not joined. Omit for both. |
| `min_commission_pct` | number 0–100 | Minimum percentage commission. Fixed-amount programs are excluded. |
| `recurring_only` | `true` / `false` | Only programs that pay commission on subscription renewals. |
| `channel` | text | Only programs that allow the channel, e.g. `TikTok`. |
| `sort` | `epc`, `commission`, `newest`, `name` | Default: `epc` (network earnings per click). |
| `limit` | 1–50 | Default 20. |
| `cursor` | text | From `next_cursor`. |

Each program includes `program_id`, `store`, `joined`, `commission` (`type`, `value`, `text`), `cookie_days`, `approval_days`, `recurring_months`, `allowed_channels`, `paid_search` (`allowed`, `no_brand` or `forbidden`), `allow_coupon_sites`, `network` (EPC, conversion, order value and approval rate – rejected sales excluded) and `join_url`. Joining happens in the dashboard via `join_url` – the API never joins programs.

### GET /programs

The programs you have joined, with commission, cookie days, channels, the saved rules and the store guidelines (truncated). `active: false` means new sales give no commission; `inactive_reason` says why (`program_paused` or `store_inactive`). The field `brand_bidding` is deprecated – use `paid_search`. Parameter: `search` (store name).

### GET /programs/{id}

Everything about one program: the store's description and audience, the guidelines verbatim, the rules that apply today (`rules.now`), the saved rules (`rules.saved`) and the date a tightening takes effect (`rules.tightening_effective_from`), a `compliance_summary`, the rule-watch mode (`enforcement.mode`: `shadow` or `enforce`), discount codes, creatives (banners with ready HTML and your link once joined), top products, network stats and your own numbers for the program (`mine`).

### GET or POST /programs/{id}/compliance

Checks a planned promotion against the program's measurable rules. Send at least one parameter. Use POST with JSON when `content` is long.

| Parameter | Description |
|---|---|
| `channel` | Planned channel, e.g. `Instagram`, `SEO`, `E-mail`. |
| `placement_url` | Full URL of the page where the link will be placed. |
| `landing_page` | Path or URL at the store. |
| `ad_type` | `paid_search_brand`, `paid_search_generic`, `coupon_site`, `cashback_site`, `social_post`, `newsletter` or `website_content`. |
| `content` | Draft text (max 20,000 characters) – checked for an advertising label. |

The response has `verdict` (`ok`, `warning` or `not_allowed`), `checks` with a verdict and a reason per rule (plus `effective_from` when a tightening is coming), the store guidelines verbatim and the rule-watch mode. Only measurable rules are judged: channel, coupon and cashback sites from the placement's domain, paid search as declared, and the landing page. The advertising-label check is heuristic, and the store's free-text guidelines are not judged.

```bash title="May I run Google Ads on generic keywords?"
curl "https://app.affilyflow.com/api/v1/programs/PROGRAM_ID/compliance?ad_type=paid_search_generic" \
  -H "Authorization: Bearer $AFFILYFLOW_API_KEY"
```

## Products

### GET /products

Searches the stores' products across open programs.

| Parameter | Type | Description |
|---|---|---|
| `query` | text | Search words (the catalogs are mostly Danish), e.g. `sofa`. Empty = the products you earn most on. |
| `program_id` | UUID | Only one program. |
| `min_commission_pct` | number 0–100 | Minimum percentage commission. |
| `in_stock_only` | `true` / `false` | Hide out-of-stock products. |
| `sort` | `earnings`, `relevance`, `price`, `rate` | Default: `relevance` with a query, otherwise `earnings`. |
| `limit` | 1–60 | Default 20. |
| `cursor` | text | From `next_cursor`. |

Each product has `product_id`, `title`, `url`, `image_url`, `price`, `currency`, `in_stock`, `store`, `program_id`, `commission`, `est_commission` and `joined`. For joined programs, `tracking_url` is your existing link sent straight to the product (`?p=`). `est_commission` is an estimate: the rate applied to the price excluding VAT, or the fixed amount.

## Sites

### GET /websites

Your registered sites with `website_id`, `domain`, `name`, `niche`, `monthly_traffic` and `created_at`.

### POST /websites

Adds a site with the same rules as **Mine sites** in the dashboard.

| Field | Description |
|---|---|
| `domain` | Required. E.g. `mysite.com`. A full URL is accepted and reduced to the domain. |
| `name` | Display name, max 120 characters. Default: the domain. |
| `niche` | E.g. `Home`, max 80 characters. |
| `monthly_traffic` | Approximate monthly visitors. |

A new site returns HTTP 201. If you already have the domain, nothing is created, and the response is the existing site with `already_existed: true` and HTTP 200. The response includes `history` with the clicks, sales and commission that already came from the domain.

### PATCH /websites/{id}

Changes `domain`, `name`, `niche` or `monthly_traffic`. Send `null` or `""` to clear `name`, `niche` or `monthly_traffic`. A domain you already use on another site is rejected with `duplicate_domain`. Sites can only be deleted in the dashboard.

### GET /sites

Sites measured from clicks – registered and discovered – with clicks, sales, commission, EPC, page-tracking coverage and method, top stores and top pages. `suggest_add: true` marks a discovered site with at least 3 clicks or a sale that you have not added.

| Parameter | Description |
|---|---|
| `from`, `to` | Optional period. Without: all time. |
| `pages_per_site` | 0–25 top pages per site. Default 5. |
| `include_minor` | Include discovered sites with very few clicks. |

### GET /pages

Pages on your sites ranked by commission, with clicks, sales, EPC, conversion, best-selling product, stores and 30-day trend. Clicks without a known page appear as one row per site with `known: false`.

| Parameter | Description |
|---|---|
| `from`, `to` | Optional period. Without: all time. |
| `site` | Only pages on this site (domain without www). |
| `known_only` | Hide the rows without a known page. |
| `min_clicks` | Minimum clicks. |
| `limit` | 1–200. Default 50. |
| `cursor` | From `next_cursor`. |

## Links

### GET /links

Your links, newest first.

| Parameter | Description |
|---|---|
| `program_id` | Only links for this program. |
| `sub_id` | Only links with this sub-ID. |
| `search` | Text in name, store, destination or sub-ID. |
| `archived` | `exclude` (default), `include` or `only`. |
| `include_stats` | `true` adds clicks, sales, commission, EPC and conversion per link. |
| `limit` | 1–200. Default 50. |
| `cursor` | From `next_cursor`. |

Each link has `link_id`, `url`, `program_id`, `store`, `active`, `name`, `landing_page`, `destination`, `sub_id`, `channel`, `utm`, `website_id`, `placement_url`, `archived`, `created_at` and `html`.

### POST /links

Creates a link – exactly like the link generator in the dashboard. You must have joined the program.

| Field | Description |
|---|---|
| `program_id` | Required. From `/catalog` or `/programs`. |
| `landing_page` | Path or full URL on the store's domain, e.g. `/products/sofa`. Default: the front page. |
| `sub_id` | Max 80 characters. |
| `campaign` | Max 80 characters. Sent as `utm_campaign` and used as the link name. |
| `channel` | One of the program's allowed channels: `SEO`, `Blog`, `Instagram`, `TikTok`, `YouTube`, `Facebook`, `Pinterest`, `E-mail`, `Website`. |
| `utm_content`, `utm_term` | Max 100 characters each. |
| `website_id` | Optional site label from `/websites`. |
| `placement_url` | Full URL of the page where the link will be placed (`https://…`). |
| `name` | The link's name in the dashboard, max 120 characters. |
| `include_qr` | `true` adds a QR code as PNG in `qr_png_base64`. |

```bash title="Create a link"
curl -X POST https://app.affilyflow.com/api/v1/links \
  -H "Authorization: Bearer $AFFILYFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"program_id":"PROGRAM_ID","landing_page":"/products/sofa","sub_id":"blog-best-sofas-oct"}'
```

A new link returns HTTP 201. If an identical link exists, it is reused with `reused: true` and HTTP 200.

```json title="Response (shortened)"
{
  "link": {
    "url": "https://app.affilyflow.com/go/3f9a1c07be",
    "short_code": "3f9a1c07be",
    "reused": false,
    "program_id": "PROGRAM_ID",
    "store": "Store",
    "landing_page": "/products/sofa",
    "sub_id": "blog-best-sofas-oct",
    "html": "<a href=\"https://app.affilyflow.com/go/3f9a1c07be\" rel=\"sponsored noopener\" target=\"_blank\" referrerpolicy=\"no-referrer-when-downgrade\">Store</a>",
    "variants": {
      "with_sub_id": "https://app.affilyflow.com/go/3f9a1c07be?sid={sub_id}",
      "deeplink": "https://app.affilyflow.com/go/3f9a1c07be?p={path_on_store}"
    },
    "warnings": []
  },
  "warnings": [],
  "quota": {
    "links_created_last_24h": 1,
    "links_remaining_24h": 499,
    "daily_link_limit": 500,
    "requests_per_minute": 120,
    "requests_used_this_minute": 1,
    "write_calls_per_minute": 60
  }
}
```

`warnings` holds rule warnings for the program – e.g. a channel or placement that is not allowed, or a tightening that is coming. Use `html` on your site: its `referrerpolicy` makes every click report the exact page it came from (Chrome, Edge and Firefox; Safari and some apps send only the domain).

### POST /links/batch

Up to 50 links in one request. The top-level fields (`program_id`, `sub_id`, `campaign`, `channel`, `website_id`, `utm_content`, `utm_term`) are defaults that each element in `items` can override. An element can also have `landing_page`, `placement_url` and `name`.

```bash title="Links to three products"
curl -X POST https://app.affilyflow.com/api/v1/links/batch \
  -H "Authorization: Bearer $AFFILYFLOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"program_id":"PROGRAM_ID","sub_id":"article-sofas-oct","items":[{"landing_page":"/products/sofa-a"},{"landing_page":"/products/sofa-b"},{"landing_page":"/products/sofa-c"}]}'
```

The response has a result per element (`index`, `ok` and either `link` or `error` with `code` and `message`), a `summary` (`requested`, `created`, `reused`, `failed`) and the remaining quota. A failing element does not stop the others.

### GET /links/{id}

One link with numbers, `performs_on` (the pages, sites or channels its clicks came from), the program status and the rules that apply today, and `variants`. `?include_qr=true` adds a QR code.

### PATCH /links/{id}

Changes `name`, `landing_page` (on the store's domain) or `website_id` (`null` removes the label). The URL never changes.

Sub-ID, campaign, channel, program and UTM values cannot be changed on an existing link, because its clicks are recorded with them. If you try, the API returns HTTP 400 `immutable_field`. Create a new link, or append `?sid=` to the existing one.

### POST and DELETE /links/{id}/archive

POST archives the link, DELETE restores it. An archived link keeps redirecting and tracking – it is only hidden from the list.

## Results

### GET /stats

Clicks, sales and commission for a period.

| Parameter | Description |
|---|---|
| `from`, `to` | Period. Default: the last 30 days. |
| `group_by` | `total` (default), `sub_id`, `program`, `link`, `day`, `site`, `page`, `source`, `device` or `country`. |
| `program_id` | Only this program. |
| `sub_id` | Only this sub-ID. |
| `compare` | `true` adds the previous period of equal length with change in percent. |
| `limit` | 1–500 rows when grouped. Default 100. `day` always returns every day. |

`totals` and each row in `rows` have `clicks`, `sales`, `sales_pending`, `sales_approved`, `sales_rejected`, `revenue`, `commission`, `commission_pending`, `commission_approved`, `conversion_rate_pct` and `epc`. Rows also have `key` and `label`. A sale counts on the day it was registered. With `site`, `page`, `source`, `device` and `country`, a sale is grouped by the click it came from.

```bash title="Last month per program, compared"
curl "https://app.affilyflow.com/api/v1/stats?from=2026-09-01&to=2026-09-30&group_by=program&compare=true" \
  -H "Authorization: Bearer $AFFILYFLOW_API_KEY"
```

### GET /sales

Individual sales, newest first.

| Parameter | Description |
|---|---|
| `from`, `to` | Period by sale date. Default: the last 30 days. |
| `status` | `pending`, `approved` or `rejected`. |
| `program_id` | Only this program. |
| `limit` | 1–200. Default 50. |
| `cursor` | From `next_cursor`. |

Each sale has `sale_id`, `created_at`, `store`, `order_ref` (only the last characters of the order number), `order_value`, `commission_base` (when commission was calculated from another amount than the order), `commission` (after any partial refunds), `commission_before_refund`, `refunded_value`, original currency and rate, `status` and `status_text`, `rejection_reason` (e.g. *Regelbrud: …* for a rule breach), `approval_due_at` for pending sales, `items`, and the click: `click_id`, `clicked_at`, `link_id`, `sub_id` and `landing_page`.

### GET /clicks

Individual clicks with their full trail: time, the page the link was on and how it is known (`page_basis`), site, source with basis, link and sub-ID, landing page, device, country, the commission locked on the click and the sales the click led to. The period follows the click date.

| Parameter | Description |
|---|---|
| `from`, `to` | Period. Default: the last 30 days. |
| `site` | Only clicks from this site (domain without www). |
| `page` | Only clicks from pages starting with this URL – or `unknown` for clicks without a known page. |
| `link_id`, `program_id`, `sub_id` | Filters. |
| `source` | Only this source, e.g. `Google`, `Instagram`, `Direkte`. |
| `outcome` | `sale`, `no_sale` or `rejected` (only rejected sales). |
| `limit` | 1–200. Default 50. |
| `cursor` | From `next_cursor`. |

## Balance and payouts

Read-only. Responses never contain an IBAN (not even masked), CPR, tax ID or address, and the API cannot request a payout.

### GET /balance

`balance` (available, pending sales, in transit, total earned and paid out – all excluding VAT), `payout` (the next automatic payout day – the first Danish bank day of the month, minimum 100 DKK – whether you can request a payout now, and what is missing, with code, text and `you_can_fix`) and the 5 latest payouts.

### GET /payouts

All payouts, newest first: status, automatic or requested, amount excluding VAT, VAT, amount transferred, dates and the self-billing invoice number. Parameters: `limit` (1–100, default 24) and `cursor`.

## Error codes

| HTTP | Code | Meaning |
|---|---|---|
| 400 | `invalid_input` | A parameter is missing or invalid. The message says which. |
| 400 | `invalid_json` | The body is not valid JSON. |
| 400 | `invalid_cursor` | `cursor` is invalid. Start over without it. |
| 400 | `channel_not_allowed` | The channel is not allowed in the program. The message lists the allowed ones. |
| 400 | `landing_page_other_host` | The landing page is not on the store's domain. |
| 400 | `invalid_landing_page` | The landing page is not a valid path or URL. |
| 400 | `unknown_website` | `website_id` does not exist on your account. |
| 400 | `immutable_field` | Sub-ID, campaign, channel, program or UTM cannot be changed on an existing link. |
| 400 | `invalid_domain` | Write the domain as `mysite.com`. |
| 401 | `missing_key` | No key was sent. |
| 401 | `invalid_key` | The key is invalid or revoked. |
| 403 | `not_joined` | You have not joined the program. The message has the join link to the dashboard. |
| 403 | `inactive` | The program or the store is not active right now. |
| 403 | `account_suspended` | The account is suspended. |
| 404 | `not_found` | The program, link or site does not exist on your account. |
| 409 | `duplicate_domain` | You already have a site with that domain. |
| 429 | `rate_limited` | Too many requests. Wait and try again. |
| 429 | `daily_link_limit` | 500 new links in the last 24 hours. Reuse a link with `?sid=` or `?p=`. |
| 500 | `server_error`, `save_failed` | Something went wrong on our side. Try again. |
| 503 | `unavailable` | The API is temporarily unavailable. |

Asking for a link or site that belongs to another affiliate returns `not_found` – the API does not reveal that it exists.

## What the API cannot do

- Join programs – that happens in the dashboard, where the store is notified.
- Delete links or sites – links can be archived, sites are deleted only in the dashboard.
- See or change bank details, tax profile or profile – or request a payout.

Kilde: https://www.affilyflow.com/docs/en/affiliate/rest-api
