# AffiliateOS publisher API: reference for AI coding assistants

> Snapshot, 30 October 2026. Written for www.affiliateos.ai from the AffiliateOS API as it runs today. Human-readable version: https://www.affiliateos.ai/help/developers. Index of every help article: https://www.affiliateos.ai/help/llms.txt

Give this file to your coding assistant before you ask it to integrate AffiliateOS. Use only the endpoints, parameters and fields listed here. Ask the developer for keys and IDs; never invent them, and never put a key or secret in client code.

## What AffiliateOS is, for a publisher

- Advertisers pay for an install, for steps inside their app or a percent of each sale. You (the publisher) show those offers to your users, often for a reward in your own currency. Each offer shows what it pays you in dollars.
- AffiliateOS records every tap on a tracking link, checks it, matches the conversion the advertiser reports and sends your server a signed postback. You credit your user from the postback.
- Ways to show offers: the Hosted Offerwall (we host the wall), the Offer API (you build the screen), the Top Offers Feed (the Offer API, with the list chosen by us), or Offer Links (our team picks offers and sends you a tracking link for each).
- Game offers paid per install pay you once, at install. Later steps post back with `payout=0` and `user_payout` set to the player's reward (on the Hosted Offerwall, `revenue=0` and `payout` equals the player's reward). On an account paid per step, the install pays you $0 and each step's `effectivePayoutUsd` is what it pays you.

## Host and authentication

- Base URL for every API: `https://affiliateos.xyz`
- Publisher API key: header `x-api-key: pk_...`. Created by an owner or admin under **My Profile**, then **API Keys**, then **Generate Key**. Shown once. Up to five keys can exist at once, so you can deploy a new key before revoking the old one.
- New keys do not expire. A legacy key that shows an expiry date keeps it; replace it before then.

## Fetch offers: Offer API

```http
GET https://affiliateos.xyz/publisher/offers
x-api-key: pk_your_key
```

Returns every active offer your account can run. Offers that are capped for today, paused or out of budget are left out until they can run again.

### Query parameters (all optional)

| Parameter | Example | Meaning |
|---|---|---|
| `country` | `US` or `US,CA` | Only offers open to players in these countries. Send it every time. |
| `state` | `TX` | A US player's state. Leaves out offers whose state rules we enforce when they exclude it. A tap on such an offer shows the player "This offer isn't available in your state". |
| `platform` | `iOS`, `Android` | Only offers for that platform. |
| `os_version` | `17.4` | With exactly one `platform`: leaves out offers the phone is too old or too new for. |
| `segment` | `top`, `install`, `popular` | Named lists. `top` is the Top Offers Feed (below). `install` is install-paid offers. `popular` is offers that earned across AffiliateOS in the last 30 days. Comma-separate to require all. |
| `tracking_type` | `CPI,CPA` | Filter by tracking type. |
| `category` | `Game` | Filter by the offer's category label. |
| `vertical` | `sports_betting`, `real_money_gaming`, `none` | Filter by vertical. |
| `name` / `id` | `harbor blocks` | Search by name, or one exact offer ID. |
| `sort`, `sort_dir` | `payout`, `desc` | `epc`, `payout`, `install_payout`, `first_event_payout`, `newest`, `popular`, `conversion_rate`, `featured`. |
| `page`, `per_page` | `1`, `100` | Turns paging on. `per_page` defaults to 50 and is capped at 200. |
| `sub_id` | `u_48213` | Only when native placements are on for your account: adds `userStatus` (`new`, `in_progress`, `completed`) per row for that player. |

Paging is opt-in. Without `page` or `per_page` you get every offer in one response. The `pagination` object (`current_page`, `per_page`, `total`, `has_more_pages`) is always present.

### Response (Example, trimmed)

