Ashby Candidate Profile — Search, Read & Update
Purpose
Operate on candidate records in an Ashby ATS tenant. Given a candidate identifier (email, name, or UUID), this skill:
- Searches for candidates by email and/or name.
- Reads the full candidate profile — contact info, social links, tags, current position/company/school, application ids, file handles (resume + attachments), custom fields, source, location, fraud status, and the list of existing notes.
- Writes new information — adds a note to the candidate (plain-text or HTML), and submits structured interview feedback against one of the candidate's applications.
The skill is API-first. Ashby publishes a complete, stable, documented REST API at https://api.ashbyhq.com, and writing to the candidate's notes / feedback streams via the API is the same operation a Recruiter performs in the UI — the entries appear in the activity timeline with the API-key's owning user as the author. The browser app at app.ashbyhq.com is reserved for read/write fallback only when the API key is unavailable, and even then it requires a logged-in SSO session (there is no email/password form to script against).
When to Use
- Recruiting-ops bots that triage candidates by email/name and dump structured profile JSON for downstream LLM scoring.
- "Look up this candidate before my call" assistants that paste a one-page summary (current company, applications, last note, latest stage) into Slack.
- Auto-noting integrations: drop a note on the candidate after an external event (sourcing tool hand-off, interview scheduled in a third-party system, Zapier-style "candidate replied on LinkedIn").
- Interviewer copilots that submit a structured Score + RichText feedback against a specific
applicationIdafter a debrief. - Not for: hiring decisions, offer changes, stage transitions — those are separate Ashby endpoints (
application.changeStage,offer.*) and should be packaged as their own skill.
Workflow
1. Authenticate
Ashby uses HTTP Basic Auth with the API key as the username and an empty password. Every request must also send Accept: application/json; version=1. There is no OAuth dance, no refresh, no per-user token — one API key per integration, scoped by the permissions checked when it was created.
ASHBY_API_KEY="<from tenant admin>"
AUTH=(-u "${ASHBY_API_KEY}:" -H "Accept: application/json; version=1" -H "Content-Type: application/json")
BASE="https://api.ashbyhq.com"
The required permission for each call is encoded in the endpoint name's module: read calls (candidate.search, candidate.info, candidate.list, candidate.listNotes) need candidatesRead; write calls (candidate.createNote, applicationFeedback.submit) need candidatesWrite. Verify with apiKey.info once at session start — that one round-trip prevents surprise 403s later in the workflow.
curl "${AUTH[@]}" -X POST "$BASE/apiKey.info" -d '{}'
2. Find the candidate (search → resolve UUID)
When the caller has only an email or display name, hit candidate.search. Email and name parameters combine with AND, so pass email alone for the most reliable hit; pass both only if you need to disambiguate name collisions.
curl "${AUTH[@]}" -X POST "$BASE/candidate.search" \
-d '{"email": "ada@example.com"}'
Response shape:
{
"success": true,
"results": [
{
"id": "e9ed20fd-d45f-4aad-8a00-a19bfba0083e",
"name": "Ada Lovelace",
"primaryEmailAddress": { "value": "ada@example.com", "type": "Work", "isPrimary": true },
"applicationIds": ["b7c8...", "a1b2..."],
"profileUrl": "https://app.ashbyhq.com/candidates/e9ed20fd-...",
"..."
}
]
}
candidate.search is capped at 100 results and is not paginated — if you anticipate >100 matches (e.g., a common name across a large org) switch to candidate.list with cursor + syncToken pagination and post-filter locally.
If you already have the candidate UUID (e.g., from a webhook payload, an earlier search, or a profileUrl like https://app.ashbyhq.com/candidates/<uuid>), skip search and go straight to candidate.info.
3. Read full profile + existing notes
CID="e9ed20fd-d45f-4aad-8a00-a19bfba0083e"
# Full profile (includes applicationIds, fileHandles, customFields, tags, source, location)
curl "${AUTH[@]}" -X POST "$BASE/candidate.info" -d "{\"id\":\"$CID\"}"
# All notes (paginated; default + max limit = 100)
curl "${AUTH[@]}" -X POST "$BASE/candidate.listNotes" -d "{\"candidateId\":\"$CID\"}"
candidate.info.results.profileUrl is the deep-link a human recruiter would paste into Slack. applicationIds[] is the foreign key for any downstream write against a specific application (e.g., feedback). To resolve those into job-titled applications, fan out one application.info per id — they are returned as bare UUIDs.
To get a resume's actual download URL: take resumeFileHandle.handle (or any element from fileHandles[]) and call file.info with it; the response carries a short-lived signed URL.
4. Add a note
curl "${AUTH[@]}" -X POST "$BASE/candidate.createNote" \
-d '{
"candidateId": "'"$CID"'",
"note": "Spoke 2026-05-19. Strong interest in IC role. Will resurface for Q3 pipeline.",
"sendNotifications": false,
"isPrivate": false
}'
The note field can be a plain string (default text/plain) or an object { "type": "text/html", "value": "<b>bold</b> text" }. HTML is silently filtered to a small allow-list: <b> <i> <u> <a> <ul> <ol> <li> <code> <pre> — anything else is stripped server-side before the note is saved.
sendNotifications: true notifies users subscribed to the candidate; default is false. isPrivate: true requires the API key to additionally carry the "Allow access to non-offer private fields" permission, or the call will 403.
5. Submit structured feedback (against an application, not a candidate)
Feedback is keyed on the application (applicationId), not the candidate, and must reference a feedback form definition. Resolve the form once at integration setup:
# Discover the form definition you want (typically the default Interview form)
curl "${AUTH[@]}" -X POST "$BASE/feedbackFormDefinition.list" -d '{}'
# -> pick one and remember its id
Then submit:
APP_ID="b7c8d9e0-..." # one element of candidate.info.results.applicationIds
FORM_ID="<from feedbackFormDefinition.list>"
curl "${AUTH[@]}" -X POST "$BASE/applicationFeedback.submit" \
-d '{
"feedbackForm": {
"formDefinitionId": "'"$FORM_ID"'",
"fieldSubmissions": [
{ "path": "_systemfield_overallRecommendation", "value": { "score": 3 } },
{ "path": "_systemfield_summary", "value": { "type": "PlainText", "value": "Solid systems thinking. Recommend onsite." } }
]
},
"applicationId": "'"$APP_ID"'"
}'
Each form field has a typed value contract — see the type matrix in Site-Specific Gotchas below; submitting the wrong shape returns success: false, errorInfo.code: "invalid_field_value". If userId is omitted, the feedback is credited to the API-key's owning user.
Browser fallback
Use the API. If you absolutely cannot get an API key, the only browser path is:
- Have a human authenticate
app.ashbyhq.cominteractively via Google SSO, Microsoft SSO, magic link, or SAML — there is no email/password form, so a headless agent cannot self-onboard. - Once a logged-in session cookie is captured, navigate to
https://app.ashbyhq.com/candidates/<candidateUuid>. The right-hand sidebar exposes "Notes" and "Feedback" panels. - Notes: focus the rich-text composer, type, and click "Add Note". Feedback: open a specific application card, choose "Submit Feedback", fill the form, click "Submit".
This is fragile (DOM not stable across releases, no anti-bot tolerance built in, MFA on most tenants), and is not recommended. The API path is strictly better in every observable dimension: lower latency, structured input/output, idempotent, surfaces the same activity entries the UI would have written.
Site-Specific Gotchas
- Errors come back as
HTTP 200 + success: false. This is the single most important Ashby footgun. Standard 4xx codes are reserved for auth (401 missing key, 403 wrong/disabled key or missing permission). Everything else — bad UUID, malformed body, validation failure, business-rule rejection — returns200 OKwith{ "success": false, "errorInfo": { "code": "...", "message": "...", "requestId": "..." } }. Never branch onresponse.okor status code alone; always parse the body and branch onbody.success. LogerrorInfo.requestIdfor any failure — Ashby support requires it. - API key permissions are module-scoped, not endpoint-scoped. Read access to one
candidate.*endpoint implies read access to all of them; same for write. But a key withcandidatesReadcannot callcandidate.createNote— it will 403 withmissing_endpoint_permission. CallapiKey.infoonce at startup and verify the permission set matches the operations you plan to perform, rather than discovering 30 calls in. Accept: application/json; version=1is required, not optional. Omit it and the API responds with a generic 406-ish error. Version pinning prevents silent breaking changes when Ashby ships v2.- HTML notes are aggressively filtered.
candidate.createNotewithtype: "text/html"accepts only<b> <i> <u> <a> <ul> <ol> <li> <code> <pre>. Tables, headers, images, divs, spans, classes, styles, scripts — all stripped silently before storage. Don't try to render a complex template; either flatten to plain text or stick to the supported tags. isPrivate: trueneeds a separately-granted permission. Even withcandidatesWrite, a private note requires "Allow access to non-offer private fields?" on the API key. Without it, the call fails — and because of the 200-with-success-false convention, you must checkbody.successto catch this. DefaultisPrivate: falseis the safe path.- Feedback submits against
applicationId, notcandidateId. A candidate can have multiple applications (different jobs).applicationFeedback.submitrequires the rightapplicationIdfromcandidate.info.results.applicationIds[]. Pick the one tied to the job/interview the feedback is about — there is no "candidate-level" feedback channel. - Feedback field paths are not in the response shape — they live on the form definition. Each
feedbackFormDefinitionreturnssections[].fields[].field.path(e.g.,_systemfield_overallRecommendation,_systemfield_summary, or custom<uuid>paths). You must fetch the definition first and map your inputs onto those exact paths, with the correctly-shaped value object (see typed-value matrix below). Submitting an unknown path returnssuccess: falsewithunknown_fieldinerrorInfo. - Feedback field-value types are not interchangeable. The field's
typeon the form definition dictates the shape of the submitted value:Boolean/Number/String/LongText/Email/Phone/Url/Date (YYYY-MM-DD)are bare scalars;Scoreis{score: 1-4};Currencyis{value, currencyCode};CompensationRangeis{type:"compensation-range", minValue, maxValue, currencyCode, interval};NumberRangeis{type:"number-range", minValue, maxValue};RichTextis{type:"PlainText", value}(Ashby explicitly does not accept rich-text documents via the API, only plain text in this wrapper);MultiValueSelectisstring[]of option values;ValueSelectis a single optionvaluestring;UUIDis the raw uuid string or{value: "<uuid>"}. Mismatch →success: false, errorInfo.code ~= "invalid_field_value". candidate.searchis hard-capped at 100 results and not paginated. Use it for "I have an email or a near-exact name". Anything bulk — name-substring scans, daily-sync extraction, > 100 expected matches — must switch tocandidate.listwith the cursor/syncToken pagination contract.candidate.listpagination is opaque-cursor + sync-token. First call sends{}or{createdAfter: <ms-epoch>}; response carriesnextCursorandmoreDataAvailable. PassnextCursoron the next call. WhenmoreDataAvailable: false, persist the returnedsyncTokenand pass it on your next polling cycle to fetch only deltas. Limit caps at 100 per page (default 100). Don't try to grep around the cursor; treat it as a black box.candidate.searchignores extra parameters silently. Sendingcompany,phone,tagId, etc. doesn't error — they're just dropped. The only filters areemailandname.- Resume / file URLs are not in
candidate.info. You getfileHandles[].handleandresumeFileHandle.handle— callfile.infowith the handle to mint a short-lived signed URL. Don't try to construct a download URL from the handle directly. profileUrlreturned in candidate responses is the only canonical deep-link to the Ashby UI. Format:https://app.ashbyhq.com/candidates/<uuid>. Use it for human-facing summaries; never try to scrape it.- Rate limits live in
X-RateLimit-*response headers; standard limit is 100 req/min per key.X-Ratelimit-Limit,X-Ratelimit-Remaining,X-Ratelimit-Reset(unix epoch) are returned on every response. On burst workloads, throttle to remaining/window or you'll get 429s. The 429 still comes back as a real HTTP status — that one is not wrapped insuccess: false. - Webhooks beat polling for change events. If you're building "react when a candidate progresses", subscribe via
webhook.createtocandidateStageChange,candidateHire,candidateMerge,applicationUpdate, etc., instead of pollingcandidate.listwithsyncToken. Same data, far lower cost. - The
app.ashbyhq.comSPA has no public email/password form. Sign-in routes only to Google OAuth, Microsoft OAuth, SAML SSO, or a magic link emailed to the user. Browser-driving without a pre-warmed cookie jar is not possible. Captured 2026-05-19 athttps://app.ashbyhq.com/signin— no<input type="password">anywhere on the page. - Don't curl
developers.ashbyhq.com/openapi.jsonexpecting an OpenAPI spec — there isn't a public one. The reference site is a ReadMe.com-rendered manual. Trust the documented endpoint shapes; trust the liveerrorInfocodes for everything else.
Expected Output
The skill produces one envelope per invocation. Three shapes by outcome:
1. Successful search + read (no writes)
{
"success": true,
"operation": "read",
"candidate": {
"id": "e9ed20fd-d45f-4aad-8a00-a19bfba0083e",
"name": "Ada Lovelace",
"primaryEmail": "ada@example.com",
"primaryPhone": "+1-555-0142",
"position": "Senior Staff Engineer",
"company": "Babbage Engines Inc.",
"school": "University of London",
"tags": [{ "id": "...", "title": "Top Candidate" }],
"socialLinks": [{ "type": "LinkedIn", "url": "https://linkedin.com/in/ada" }],
"applicationIds": ["b7c8d9e0-...", "a1b2c3d4-..."],
"resumeHandle": "fh_aB1cD2eF3...",
"customFields": [{ "id": "...", "title": "Years of Experience", "value": 12 }],
"source": { "title": "Referral - Engineering" },
"location": { "locationSummary": "London, UK" },
"fraudStatus": "PassedFraudCheck",
"profileUrl": "https://app.ashbyhq.com/candidates/e9ed20fd-d45f-4aad-8a00-a19bfba0083e",
"notes": [
{
"id": "n_001",
"createdAt": "2026-05-12T14:33:21.000Z",
"isPrivate": false,
"content": "Initial outreach — replied within 4h.",
"author": { "id": "u_001", "firstName": "Joey", "lastName": "Joe", "email": "joey@acme.com" }
}
]
}
}
2. Successful write (note added)
{
"success": true,
"operation": "createNote",
"candidateId": "e9ed20fd-d45f-4aad-8a00-a19bfba0083e",
"note": {
"id": "n_002",
"createdAt": "2026-05-19T17:04:55.123Z",
"isPrivate": false,
"content": "Spoke 2026-05-19. Strong interest in IC role. Will resurface for Q3 pipeline.",
"author": { "id": "u_001", "firstName": "Joey", "lastName": "Joe", "email": "joey@acme.com" }
}
}
3. Successful write (feedback submitted)
{
"success": true,
"operation": "submitFeedback",
"applicationId": "b7c8d9e0-...",
"submittedFormInstanceId": "sfi_001",
"submittedValues": {
"_systemfield_overallRecommendation": { "score": 3 },
"_systemfield_summary": { "type": "PlainText", "value": "Solid systems thinking. Recommend onsite." }
}
}
4. Failure (no candidate match)
{
"success": false,
"reason": "candidate_not_found",
"query": { "email": "nope@example.com" }
}
5. Failure (Ashby API rejected — note the success: false body even when HTTP is 200)
{
"success": false,
"reason": "api_error",
"httpStatus": 200,
"errorInfo": {
"code": "missing_endpoint_permission",
"message": "The supplied API key does not have permission to access this endpoint",
"requestId": "01JSJ8FEK5ZN4XQBZP7DBKK7ZC"
}
}