# Affily for agents

You can do most of onboarding someone to Affily — sign them up, upload their assets, build their
profile and rates — without a human touching a form. Three steps stay with them, in a browser:
connecting a social account, publishing, and applying to programs. This document is the whole API
you need.

This file is served at **`/agents.md`** (and an index at **`/llms.txt`**).
Machine-readable spec: **`GET /api/v1/openapi.json`**
Live entry point: **`GET /api/v1`** (no credentials; returns the rules and the current scopes)

---

## Four rules to relay honestly

Read these before you start, because you will need to tell your user about them.

1. **Nothing you create is public.** A profile built with an API key lands as a draft: absent from
   the directory, absent from the sitemap, and its public page returns a 404. The account holder
   must sign in and publish it themselves, and their email address must be verified first. **Do not tell your user their profile is live** — it
   isn't, and they will find out.

2. **Numbers you enter are self-reported and are labelled as such.** You cannot mark them verified;
   the field does not exist in any input schema. Whatever you enter will be published as *your
   user's claim about themselves*, so don't invent figures to fill the form. Ask, or leave blank.
   Verified numbers come only from an account the creator connects themselves (see step 4).

3. **A creator applies only after connecting a social account.** TikTok, Instagram, YouTube or a
   Facebook Page, connected in one click from their dashboard, in a browser. An API key cannot
   connect one, and cannot apply.

4. **Reading needs no credentials at all.** The entire directory — every program's commission rate,
   cookie window, payout schedule and real tracked conversion rate — is public. You do not need a
   key to compare offers. This is unusual and it is deliberate.

---

## Reading the marketplace (no auth)

```http
GET /api/programs?category=saas&recurringOnly=true&minRateBps=2000&sort=recurring
GET /api/programs/:slug
GET /api/creators?niche=skincare&minFollowers=10000&maxRateCents=100000
GET /api/creators/:slug
```

Every program response carries `termsSummary` (e.g. `"20% of sale, recurring for 12 billing
cycles"`), the full `events` table of what the program pays for, and `performance` with the real
tracked conversion rate. A `null` in `conversionRatePct` or `epc` means *not enough traffic to
say* — it is not zero, and you should not report it as zero.

Every program pays in one `currency` — `eur`, `usd` or `gbp` — chosen when it is made and fixed once a
creator applies. Its amounts (flat rates, prizes, gift values) are cents of that currency, and
dashboards never add one currency to another: they list each (`money`) alongside the main one.
An order reported in a different currency is recorded but not paid.

Every program has dates: `startsAt`, `endsAt` (renewing a year at a time while `autoExtend` is
true, so the `endsAt` you read is the date it next renews on) and `phase` (`upcoming`, `running`,
`ended`). `intake.mode` is `rolling` (apply any time until the end) or `window` (apply between
`intake.opensAt` and `intake.closesAt`, before the program starts); `intake.state` says whether
applications are open right now. Ended programs drop out of the listing. Nothing new is credited
outside a program's dates. What was earned inside them — renewals, and sales a brand records from
an earlier action — keeps paying after the end only when `paysAfterEnd` is true (off by default;
once creators have joined it can be switched on, never off). A brand-recorded sale while the
program runs has no deadline.

A program may also carry a `competition`: prizes paid on top of the commission to whoever leads
when it ends (`endsAt`) — most of one tracked action (`event_count`, e.g. viewings booked) or the
best single video (`video_views`, `video_shares`…). A prize with `kind: "first_to"` and a
`threshold` instead goes to the first video read at or past that number (say 100,000 views, on any
platform), and can be awarded as soon as one is. Each prize shows its `amountCents` and, once
the brand awards it, the winner. Prizes are paid through Affily like commissions.

---

## Onboarding someone

### 1. Sign them up

```http
POST /api/v1/signup
{ "email": "rae@example.com", "name": "Rae Whitfield",
  "role": "creator", "agentName": "Claude" }
```

Returns an API key **shown exactly once** — it is stored as a hash and cannot be retrieved later.
Capture it before continuing.

The key is scoped to `profile:write` and `uploads:write`, which are the only scopes there are
today. It works on `/api/v1` and nowhere else: sent to any other route it is ignored, so it cannot
create programs, read earnings, change a password or mint another key. Your user can mint more keys,
with the same scopes, from `/dashboard/keys`.

Rate limited to 10 signups per hour per address.

### 2. Upload their assets

```http
POST /api/v1/uploads
Authorization: Bearer affily_sk_...
{ "data": "<base64>", "filename": "headshot.jpg" }
```

Multipart works too if you have a file handle. Type is determined by the file's **magic bytes**, not
its name or declared MIME — a mislabelled file is rejected. PNG, JPEG, GIF, WebP, MP4 and WebM, up
to 8MB. Identical bytes return the same URL, so retrying is free.

### 3. Build the profile

```http
POST /api/v1/creators
Authorization: Bearer affily_sk_...
{
  "displayName": "Rae Whitfield",
  "headline": "Ingredient-led skincare, minimal hype",
  "bio": "...",
  "avatarUrl": "<url from step 2>",
  "niches": ["skincare", "beauty"],
  "platforms": [{
    "platform": "tiktok", "handle": "@raereads",
    "url": "https://tiktok.com/@raereads",
    "followers": 9200, "avgViews": 3400, "engagementRateBps": 740
  }],
  "startingRates": [{ "deliverable": "TikTok video", "amountCents": 18000 }],
  "demographics": { "topCountries": [{ "code": "GB", "pct": 58 }] }
}
```

