The RecruitAffiliates.ai API
Use RecruitAffiliates.ai from your own code: keys, test mode, errors, retries, paging, rate limits and every endpoint.
The API lets your own code do what you do in the app. You can pull your best matches into a sheet, save them and find their emails. You can also start searches, write deals and move people through your pipeline.
It works on the trial and on every paid plan. Launch and the trial can make 30 requests a minute, Grow 120 and Enterprise 300. On a plan that has ended, every call answers 402 with the code finder_api_required.
Base URL: https://api.affiliaterail.com/v1/finder
Get a key
An API key is a long secret string that proves a request comes from your account. Anyone holding it can act as you, so keep it out of code you share.
- Open Settings in RecruitAffiliates.ai and go to the API tab.
- Create a key. Give it a name you'll recognise later, like "Zapier".
- Pick test or live.
- Copy the key straight away. We show it once and never again.
There are two kinds of key:
- Test keys start with
rail_test_. They only see sample data. They never spend an email lookup, never start a real search and never send an email. Use one while you build. - Live keys start with
rail_live_. They act on your real account and spend real email lookups.
What a test key can change
Every new brand comes with 12 sample prospects: made-up matches whose ids start with sample_. A test key can read those and change a few things on them. It never touches anything real.
- Sample prospects: restore them, change their tags and set a follow-up date. Deals work too, on a sample prospect you saved in the app.
- Email finds and searches: you get a fake address or a fake search back. No email lookup is spent and nothing runs.
- Changes that record an event: an event is a line in your account's history, and saving someone writes one. So does hiding them, moving their stage or lane, giving them an owner or adding a note. A test key can make all of these on a sample prospect. Its events are marked test (
livemodeisfalse). They reach your test webhook endpoints only: never your live webhooks, your team's notifications, your email, your reports or the Activity page. That is how a Zapier, Make or n8n test run works without touching real work. Hiding someone for a reason that changes your brand (sells_own_product,too_small,too_big,not_my_topic) and saving onto a list are still refused with403sandbox_real_row. - Your brand, your lists and your exclusions: these are always real, so a test key can't change them. That covers any change to your brand or your lists, and any new exclusion. Marking someone Do not contact writes an exclusion, so it's covered too. You get the same
403. - Campaigns: a test key can't start or steer one. You get
501sandbox_no_send. - Webhooks: a test key can add test endpoints and send them test events. It can't see or change a live one. See test mode.
- Threads: a test key sees none, because every thread is real mail.
Reading your brand, lists and exclusions works fine with a test key.
Each key has a scope. A scope is the set of things a key is allowed to do. finder:read can look but not change anything. finder:write can also save, find and edit. When you make a key, pick Read only for finder:read alone, or Read and change for both. Give a key the smallest scope it needs. A sheet or a dashboard that only pulls numbers wants Read only.
Can send email
A key can't send email unless you say so. When you make a Read and change key, there's one more box: Can send email. It's off unless you tick it. Ticking it adds a third scope, finder:send, which lets a tool launch and approve campaigns, reply and invite from your mailboxes.
finder:writenever includes it. A key made before this box existed can't send.- A Read only key can't carry it.
- Every send from a key waits a few minutes before it leaves, so you can undo it. Your Developers tab shows which keys can send.
- An assistant you connected with the Connect and Allow buttons never sends, even if you tick the box on another key.
Only tick it on a key for a tool you trust to write to people for you.
You can revoke a key from the same tab. It stops working at once.
Send the key
Put the key in the Authorization header of every request:
curl https://api.affiliaterail.com/v1/finder/brands \
-H "Authorization: Bearer rail_test_..."A missing or wrong key gets a 401. A key without the right scope gets a 403.
Most calls work on one brand. Send brand_id to pick it. Leave it off and we use your oldest brand. GET /brands lists the ids.
Versions
The version is in the path: /v1/. Here's what we promise about it.
- We add things to v1 without warning. That means new endpoints, new optional fields and new error codes. Write your code so an extra field doesn't break it.
- We never rename or remove anything in v1.
- A breaking change gets a new version, like
/v2/. You get at least 90 days' notice before v1 changes in a way that breaks you.
Errors
Every error has the same shape:
{
"error": {
"type": "invalid_request_error",
"code": "cursor_invalid",
"message": "That cursor doesn't belong to this list. Send the same filters, view and sort as the request that returned it, or start again without a cursor.",
"param": "cursor",
"docs_url": "https://docs.affiliaterail.com/finder/api#cursor_invalid",
"fix": "Send the same filters, view and sort as the call that gave you the cursor, or start again without one.",
"next_tool": null
}
}typeis the kind of failure. Branch on this in your code.codeis the exact reason. Each one has its own row in the table below.messageis a sentence you can show a person.paramnames the field that's wrong, when one is.docs_urllinks straight to that code's row below.fixis one sentence on what to do next, with a link to the right screen when it needs you. An assistant can act on it or read it to you.next_toolnames the MCP tool that fixes it, likerecruit_save, or isnullwhen only you can fix it.
| Status | What it means |
|---|---|
400, 422 | Something in the request is wrong. Read message and fix it. |
401 | Your key is missing or wrong. |
402 | Your plan doesn't include this, or you're out of email lookups. |
403 | The key's scope doesn't allow this, or a test key tried to change real data. |
404 | That thing doesn't exist, or it isn't yours. |
409 | It clashes with something already happening. Wait a second and retry. |
429 | Too many requests. See rate limits below. |
500 | Something broke on our side. Retry with the same idempotency key. |
501 | A test key can't do this. |
Error codes
Nothing is charged when any of these comes back. If a call spent an email lookup and then failed, it gives the email lookup back before it answers.
| Code | Status | What happened, and what to do |
|---|---|---|
parameter_invalid | 400 | A field has the wrong value. param says which. |
parameter_missing | 400 | A field you need is missing, or you sent nothing to change. |
cursor_invalid | 400 | The cursor came from a different list. Send the same filters and sort, or start again. |
terms_invalid | 400 | The deal terms don't add up, like a rate over 100%. param names the field. |
too_many_competitors, too_many_keywords, too_many_hashtags, nothing_to_search, bad_country, bad_language, no_target_countries, summary_required | 400 | The brand update breaks one of the brand's rules. message says which. |
missing_key | 401 | No key in the Authorization header. |
invalid_key | 401 | That key is wrong or was revoked. |
finder_api_required | 402 | Your plan has ended or is read-only. Pick a plan in Settings, under Billing. |
plan_read_only | 402 | Your trial has ended. You can read everything, but new searches and finds are off. |
insufficient_credits | 402 | You're out of email or searches this month. |
campaign_cap_reached | 402 | Your plan runs this many campaigns at once. Stop one first. |
webhooks_not_on_plan | 402 | Your plan doesn't include webhooks. |
lanes_not_on_plan | 402 | Your plan doesn't include lanes. |
insufficient_scope | 403 | The key needs finder:write for this, or finder:send for anything that sends (see Can send email). |
sandbox_real_row | 403 | Test keys only change sample prospects. This would change your real brand, a list or your exclusions, or hide someone for a reason that changes your brand. Use a live key. |
resource_missing | 404 | No such thing on your account. Another account's ids get this too. |
idempotency_key_in_flight | 409 | The first request with this idempotency key is still running. Retry in a second. |
reveal_in_progress | 409 | This email is already being looked up. Ask again in a minute. |
deal_frozen | 409 | These terms are agreed, so they can't change. |
replay_too_old | 422 | You can replay deliveries from the last 30 days. This one is older. |
idempotency_key_reused | 422 | You used this idempotency key for a different request. Use a new one. |
not_saved | 422 | Save the prospect first. Email finds, deals and stages are for saved prospects. |
wrong_stage | 422 | That lane belongs to another stage. Move the card's stage with lane_id instead. |
do_not_contact | 422 | This person is marked do not contact, so we don't look up their address. |
recently_checked | 422 | We looked for this email lately and found nothing. message says when you can try again. |
providers_paused | 422 | We've paused searches and finds for a short while. Try again later. |
invalid_stage, stage_reason, activate_through_handoff | 422 | That stage move isn't allowed. declined and on_hold need a reason. Activated is set in the app. |
no_offer, invalid_verdict | 422 | Write your default offer in the app first, or pick a verdict from the list. |
wrong_state, preflight_failed | 422 | The campaign can't do that now. Only a running one pauses; only a paused one resumes, and only if its checks still pass. |
inbox_unavailable, invalid_source, invalid_name | 422 | Pick one of your mailboxes and one of your lists, and a name up to 80 characters. |
hide_reason, list_name, do_not_contact_reason | 422 | Pick a reason or a name from the allowed list. |
rate_limited | 429 | Too many requests. Wait for the seconds in Retry-After. |
key_daily_cap_reached | 429 | This key reached its daily limit on email lookups or searches. Retry-After is the seconds to midnight UTC. Don't retry sooner. |
search_start_limited | 429 | Too many first searches started lately. Wait a few minutes. |
nothing_left | 422 | There is nothing new left to find for this search. Nothing was charged. |
search_start_failed | 500 | The search didn't start. Nothing was charged. Try again. |
feature_off | 404 | This part isn't switched on for your account yet. |
internal | 500 | Something broke on our side. Retry with the same idempotency key. |
sandbox_no_send | 501 | Test keys can't create or change campaigns, because a campaign sends real email. |
Retries without doubles
An idempotency key is a unique string you send so a retried request never runs twice. Send one with every POST in the Idempotency-Key header. A UUID works well.
Let's say you find an email and your connection drops before the answer comes back. You don't know if the email lookup was spent. Send the same request again with the same key. If the first one finished, you get its answer back and no second email lookup is spent. The response has Idempotent-Replayed: true so you can tell.
- We keep a key for 24 hours.
- The same key with a different request body gets a
422. - The same key while the first request is still running gets a
409. Wait a second and retry.
Use a new key for each new thing you want to do.
Paging through lists
Lists come back a page at a time. This is cursor pagination: each page hands you a marker, called a cursor, that points at the next page.
{
"data": [ ... ],
"has_more": true,
"total_count": 212,
"next_cursor": "eyJvZmZzZXQiOjI1fQ"
}Send limit to set the page size, up to 100. When has_more is true, ask again with cursor=<next_cursor>. Keep the same filters and sort while you walk a list, or the cursor is rejected.
Rate limits
A rate limit caps how many requests you can make in a minute, so one busy script can't slow the app for everyone.
On Launch and the trial you can make 30 requests a minute, on Grow 120, and on Enterprise 300. That limit covers your whole account, and all your keys share it.
Every response tells you where you stand:
X-RateLimit-Limit: your limit for the minute.X-RateLimit-Remaining: how many you have left.X-RateLimit-Reset: when the count starts again.X-RateLimit-Scope: organization.
Go over and you get a 429 with a Retry-After header. It's the number of seconds to wait. Wait that long, then retry.
Request ids and the log
Every response carries a Rail-Request-Id header. It's a unique id for that one request. Log it next to anything that goes wrong, and send it to us if you need help.
You can see the last 100 requests for a key in the API tab, or with GET /requests. Each row shows the method, path, status, error code and how long it took.
Worked example: save your top 10, then find their emails
This takes your 10 best-fit matches, saves them, and finds each one's email. Step 1 works with a test key, and so does saving a sample match (it sends a test event). Step 3 needs a live key: an email find spends an email lookup when it finds an address.
KEY="rail_live_..."
BASE="https://api.affiliaterail.com/v1/finder"
# 1. Your 10 best matches
curl "$BASE/prospects?sort=fit&limit=10" \
-H "Authorization: Bearer $KEY"
# 2. Save them (use the ids from step 1)
curl -X POST "$BASE/prospects/save" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"ids": ["fdi_...", "fdi_..."]}'
# 3. Reveal one email. Repeat for each id, with a new key each time.
curl -X POST "$BASE/prospects/fdi_.../reveal_email" \
-H "Authorization: Bearer $KEY" \
-H "Idempotency-Key: $(uuidgen)"import { RecruitAffiliates } from "@recruitaffiliates/sdk";
const ra = new RecruitAffiliates({ apiKey: process.env.RA_API_KEY! });
// 1. Your 10 best matches
const top = await ra.affiliates.list({ sort: "fit", limit: 10 });
const ids = top.data.map((p) => p.id);
// 2. Save them. The SDK adds an Idempotency-Key to every write.
await ra.affiliates.save({ ids });
// 3. Reveal each email. One email credit each, only when an address comes back.
for (const id of ids) {
const result = await ra.affiliates.revealEmail(id);
console.log(id, result.email ?? "no email found");
}An email find spends one email lookup, only when a usable address comes back. If we find nothing, the email lookup comes straight back. Email lookups has the full rules.
If you'd rather not write code, the recipes for Zapier, Make and n8n do the same with a few clicks.
Endpoints
All paths start with https://api.affiliaterail.com/v1/finder.
| Method and path | What it does | Scope |
|---|---|---|
GET /brands | List your brands | finder:read |
GET /brands/{id} | One brand, with its latest search | finder:read |
POST /brands/{id} | Update a brand's profile | finder:write |
GET /prospects | Your matches. Filter by view, channel, fit, search; order by sort | finder:read |
GET /prospects/{id} | One match, in full | finder:read |
GET /saved | Saved, in a deal, or hidden (view=saved, deals or hidden) | finder:read |
POST /prospects/save | Save up to 100 matches | finder:write |
POST /prospects/hide | Hide matches | finder:write |
POST /prospects/restore | Bring hidden matches back | finder:write |
POST /prospects/{id}/reveal_email | Find one saved match's email (one email lookup) | finder:write |
POST /prospects/{id} | Set the owner, tags or a follow-up date, add a note, or mark do not contact | finder:write |
GET /prospects/{id}/notes | The team's notes on one match, newest first | finder:read |
POST /prospects/{id}/notes | Add a note | finder:write |
GET /lists | Your lists | finder:read |
POST /lists | Create a list | finder:write |
POST /lists/{id}/members | Add or remove people on a list | finder:write |
GET /searches | Your recent searches | finder:read |
GET /searches/{id} | One search and how far it's got | finder:read |
POST /searches | Start a search (one search) | finder:write |
POST /searches/clone-profile | Read creators you already rate into a profile (free) | finder:write |
POST /searches/clone | Find creators like them, on that profile (one search) | finder:write |
GET /deals | Your deals | finder:read |
GET /deals/{id} | One deal, with every version of its terms | finder:read |
POST /deals | Offer terms to a saved match | finder:write |
POST /deals/{id} | Offer new terms as the next version | finder:write |
GET /pipeline | Your pipeline, by stage | finder:read |
POST /pipeline/{discovery_id}/stage | Move someone to another stage, and into a lane if you like | finder:write |
GET /lanes | Your lanes, in board order | finder:read |
POST /pipeline/{discovery_id}/lane | Move someone into a lane of the stage they're in | finder:write |
GET /threads | Your Mailroom threads, the ones that need a reply first | finder:read |
GET /threads/{id} | One thread and its messages, without the text | finder:read |
GET /credits | What you have left this month | finder:read |
GET /credits/ledger | Every email lookup spent and refunded | finder:read |
GET /campaigns | Your campaigns | finder:read |
GET /campaigns/{id} | One campaign, with its numbers | finder:read |
POST /campaigns | Create a draft campaign | finder:write |
POST /campaigns/{id}/pause | Pause a campaign | finder:write |
POST /campaigns/{id}/resume | Resume a paused campaign | finder:write |
POST /campaigns/{id}/stop | Stop a campaign for good | finder:write |
GET /exclusions | Domains and people you never want to see | finder:read |
POST /exclusions | Add exclusions | finder:write |
GET /settings | Your plan, seats and connected mailboxes | finder:read |
GET /team | Who's on your team, with the ids other calls take | finder:read |
GET /segments | Your smart segments, with how many are in each now | finder:read |
GET /segments/{id}/members | Who's in a segment now | finder:read |
GET /activity | What your team and the product did, newest first | finder:read |
GET /prospects/{id}/activity | One match's timeline | finder:read |
GET /notifications | Your recent notifications | finder:read |
GET /requests | The request log for the key you're calling with | finder:read |
Notes and your team
A note is a line your team keeps on a match, with an author and a time. Each match has a thread of them.
POST /prospects/{id}/noteswrites as whoever made the key. Sendauthor_user_idto write as someone else on the team. If the key's maker has left, it writes as the account owner.GET /teamgives each person'suser_id. That's whatassignee_user_idandauthor_user_idtake. Webhook events name people byidinstead.GET /teamshows email addresses only when an owner or an admin made the key.- A test key can read and add notes on sample matches. The
note.addedevent it sends is a test event.
Segments
A segment is a saved filter over your saved matches, like "UK YouTubers with an email". It isn't a fixed list. Who's in it changes as your matches change, so GET /segments/{id}/members works it out fresh each time you ask. "Owner is me" means whoever made the key. Want to email one? Send segment_id to POST /campaigns and you get a draft that a person still starts in the app.
Activity
GET /activity is the same feed as the Activity page: stage moves, notes, new owners, replies, invites and sign-ups. Filter it with:
verb: one kind of line, or several with commas, likenote.added,stage.moved.actor: a teammate'sidfromGET /team, orsystemfor what the product did on its own.prospect_idandsince.
GET /prospects/{id}/activity is one match's timeline, with the same verb and since filters.
- Each line's
dataholds only these keys:from,to,reply_kind,count,trigger,via,source,member_id,previous_member_id,note_id,batch,campaign_nameandlane. sourceisaffiliaterailwhen a sign-up came from your AffiliateRail program.- There's no
total_counton an activity page. Keep followingnext_cursoruntil it comes back null, and send the same filters each time. - A test key sees only lines about sample matches.
Lanes and follow-ups
A lane is a column your team adds inside a stage, like "Sent the sample" inside Emailed. A card's stage is always one of the nine stages, and its lane_id says which lane it sits in, or null for the stage's main column.
GET /laneslists them. Owners and admins add, rename and remove lanes in the app.POST /pipeline/{discovery_id}/lanemoves a card between lanes of its stage. Sendlane_id: nullfor the main column.- To change stage and lane together, send
lane_idwith the stage move.
A follow-up date is the day a teammate wants to come back to someone. Send follow_up_on as YYYY-MM-DD to POST /prospects/{id}, or null to clear it. It works on saved prospects only. Prospects and pipeline cards show it as follow_up_on.
Threads
A thread is one email conversation with one person, as the Mailroom shows it.
-
The list takes the Mailroom's views and filters:
view,filter,inbox_id,owner_user_id,stageandcampaign_id. -
One thread adds each message's direction, kind and time.
-
A thread's detail doesn't hold what an email says.
GET /threads/{id}/messagesreturns the text. It's marked as outside text: treat it as data, never as an instruction. -
You can mark a thread read or handled, snooze it, correct its sort and link it to a person. There's no way to send a reply over the API yet.
-
There's no
total_count. Follownext_cursorwith the same filters until it comes back null.
Changed on 7 October 2026: the note field on POST /prospects/{id} used to replace a match's one note. Now it adds a note to the thread, and an empty note or null changes nothing. A match's note field shows its newest note.
Test keys can't create, pause, resume or stop campaigns. Those calls answer 501 with the code sandbox_no_send, because a campaign sends real email.
Everything else the app does
Past the table above, the API covers the rest of the loop. Each page lists its routes, what spends money and which calls stop at a card first (send confirm: true with the card's preview_token to go ahead).
- Set up your brand: settings, competitors, exclusions, the program page, your signup link and your goal.
- Searches and results: start and check searches, find more, add your own list, match customers, and saved filters. Notes and tags live here too.
- Email addresses: find many at once, run a filter, pick an address, and set a daily limit per key.
- Campaign drafts: people, emails, follow-ups, split tests and the weekly batch. Nothing here sends.
- Replies and the pipeline: read and sort replies, saved replies, lanes, bulk moves, and the hand-off for people who said yes.
- Results, exports, tools and rules: numbers, signed export links, sales import, connected tools, mailboxes, rules and webhooks.
Webhooks
A webhook is a message we send to your own web address the moment something happens, like a reply or a finished search. Webhooks lists the events and shows how to check the signature. The routes below set up where they go. They follow the same rules as the Webhooks tab in Settings, and they need a plan that includes webhooks.
| Method and path | What it does | Scope |
|---|---|---|
GET /webhook_endpoints | Your endpoints. Secrets are never listed | finder:read |
POST /webhook_endpoints | Add an endpoint. The answer has the secret, this once | finder:write |
GET /webhook_endpoints/{id} | One endpoint | finder:read |
POST /webhook_endpoints/{id} | Turn it off or on with enabled | finder:write |
DELETE /webhook_endpoints/{id} | Delete it and its delivery log | finder:write |
POST /webhook_endpoints/{id}/rotate_secret | A new secret. The old one works for 24 more hours | finder:write |
POST /webhook_endpoints/{id}/test | Send one sample event now, and see what came back | finder:write |
GET /webhook_endpoints/{id}/deliveries | The last 50 deliveries, with the full request and response | finder:read |
POST /webhook_deliveries/{id}/replay | Send a delivery from the last 30 days again | finder:write |
To add an endpoint, send its url and the event_types you want. Leave event_types empty for every event. Set kind to slack for a Slack incoming webhook address, and livemode to false for a test endpoint. A test key always makes a test endpoint.
KEY="rail_live_..."
curl https://api.affiliaterail.com/v1/finder/webhook_endpoints \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.example.com/recruit", "event_types": ["reply.received", "deal.agreed"]}'Save the secret from the answer straight away. We never show it again. If you lose it, rotate it.
A test key sees only test endpoints and can't change anything here. It gets 403 with the code sandbox_real_row.
People who asked to be removed
Anyone can ask us to stop contacting them. When they do, the API forgets them for every customer. They don't appear in prospect lists, saved lists or the pipeline. Asking for one by id gives 404, and an email find gives 404 too, so nothing is looked up or charged.
What stays in the app
Some things only happen in RecruitAffiliates.ai itself, never over the API:
- Launching or approving a campaign, or sending any email.
- Marking terms agreed.
You see and confirm those yourself, every time.
Was this page helpful?