```json
{
  "status": "success",
  "data": {
    "offers": [
      {
        "id": "aff_7c2e91b04d3a",
        "numericId": 241889305712604,
        "name": "Harbor Blocks",
        "trackingType": "CPI",
        "platforms": ["ios"],
        "isPaidOnInstall": true,
        "effectivePayoutUsd": 4.2,
        "networkEpc": 0.31,
        "trackingUrl": "https://click.affiliateos.ai/click?off_id=aff_7c2e91b04d3a&pub_id=...&partner_id=...&sub_id={SUB_ID}&device_id={DEVICE_ID}",
        "goals": [
          { "id": "1", "numericId": 51772690341208, "name": "Install", "effectivePayoutUsd": 4.2, "userRewardUsd": 1.5, "userRewardIsAdvertiserSet": true },
          { "id": "6", "numericId": 51772690341213, "name": "Reach level 10", "effectivePayoutUsd": 0, "userRewardUsd": 1.5, "userRewardIsAdvertiserSet": true }
        ]
      }
    ],
    "pagination": { "current_page": 1, "per_page": 1, "total": 1, "has_more_pages": false }
  }
}
```

### Fields that matter

- `id`: the offer ID (string). `numericId`: an integer twin of `id` for systems that store numbers.
- `effectivePayoutUsd`: what we pay you for the offer.
- `isPaidOnInstall`: true when the offer pays on a pure install.
- `networkEpc`: earnings per click in USD, an estimate, never a guarantee.
- `trackingUrl`: the link to send the user through. Always use `trackingUrl` as returned; do not build the host yourself. Replace `{SUB_ID}` with your user's ID and, when you have it, `{DEVICE_ID}` with the device's advertising ID.
- `goals[]`: each step. `effectivePayoutUsd` is what that step pays you. `userRewardUsd` is the player's reward for it: pay it as shown when `userRewardIsAdvertiserSet` is true, including when it is `0`; when false it is a suggested share of your payout.
- Rows also carry images (`largeImage`, `squareImage`, often null), copy (`description`, `shortDescription`, `requirements`), `categories`, `contentType`, `verticals`, `bundleId`, `storeId`, `geoTargeting` and `installPayoutUsd`.
- When the same app is listed more than once, you get at most one install-paid listing and one event-paid listing per app and platform.
- `GET /publisher/offers/:offerId` returns one offer in full, also for offers held out of the list, so users who started it keep their progress.
- `GET /publisher/offers/availability?ids=aff_...,aff_...` checks cards you saved earlier. Send 1 to 100 offer IDs, at most every 60 seconds for cards on screen. Each row returns `id`, `available`, `cap` and `capResetsAt`, plus `checkedAt` on the response. Drop any card with `available: false`.

## Top Offers Feed

```http
GET https://affiliateos.xyz/publisher/offers?segment=top&country=US
x-api-key: pk_your_key
```

The same call, with the list chosen by us: our team's picks, plus offers that pay $1 or more, converted 10 or more times in the last 30 days and had 15% or fewer reversed. Any offer with 100 or more clicks and no conversion in the last 7 days drops out, picks included. Rebuilt daily. Rows come back in a fixed order: up to 60 install-paid games by highest install payout, then up to 40 offers paid on one action (sign-ups, trials, deposits), our picks first and then the most conversions. On an account paid per step, only the second list applies. `sort` is ignored. Each row is your own row (your payout, your tracking link, your filters). If the Top Offers Feed is switched off, `segment=top` returns an empty list.

## Tracking links

- Send each user through the offer's `trackingUrl` with **their own** ID in `sub_id`. `sub_id` is the value that tells you whose balance to credit, so give every user their own. One shared value, or the literal `{SUB_ID}` left in the link, makes every conversion land on one "user".
- Optional: `sub1` to `sub10` (up to 128 characters each) come back on every postback for that conversion, as parameters and as `{sub1}` to `{sub10}` macros. Kept 90 days, never sent to the advertiser.
- Optional: `subsource_id` (up to 128 characters) for your own placement reporting. Not sent back on postbacks.
- `{OFFER_ID}` in a link accepts the offer's `numericId` as well as its `id`.
- A user who already installed and was credited for an offer is sent to the app's store page instead, since that link can't credit them twice.

### What a player can see after a tap

| Page title (exact) | Meaning |
|---|---|
| This offer isn't available for your account | Our traffic checks refused the tap. The offer is still live. |
| This offer isn't available in your state | The offer doesn't take players from that US state. |
| This offer has ended | The offer is gone; the link outlived it. |
| Download a game | The game isn't taking new players right now. |
| This offer is no longer available | The advertiser's side would not serve the tap (capped, paused, out of budget). The page carries no brand name. |
| This offer could not be opened | We couldn't reach the offer's link. The page has a Try again button. |

