Agent API
Let your own agent, script or app see what your recruiters see in Wintro, read-only
The Agent API lets your own agent (Copilot Studio, ChatGPT, Claude) or script see what your recruiters see in Wintro: your vacancies, their best candidates, and which colleagues can introduce each one. It only reads, and only your own company.
What it answers
| Question | Action | In Wintro, that is |
|---|---|---|
| Which vacancies do we have? | vacancies/list |
the vacancy list |
| Who are the best candidates for this vacancy, and who can introduce them? | candidates/ranked |
a vacancy’s candidates page |
| We found someone elsewhere (our ATS, LinkedIn): does anyone here know them, and did anything already happen? | candidates/lookup |
searching for a person |
| Who exactly can introduce this one candidate, and was anyone asked already? | candidates/introducers |
opening one candidate’s introducers |
| What are my limits, and is anything paused? | agent/status |
Most runs go: find the vacancy, rank its candidates, and look up anyone found outside Wintro before reaching out. candidates/introducers is the detail view of one candidate: candidates/ranked already gives each candidate’s five closest introducers and what already happened with them, so you rarely need it.
Quick start
Create a token
A recruiter admin, in Wintro: Settings > API > Create token, a name, and the actions this token may do (every one is ticked; untick what your agent should not do). Copy the token: it is shown once. Keep it secret: store it where your agent’s connection keeps secrets (the connection in Copilot Studio, a secret store or environment variable for your own code), never in a chat, a prompt, an email or code, and if it leaks, revoke it under Settings > API and make a new one.
List your vacancies
export WINTRO_TOKEN="wintro_..." curl -s https://www.wintro.app/api/wintro/app/vacancies/list \ -H "Authorization: Bearer $WINTRO_TOKEN" \ -H "Content-Type: application/json" \ -d '{}'Get the best candidates for one vacancy
curl -s https://www.wintro.app/api/wintro/app/candidates/ranked \ -H "Authorization: Bearer $WINTRO_TOKEN" \ -H "Content-Type: application/json" \ -d '{"vacancyId": 4182, "limit": 10}'
Every call is a POST to https://www.wintro.app/api/wintro/app/<action> with a JSON body ({} when there is nothing to send), even when it only reads.
Building an agent? Give it the OpenAPI description instead of this page: it lists exactly the actions your token can call, each with a description written for an agent.
curl -s https://www.wintro.app/api/wintro/_openapi.json \
-H "Authorization: Bearer $WINTRO_TOKEN"
Add ?format=swagger2 for Power Apps and Copilot Studio, or download wintro-connector.swagger.json under Settings > API. Setting up Copilot Studio: Use cases.
vacancies/list
{ "live": true }
| Field | |
|---|---|
live |
true: only vacancies live in Wintro, the ones with candidates. |
query |
Matches the title, a vacancy id or an ATS id. |
atsId |
The vacancy with this id in your ATS (a Carerix id, say). |
ids |
Exactly these vacancy ids, up to 300. |
view |
all (default), no-criteria, few-matches. |
limit |
1-500, default 100. |
{
"total": 11,
"vacancies": [
{
"vacancyId": 4182,
"companyId": 212,
"companyName": "Fabrikam",
"title": "Dynamics 365 F&O Consultant",
"atsId": "VAC-2291",
"status": "open",
"live": true,
"matchCount": 37,
"avgScore": 71,
"criteriaCount": 8,
"openedAt": "2026-09-30T08:12:00Z",
"location": { "text": "Ghent, Belgium", "searchedIn": ["Ghent, Belgium", "Antwerp, Belgium"] }
}
],
"companies": []
}
Open vacancies come first. status is open or closed; archived vacancies are not listed. live: false means the vacancy came from your ATS but is not switched on in Wintro yet: it has few or no candidates. location.text is where the job is, as the vacancy says; location.searchedIn names the places Wintro looks for candidates (each with some distance around it), empty when the search is not tied to a place. companies is always empty for your token. total counts every vacancy your filters keep; more than you got back means raise limit or narrow the filters.
candidates/ranked
{ "vacancyId": 4182, "minScore": 70 }
| Field | |
|---|---|
vacancyId |
Required. |
limit, offset |
Page size 1-100 (default 10), and where it starts. |
minScore |
Only fits at least this good, 0-100, never below your minimum match score (default: that score, 50 unless an admin changed it in Settings › Organization; what recruiters see). |
colleagues |
LinkedIn URLs or slugs of up to 20 colleagues: only the candidates they know. |
detail |
true: every field on its own, empty ones as null (see below). |
{
"vacancyId": 4182,
"vacancyLocation": { "text": "Ghent, Belgium", "searchedIn": ["Ghent, Belgium", "Antwerp, Belgium"] },
"total": 37,
"count": 10,
"hasMore": true,
"nextOffset": 10,
"candidates": [
{
"matchId": 918273,
"profileId": 55120,
"name": "Lotte Janssens",
"currentTitle": "Senior Functional Consultant D365",
"currentCompany": "Contoso Belgium",
"location": "Ghent, Belgium",
"profileScore": 84,
"hireScore": 91,
"pathCount": 3,
"introducers": [
{ "name": "Pieter De Smet", "profileUrl": "https://www.linkedin.com/in/pieter-de-smet" }
],
"fit": [
"Fluent in Dutch (must): yes — Their profile lists Dutch as native.",
"3+ years with D365 Finance & Operations: yes — Six years as a D365 F&O functional consultant at two partners.",
"Experience with Azure DevOps: partly — Release work is mentioned, but no tool is named."
],
"status": "recommended by Pieter De Smet 9 Oct",
"profileUrl": "https://www.linkedin.com/in/lotte-janssens",
"wintroUrl": "https://www.wintro.app/dashboard/candidates/4182/live?search=55120"
}
]
}
hireScoreorders the list, from our hire history: how many colleagues know them and how close the tie weigh most. Between equals, the better fit (profileScore) comes first.introducers: up to five, closest first, each with their LinkedIn (pass it tocolleagues).pathCountis how many there are in all;candidates/introducerslists every one. Withcolleagues, only those colleagues.fitandstatus: why they fit, and what already happened. See Readingfitandstatus.vacancyLocation: where the vacancy is, the same as invacancies/list: weigh it against each candidate’slocation.unknownColleaguescomes only when some colleagues you named are not colleagues here sharing their network.- No lookup needed: people your recruiters rejected or blocked, and those whose referral ended, are left out already, and each row says what happened. Use
candidates/lookupfor people found elsewhere.
Paging: pass nextOffset as the next offset while hasMore is true. At most the 500 best fits are ranked (with colleagues, everyone they know).
candidates/lookup
{ "linkedin": "https://www.linkedin.com/in/lotte-janssens" }
A URL in any form (nl.linkedin.com, tracking parameters, capitals) or the bare slug works.
{
"found": true,
"profile": { "profileId": 55120, "name": "Lotte Janssens", "headline": "D365 F&O | Finance & Supply Chain", "profileUrl": "https://www.linkedin.com/in/lotte-janssens" },
"introducers": [
{ "name": "Pieter De Smet", "profileUrl": "https://www.linkedin.com/in/pieter-de-smet" }
],
"matches": [
{
"matchId": 918273,
"vacancyId": 4182,
"vacancyTitle": "Dynamics 365 F&O Consultant",
"vacancyOpen": true,
"score": 84,
"fit": ["Fluent in Dutch (must): yes — Their profile lists Dutch as native."],
"status": "recommended by Pieter De Smet 9 Oct",
"wintroUrl": "https://www.wintro.app/dashboard/candidates/4182/live?search=55120"
}
]
}
matches: every vacancy of yours they were matched to, each withfitandstatusas incandidates/ranked.blocked: true: your recruiters blocked or snoozed them: don’t reach out. Left out when they are not.{ "found": false }: nobody at your company knows them through Wintro, and they were never matched to your vacancies; nothing else is said of them.headlinecomes whenever they have one.
Several people at once: linkedins takes up to 25, and the answer lists each in people, in the order asked, with the LinkedIn as you gave it. Each person counts as one call against your limits.
{ "linkedins": ["https://www.linkedin.com/in/lotte-janssens", "jan-peeters"] }
{
"people": [
{ "linkedin": "https://www.linkedin.com/in/lotte-janssens", "found": true, "profile": { "profileId": 55120, "name": "Lotte Janssens" }, "introducers": [{ "name": "Pieter De Smet", "profileUrl": "https://www.linkedin.com/in/pieter-de-smet" }] },
{ "linkedin": "jan-peeters", "found": false }
]
}
Give linkedin or linkedins, not both; a 400 names any that is not a LinkedIn profile.
candidates/introducers
This action takes a matchId from candidates/ranked or candidates/lookup and lists every colleague who can introduce that candidate, closest first, and whether a recruiter already asked them about this candidate.
{ "matchId": 918273 }
{
"matchId": 918273,
"blocked": false,
"count": 3,
"intros": [
{ "userId": 311, "firstName": "Pieter", "lastName": "De Smet", "headline": "Solution Architect", "profileUrl": "https://www.linkedin.com/in/pieter-de-smet", "nudgedAt": "2026-10-08T09:00:00Z", "recommendedAt": "2026-10-09T14:03:00Z" }
]
}
nudgedAt: when a recruiter asked this colleague about the candidate, null if nobody did. recommendedAt: when the colleague answered, null if they did not yet.
agent/status
{}
{
"limits": { "perMinute": 60, "perDay": 5000 },
"callsToday": 212,
"paused": [],
"limited": [],
"notices": []
}
Call it when a run starts. It always answers, even when an action is paused.
Reading fit and status
fit: why they fit, up to five of the vacancy’s criteria, must-haves first, one line each: criterion (must): yes / partly / no — why. The why is cut at about 90 characters with …; detail: true has it whole. Left out for a fit scored before criteria were judged one by one.
status: what already happened, in one line, the referral first, parts joined by ;. Left out when nothing happened.
status says |
Means | Do |
|---|---|---|
referred by An Claes: interview, recommended by An Claes 9 Oct |
A colleague’s referral, and after the colon its stage | Don’t approach them separately: the referral runs through Wintro. |
A stage word without a date: interview, contacted, hired |
A referral’s stage | The same. |
asked 3 Oct |
A recruiter asked a colleague about them | |
contacted 5 Oct, with a date |
A recruiter marked that they reached out | Don’t reach out again. |
agreed to be contacted 4 Oct |
The candidate agreed, after a referral | |
not a fit, says An Claes 2 Oct (lookup only) |
A colleague said they don’t fit | |
accepted 6 Oct (lookup only) |
A recruiter shortlisted them | |
rejected 6 Oct (lookup only) |
Your team decided against them for that vacancy |
A date in another year carries its year: 3 Oct 2025.
Compact or detail
By default empty fields are left out: no location, no introducers, no status means there is none. currentTitle and currentCompany come from their employment; headline, their LinkedIn headline, comes instead only when no current job is known.
detail: true gives every field on its own, empty ones as null:
candidates/ranked:firstNameandlastName,headline,fitas objects (criterion,statusofgood_match/potential_fit/not_a_fit,required,reason), introducers withuserIdandheadline, and the times one by one instead ofstatus(colleagueAskedAt,recommendedAt,recommendedBy,referredAt,referredBy,referralStage,consentAt,contactedAt, ISO,nullwhen it did not happen).candidates/lookup:firstNameandlastName,blockedalways, introducers withuserIdandheadline, and per matchfitas objects,referralStageand everyactivitywith its time and who did it.
Limits
One request is one call, however many candidates it returns: a page of 20 ranked candidates, with their introducers and status, is one call. The one exception is a lookup of several people, which counts one call per person (25 people are 25 calls), as if each were asked alone.
Two limits, checked in this order:
- Per address, before the token is read: 600 calls a minute from one IP. Every answer carries
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset. - Per company, all tokens together: 60 calls a minute and 5,000 a day (your plan may say otherwise;
agent/statushas yours). The day resets at midnight UTC. Every answer carries what is left:
X-RateLimit-Minute-Remaining: 57
X-RateLimit-Day-Remaining: 4788
with -Limit and -Reset (a Unix time in seconds) beside each.
Errors
Errors are { "error": { "code": "…", "message": "…" } }, except the two noted.
| Status | Code | What to do |
|---|---|---|
400 |
BAD_REQUEST |
The message names the field. Fix the request; don’t retry it as is. |
401 |
UNAUTHORIZED |
Token missing, wrong or revoked. Don’t retry; ask for a new token. |
403 |
FORBIDDEN |
The token may not call this action (its admin left it out; the message names it), or the Agent API is off on your plan. Don’t retry. |
404 |
NOT_FOUND |
Not one of your vacancies or matches. Check the id. |
404 |
(none) | An action that does not exist: { "message": "unknown action 'x'" }. Check the name. |
429 |
RATE_LIMITED |
Over your company’s minute or day. Wait Retry-After seconds. |
429 |
ACTION_LIMITED |
Wintro holds this action to fewer calls for now. Wait Retry-After seconds. |
429 |
(none) | Over the per-address limit: { "error": "Too Many Requests", "retryAfter": 12 }. Wait Retry-After seconds. |
503 |
RATE_LIMIT_UNAVAILABLE |
Limits cannot be checked right now. Wait Retry-After seconds (30). |
503 |
PAUSED |
Wintro paused this action; the message says why and until when. Use the other actions; agent/status lists what is paused. |
500 |
INTERNAL |
Our error. Retry once after a few seconds, then stop. |
Notices from Wintro (a change, maintenance) come on every answer as X-Wintro-Notice, and in agent/status.
Not there yet
- Searching by free text or a CV: a search starts from a vacancy, a LinkedIn profile, or a list of colleagues.
- Anything that writes: the API only reads.
Switching the Agent API off on your plan stops every token with Agent API ticked at once. To stop one token, revoke it under Settings > API.
