GetAff
Sign inList my program
API documentation

Quickstart: from a key to an enrolled partner

The whole partner-recruiting flow over the REST API, in about ten minutes: eight calls, each in curl, Node.js and Python, with what the answer looks like. Only one step spends credits, and it asks first.

Before you start

  • A GetAff account with at least one site. Every partner search is scoped to a site, and sites are created in the app.
  • An API key with the scopes read, search, write and spend, created from the “API keys” page. Without spend, everything works except the paid step 7.
  • The Pro plan for step 7: paid e-mail lookups (like live provider searches) are not on the free plan, which answers 403 plan_required.
  • For step 8, set up once in the app: a connected mailbox, complete sender settings (name, company, postal address) and a published programme.
API keys

Set up

Keep the key in an environment variable, on a server — never in a browser. The Node.js and Python samples use the small helper below: it sends the key, JSON-encodes the body, sets Idempotency-Key when given one, and raises on any error except 402, which step 7 handles. Errors are application/problem+json: branch on error.code, never on the message.

curl
export GETAFF_API_KEY='gaff_live_…'   # scopes: read search write spend
BASE=https://www.getaff.org/api
SITE=Wq3b9ZK1                         # the site id from step 1
Node.js
// Node.js 18+: fetch and crypto.randomUUID are built in.
const BASE = 'https://www.getaff.org/api';