## Postbacks

When a user completes a step, AffiliateOS sends a GET request to your postback URL.

```text
GET https://api.pebble.example/afos?action_id=act_5Kq2Wc&sub_id=u_48213&offer_id=aff_7c2e91b04d3a&status=completed&payout=4.20&user_payout=1.50&conversion_name=Install&offer_name=Harbor+Blocks&goal_id=1&is_offer_complete=false&clicktimestamp=2026-10-29T19:06:00Z&verifier=9f86d081...
```

| Parameter | Sent | Meaning |
|---|---|---|
| `action_id` | Always | The conversion's ID. A reversal repeats it. |
| `sub_id` | Always | The user ID from the tracking link, or the wall's `uid`. |
| `offer_id` | Always | The offer ID, as in the Offer API. |
| `offer_id_numeric` | When it has one | Integer twin of `offer_id`. |
| `status` | Always | `completed` or `reversed`. |
| `payout` | Always | Feed and links: what we pay you for this step, in USD (0 on later steps of install-paid games). Hosted Offerwall: the player's reward. |
| `user_payout` | When a player reward resolves | The player's reward in USD. Credit this number as sent. |
| `revenue` | Hosted Offerwall | Your earnings on wall postbacks. Use it for revenue tracking when present. Do not add it to `payout`. |
| `placement` | Hosted Offerwall | `offerwall`. |
| `conversion_name` | Always | The step's name, for example `Install` or `Reach level 10`. |
| `offer_name` | When the offer resolves | The offer's title. Store it: a pulled offer can no longer be looked up. |
| `goal_id`, `goal_id_numeric` | Per step | Match against `goals[].id` and `goals[].numericId`. |
| `is_offer_complete` | Always | `true` when the last step is done. |
| `clicktimestamp` | Usually | Click time as the advertiser's side reported it. Format varies; parse defensively. |
| `reason` | Some reversals | Why the conversion was taken back, in plain words. |
| `manual_credit` | Manual credits | `1` for a credit that is not a normal step completion. |
| `sub1` to `sub10` | When the link had them | Your own values from the tracking link. |
| `test` | Tests | `true` on a test postback. Don't credit it. |
| `verifier` | Always | HMAC-SHA256 signature. See below. |

Rules:

- Return HTTP 200. Credit on `completed`, deduct on `reversed`.
- A reversal repeats the original `action_id`. Dedupe on `action_id` **and** `status` together.
- A step that pays you $0 still posts back, with `payout=0` and `user_payout` set to the player's reward when it has one (on the Hosted Offerwall, `revenue=0` and `payout` equals the player's reward).
- A step removed from an offer, or an offer pulled from the feed, can still post back for users already past it.

### Signatures

Postbacks are never sent unsigned. Without an active postback secret (`ps_...`, created under **Postbacks**, then **Create postback secret**, shown once), conversions still count in your reports but nothing is sent.

To verify: take the full URL exactly as received, remove the `verifier` parameter, compute HMAC-SHA256 of the rest with your secret, compare hex digests. Never rebuild the URL from a fixed list of parameters; optional parameters are signed too.

