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,writeandspend, created from the “API keys” page. Withoutspend, 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.
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.
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 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 };
}# 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, payloadEvery 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 -s "$BASE/v1/sites" \
-H "Authorization: Bearer $GETAFF_API_KEY"const { json: sites } = await getaff('GET', '/v1/sites');
const siteId = sites.data[0].id;_, sites = getaff("GET", "/v1/sites")
site_id = sites["data"][0]["id"]{
"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 -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"]}'await getaff('PATCH', `/v1/sites/${siteId}/criteria`, {
body: { keywords: ['wedding planning', 'bridal'], countries: ['FR', 'BE'] },
idempotencyKey: crypto.randomUUID(),
});getaff(
"PATCH",
f"/v1/sites/{site_id}/criteria",
{"keywords": ["wedding planning", "bridal"], "countries": ["FR", "BE"]},
str(uuid.uuid4()),
){
"data": {
"criteria": {
"keywords": [
"wedding planning",
"bridal"
],
"niches": [
"weddings"
],
"platforms": [
"instagram",
"youtube"
],
"countries": [
"FR",
"BE"
],
"languages": [
"fr"
],
"bands": [
"micro",
"mid"
],
"activeWithinDays": 90
},
"edited": true,
"droppedNiches": []
}
}3. Run a partner search
POST /v1/sites/{id}/partner-search (scope search) runs the search the app runs: the GetAff creator index ranked for your programme, lookalikes of your shortlist and the creators who promote your competitors — merged, deduplicated and ranked. Without includeLive it spends no credit. With includeLive: true it also queries the live creator-data providers: that needs spend and a confirmCost, exactly as in step 7. statuses tells you what each source did.
curl -s -X POST "$BASE/v1/sites/$SITE/partner-search" \
-H "Authorization: Bearer $GETAFF_API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"limit":50}'const { json: run } = await getaff('POST', `/v1/sites/${siteId}/partner-search`, {
body: { limit: 50 },
idempotencyKey: crypto.randomUUID(),
});
console.log(run.data.total, run.data.statuses.map((s) => `${s.id}: ${s.status}`));_, run = getaff(
"POST", f"/v1/sites/{site_id}/partner-search", {"limit": 50}, str(uuid.uuid4())
)
print(run["data"]["total"], [(s["id"], s["status"]) for s in run["data"]["statuses"]]){
"data": {
"siteId": "Wq3b9ZK1",
"total": 58,
"statuses": [
{
"id": "index",
"kind": "index",
"status": "ok",
"count": 42
}
],
"creditsSpent": 0,
"searchId": "mh7c2k0qx4f9za"
}
}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 -s "$BASE/v1/sites/$SITE/partner-search/latest?minPfs=60&limit=50" \
-H "Authorization: Bearer $GETAFF_API_KEY"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);_, 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]]{
"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 -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"]}'const { json: shortlist } = await getaff('POST', `/v1/sites/${siteId}/shortlist`, {
body: { keys },
idempotencyKey: crypto.randomUUID(),
});
console.log(shortlist.added, shortlist.unknownKeys);_, shortlist = getaff(
"POST", f"/v1/sites/{site_id}/shortlist", {"keys": keys}, str(uuid.uuid4())
)
print(shortlist["added"], shortlist["unknownKeys"]){
"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 -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"]}'const { json: written } = await getaff(
'POST',
`/v1/sites/${siteId}/shortlist/to-prospects`,
{ body: { keys }, idempotencyKey: crypto.randomUUID() },
);
const prospectIds = written.prospectIds;_, written = getaff(
"POST",
f"/v1/sites/{site_id}/shortlist/to-prospects",
{"keys": keys},
str(uuid.uuid4()),
)
prospect_ids = written["prospectIds"]{
"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.
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}'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');
}
}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.
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…
{
"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 -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"]}'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));_, 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"]]){
"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
}
]
}
}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)…
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.
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.