Turrilum/API Referencev1

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/v1

Authentication

Authenticate by passing your API key as a Bearer token in the Authorization header. API keys are created in Settings → API Keys.

Request header
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

  1. Go to Settings → API Keys in your Turrilum account.
  2. Click New API key, enter a descriptive name, and choose an environment.
  3. Select the scopes needed for your integration (see Scopes).
  4. 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.

EnvironmentKey prefixData
livesk_live_*Production data.
testsk_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.

ScopeTypeGrants access to
readreadAll read:* scopes (aggregate alias)
read:candidatesreadGET /candidates, GET /candidates/{id}
read:applicationsreadGET /applications, GET /applications/{id}
read:jobsreadGET /jobs, GET /jobs/{id}
read:offersreadGET /offers, GET /offers/{id}
read:interviewsreadGET /interviews, GET /interviews/{id}
write:candidateswritePOST /candidates, PATCH /candidates/{id}
write:applicationswritePOST /applications, PATCH /applications/{id}
write:jobswritePOST /jobs, PATCH /jobs/{id}
write:offerswritePOST /offers, PATCH /offers/{id}
write:interviewswritePOST /interviews, PATCH /interviews/{id}
webhooks:managereadManage webhook subscriptions

Pagination

List endpoints use cursor-based (keyset) pagination. All list responses share the same envelope shape:

Response envelope
{
  "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.

ParameterTypeDescription
limitintegerMax records to return. Range 1–200, default 50.
starting_afterstringOpaque cursor from the previous page's next_cursor.
modified_afterISO-8601Only return records updated after this datetime.
include_deletedbooleanInclude 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:

Error body
{
  "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.

codeStatusTitleWhen
unauthorized401UnauthorizedNo Authorization header.
invalid_api_key401Invalid API KeyKey not found or wrong format.
invalid_token401Invalid TokenHash mismatch — key tampered or corrupt.
key_revoked401Key RevokedKey was explicitly revoked.
key_expired401Key ExpiredKey has passed its expiry date.
insufficient_scope403Insufficient ScopeKey lacks the required scope.
not_found404Not FoundResource id not found or belongs to another company.
method_not_allowed405Method Not AllowedHTTP method not supported on this path.
validation_error400Validation ErrorRequired field missing or invalid value.
invalid_parameter400Invalid ParameterA field has an invalid value.
bad_request400Bad RequestMalformed JSON body.
idempotency_key_reused422Idempotency Key ReusedSame Idempotency-Key, different request body.
rate_limited429Too Many RequestsRate limit exceeded. See Retry-After header.
internal_error500Internal ErrorUnexpected 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:

HeaderDescription
RateLimit-LimitRequest ceiling for the current 60s window.
RateLimit-RemainingRequests remaining before you are throttled.
RateLimit-ResetUnix epoch (seconds) when the window resets.
X-RateLimit-LimitLegacy alias for RateLimit-Limit.
X-RateLimit-RemainingLegacy alias for RateLimit-Remaining.
X-RateLimit-ResetLegacy alias for RateLimit-Reset.
Retry-AfterSeconds 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.

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:

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

Node.js verification snippet
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: { ... } }.

Eventdata payload
candidate.createdSerialized candidate object
candidate.hiredSerialized application object (milestone=hired)
application.createdSerialized application object
application.stage_changedSerialized application (new stage_id)
application.status_changedSerialized application (status=archived)
job.createdSerialized job object
job.closedSerialized job (status=closed)
offer.createdSerialized offer object
offer.sentSerialized offer (status=sent)
offer.signedSerialized offer (status=signed)
offer.declinedSerialized offer (status=declined)
interview.scheduledSerialized interview object
interview.cancelledSerialized interview (status=cancelled)

Endpoints

Candidates

GET
/api/v1/candidates

List candidates with keyset pagination.

read:candidates
GET
/api/v1/candidates/{id}

Retrieve a single candidate by id.

read:candidates
POST
/api/v1/candidates

Create a candidate. Returns 201 + object.

write:candidates
PATCH
/api/v1/candidates/{id}

Update mutable fields (name, email, phone).

write:candidates

Applications

GET
/api/v1/applications

List applications.

read:applications
GET
/api/v1/applications/{id}

Retrieve a single application.

read:applications
POST
/api/v1/applications

Create an application (candidate_id + job_id). Fires application.created.

write:applications
PATCH
/api/v1/applications/{id}

Move stage (stage_id) or archive (archive: true). Fires stage_changed / status_changed.

write:applications

Jobs

GET
/api/v1/jobs

List job requisitions.

read:jobs
GET
/api/v1/jobs/{id}

Retrieve a single job.

read:jobs
POST
/api/v1/jobs

Create a job. Fires job.created.

write:jobs
PATCH
/api/v1/jobs/{id}

Update fields or set status (open/draft/closed). Fires job.closed on close.

write:jobs

Offers

GET
/api/v1/offers

List offers.

read:offers
GET
/api/v1/offers/{id}

Retrieve a single offer.

read:offers
POST
/api/v1/offers

Create a draft offer. Fires offer.created.

write:offers
PATCH
/api/v1/offers/{id}

Update fields or transition with action: "send". Fires offer.sent.

write:offers

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

GET
/api/v1/interviews

List scheduled interviews.

read:interviews
GET
/api/v1/interviews/{id}

Retrieve a single interview.

read:interviews
POST
/api/v1/interviews

Schedule an interview (candidate_id, title, starts_at). Fires interview.scheduled.

write:interviews
PATCH
/api/v1/interviews/{id}

Reschedule (starts_at) or cancel (action: "cancel"). Fires interview.cancelled on cancel.

write:interviews

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:

  1. 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.
  2. 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.
  3. Environment variables to provision: LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET, LINKEDIN_APPLY_WEBHOOK_SECRET (for verifying inbound Apply Connect webhooks). Once set, the stub in lib/dispositions/providers/linkedin.ts becomes active.
  4. 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; store applicationId from the payload into application.externalId so disposition reports can reference it.
  5. Disposition endpoint — once credentials are live, replace the stub body in sendDisposition() with: POST https://api.linkedin.com/v2/jobs/{externalJobId}/applicantTrackingStatuses with 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.