```js
const crypto = require("crypto");

app.get("/afos", (req, res) => {
  const url = new URL(`${req.protocol}://${req.get("host")}${req.originalUrl}`);
  const verifier = url.searchParams.get("verifier");
  url.searchParams.delete("verifier");
  const expected = crypto
    .createHmac("sha256", process.env.AFOS_POSTBACK_SECRET)
    .update(url.toString())
    .digest("hex");
  if (expected !== verifier) return res.status(401).send("Invalid verifier");
  // Credit on completed, deduct on reversed. Dedupe on action_id + status.
  res.sendStatus(200);
});
```

### Setting up postbacks

- In the portal: **Postbacks**, then **Add postback**. Or `GET/POST /publisher/postbacks` and `PATCH/DELETE /publisher/postbacks/:id`, with your `x-api-key` or a dashboard session. A publisher key can create, change and delete postback rows, so treat a leaked key as able to redirect your postbacks. The postback secret itself can only be created from the dashboard.
- URLs may contain macros, in any case: `{sub_id}` `{transaction_id}` `{payout}` `{revenue}` `{user_payout}` `{offer_id}` `{goal_id}` `{offer_id_numeric}` `{goal_id_numeric}` `{conversion_name}` `{offer_name}` `{status}` `{reason}` `{purchase_type}` `{purchase_amount}` `{sub1}` to `{sub10}`. Every parameter above is appended to the URL either way.
- Row types: `conversion` rows receive conversions (and events unless an `event` row applies); `event` rows receive later steps only. Offer-specific rows beat global rows.
- Once you save any postback row, the account-level fallback URL stops being used. If you add event rows, add a conversion row too.
- Test: **Send test postback** on a saved row (or `POST /publisher/postbacks/:id/test`). It uses the same signing and adds `test=true`. A missing secret returns 400. A passing test proves your URL answers, not that live conversions route there.

## Hosted Offerwall

A complete wall we host at `https://offerwall.affiliateos.xyz/w/{appId}` (`appId` starts with `owapp_`). Conversions arrive on your usual postbacks; `sub_id` is the `uid` you launched with.

### Simple launch (new walls)

```html
<iframe
  src="https://offerwall.affiliateos.xyz/w/owapp_your_app?uid=YOUR_USER_ID&source=iframe&country=US"
  style="width:100%;height:720px;border:0"
  allow="clipboard-write"
></iframe>
```

Copy the snippet from Docs in the portal (or from your property's Integrations under Properties & Apps) and fill in your user's ID. Mobile apps load the same URL in a webview.

New walls start on Simple launches: the `uid` alone opens the wall. Older walls may require a signature on every launch. We change this setting for you: email hello@affiliateos.ai with your wall ID (`owapp_...`), and switch your links over first.

### Signed launch

Required on a wall set to signed launches, which includes older walls. To require signatures on a newer wall, email hello@affiliateos.ai with your wall ID. Every launch must then carry `ts`, `nonce` and `sig`:

```text
sig = hex(HMAC_SHA256(appSecret, appId + "\n" + uid + "\n" + ts + "\n" + nonce))
```

- Sign on your server. The app secret (`ows_...`) never reaches a browser or an app bundle.
- Each signed URL works once and expires after 5 minutes. A reused or expired one shows "This offerwall link is invalid or expired."
- On accounts with **Properties & Apps**, **Test launch** on the property's **Integrations** tab opens your wall in test mode for a fixed test user, before you write signing code.

### Launch parameters

| Parameter | Meaning |
|---|---|
| `uid` | Your stable user ID. Comes back as `sub_id`. |
| `country` | ISO country. Always send it: without it the wall can't filter by location. |
| `platform` | `ios`, `android` or `web`. |
| `source` | `hosted`, `iframe`, `ios` or `android`. |
| `device_id` | The advertising ID when you have it (not signed). |
| `property_id` | A Properties & Apps ID, when you use properties. In signed mode it is a fifth signed field. |

### What your users are paid

Users see a share of your payout that you set in the portal. Where the advertiser set a reward for a step, the user gets that amount instead. Either way, credit `user_payout` from the postback as sent.

### Events from the wall

In an iframe or webview the wall posts messages prefixed `affiliateos.offerwall.`: `ready`, `height_changed`, `offer_started`, `offer_redirect` (iframes only), `close_requested`, `error`. Never credit from these: conversions reach you as postbacks.

## Messages your users can see on the wall

| Text (exact) | Meaning |
|---|---|
| This offerwall link is invalid or expired. | The launch was refused: a signed link was reused, older than 5 minutes or signed with the wrong secret, or an unsigned link opened a wall set to Signed. Sign a fresh URL for every launch, or email us with your wall ID to allow simple links. |
| No offers yet | Nothing on the wall can run for this player's country and device right now. |

## Things never to do

- Never ship an API key, postback secret or wall secret in an app or a web page.
- Never credit a postback that fails verification, or one with `test=true`.
- Never reuse one `sub_id` for several users.
- Never coach users around "This offer isn't available for your account".
