Turrilum Partner API
The Turrilum v1 Partner API provides programmatic access to candidates, applications, jobs, offers, and interviews in your Turrilum account. It is a REST API that accepts JSON request bodies and returns JSON (or RFC 9457 Problem Details on errors).
Base URL
https://app.turrilum.com/api/v1Authentication
Authenticate by passing your API key as a Bearer token in the Authorization header. API keys are created in Settings → API Keys.
Authorization: Bearer sk_live_a1b2c3d4e5f6...Keys prefixed sk_live_ operate on production data. Keys prefixed sk_test_ are sandbox keys — see Test vs Live.
Creating a key
- Go to Settings → API Keys in your Turrilum account.
- Click New API key, enter a descriptive name, and choose an environment.
- Select the scopes needed for your integration (see Scopes).
- Copy the key immediately — it is only shown once.
Test vs Live Environment
Every API key has an environment: live or test. The environment is embedded in the key prefix (sk_live_* / sk_test_*) and returned on every API response in the X-Turrilum-Environment header.
| Environment | Key prefix | Data |
|---|---|---|
| live | sk_live_* | Production data. |
| test | sk_test_* | Sandbox key — exercises the identical API surface. Data is currently shared with the live plane; there is no separate sandbox data store. Use clearly-labelled test records (e.g. prefix names with TEST:) to distinguish them. |
Scopes
Each API key is granted a set of scopes that control which endpoints it can call. Requests to endpoints outside the key's scopes return 403 insufficient_scope.
| Scope | Type | Grants access to |
|---|---|---|
read | read | All read:* scopes (aggregate alias) |
read:candidates | read | GET /candidates, GET /candidates/{id} |
read:applications | read | GET /applications, GET /applications/{id} |
read:jobs | read | GET /jobs, GET /jobs/{id} |
read:offers | read | GET /offers, GET /offers/{id} |
read:interviews | read | GET /interviews, GET /interviews/{id} |
write:candidates | write | POST /candidates, PATCH /candidates/{id} |
write:applications | write | POST /applications, PATCH /applications/{id} |
write:jobs | write | POST /jobs, PATCH /jobs/{id} |
write:offers | write | POST /offers, PATCH /offers/{id} |
write:interviews | write | POST /interviews, PATCH /interviews/{id} |
webhooks:manage | read | Manage webhook subscriptions |
Pagination
List endpoints use cursor-based (keyset) pagination. All list responses share the same envelope shape:
{
"data": [ ... ],
"has_more": true,
"next_cursor": "eyJpZCI6IjAxOTI..."
}Pass next_cursor as the starting_after query parameter in your next request to fetch the following page. When has_more is false, you have reached the last page.
| Parameter | Type | Description |
|---|---|---|
limit | integer | Max records to return. Range 1–200, default 50. |
starting_after | string | Opaque cursor from the previous page's next_cursor. |
modified_after | ISO-8601 | Only return records updated after this datetime. |
include_deleted | boolean | Include soft-deleted records (deleted_at is set). Default false. |
Errors
Error responses use RFC 9457 Problem Details with Content-Type: application/problem+json. Every error body contains:
{
"type": "https://turrilum.com/docs/errors/validation_error",
"title": "Validation Error",
"status": 400,
"detail": "name is required.",
"instance": "/api/v1/candidates",
"code": "validation_error",
"request_id": "01920f3b-c1a9-7000-89d2-5f5b8f9b1234"
}The request_id matches the X-Request-Id response header on every response (both success and error). Include it when contacting support.
| code | Status | Title | When |
|---|---|---|---|
unauthorized | 401 | Unauthorized | No Authorization header. |
invalid_api_key | 401 | Invalid API Key | Key not found or wrong format. |
invalid_token | 401 | Invalid Token | Hash mismatch — key tampered or corrupt. |
key_revoked | 401 | Key Revoked | Key was explicitly revoked. |
key_expired | 401 | Key Expired | Key has passed its expiry date. |
insufficient_scope | 403 | Insufficient Scope | Key lacks the required scope. |
not_found | 404 | Not Found | Resource id not found or belongs to another company. |
method_not_allowed | 405 | Method Not Allowed | HTTP method not supported on this path. |
validation_error | 400 | Validation Error | Required field missing or invalid value. |
invalid_parameter | 400 | Invalid Parameter | A field has an invalid value. |
bad_request | 400 | Bad Request | Malformed JSON body. |
idempotency_key_reused | 422 | Idempotency Key Reused | Same Idempotency-Key, different request body. |
rate_limited | 429 | Too Many Requests | Rate limit exceeded. See Retry-After header. |
internal_error | 500 | Internal Error | Unexpected server error. Please retry. |
Rate Limits
Requests are rate-limited per API key using a fixed-window counter backed by Postgres (durable across all serverless instances). The default limit is 600 requests per minute. Per-key overrides can be configured by your account administrator.
Every response (including 429s) includes rate-limit headers:
| Header | Description |
|---|---|
RateLimit-Limit | Request ceiling for the current 60s window. |
RateLimit-Remaining | Requests remaining before you are throttled. |
RateLimit-Reset | Unix epoch (seconds) when the window resets. |
X-RateLimit-Limit | Legacy alias for RateLimit-Limit. |
X-RateLimit-Remaining | Legacy alias for RateLimit-Remaining. |
X-RateLimit-Reset | Legacy alias for RateLimit-Reset. |
Retry-After | Seconds to wait before retrying. Present only on 429 responses. |
Idempotency
All write endpoints (POST and PATCH) support the Idempotency-Key request header. Supply a unique key (UUID recommended) per logical operation; retrying with the same key within 24 hours returns the original response instead of re-executing the request.
POST /api/v1/candidates
Authorization: Bearer sk_live_...
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{ "name": "Jordan Lee", "email": "jordan@example.com" }When a replayed response is returned, the Idempotency-Replayed: true header is present. If you retry with the same key but a different request body, the API returns 422 idempotency_key_reused.
Outbound Webhooks
Turrilum can push events to your endpoint as resources change. Manage subscriptions in Settings → Webhooks. Each delivery includes a signature so you can verify authenticity.
Signature verification
Each request includes a Turrilum-Signature header of the form:
Turrilum-Signature: t=1714000000,v1=abc123def456...To verify: concatenate the timestamp t, a period ., and the raw request body. Compute HMAC-SHA256 using your webhook signing secret, then compare against v1. Reject requests where the timestamp is more than 5 minutes old.
import { createHmac } from "node:crypto"
function verifySignature(secret, rawBody, signatureHeader) {
const [tPart, v1Part] = signatureHeader.split(",")
const t = tPart.replace("t=", "")
const v1 = v1Part.replace("v1=", "")
const payload = t + "." + rawBody
const expected = createHmac("sha256", secret)
.update(payload)
.digest("hex")
const isValid = expected === v1
const age = Date.now() / 1000 - Number(t)
return isValid && age < 300 // reject if >5 min old
}Event types
All event payloads share the envelope { id, event, created_at, data: { ... } }.
| Event | data payload |
|---|---|
candidate.created | Serialized candidate object |
candidate.hired | Serialized application object (milestone=hired) |
application.created | Serialized application object |
application.stage_changed | Serialized application (new stage_id) |
application.status_changed | Serialized application (status=archived) |
job.created | Serialized job object |
job.closed | Serialized job (status=closed) |
offer.created | Serialized offer object |
offer.sent | Serialized offer (status=sent) |
offer.signed | Serialized offer (status=signed) |
offer.declined | Serialized offer (status=declined) |
interview.scheduled | Serialized interview object |
interview.cancelled | Serialized interview (status=cancelled) |
Endpoints
Candidates
/api/v1/candidatesList candidates with keyset pagination.
/api/v1/candidates/{id}Retrieve a single candidate by id.
/api/v1/candidatesCreate a candidate. Returns 201 + object.
/api/v1/candidates/{id}Update mutable fields (name, email, phone).
Applications
/api/v1/applicationsList applications.
/api/v1/applications/{id}Retrieve a single application.
/api/v1/applicationsCreate an application (candidate_id + job_id). Fires application.created.
/api/v1/applications/{id}Move stage (stage_id) or archive (archive: true). Fires stage_changed / status_changed.
Jobs
/api/v1/jobsList job requisitions.
/api/v1/jobs/{id}Retrieve a single job.
/api/v1/jobsCreate a job. Fires job.created.
/api/v1/jobs/{id}Update fields or set status (open/draft/closed). Fires job.closed on close.
Offers
/api/v1/offersList offers.
/api/v1/offers/{id}Retrieve a single offer.
/api/v1/offersCreate a draft offer. Fires offer.created.
/api/v1/offers/{id}Update fields or transition with action: "send". Fires offer.sent.
Note: action: "send" marks the offer as sent and fires offer.sent, but does not deliver an outbound email — email delivery is handled via the Turrilum UI.
Interviews
/api/v1/interviewsList scheduled interviews.
/api/v1/interviews/{id}Retrieve a single interview.
/api/v1/interviewsSchedule an interview (candidate_id, title, starts_at). Fires interview.scheduled.
/api/v1/interviews/{id}Reschedule (starts_at) or cancel (action: "cancel"). Fires interview.cancelled on cancel.
Interviews created via the API use provider=api. No calendar events are created or updated — calendar integrations are session-scoped to the Turrilum UI.
Job Board Integrations
Turrilum automatically reports applicant status back to the originating job board whenever a recruiter moves a candidate through the pipeline (stage change, archive, hire). This is called disposition sync and uses a provider-agnostic dispatch layer (lib/dispositions/dispatch.ts) wired into changeStage, archiveApplication, hireAndCloseJob, and applyStageChange. Adding a new board means implementing one DispositionProvider file — no bespoke per-board code elsewhere.
Indeed (active)
Disposition sync to Indeed is active once Indeed ATS Partner credentials are configured in the Super Admin settings (or the INDEED_CLIENT_ID / INDEED_CLIENT_SECRET / INDEED_EMPLOYER_ID env vars are set). The provider calls the Indeed Job Sync GraphQL applicantStatusUpdatemutation and maps Turrilum milestones to Indeed's status enum: APPLIED, REVIEWED, INTERVIEWING, OFFER_EXTENDED, HIRED, REJECTED, WITHDRAWN. Only applications that arrived via the Indeed Apply webhook (those whose id starts with indeed_) are synced; internally-added candidates are silently skipped.
LinkedIn RSC / Apply Connect (requires partner approval)
LinkedIn disposition sync is a stub — the dispatch layer detects the provider key and no-ops with a logged warning until LinkedIn partner credentials are set. To activate it, the following must be procured:
- LinkedIn Partner Program approval — apply at linkedin.com/talent-solutions/recruiter-system-connect. LinkedIn requires an existing ATS customer base and a signed partner agreement before they issue credentials.
- OAuth 2.0 app in the LinkedIn Developer Portal — create an app at developer.linkedin.com, then request the Recruiter System Connect and Apply Connect products for that app. These products are gated and only available after partner approval.
- Environment variables to provision:
LINKEDIN_CLIENT_ID,LINKEDIN_CLIENT_SECRET,LINKEDIN_APPLY_WEBHOOK_SECRET(for verifying inbound Apply Connect webhooks). Once set, the stub inlib/dispositions/providers/linkedin.tsbecomes active. - Inbound Apply Connect webhook — register your webhook URL (
/api/linkedin/apply, to be built) in the LinkedIn Developer Portal. LinkedIn will POST applicant data there; storeapplicationIdfrom the payload intoapplication.externalIdso disposition reports can reference it. - Disposition endpoint — once credentials are live, replace the stub body in
sendDisposition()with:POST https://api.linkedin.com/v2/jobs/{externalJobId}/applicantTrackingStatuseswith body{ applicantId, status }, using the OAuth 2.0 client-credentials token with RSC scopes.
Until all five steps are complete, Turrilum will log a single warning per server instance and skip LinkedIn disposition syncs without affecting recruiter actions.