Elsin for scripts and agents.
A REST API, a remote MCP server and a command-line client.
Overview
Three ways in, all built from one list of operations: a REST API at /api/v1 described by an OpenAPI 3.1 document at /api/v1/openapi.json, a remote MCP server at /api/mcp where each operation is a tool, and the command-line client, typed from the same document.
Available to Elsin customers at your Elsin app address (https://elsin.io). Examples use https://elsin.io. If the API is not switched on for your deployment yet, calls return 503 with the code unavailable; ask us and we will enable it.
- Every call acts as the member who created the credential, capped at the credential's role, inside one organization.
- Lists use cursor pagination: each returns
dataandnext_cursor. Sendnext_cursorback ascursor, with alimitof 1 to 100. - Send an
Idempotency-Keyheader with a POST and a retry within 24 hours returns the first result instead of running twice. - Every response carries an
X-Request-Id. Every error has the same shape:code,message, optionaldetailsandrequest_idundererror.
Authentication
API keys
Create a key in Settings, under API & agents. Give it a role no higher than your own, narrow it to some resources if you like, and pick when it expires: 7, 30, 90 or 365 days (90 by default). Keys look like elsin_live_ followed by 40 letters and digits. A key is shown once; Elsin keeps only a SHA-256 hash of it. Revoke it in the same place at any time, and if your own role drops, every key you made drops with it.
Send it with every request:
Authorization: Bearer elsin_live_…
# or, for tools that cannot set Authorization:
X-Api-Key: elsin_live_…OAuth 2.1 for AI assistants
MCP clients sign in with OAuth instead of a pasted key. The authorization server supports:
- Client ID Metadata Documents, so an assistant can name itself by an https URL, and dynamic client registration (RFC 7591) for clients that register instead.
- The device authorization grant (RFC 8628), which
elsin loginuses. - The authorization code flow with PKCE, S256 only. Clients are public: there is no client secret.
- Access tokens that last 1 hour, refresh tokens that rotate on use and last 30 days, and codes that expire after 10 minutes.
- Token revocation (RFC 7009).
When you approve an assistant you pick the organization and the role it gets. Disconnect it any time under Connected apps. Discovery documents are at:
https://elsin.io/.well-known/oauth-protected-resource
https://elsin.io/.well-known/oauth-authorization-serverScopes and roles
A credential with no scopes reaches every resource its role reaches. Scopes only ever narrow that, never widen it, and a write scope includes the read scope of the same resource. OAuth clients ask for scopes as a space-separated list.
| Scopes | Covers |
|---|---|
| jobs:read, jobs:write | Jobs, their stages and application questions, publishing and archiving. |
| candidates:read, candidates:write | Candidates, applications, pipelines, tags, notes and stage moves. |
| review:read, review:write | The review queue, agent proposals and trust decisions. |
| interviews:read, interviews:write | Interviews, scheduling, scorecards and the member list. |
| talent:read, talent:write | Talent pools and their candidates. |
| analytics:read | The hiring summary. |
| settings:read, settings:write | Organization settings and the trust and agent policy. |
| audit:read | The API audit log. |
Roles set what a credential may do, from lowest to highest. A key acts with its own role and never above its creator's current one.
| Role | Can |
|---|---|
| Viewer | Reads only. An interviewer that cannot write. |
| Interviewer | Adds notes and saves or submits their own scorecards. |
| Recruiter | Manages jobs, moves applications, schedules interviews and decides on trust. |
| Admin | Also changes organization settings and reads the audit log. Owners grant up to admin; owner-only acts stay in the app. |
MCP server
The MCP server is at https://elsin.io/api/mcp. It speaks Streamable HTTP and is stateless: each POST is authenticated, served and forgotten. Its tools are the 39 operations below, and it offers read-only resources: the trust policy (elsin://policy), each job (elsin://jobs/{job_id}), each job's pipeline and each candidate. An assistant sees only the tools its role and scopes allow, and is told to call whoami and get_trust_policy before acting.
Cursor and VS Code
One click adds Elsin. The editor sends you to Elsin to approve it the first time it calls a tool.
No Cursor on this device? Open the Cursor install page.
Claude Code
Add the server, then run /mcp and sign in. Or pass a key as a header.
claude mcp add --transport http elsin https://elsin.io/api/mcp
# then run /mcp in Claude Code and sign in to Elsin
# add --scope user to use it in every projectclaude mcp add --transport http elsin https://elsin.io/api/mcp \
--header "Authorization: Bearer $ELSIN_API_KEY"Claude on the web or desktop
Open Customize, then Connectors, choose Add custom connector and paste the MCP URL. On Team and Enterprise plans an owner adds it under Organization settings, Connectors, Add, Custom, Web. Claude sends you to Elsin to approve it. A desktop extension bundle is not offered: bundles run local servers only, and Elsin is remote.
ChatGPT
Open Settings, Apps, Advanced settings and turn on Developer mode. Choose Create, paste the MCP URL and pick OAuth. Which plans can use write tools is up to OpenAI; read tools work more widely.
Windsurf
Add Elsin to ~/.config/devin/mcp_config.json (Devin Desktop), or ~/.codeium/windsurf/mcp_config.json on older Windsurf installs.
{
"mcpServers": {
"elsin": {
"serverUrl": "https://elsin.io/api/mcp"
}
}
}Grok API
xAI's Responses API calls Elsin from xAI's servers, so it uses an API key rather than a sign-in. Send this body to https://api.x.ai/v1/responses.
{
"model": "<a Grok model>",
"input": "Which open jobs have flagged candidates waiting for review?",
"tools": [
{
"type": "mcp",
"server_url": "https://elsin.io/api/mcp",
"server_label": "elsin",
"authorization": "Bearer <your Elsin API key>"
}
]
}Other assistants
Any client that supports remote MCP servers connects the same way: it identifies itself, sends you to Elsin to approve, then calls the tools. If a client only takes a header, give it an API key as a bearer token.
Example requests
List open jobs
curl "https://elsin.io/api/v1/jobs?status=open&limit=20" \
-H "Authorization: Bearer $ELSIN_API_KEY"{
"data": [
{ "id": "6f1c…", "title": "Staff Engineer", "status": "open", … }
],
"next_cursor": "eyJ2Ijoi…"
}Ask a flagged candidate to verify
action is one of clear, keep_flagged or request_verification. There is no reject action. When your organization requires a person to confirm agent trust decisions, which is the default, the call files a proposal and returns 202; a person confirms it in the review queue.
curl -X POST "https://elsin.io/api/v1/review/$CANDIDATE_ID/decisions" \
-H "Authorization: Bearer $ELSIN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5d0e1a52-…" \
-d '{ "action": "request_verification", "note": "Asked for an ID check" }'HTTP/1.1 202 Accepted
{
"data": {
"outcome": "proposed",
"proposal": { "id": "…", "action": "request_verification", "status": "pending", … },
"level": null,
"verification_link": null
}
}Errors and rate limits
Each credential gets a burst of 120 calls, refilled at 2 a second. Responses carry RateLimit-Limit and RateLimit-Remaining, and a 429 carries Retry-After. The limiter fails closed: if it cannot be read, the call is refused.
HTTP/1.1 403 Forbidden
{
"error": {
"code": "insufficient_scope",
"message": "…",
"request_id": "3b9f2c1e-…"
}
}Operations
Paths are relative to /api/v1. The tool name is the same operation over MCP. The OpenAPI document at /api/v1/openapi.json has every field, parameter and error.
Organization
| Endpoint and tool | What it does |
|---|---|
| GET/mewhoami | Who this credential acts asAny scope, viewer or above |
| GET/memberslist_members | List membersinterviews:read, viewer or above |
| GET/settingsget_settings | Read organization settingssettings:read, viewer or above |
| PATCH/settingsupdate_settings | Change organization settingssettings:write, admin or above |
| GET/policyget_trust_policy | Read the trust and agent policysettings:read, viewer or above |
| GET/auditlist_audit_log | Read the API audit logaudit:read, admin or above |
Jobs
| Endpoint and tool | What it does |
|---|---|
| GET/jobslist_jobs | List jobsjobs:read, viewer or above |
| POST/jobscreate_job | Create a jobjobs:write, recruiter or above |
| GET/jobs/{job_id}get_job | Get a jobjobs:read, viewer or above |
| PATCH/jobs/{job_id}update_job | Update a jobjobs:write, recruiter or above |
| DELETE/jobs/{job_id}archive_job | Archive a jobjobs:write, recruiter or above |
| POST/jobs/{job_id}/statusset_job_status | Publish, pause, close or reopen a jobjobs:write, recruiter or above |
| GET/jobs/{job_id}/stageslist_job_stages | List a job's stagesjobs:read, viewer or above |
| PUT/jobs/{job_id}/stagesreplace_job_stages | Replace a job's stagesjobs:write, recruiter or above |
| GET/jobs/{job_id}/questionslist_job_questions | List a job's application questionsjobs:read, viewer or above |
| PUT/jobs/{job_id}/questionsreplace_job_questions | Replace a job's application questionsjobs:write, recruiter or above |
Candidates and applications
| Endpoint and tool | What it does |
|---|---|
| GET/jobs/{job_id}/pipelineget_job_pipeline | Get a job's pipelinecandidates:read, viewer or above |
| GET/candidateslist_candidates | List candidatescandidates:read, viewer or above |
| GET/candidates/{candidate_id}get_candidate | Get a candidatecandidates:read, viewer or above |
| PUT/candidates/{candidate_id}/tagsset_candidate_tags | Set a candidate's tagscandidates:write, recruiter or above |
| GET/candidates/{candidate_id}/noteslist_candidate_notes | List notes on a candidatecandidates:read, viewer or above |
| POST/candidates/{candidate_id}/notesadd_candidate_note | Add a notecandidates:write, interviewer or above |
| GET/applicationslist_applications | List applicationscandidates:read, viewer or above |
| GET/applications/{application_id}get_application | Get an applicationcandidates:read, viewer or above |
| POST/applications/{application_id}/movemove_application | Move an application to a stagecandidates:write, recruiter or above |
Review
| Endpoint and tool | What it does |
|---|---|
| GET/reviewlist_review_queue | List the review queuereview:read, viewer or above |
| GET/review/proposalslist_review_proposals | List agent proposalsreview:read, viewer or above |
| GET/review/{candidate_id}get_review_item | Get one candidate's reviewreview:read, viewer or above |
| POST/review/{candidate_id}/decisionsdecide_review | Clear, keep flagged, or ask to verifyreview:write, recruiter or above |
Interviews and scorecards
| Endpoint and tool | What it does |
|---|---|
| GET/interviewslist_interviews | List interviewsinterviews:read, viewer or above |
| POST/interviewsschedule_interview | Schedule an interviewinterviews:write, recruiter or above |
| GET/interviews/{interview_id}get_interview | Get an interviewinterviews:read, viewer or above |
| GET/interviews/{interview_id}/scorecardslist_scorecards | List an interview's scorecardsinterviews:read, viewer or above |
| PUT/interviews/{interview_id}/scorecardsubmit_scorecard | Save or submit your scorecardinterviews:write, interviewer or above |
Talent pools and analytics
| Endpoint and tool | What it does |
|---|---|
| GET/talent/poolslist_talent_pools | List talent poolstalent:read, viewer or above |
| POST/talent/poolscreate_talent_pool | Create a talent pooltalent:write, recruiter or above |
| GET/talent/pools/{pool_id}/candidateslist_talent_pool_candidates | List a pool's candidatestalent:read, viewer or above |
| POST/talent/pools/{pool_id}/candidatesadd_talent_pool_candidates | Add candidates to a pooltalent:write, recruiter or above |
| GET/analytics/summaryget_analytics_summary | Hiring summaryanalytics:read, viewer or above |
Webhooks
Admins add endpoints in Settings, Webhooks. Each event is one JSON POST per endpoint that listens for it.
POST /your/endpoint
Content-Type: application/json
Elsin-Signature: t=1793000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Elsin-Event-Id: 0b6c6f0e-6b8e-4c3e-9a8e-2b6f2d1c9e11
Elsin-Event-Type: application.hired
Elsin-Delivery-Id: 7f1d9a52-3a43-4f4f-8c55-0d2e5b0b4a10
{
"id": "0b6c6f0e-6b8e-4c3e-9a8e-2b6f2d1c9e11",
"type": "application.hired",
"created_at": "2026-10-23T18:04:11.000Z",
"organization_id": "11ea5e16-0061-56c6-b06b-ed7de3b9011a",
"data": {
"actor_id": "a5b84e1d-8b29-5118-a3c8-e21e56a59ceb",
"subject": {
"candidate_id": "…", "application_id": "…", "job_id": "…",
"interview_id": null, "offer_id": null
},
"candidate": {
"id": "…", "name": "Ada Okafor", "email": "ada@example.com",
"verification_level": "verified"
},
"job": { "id": "…", "title": "Staff Engineer" },
"facts": { "stage": "Hired", "stageKind": "hired" }
}
}Elsin-Signature carries a Unix timestamp and an HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with the endpoint's secret. Refuse timestamps older than 300 seconds.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, header, rawBody) {
let t = null;
const signatures = [];
for (const part of (header ?? '').split(',')) {
const [key, value] = part.trim().split('=', 2);
if (key === 't') t = Number(value);
if (key === 'v1' && value) signatures.push(Buffer.from(value));
}
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'));
return signatures.some((s) => s.length === expected.length && timingSafeEqual(s, expected));
}import hashlib, hmac, time
def verify(secret: str, header: str | None, raw_body: bytes) -> bool:
t, signatures = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
t = int(value)
elif key == "v1" and value:
signatures.append(value)
if t is None or abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, expected) for s in signatures)import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
func Verify(secret, header string, rawBody []byte) bool {
var t int64 = -1
var signatures []string
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
switch key {
case "t":
if n, err := strconv.ParseInt(value, 10, 64); err == nil {
t = n
}
case "v1":
if value != "" {
signatures = append(signatures, value)
}
}
}
if t < 0 || math.Abs(float64(time.Now().Unix()-t)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
for _, s := range signatures {
if hmac.Equal([]byte(s), []byte(expected)) {
return true
}
}
return false
}- Anything but a 2xx is retried, 8 attempts in all, after 1m, 5m, 30m, 2h, 6h, 12h, 24h. Redirects are not followed.
- An endpoint is switched off after 20 failed attempts in a row. Turning it back on resumes deliveries.
- Elsin-Event-Id is the same on a retry or a resend, so receivers can drop repeats.
- Send test posts a webhook.test event.
- Each delivery names the candidate (name, email and verification level) and the job title, so a Zap or flow can act without a second call. It is signed the same way.
- For more, fetch with your API key: GET /api/v1/candidates/{candidate_id} and /api/v1/applications/{application_id}.
Subscribing from Zapier, Make or n8n
Tools that subscribe and unsubscribe for you use REST Hooks. POST /webhooks with a target_url and an event (an event type, or * for every one) adds an endpoint and returns its id and signing secret, shown once. DELETE /webhooks/{webhook_id} removes it, and GET /webhooks lists them. Subscribing needs settings:write and the admin role; listing needs settings:read.
curl -X POST "https://elsin.io/api/v1/webhooks" \
-H "Authorization: Bearer $ELSIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "target_url": "https://hooks.zapier.com/…", "event": "application.created" }'
HTTP/1.1 201 Created
{
"data": {
"id": "9c2e…",
"target_url": "https://hooks.zapier.com/…",
"events": ["application.created"],
"secret": "whsec_…"
}
}
curl -X DELETE "https://elsin.io/api/v1/webhooks/9c2e…" -H "Authorization: Bearer $ELSIN_API_KEY"
HTTP/1.1 204 No ContentWhat an agent can never do
These rules are part of the platform, stated to every MCP client in its instructions and returned by get_trust_policy.
- No one is rejected by the API. There is no reject operation. Only a person rejects, in the app. A verification flag routes a candidate to a person for review and never removes them.
- A person confirms agent trust decisions. By default, decide_review files a proposal that a person confirms in the review queue. Whether that confirmation is needed is changed in the app, never through the API.
- No face data, no documents. Face images and video never leave the candidate's device. The API exposes a verification verdict and its reasons only.
- Every call is audited. Every API and MCP call is written to an audit log the organization's admins can read: the key or app, the agent's name and what it touched.
- A key never outranks its member. A credential acts as the member who created it, capped at its own role and never above that member's current role, inside one organization. The database enforces it through row-level security.
- Filters sort, they do not delete. Filters such as verified-only sort people into separate lists. They never delete or reject anyone.
Command line
The elsin command is a client for the REST API. It needs Node 24 or newer. It is not on the npm registry yet; install it with npm from this site:
npm install -g https://elsin.io/downloads/elsin-cli.tgzelsin login shows a short code and opens Elsin in your browser, where you approve it for one organization and role. It keeps the sign-in in your system keychain when one is available, otherwise in your user config folder with file mode 0600. To use a key instead, pipe it in: printf %s "$KEY" | elsin login. ELSIN_API_KEY and ELSIN_API_URL override what is stored.
elsin login --url https://elsin.io # shows a code; approve it in your browser
elsin whoami
elsin jobs ls --status open
elsin candidates ls --verified --job <job_id> --all
elsin review ls
elsin review decide <candidate_id> --action request_verification --note "Asked for ID check"
elsin jobs ls --all --json | jq '.[].title'--json prints only the response data, and --all follows every page. A review decision may come back as a proposal; the CLI says so. Exit code 2 means bad usage, 1 means the API refused or could not be reached.
For Greenhouse, Ashby, Lever and Workable without code, see integrations.