async function getaff(method, path, { body, idempotencyKey } = {}) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.GETAFF_API_KEY}`,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
      ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json();
  // Errors are application/problem+json: branch on error.code, never on the message.
  if (!res.ok && res.status !== 402) {
    throw new Error(`${res.status} ${json.error.code}: ${json.detail}`);
  }
  return { status: res.status, json };
}
Python (requests)
# pip install requests
import os
import uuid

import requests

BASE = "https://www.getaff.org/api"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['GETAFF_API_KEY']}"


def getaff(method, path, body=None, idempotency_key=None):
    headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
    r = session.request(method, BASE + path, json=body, headers=headers, timeout=90)
    payload = r.json()
    # Errors are application/problem+json: branch on error.code, never on the message.
    if not r.ok and r.status_code != 402:
        raise RuntimeError(f"{r.status_code} {payload['error']['code']}: {payload['detail']}")
    return r.status_code, payload
Idempotency, on every write

Every write in this guide accepts an Idempotency-Key header (8 to 128 characters of A-Z a-z 0-9 _ - : .; a UUID fits). A retry with the same key and the same body within 24 hours replays the first success with Idempotent-Replayed: true instead of running again; the same key with another body answers 409 idempotency_conflict. Errors are not stored, so a corrected request can reuse its key. One key per operation, reused on its retries.

1. List your sites

GET /v1/sites returns the sites of your account, each with the affiliate programme it runs on GetAff. Keep the id of the site you are recruiting for: every following call uses it.

curl
curl -s "$BASE/v1/sites" \
  -H "Authorization: Bearer $GETAFF_API_KEY"
Node.js
const { json: sites } = await getaff('GET', '/v1/sites');
const siteId = sites.data[0].id;
Python (requests)
_, sites = getaff("GET", "/v1/sites")
site_id = sites["data"][0]["id"]
Response (trimmed)
{
  "data": [
    {
      "id": "Wq3b9ZK1",
      "host": "nyna.ai",
      "url": "https://nyna.ai",
      "name": "Nyna",
      "createdAt": "2026-09-02T10:11:00.000Z",
      "programme": {
        "name": "Nyna Partners",
        "slug": "nyna-partners",
        "markets": [
          "FR",
          "BE"
        ],
        "model": "cps"
      }
    }
  ]
}

2. Set the search criteria

PATCH /v1/sites/{id}/criteria (scope write) replaces each field you send and keeps the others: keywords, niches, platforms, countries, languages, audience bands, recency. { "reset": true } goes back to the criteria GetAff derives from the site. Skip this step to search with those derived criteria.

curl
curl -s -X PATCH "$BASE/v1/sites/$SITE/criteria" \
  -H "Authorization: Bearer $GETAFF_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"keywords":["wedding planning","bridal"],"countries":["FR","BE"]}'
Node.js
await getaff('PATCH', `/v1/sites/${siteId}/criteria`, {
  body: { keywords: ['wedding planning', 'bridal'], countries: ['FR', 'BE'] },
  idempotencyKey: crypto.randomUUID(),
});
Python (requests)
getaff(
    "PATCH",
    f"/v1/sites/{site_id}/criteria",
    {"keywords": ["wedding planning", "bridal"], "countries": ["FR", "BE"]},
    str(uuid.uuid4()),
)
Response (trimmed)
{
  "data": {
    "criteria": {
      "keywords": [
        "wedding planning",
        "bridal"
      ],
      "niches": [
        "weddings"
      ],
      "platforms": [
        "instagram",
        "youtube"
      ],
      "countries": [
        "FR",
        "BE"
      ],
      "languages": [
        "fr"
      ],
      "bands": [
        "micro",
        "mid"
      ],
      "activeWithinDays": 90
    },
    "edited": true,
    "droppedNiches": []
  }
}

4. Read the ranked results

GET /v1/sites/{id}/partner-search/latest pages through the last run (kept 30 days; every run is also saved under /partner-search/history). Each result carries its key, a fit score fit.pfs from 0 to 100 and fit.reasons, the why of its rank. minPfs keeps the strong fits; follow page.nextCursor for more.

curl
curl -s "$BASE/v1/sites/$SITE/partner-search/latest?minPfs=60&limit=50" \
  -H "Authorization: Bearer $GETAFF_API_KEY"
Node.js
const { json: latest } = await getaff(
  'GET',
  `/v1/sites/${siteId}/partner-search/latest?minPfs=60&limit=50`,
);
for (const r of latest.data.results) {
  console.log(r.rank, r.handle, r.fit.pfs, r.fit.reasons.map((x) => x.code));
}
const keys = latest.data.results.slice(0, 10).map((r) => r.key);
Python (requests)
_, latest = getaff("GET", f"/v1/sites/{site_id}/partner-search/latest?minPfs=60&limit=50")
for r in latest["data"]["results"]:
    print(r["rank"], r["handle"], r["fit"]["pfs"], [x["code"] for x in r["fit"]["reasons"]])
keys = [r["key"] for r in latest["data"]["results"][:10]]
Response (trimmed)
{
  "data": {
    "total": 58,
    "results": [
      {
        "key": "instagram:1784140",
        "rank": 1,
        "platform": "instagram",
        "handle": "lea.mariage",
        "displayName": "Léa · Mariage",
        "audience": 48200,
        "country": "FR",
        "fit": {
          "pfs": 86,
          "excluded": false,
          "reasons": [
            {
              "code": "topic",
              "terms": [
                "mariage"
              ],
              "host": null
            }
          ]
        }
      }
    ]
  },
  "page": {
    "limit": 50,
    "hasMore": true,
    "nextCursor": "NTA"
  }
}

5. Shortlist the best

POST /v1/sites/{id}/shortlist (scope write) adds results of the latest search by key, up to 50 per call; the shortlist holds 100. A creator already shortlisted is refreshed, not duplicated; a key that is not in the latest results comes back in unknownKeys and nothing is added for it.

curl
curl -s -X POST "$BASE/v1/sites/$SITE/shortlist" \
  -H "Authorization: Bearer $GETAFF_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"keys":["instagram:1784140"]}'
Node.js
const { json: shortlist } = await getaff('POST', `/v1/sites/${siteId}/shortlist`, {
  body: { keys },
  idempotencyKey: crypto.randomUUID(),
});
console.log(shortlist.added, shortlist.unknownKeys);
Python (requests)
_, shortlist = getaff(
    "POST", f"/v1/sites/{site_id}/shortlist", {"keys": keys}, str(uuid.uuid4())
)
print(shortlist["added"], shortlist["unknownKeys"])
Response (trimmed)
{
  "data": [
    {
      "key": "instagram:1784140",
      "handle": "lea.mariage",
      "pfs": 86,
      "status": "selected",
      "prospectId": null
    }
  ],
  "max": 100,
  "added": 1,
  "full": false,
  "unknownKeys": []
}

6. Turn them into prospects

POST /v1/sites/{id}/shortlist/to-prospects (scope write) writes shortlisted creators to your private prospects. It spends no credit, but counts against the discovery quota of your plan (quota). Without keys, every shortlisted creator not yet a prospect is added. A free pass over the public bio sometimes finds an address already (enriched). Keep prospectIds.

curl
curl -s -X POST "$BASE/v1/sites/$SITE/shortlist/to-prospects" \
  -H "Authorization: Bearer $GETAFF_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"keys":["instagram:1784140"]}'
Node.js
const { json: written } = await getaff(
  'POST',
  `/v1/sites/${siteId}/shortlist/to-prospects`,
  { body: { keys }, idempotencyKey: crypto.randomUUID() },
);
const prospectIds = written.prospectIds;
Python (requests)
_, written = getaff(
    "POST",
    f"/v1/sites/{site_id}/shortlist/to-prospects",
    {"keys": keys},
    str(uuid.uuid4()),
)
prospect_ids = written["prospectIds"]
Response (trimmed)
{
  "written": 1,
  "alreadyInProspects": 0,
  "enriched": 0,
  "prospectIds": [
    "kJ2p8wQxYb_ig_1784140"
  ],
  "quota": {
    "used": 141,
    "limit": 2000
  }
}

7. Find e-mail addresses (paid)

POST /v1/prospects/find-emails (scope spend, Pro plan) looks up an address at the creator-data providers for prospects that still have none. It never replaces an existing address, and the suppression list applies. Because it spends credits, it takes two calls.

curl
IDEM=$(uuidgen)   # one key for the whole operation

# 7a. No confirmCost: 402 with the estimate, nothing spent
curl -s -X POST "$BASE/v1/prospects/find-emails" \
  -H "Authorization: Bearer $GETAFF_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $IDEM" \
  -d '{"ids":["kJ2p8wQxYb_ig_1784140"]}'

# 7b. A human agreed to error.details.estimate.credits: confirm, same key
curl -s -X POST "$BASE/v1/prospects/find-emails" \
  -H "Authorization: Bearer $GETAFF_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $IDEM" \
  -d '{"ids":["kJ2p8wQxYb_ig_1784140"],"confirmCost":1}'
Node.js
const idempotencyKey = crypto.randomUUID(); // one key for the whole operation
const ask = await getaff('POST', '/v1/prospects/find-emails', {
  body: { ids: prospectIds },
  idempotencyKey,
});
if (ask.status === 402 && ask.json.error.code === 'cost_confirmation_required') {
  const { credits } = ask.json.error.details.estimate;
  // askHuman: your own approval step (a prompt, a Slack button, a ticket…).
  if (await askHuman(`Look up ${prospectIds.length} e-mail(s) for up to ${credits} credits?`)) {
    const { json: found } = await getaff('POST', '/v1/prospects/find-emails', {
      body: { ids: prospectIds, confirmCost: credits },
      idempotencyKey,
    });
    console.log(found.found, 'found,', found.none, 'without an address');
  }
}
Python (requests)
idem = str(uuid.uuid4())  # one key for the whole operation
status, ask = getaff("POST", "/v1/prospects/find-emails", {"ids": prospect_ids}, idem)
if status == 402 and ask["error"]["code"] == "cost_confirmation_required":
    credits = ask["error"]["details"]["estimate"]["credits"]
    if input(f"Spend up to {credits} credits? [y/N] ").strip().lower() == "y":
        _, found = getaff(
            "POST",
            "/v1/prospects/find-emails",
            {"ids": prospect_ids, "confirmCost": credits},
            idem,
        )
        print(found["found"], "found,", found["none"], "without an address")

7a — Ask for the estimate

Send the call without confirmCost. The answer is 402 cost_confirmation_required, with the exact estimate in error.details.estimate — and nothing is spent. Show estimate.credits to a human.

402
HTTP/1.1 402 Payment Required
Content-Type: application/problem+json

{
  "type": "https://www.getaff.org/docs/errors/cost_confirmation_required",
  "title": "Cost confirmation required",
  "status": 402,
  "detail": "Looking up 1 address(es) would spend up to 1 credits. Nothing was spent. Resend with `confirmCost` >= `error.details.estimate.credits`.",
  "instance": "urn:getaff:request:req_6f1c2a9e0b7d4c55a1f3e2d8c9b0a7e6",
  "error": {
    "code": "cost_confirmation_required",
    "message": "Looking up 1 address(es) would spend up to 1 credits. Nothing was spent. Resend with `confirmCost` >= `error.details.estimate.credits`.",
    "details": {
      "estimate": {
        "credits": 1,
        "action": "email_lookup",
        "units": 1,
        "lines": [],
        "remainingToday": 199
      }
    }
  }
}

7b — Confirm and run

Once they agree, resend the same call with confirmCost at least equal to estimate.credits: it is the most you accept to spend. Keep the same Idempotency-Key: the 402 was not stored, so the confirmed call runs; if it times out and you retry it unchanged, you get its first result back (Idempotent-Replayed: true) instead of paying twice. results[].status says, prospect by prospect, found, none, daily_limit…

200 OK
{
  "looked": 1,
  "found": 1,
  "none": 0,
  "stopped": 0,
  "skipped": 0,
  "estimate": {
    "credits": 1,
    "action": "email_lookup",
    "units": 1,
    "lines": [],
    "remainingToday": 199
  },
  "results": [
    {
      "prospectId": "kJ2p8wQxYb_ig_1784140",
      "status": "found"
    }
  ]
}

8. Enrol in a sequence

POST /v1/outreach/sequences (scope write) schedules the three-step sequence — an opening and two follow-ups — for one prospect, with every check the app makes: a connected mailbox (pass mailboxId when you have several; GET /v1/outreach/mailboxes lists them), complete sender settings, a published programme, a valid address that is not on the suppression list, and the compliance regime of the recipient’s country. attestations are your record of the lawful basis, as in the GetAff composer; without draft, the draft saved for this prospect in GetAff is used.

curl
curl -s -X POST "$BASE/v1/outreach/sequences" \
  -H "Authorization: Bearer $GETAFF_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"prospectId":"kJ2p8wQxYb_ig_1784140",
       "attestations":["b2b_role_relevant","legitimate_interest_assessed"]}'
Node.js
const { json: sequence } = await getaff('POST', '/v1/outreach/sequences', {
  body: {
    prospectId: prospectIds[0],
    attestations: ['b2b_role_relevant', 'legitimate_interest_assessed'],
  },
  idempotencyKey: crypto.randomUUID(),
});
console.log(sequence.data.state, sequence.data.steps.map((s) => s.dueAt));
Python (requests)
_, sequence = getaff(
    "POST",
    "/v1/outreach/sequences",
    {
        "prospectId": prospect_ids[0],
        "attestations": ["b2b_role_relevant", "legitimate_interest_assessed"],
    },
    str(uuid.uuid4()),
)
print(sequence["data"]["state"], [s["dueAt"] for s in sequence["data"]["steps"]])
Response (trimmed)
{
  "data": {
    "id": "kJ2p8wQxYb_ig_1784140",
    "prospectId": "kJ2p8wQxYb_ig_1784140",
    "mailboxEmail": "partners@nyna.ai",
    "state": "active",
    "regimeId": "eu_gdpr",
    "steps": [
      {
        "key": "opening",
        "state": "scheduled",
        "dueAt": "2026-10-27T08:30:00.000Z",
        "sentAt": null
      },
      {
        "key": "followup1",
        "state": "scheduled",
        "dueAt": "2026-11-01T08:30:00.000Z",
        "sentAt": null
      },
      {
        "key": "followup2",
        "state": "scheduled",
        "dueAt": "2026-11-08T08:30:00.000Z",
        "sentAt": null
      }
    ]
  }
}
No credit, but real e-mails

Enrolment spends no credit, so it takes no confirmCost. It does schedule real e-mails from your mailbox: have a human approve the prospect and the message before you call it — the MCP tool enrol_in_sequence enforces exactly that with a two-step confirmation. The API never sends an e-mail itself; the mailbox sends each step at its dueAt. Each refusal has its own code: mailbox_required, draft_required, outreach_settings_incomplete, program_required, recipient_suppressed, compliance_blocked (with details.missing)…

Rate limits

Every response carries RateLimit-Policy and RateLimit (and X-RateLimit-Limit and X-RateLimit-Remaining); a 429 carries Retry-After, in seconds. The app’s own ceilings apply too — for instance 6 partner searches a minute per account.

Rate limits

Next

Subscribe to the prospect.replied webhook to hear about replies instead of polling; the reference details every field and every error of each endpoint; the same flow is open to an AI assistant through the MCP server.