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 data and next_cursor. Send next_cursor back as cursor, with a limit of 1 to 100.
  • Send an Idempotency-Key header 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, optional details and request_id under error.

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:

Header
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 login uses.
  • 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:

Discovery
https://elsin.io/.well-known/oauth-protected-resource
https://elsin.io/.well-known/oauth-authorization-server

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

API scopes
ScopesCovers
jobs:read, jobs:writeJobs, their stages and application questions, publishing and archiving.
candidates:read, candidates:writeCandidates, applications, pipelines, tags, notes and stage moves.
review:read, review:writeThe review queue, agent proposals and trust decisions.
interviews:read, interviews:writeInterviews, scheduling, scorecards and the member list.
talent:read, talent:writeTalent pools and their candidates.
analytics:readThe hiring summary.
settings:read, settings:writeOrganization settings and the trust and agent policy.
audit:readThe 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.

API roles
RoleCan
ViewerReads only. An interviewer that cannot write.
InterviewerAdds notes and saves or submits their own scorecards.
RecruiterManages jobs, moves applications, schedules interviews and decides on trust.
AdminAlso 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 Code
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 project
Claude Code, with a key
claude 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.

mcp_config.json
{
  "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.

Request body
{
  "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

Request
curl "https://elsin.io/api/v1/jobs?status=open&limit=20" \
  -H "Authorization: Bearer $ELSIN_API_KEY"
Response
{
  "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.

Request
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" }'
Response
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.

Error
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

Organization operations
Endpoint and toolWhat it does
GET/mewhoamiWho this credential acts asAny scope, viewer or above
GET/memberslist_membersList membersinterviews:read, viewer or above
GET/settingsget_settingsRead organization settingssettings:read, viewer or above
PATCH/settingsupdate_settingsChange organization settingssettings:write, admin or above
GET/policyget_trust_policyRead the trust and agent policysettings:read, viewer or above
GET/auditlist_audit_logRead the API audit logaudit:read, admin or above

Jobs

Jobs operations
Endpoint and toolWhat it does
GET/jobslist_jobsList jobsjobs:read, viewer or above
POST/jobscreate_jobCreate a jobjobs:write, recruiter or above
GET/jobs/{job_id}get_jobGet a jobjobs:read, viewer or above
PATCH/jobs/{job_id}update_jobUpdate a jobjobs:write, recruiter or above
DELETE/jobs/{job_id}archive_jobArchive a jobjobs:write, recruiter or above
POST/jobs/{job_id}/statusset_job_statusPublish, pause, close or reopen a jobjobs:write, recruiter or above
GET/jobs/{job_id}/stageslist_job_stagesList a job's stagesjobs:read, viewer or above
PUT/jobs/{job_id}/stagesreplace_job_stagesReplace a job's stagesjobs:write, recruiter or above
GET/jobs/{job_id}/questionslist_job_questionsList a job's application questionsjobs:read, viewer or above
PUT/jobs/{job_id}/questionsreplace_job_questionsReplace a job's application questionsjobs:write, recruiter or above

Candidates and applications

Candidates and applications operations
Endpoint and toolWhat it does
GET/jobs/{job_id}/pipelineget_job_pipelineGet a job's pipelinecandidates:read, viewer or above
GET/candidateslist_candidatesList candidatescandidates:read, viewer or above
GET/candidates/{candidate_id}get_candidateGet a candidatecandidates:read, viewer or above
PUT/candidates/{candidate_id}/tagsset_candidate_tagsSet a candidate's tagscandidates:write, recruiter or above
GET/candidates/{candidate_id}/noteslist_candidate_notesList notes on a candidatecandidates:read, viewer or above
POST/candidates/{candidate_id}/notesadd_candidate_noteAdd a notecandidates:write, interviewer or above
GET/applicationslist_applicationsList applicationscandidates:read, viewer or above
GET/applications/{application_id}get_applicationGet an applicationcandidates:read, viewer or above
POST/applications/{application_id}/movemove_applicationMove an application to a stagecandidates:write, recruiter or above

Review

Review operations
Endpoint and toolWhat it does
GET/reviewlist_review_queueList the review queuereview:read, viewer or above
GET/review/proposalslist_review_proposalsList agent proposalsreview:read, viewer or above
GET/review/{candidate_id}get_review_itemGet one candidate's reviewreview:read, viewer or above
POST/review/{candidate_id}/decisionsdecide_reviewClear, keep flagged, or ask to verifyreview:write, recruiter or above

Interviews and scorecards

Interviews and scorecards operations
Endpoint and toolWhat it does
GET/interviewslist_interviewsList interviewsinterviews:read, viewer or above
POST/interviewsschedule_interviewSchedule an interviewinterviews:write, recruiter or above
GET/interviews/{interview_id}get_interviewGet an interviewinterviews:read, viewer or above
GET/interviews/{interview_id}/scorecardslist_scorecardsList an interview's scorecardsinterviews:read, viewer or above
PUT/interviews/{interview_id}/scorecardsubmit_scorecardSave or submit your scorecardinterviews:write, interviewer or above

Talent pools and analytics

Talent pools and analytics operations
Endpoint and toolWhat it does
GET/talent/poolslist_talent_poolsList talent poolstalent:read, viewer or above
POST/talent/poolscreate_talent_poolCreate a talent pooltalent:write, recruiter or above
GET/talent/pools/{pool_id}/candidateslist_talent_pool_candidatesList a pool's candidatestalent:read, viewer or above
POST/talent/pools/{pool_id}/candidatesadd_talent_pool_candidatesAdd candidates to a pooltalent:write, recruiter or above
GET/analytics/summaryget_analytics_summaryHiring summaryanalytics:read, viewer or above

Webhooks

Admins add endpoints in Settings, Webhooks. Each event is one JSON POST per endpoint that listens for it.

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

Node.js
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));
}
Python
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)
Go
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.

REST Hooks
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 Content

What 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:

Shell
npm install -g https://elsin.io/downloads/elsin-cli.tgz

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

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