Use `POST /api/v1/brands` with `companyName` for a brand instead. A brand can also take a
`coverUrl` (a wide banner, about 3:1) and a `gallery` of up to 24
`{ "type": "image" | "video" | "youtube", "url": "...", "caption": "..." }` items: upload photos and
clips first and use the returned URL; a `youtube` item's `url` is any link to one YouTube video.
Sending `gallery` replaces the whole list. Amounts are **integer cents**;
rates are **integer basis points** (740 = 7.4%). Both reject floats.

Every write returns the publish state:

```json
{ "published": false,
  "publishing": {
    "status": "draft",
    "visibleInDirectory": false,
    "note": "This profile is NOT publicly visible yet...",
    "reviewUrl": "https://affily.me/dashboard/creator/profile" } }
```

### 4. Tell your user

They get an email pointing at the review screen. Tell them too — say the profile is built, that it
is **not live**, and what is left for them to do, signed in, in a browser:

1. **Connect a social account** — TikTok, Instagram, YouTube or a Facebook Page, in one click from
   the profile page. Affily reads the followers, average views and engagement from the platform and
   marks them verified; a verified row replaces the one you typed for that platform and cannot be
   edited by hand. It is also what lets them apply to programs. An API key cannot connect an
   account.
2. **Publish the profile** (their email address must be confirmed first). Send them the
   `reviewUrl`.

Anything else you put in `platforms` — a blog, a newsletter, a podcast — stays self-reported.

The same connection reads the views and shares of competition videos a creator enters (TikTok,
Instagram and YouTube; a Facebook Page gives followers only).

---

## Every field is checkable

`PATCH /api/v1/creators/me` and `PATCH /api/v1/brands/me` take the same shape for edits.

**One account can run several brands** (a hotel, an app, a fintech…). Each `POST /api/v1/brands`
adds one, up to 10, and makes it the current brand. `GET /api/v1/me` lists them all in
`brandProfiles`, each with its `id`. `PATCH /api/v1/brands/me` edits the current one; to edit a
specific brand use `PATCH /api/v1/brands/:id`, or send `X-Affily-Brand: <id>` with any request.
`GET /api/v1/me` returns the account, both profiles, their publish state, and which credential you
authenticated with.
`GET /api/v1/onboarding` says what is done and what is left — including the steps only your user can
take, like connecting a social account and publishing — so you can tell them exactly what remains.

---

## Conversion tracking (brands)

**Best option: connect the platform.** If the brand uses Stripe or Shopify, they create one webhook
in their own dashboard and paste the signing secret into Affily. No code at all, unblockable, and
for Stripe it captures every subscription renewal automatically. Shopify needs literally nothing
else — it records the landing URL on the order, which already carries `aff_click`. A WooCommerce
or Shopify store, or a Stripe account, can instead be connected in one click from the program's
actions page: the brand approves Affily on the platform and Affily wires up the webhooks itself.
That needs the signed-in brand in a browser; an API key cannot start it.

Otherwise, S2S. This needs **no API key**; a program's signing secret is on its integration page.

The browser tag is the **last resort** — blockable, forgeable, and it loses roughly 20–40% of
conversions. Use it only where there is no server and no supported platform:

```js
affily.track('signup', { customerId: user.id });   // fine: no money attached
```

Server-to-server, HMAC-signed over `programId:orderId:amountCents:timestamp`:

```http
POST /api/track/postback?program=aff_...&event=purchase&order_id=1001
     &amount_cents=4900&click_id=...&ts=...&sig=...
```

Idempotent on `order_id` per program: a retry, or both channels firing, produces one commission.
For recurring programs pass `subscription_id` on the first sale and every renewal — renewals have no
click, so they are attributed by subscription lineage instead.

**Actions only the brand records.** An action marked `manualOnly` (an offline sale, a signed
contract) is refused from the tag, postbacks and connected stores — anyone can fire a tag. The
brand records it in the dashboard from the tracked action it followed (the viewing that became a
sale), and it is credited to the creator who brought that action — however long afterwards, past
any cookie window and after the program has ended, as long as that action happened while it ran.

**Creator codes.** A brand can give every creator a discount code (`RAE10`) from the program's
actions page. Pass the code(s) used at checkout as `code=` (comma-separated if several); one that
belongs to an active creator credits them with no click and no cookie window, and wins over
`click_id`. Shopify orders carry their discount codes already, so a connected Shopify store needs
nothing extra.

---

## Errors

Standard HTTP. `400` validation (with `error.details` per field), `401` no or bad credential — or an
API key sent outside `/api/v1`, `403` missing scope or not yours, `409` already exists, `429` rate
limited.

A `403` with `error.details.code` is a step only your user can take: `EMAIL_UNVERIFIED` (confirm
their email before publishing) or `SOCIAL_REQUIRED` (connect a social account before applying).

A `403` mentioning a scope means the key wasn't granted it — that's the design, not a bug.

---

## What a key can never do

Work outside `/api/v1`, change a password, grant a role, mint another key, publish a profile,
connect a social account, apply to a program, or mark self-reported numbers as verified. Those need
a signed-in human, so a leaked key cannot be escalated into account takeover — and the directory
cannot be filled programmatically.
