Customer API

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

  1. 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.

  2. 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 '{}'
  3. 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"
    }
  ]
}
  • hireScore orders 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 to colleagues). pathCount is how many there are in all; candidates/introducers lists every one. With colleagues, only those colleagues.
  • fit and status: why they fit, and what already happened. See Reading fit and status.
  • vacancyLocation: where the vacancy is, the same as in vacancies/list: weigh it against each candidate’s location.
  • unknownColleagues comes 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/lookup for 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 with fit and status as in candidates/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.
  • headline comes 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: firstName and lastName, headline, fit as objects (criterion, status of good_match / potential_fit / not_a_fit, required, reason), introducers with userId and headline, and the times one by one instead of status (colleagueAskedAt, recommendedAt, recommendedBy, referredAt, referredBy, referralStage, consentAt, contactedAt, ISO, null when it did not happen).
  • candidates/lookup: firstName and lastName, blocked always, introducers with userId and headline, and per match fit as objects, referralStage and every activity with 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:

  1. 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.
  2. Per company, all tokens together: 60 calls a minute and 5,000 a day (your plan may say otherwise; agent/status has 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.