How it works Features Pricing In-house teams API Log in Start free

Aptly Intelligence
API Reference

● Live REST API v1 JSON

The Aptly Intelligence API lets you add AI-powered CV screening to any ATS, HR platform, or custom workflow. It has two modes, and you can use either or both.

Stateless scoring

A single endpoint, POST /api/v1/screen. Submit a job specification and an array of candidate CVs, and receive structured scoring, match reasoning, and gap analysis for each candidate. Nothing is written into your Aptly workspace. Results are retained for 72 hours and then purged.

This mode is asynchronous: you submit a batch and receive a job_id immediately, then poll for results or receive them via webhook when processing is complete.

Persistent integration sync

The /api/v1/ats/ endpoints. Push jobs and candidates from your ATS into your Aptly workspace, where recruiters run screening, references, video introductions, and scorecards, then pull the resulting intelligence and artifact availability back into your ATS. Records created this way persist like any data created in the app, until deleted in the app. They are not subject to the 72-hour window. See ATS integration overview.

ModeWhat it writesRetentionAuth
Stateless scoring
POST /api/v1/screen
Nothing in your Aptly workspace 72 hours, then purged X-API-Key
Integration sync
/api/v1/ats/ endpoints
Jobs, candidates, and applications in your Aptly workspace Until deleted in the app X-API-Key

Both modes authenticate with the same X-API-Key header and require an active Aptly subscription with API access enabled.

An active Aptly subscription is required to generate API keys. Log in at app.aptly.pro, go to Profile → API Keys, and generate your first key. Keys are shown once. Store them securely.

Authentication

All API requests must include your API key in the X-API-Key header. Keys are prefixed with aptly_ and are tied to your Aptly organisation.

HTTP Required header
X-API-Key: aptly_your_key_here
Never expose your API key in client-side code, public repositories, or URLs. Treat it like a password. If a key is compromised, revoke it immediately from your API Keys panel and generate a new one.

Key management

You can create up to 5 active API keys per organisation. Keys can be created and revoked from the Profile → API Keys tab in your Aptly dashboard, or programmatically via the key management endpoints documented below.

When a key is created, the full key value is shown once and cannot be retrieved again. Only the prefix (first 20 characters) is stored and displayed for identification.


Quick start

The minimal flow to screen a batch of candidates is two API calls:

  1. POST /api/v1/screen: submit your job spec and candidates, receive a job_id
  2. GET /api/v1/jobs/{job_id}: poll until status is complete, then read results

If you provide a webhook_url, step 2 is optional; results will be pushed to you when ready.

cURL Step 1: submit a screening job
curl -X POST https://api.aptly.pro/api/v1/screen \
  -H "X-API-Key: aptly_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "job_spec": "Senior Python developer with 5+ years FastAPI experience...",
    "candidates": [
      {
        "candidate_ref": "internal-id-001",
        "cv_text": "7 years Python experience, built APIs with FastAPI..."
      }
    ]
  }'
JSON Response: job accepted
{
  "job_id":              "3f8a2b1c-d4e5-4f67-89ab-cdef01234567",
  "status":            "pending",
  "total_candidates":  1,
  "expires_at":        "YYYY-MM-DDTHH:MM:SS.ssssss"
}
cURL Step 2: poll for results
curl https://api.aptly.pro/api/v1/jobs/3f8a2b1c-d4e5-4f67-89ab-cdef01234567 \
  -H "X-API-Key: aptly_your_key_here"

Downloads

Skip writing requests by hand. Use these artifacts to start integrating in under two minutes.


Base URL

URL
https://api.aptly.pro

All endpoints are prefixed with this base URL. The API accepts and returns JSON. All timestamps are in ISO 8601 format (UTC).


Submit a screening job

POST /api/v1/screen Submit candidates for scoring against a job spec

Submits a batch of candidates for scoring. This is the stateless mode: nothing is written into your Aptly workspace. Returns a job_id immediately. Processing happens asynchronously: poll GET /api/v1/jobs/{job_id} or supply a webhook URL to receive results.

Request body

FieldTypeRequiredDescription
job_spec string Required The full job description or specification. Plain text. The more detail, the more accurate the scoring. There is no strict minimum length but a thorough spec produces better results.
candidates array Required Array of candidate objects. Minimum 1, maximum 200. All candidate_ref values must be unique within a single request.
candidates[].candidate_ref string Required Your internal identifier for this candidate. Opaque; any string is valid. Echoed back in results so you can match scores to your own records.
candidates[].cv_text string Required The candidate's CV as plain text. Your application is responsible for extracting text from PDFs or Word documents before submitting. The first 10,000 characters are used for scoring.
webhook_url string Optional A URL to receive results via HTTP POST when processing is complete. Must be publicly accessible. See Webhooks for payload details and retry behaviour.

Response

FieldTypeDescription
job_id string UUID for this job. Use this to poll for results.
status string Always pending on initial submission.
total_candidates integer Number of candidates accepted for processing.
expires_at string ISO 8601 timestamp when results will be purged (72 hours from submission).
JSON Example request body
{
  "job_spec": "Senior Python Developer with 5+ years of experience building production APIs. Must have: FastAPI or Django REST, PostgreSQL, experience with async Python. Nice to have: AWS, Docker, Redis.",
  "candidates": [
    {
      "candidate_ref": "ats-candidate-8821",
      "cv_text": "Jane Smith. Software Engineer. 7 years Python experience..."
    },
    {
      "candidate_ref": "ats-candidate-9034",
      "cv_text": "Mark Chen. Backend Developer. 3 years Node.js, 1 year Python..."
    }
  ],
  "webhook_url": "https://yourapp.com/webhooks/aptly"
}

Retrieve job results

GET /api/v1/jobs/{job_id} Poll status and retrieve scored results

Returns the current status of a screening job. When status is complete, the results and failed arrays are included in the response. Results are available for 72 hours from submission, after which this endpoint returns 410 Gone.

Path parameters

ParameterTypeDescription
job_id string The UUID returned by POST /api/v1/screen.

Response

FieldTypeDescription
job_id string The job UUID.
status string One of: pending, processing, complete, failed.
total_candidates integer Total candidates submitted.
processed_candidates integer Candidates scored so far. Useful for progress tracking during processing status.
created_at string ISO 8601 timestamp of job submission.
completed_at string | null ISO 8601 timestamp of completion. null until complete.
expires_at string ISO 8601 timestamp when results will be purged.
results array Array of scored candidate objects. Only present when status is complete. See Response schema.
failed array Array of candidates that could not be scored, each with candidate_ref and error. Only present when status is complete.
Recommended polling interval: Check every 3 seconds for small batches (under 10 candidates), every 10 seconds for larger batches. Most jobs complete in under 60 seconds. If you are using webhooks, you do not need to poll at all.

List API keys

GET /api/v1/keys List all API keys for the authenticated account

Returns all API keys associated with the account, including revoked keys. Authentication for this endpoint uses your existing Aptly JWT token (the standard Authorization: Bearer {token} header), not an API key.

JSON Example response
{
  "api_access_enabled":     true,
  "org_api_cvs_this_month": 142,
  "committed_scans":        1000,
  "hard_cap_scans":         1500,
  "keys": [
    {
      "id":               1,
      "name":             "Production",
      "key_prefix":       "aptly_upMY--sVyRGeuY",
      "revoked":          false,
      "created_at":       "2026-04-24T01:33:11.984978",
      "last_used_at":     "2026-04-24T09:12:44.000000",
      "monthly_cv_count": 142,
      "billing_month":    "2026-04"
    }
  ]
}

Create an API key

POST /api/v1/keys Generate a new API key

Creates a new API key. The full key value is returned once only in the response and cannot be retrieved again. Store it securely immediately. Uses JWT authentication.

Request body (form data)

FieldTypeRequiredDescription
name string Required A label for this key. Max 100 characters. e.g. Production, Staging, Integration test.
JSON Example response: key shown once
{
  "id":          2,
  "name":        "Production",
  "key":         "aptly_upMY--sVyRGeuYGScwOWN4yqlzKRDsx3QwpA-ucNUUd-bCL7DuAFDQ",
  "key_prefix":  "aptly_upMY--sVyRGeuY",
  "created_at":  "2026-04-24T01:33:11.984978",
  "warning":     "Store this key securely. It will not be shown again."
}

Error responses

StatusMeaningCommon causes
400 Bad Request Maximum 5 active API keys allowed. Revoke an existing key before creating a new one.
401 Unauthorized Missing or invalid JWT.
403 Forbidden Email address has not been verified, or API access is not enabled for your organisation.
422 Unprocessable Entity Missing or invalid name form field.

Revoke an API key

DELETE /api/v1/keys/{key_id} Permanently revoke an API key

Revokes an API key immediately. Any requests using a revoked key will receive 401 Unauthorized. This action is irreversible; you will need to generate a new key and update any integrations. Uses JWT authentication.

Path parameters

ParameterTypeDescription
key_id integer The numeric id of the key to revoke, as returned by GET /api/v1/keys.

Error responses

StatusMeaningCommon causes
401 Unauthorized Missing or invalid JWT.
403 Forbidden The authenticated user has no organisation associated with their account.
404 Not Found The key_id does not exist or does not belong to your organisation.

ATS integration overview

The ATS integration endpoints connect your ATS to Aptly as a companion product. Aptly stays a standalone product with its own login. Your ATS pushes jobs and candidates in, recruiters run screening, references, video introductions, and scorecards inside Aptly, and your ATS pulls the resulting intelligence and artifact availability back. Records created this way live in your Aptly workspace and persist until deleted in the app.

Identity

Every record pushed through the integration is identified by a four-part tuple:

  • Your organisation, implicit from the API key. Never sent in the payload.
  • source_system: the type of ATS, e.g. bullhorn_sf.
  • source_system_id: the id of your ATS instance or tenant, e.g. a Salesforce org id. Required because external ids are only unique within a single instance.
  • external_id: the record's id inside that instance.

Upserts match on the full tuple. Pushing the same identity again updates the existing record instead of creating a duplicate.

Metering

Pushing jobs and candidates is free and does not consume scans. Scoring happens in the app, initiated by the recruiter, and debits the organisation's normal plan scans. Jobs created through the integration count against the plan's job limits in the same way as jobs created in the app.

Quick start

The minimal sync is three calls: create the job, push candidates onto it, pull results back.

cURL Step 1: create or update a job
curl -X POST https://api.aptly.pro/api/v1/ats/jobs \
  -H "X-API-Key: aptly_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "source_system": "bullhorn_sf",
    "source_system_id": "00D8E0000008abcUAA",
    "external_id": "JOB-1042",
    "title": "Senior Python Developer",
    "job_spec": "Senior Python developer with 5+ years FastAPI experience..."
  }'

The response includes aptly_job_id. Use it in the next two calls.

cURL Step 2: push candidates onto the job
curl -X POST https://api.aptly.pro/api/v1/ats/jobs/92/candidates \
  -H "X-API-Key: aptly_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "source_system": "bullhorn_sf",
    "source_system_id": "00D8E0000008abcUAA",
    "candidates": [
      {
        "external_id": "CAND-8821",
        "name": "Jane Smith",
        "email": "jane.smith@example.com",
        "cv_text": "7 years Python experience, built APIs with FastAPI..."
      }
    ]
  }'
cURL Step 3: pull results back
curl https://api.aptly.pro/api/v1/ats/jobs/92 \
  -H "X-API-Key: aptly_your_key_here"

Between steps 2 and 3, the recruiter screens the pushed candidates in the app. Step 3 can be called at any time and on any schedule; unscored candidates are included with scored: false.


Create or update a job

POST /api/v1/ats/jobs Upsert a job by its ATS identity

Creates a job in your Aptly workspace, or updates the existing one when the identity tuple matches. The job appears in the app immediately and behaves like any job created there.

Request body

FieldTypeRequiredDescription
source_system string Required The type of ATS pushing the record, e.g. bullhorn_sf.
source_system_id string Required The id of your ATS instance or tenant, e.g. a Salesforce org id.
external_id string Required The job's id inside that instance.
title string Required Job title. Maximum 200 characters.
job_spec string Optional The full job description, plain text. Recruiters can also add or edit it in the app.
client_name string Optional Client or end-employer name, stored as plain text.
client_context string Optional Background on the client. Used as a scoring input.
client_values string Optional Client priorities as comma-separated axis:state pairs, e.g. technical:more,industry:less. Axes: technical, industry, seniority, culture. States: less, more. Omit an axis for normal weighting.
location string Optional Free text.
work_mode string Optional One of remote, hybrid, onsite.
employment_type string Optional One of permanent, fixed_term, contract, temporary.
hours string Optional One of full_time, part_time, flexible.
salary string Optional Free text.
hiring_manager_name string Optional Hiring manager contact name. hiring_manager_email and hiring_manager_phone are also accepted, both optional strings.

Behaviour

The job is matched on the full identity tuple. created is true on the first push and false when an existing job matched and was updated.

Updates are non-null-wins. An omitted or empty field never clears stored data, and there is no way to clear a field through this API; clearing is done in the app. Send the full payload on every sync. Partial payloads are safe, they just only add or replace.

Changing job_spec, client_context, or client_values updates the job's scoring inputs. Candidates already scored on the job are marked in the app as scored before the update, so recruiters can see which results predate the change.

Job limits are enforced on create only. A plan at its active job limit receives a 403 with a message naming the limit. Updates always pass.

Response

FieldTypeDescription
aptly_job_id integer Aptly's id for the job. Use it in the candidate push and results pull endpoints.
created boolean true on first push, false when an existing job matched.
source_system string Echoed from the request.
source_system_id string Echoed from the request.
external_id string Echoed from the request.
JSON Example response
{
  "aptly_job_id":     92,
  "created":          true,
  "source_system":    "bullhorn_sf",
  "source_system_id": "00D8E0000008abcUAA",
  "external_id":      "JOB-1042"
}

Push candidates to a job

POST /api/v1/ats/jobs/{aptly_job_id}/candidates Upsert candidates and their applications on a job

Pushes a batch of up to 200 candidates onto a job. Each candidate is upserted into your candidate database and given one application on the job.

Path parameters

ParameterTypeDescription
aptly_job_id integer Aptly's id for the job, as returned by POST /api/v1/ats/jobs.

Request body

FieldTypeRequiredDescription
source_system string Required The type of ATS pushing the records.
source_system_id string Required The id of your ATS instance or tenant.
candidates array Required 1 to 200 candidate objects.
candidates[].external_id string Required The candidate's id inside your ATS instance.
candidates[].name string Required The candidate's full name.
candidates[].email string Optional Used for the duplicate-match fallback described below. The stored email on a matched candidate is never overwritten.
candidates[].phone string Optional Written only when present.
candidates[].cv_text string Optional The CV as plain text, if your ATS has already extracted it.
candidates[].cv_file_base64 string Optional The CV file as base64. PDF or Word (.pdf, .docx, .doc). Requires cv_filename; the MIME type is derived from the filename extension. Aptly extracts the text on receipt.
candidates[].cv_filename string Optional The original filename. Required when cv_file_base64 is sent.

Behaviour

Validation is atomic. The batch is checked up front, and a malformed payload (a candidate missing external_id or name, or a file without a filename) rejects the whole batch with a 400 naming the offending entries. Nothing is written partially.

Candidates land in the Applied stage, unscored. Scoring is done by the recruiter in the app and uses the organisation's plan scans.

Duplicates are matched by external identity first, then by email within your organisation. On an email match the external identity is stamped onto the existing candidate and application, so future syncs match directly, and the response reports skipped_duplicate with the existing ids. A pushed CV replaces the stored CV on the matched candidate. Non-null-wins applies throughout: data is never cleared, only replaced.

Candidates without a CV are accepted. Screening them in the app returns a clear error until a CV exists, pushed through this endpoint or uploaded by the recruiter.

ATS-pushed applications store no consent artifact in Aptly. The source ATS remains the system of record for candidate consent and lawful basis. Make sure your own processing notices cover screening performed in Aptly.

Response

The response is {"count": N, "results": [...]} with one result per submitted candidate, in order.

FieldTypeDescription
external_id string Echoed from the request.
aptly_candidate_id integer The candidate's id in your Aptly candidate database.
aptly_application_id integer The application on this job.
status string created, or skipped_duplicate when an existing candidate and application matched. On a duplicate, the ids point at the existing rows.
cv string extracted (file parsed successfully), provided (cv_text stored as sent), none (no CV sent), or unreadable (the file could not be parsed; the candidate was still created, without CV text).
JSON Example response
{
  "count": 2,
  "results": [
    {
      "external_id":          "CAND-8821",
      "aptly_candidate_id":   342,
      "aptly_application_id": 460,
      "status":               "created",
      "cv":                   "provided"
    },
    {
      "external_id":          "CAND-9034",
      "aptly_candidate_id":   298,
      "aptly_application_id": 431,
      "status":               "skipped_duplicate",
      "cv":                   "extracted"
    }
  ]
}

Pull job results

GET /api/v1/ats/jobs/{aptly_job_id} Read the job and every application on it

Returns the job and every application on it, ordered by score descending. Structured intelligence (scores, verdicts, reasons) is returned as data. References, video introductions, and scorecards are returned as availability flags plus a single deep link into the app, not as content.

Path parameters

ParameterTypeDescription
aptly_job_id integer Aptly's id for the job, as returned by POST /api/v1/ats/jobs.

Job fields

FieldTypeDescription
aptly_job_id integer Aptly's id for the job.
title string Job title.
status string The job's status in Aptly, e.g. open or closed.
source_system string | null The ATS identity, with source_system_id and external_id. null on jobs not created through the integration.
scoring_inputs_updated_at string | null ISO 8601 timestamp of the last change to job_spec, client_context, or client_values. Compare against each candidate's last_scored_at to spot scores that predate a spec change.
candidates array One object per application on the job, fields below.

Per-candidate fields

FieldTypeDescription
aptly_application_id integer The application id. Also the id used in aptly_link.
aptly_candidate_id integer The candidate's id in your Aptly candidate database.
external_id string | null Your ATS id for the candidate, with source_system and source_system_id. null for candidates that did not come through the integration, e.g. uploaded by the recruiter in the app.
candidate_name string With candidate_email (string | null).
stage string Pipeline stage: applied, screened, shortlisted, interviewing, offered, placed, or rejected.
score integer 0 to 100. 0 until scored; check scored.
verdict string | null Strong shortlist, Borderline, or Do not progress. null until scored.
confidence string | null Scoring confidence: low, medium, or high. null until scored.
score_summary string | null One-sentence verdict summary.
ai_cv_flag string AI-written CV detection: None, Mild, Moderate, or Strong. Always a string; unscored applications carry the literal string "None", not JSON null. ai_cv_flag_reason (string | null) explains the flag when set.
hidden_gem boolean true when the candidate carries unique credentials others lack, even at a lower score. hidden_gem_reason (string | null) explains why.
match_reasons array Up to 4 evidence-based reasons the candidate fits. Empty until scored.
gaps array Up to 3 specific items to probe in interview. Empty until scored.
last_scored_at string | null ISO 8601 timestamp of the most recent scoring. null until scored.
scored boolean true once the application has been scored. Check this before writing scores back to your ATS.
references_available boolean true when at least one completed reference exists.
video_available boolean true when a video introduction has been recorded and is still available.
scorecards_count integer Number of submitted interview scorecards. scorecards_available (boolean) is true when the count is above zero.
aptly_link string Deep link to the candidate profile in the app. Requires an Aptly login.
JSON Example response (trimmed): one scored candidate, one unscored
{
  "aptly_job_id":              92,
  "title":                     "Senior Python Developer",
  "status":                    "open",
  "source_system":             "bullhorn_sf",
  "source_system_id":          "00D8E0000008abcUAA",
  "external_id":               "JOB-1042",
  "scoring_inputs_updated_at": "2026-06-10T08:14:02.118450",
  "candidates": [
    {
      "aptly_application_id": 460,
      "aptly_candidate_id":   342,
      "external_id":          "CAND-8821",
      "source_system":        "bullhorn_sf",
      "source_system_id":     "00D8E0000008abcUAA",
      "candidate_name":       "Jane Smith",
      "candidate_email":      "jane.smith@example.com",
      "stage":                "shortlisted",
      "score":                84,
      "verdict":              "Strong shortlist",
      "confidence":           "high",
      "score_summary":        "Seven years of production Python and FastAPI meet the senior band.",
      "ai_cv_flag":           "None",
      "ai_cv_flag_reason":    null,
      "hidden_gem":           false,
      "hidden_gem_reason":    null,
      "match_reasons":        ["7 years Python meets the senior requirement", "FastAPI experience matches the primary stack"],
      "gaps":                 ["No AWS or cloud infrastructure experience mentioned"],
      "last_scored_at":       "2026-06-10T09:02:41.553210",
      "scored":               true,
      "references_available": true,
      "video_available":      false,
      "scorecards_count":     2,
      "scorecards_available": true,
      "aptly_link":           "https://app.aptly.pro/#candidate-profile?application_id=460"
    },
    {
      "aptly_application_id": 431,
      "aptly_candidate_id":   298,
      "external_id":          "CAND-9034",
      "source_system":        "bullhorn_sf",
      "source_system_id":     "00D8E0000008abcUAA",
      "candidate_name":       "Mark Chen",
      "candidate_email":      "mark.chen@example.com",
      "stage":                "applied",
      "score":                0,
      "verdict":              null,
      "confidence":           null,
      "score_summary":        null,
      "ai_cv_flag":           "None",
      "ai_cv_flag_reason":    null,
      "hidden_gem":           false,
      "hidden_gem_reason":    null,
      "match_reasons":        [],
      "gaps":                 [],
      "last_scored_at":       null,
      "scored":               false,
      "references_available": false,
      "video_available":      false,
      "scorecards_count":     0,
      "scorecards_available": false,
      "aptly_link":           "https://app.aptly.pro/#candidate-profile?application_id=431"
    }
  ]
}
Unscored applications are included with score 0 and verdict null. Always check scored before writing a score back to your ATS; 0 is not a verdict.
aptly_link opens the candidate profile for a logged-in Aptly user. It is not server-fetchable, and there are no public read URLs for references, videos, or scorecards. Treat the flags as availability signals and the link as a recruiter shortcut.

Interview prep is not included in this response. It is generated on demand in the app and not persisted, so there is nothing stored to return.


Limitations

Constraints worth designing around when building against the ATS integration:

  • No consent artifact. ATS-pushed applications store no consent record in Aptly. The source ATS remains the system of record for candidate consent and lawful basis.
  • Deep links require an Aptly login. There are no public read URLs for references, video introductions, or scorecards. The availability flags are signals, not links to content.
  • Non-null-wins updates. An omitted or empty field never clears stored data, and there is no way to clear a field through the API. Clearing is done in the app.
  • No interview prep. Interview prep is generated on demand in the app and not persisted, so it is not available through the API.
  • Check the scored flag. Unscored applications carry score 0 and verdict null. Always check scored before writing a score back to your ATS.

ATS event webhooks

When something changes on a candidate in your Aptly workspace, Aptly can send a small signed signal to a URL you choose, so your connector knows to re-pull rather than poll on a timer. The signal carries identifiers only, never candidate details: you fetch the detail with GET /api/v1/ats/jobs/{aptly_job_id} using your API key.

Setup

The organisation owner registers the webhook in the Aptly app under Profile then Webhooks: they set the destination URL and generate a signing secret, which is shown once. The owner shares that secret with you privately. There is no API for registration. Aptly delivers to a single URL per organisation over HTTPS only.

Events

Event typeFires when
application.scored An application's score is set or changes.
application.stage_changed An application moves between pipeline stages (not on initial creation).
application.reference_received A reference is submitted for the candidate.
application.video_received A candidate finishes recording their video introduction.
application.scorecard_submitted An interview scorecard is submitted.

Payload

The body is JSON and carries no PII. Fields:

FieldTypeDescription
event_id string A unique id for this event. Use it for idempotency and de-duplication.
event_type string One of the five event types above.
occurred_at string ISO 8601 UTC, with a trailing Z.
aptly_job_id integer The job the application belongs to.
aptly_application_id integer The application that changed. Use it to re-pull via the job results endpoint.
stage string Present only on application.stage_changed. The new pipeline stage.
JSON application.scored body
{
  "event_id":             "a3f8b2c1-9d4e-4a67-b8ab-1c2d3e4f5a6b",
  "event_type":           "application.scored",
  "occurred_at":          "2026-06-18T10:32:14Z",
  "aptly_job_id":         92,
  "aptly_application_id": 460
}
JSON application.stage_changed body
{
  "event_id":             "7c2e9a14-3b8d-4f21-9e6a-5d4c3b2a1f09",
  "event_type":           "application.stage_changed",
  "occurred_at":          "2026-06-18T11:05:48Z",
  "aptly_job_id":         92,
  "aptly_application_id": 460,
  "stage":                "shortlisted"
}

Headers

Every delivery carries these HTTP headers:

HeaderValue
Content-Type application/json
X-Aptly-Webhook-Id The event_id, the same value as in the body.
X-Aptly-Webhook-Timestamp Unix seconds (an integer as a string) when the delivery was signed.
X-Aptly-Webhook-Signature The literal v1, followed by a base64 HMAC-SHA256.
User-Agent Aptly-Webhooks/1

Verifying the signature

The signed content is the X-Aptly-Webhook-Timestamp value, then a literal dot, then the exact raw request body bytes as received. Do not re-serialise or re-parse the JSON first; verify against the raw body. Compute base64(HMAC_SHA256(your_signing_secret, "{timestamp}.{body}")), prefix it with v1,, and compare it to the X-Aptly-Webhook-Signature header using a constant-time comparison. Reject if it does not match. Also reject if the timestamp is older than a few minutes (5 minutes is a sound default) to prevent replay.

Python verify_webhook.py
import hmac
import hashlib
import base64
import time

SIGNING_SECRET  = "your_signing_secret_from_the_app"
MAX_AGE_SECONDS = 300  # reject anything older than 5 minutes

def verify(timestamp, signature, raw_body):
    # raw_body is the exact bytes received, do not re-serialise the JSON
    if abs(time.time() - int(timestamp)) > MAX_AGE_SECONDS:
        return False

    signed = timestamp.encode("utf-8") + b"." + raw_body
    digest = hmac.new(SIGNING_SECRET.encode("utf-8"), signed, hashlib.sha256).digest()
    expected = "v1," + base64.b64encode(digest).decode("utf-8")

    # constant-time compare
    return hmac.compare_digest(expected, signature)
JavaScript verifyWebhook.mjs (Node 18+)
const crypto = require("crypto");

const SIGNING_SECRET  = "your_signing_secret_from_the_app";
const MAX_AGE_SECONDS = 300; // reject anything older than 5 minutes

function verify(timestamp, signature, rawBody) {
  // rawBody is the exact Buffer received, do not re-serialise the JSON
  const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10));
  if (age > MAX_AGE_SECONDS) return false;

  const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
  const digest = crypto.createHmac("sha256", SIGNING_SECRET).update(signed).digest("base64");
  const expected = `v1,${digest}`;

  // constant-time compare; timingSafeEqual needs equal lengths
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Delivery and retry

  • Delivery is over HTTPS only. Your endpoint should respond 2xx quickly.
  • On a non-2xx or no response, Aptly retries with backoff, up to 5 attempts total over roughly 40 minutes, after which the event is marked failed and not retried.
  • Because the signal only tells you to re-pull, a missed event is recoverable: GET /api/v1/ats/jobs/{aptly_job_id} is the source of truth, so a periodic reconciliation pull is a sound backstop.
  • The owner can pause and resume delivery, or rotate the signing secret, in the app at any time. After a rotation, old secrets stop verifying immediately.
Deliveries carry no candidate PII, so the signal itself is safe to log. Fetch the actual detail over the authenticated API with your API key.

Request schema

Full schema for the POST /api/v1/screen request body.

JSON Full request schema
{
  "job_spec":    "string",             // required: full job description, plain text
  "candidates": [                      // required: 1 to 200 items
    {
      "candidate_ref": "string",  // required: your internal ID, unique per request
      "cv_text":       "string"   // required: plain text CV content
    }
  ],
  "webhook_url": "string | null"     // optional: must be a publicly accessible HTTPS URL
}

Response schema

Each scored candidate in the results array has the following structure.

JSON Scored candidate object
{
  "candidate_ref":  "string",    // echoed from your request
  "score":          84,          // integer 0–100
  "verdict":        "Strong shortlist",  // see Verdicts section
  "match_reasons": [
    "7 years Python meets the senior requirement",
    "FastAPI experience directly matches primary stack",
    "Led distributed backend teams, signals seniority"
  ],                              // up to 4 items, evidence-based
  "gaps": [
    "No AWS or cloud infrastructure experience mentioned",
    "No mention of system design at distributed scale"
  ]                               // up to 3 items, specific and actionable
}

Failed candidate object

Candidates in the failed array were not scored due to a processing error. Your job still completes; partial results are always returned.

JSON Failed candidate object
{
  "candidate_ref": "ats-candidate-9034",
  "error":          "JSON decode error"
}

Verdicts and scores

Every scored candidate receives both a numeric score and a categorical verdict. The verdict is derived from the score using fixed thresholds.

Score rangeVerdictMeaning
75 – 100 Strong shortlist Candidate meets all key requirements with strong, specific evidence in their CV. Recommend progressing.
50 – 74 Borderline Candidate meets most requirements but has notable gaps or ambiguity. Worth reviewing the gaps field to decide whether to progress.
0 – 49 Do not progress Significant gaps against the job specification. Not recommended to progress without further information.
Scores are absolute, not relative. A score of 80 means the candidate is a strong fit for the job spec, not that they ranked first among the batch. If you submit the same candidate against two different job specs, their scores may differ significantly.

Screening webhooks

This callback applies only to the stateless POST /api/v1/screen mode and fires once when a batch completes. For the integration sync mode, where Aptly signals you as candidates change in your workspace, see ATS event webhooks.

If you supply a webhook_url in your screening request, Aptly will POST the completed results to that URL when processing finishes. This eliminates the need to poll.

Delivery

  • Aptly makes a single POST request with a JSON body when the job reaches complete status
  • Your endpoint must respond with a 2xx status code within 15 seconds
  • If your endpoint is unavailable or returns a non-2xx status, Aptly retries up to 3 times after the initial attempt, with backoff delays of 5 seconds, 30 seconds, and 120 seconds between attempts (4 total attempts).
  • After 4 failed attempts, no further retries are made. Retrieve results via polling instead.

Webhook payload

JSON Webhook POST body
{
  "job_id":  "3f8a2b1c-d4e5-4f67-89ab-cdef01234567",
  "status":  "complete",
  "results": [
    {
      "candidate_ref":  "ats-candidate-8821",
      "score":          84,
      "verdict":        "Strong shortlist",
      "match_reasons":  ["..."],
      "gaps":           ["..."]
    }
  ],
  "failed": []
}
Your webhook endpoint must be publicly accessible over HTTPS. Localhost URLs and private network addresses will not work. For local development, use a tunnelling tool like ngrok to expose a local endpoint.

Errors

Aptly uses standard HTTP status codes. Error responses include a JSON body. For most errors the body contains a single detail string. For 422 (validation errors), detail is an array of validation error objects; see Validation errors (422).

JSON Error response structure
{
  "detail": "Duplicate candidate_ref values found, all candidate_ref values must be unique within a request"
}
StatusMeaningCommon causes
200 OK Request succeeded.
400 Bad Request Missing required field, duplicate candidate_ref, over 200 candidates, empty job_spec.
401 Unauthorized Missing X-API-Key header, invalid key, or revoked key.
403 Forbidden API access is not enabled for your organisation. Contact hello@aptly.pro to get started.
404 Not Found The job_id does not exist or does not belong to your account.
410 Gone Results have expired. Stateless screening jobs are retained for 72 hours from submission.
422 Unprocessable Entity Request body failed validation (missing required field, wrong type, malformed JSON). See Validation errors (422) for the response body shape.
429 Too Many Requests Monthly CV quota would be exceeded for your organisation. See Quota exceeded (429) for the response body shape.
500 Server Error An unexpected error occurred on our side. If this persists, contact hello@aptly.pro.

Quota exceeded (429)

When a request would push your monthly usage over your hard cap, the API rejects the request with status 429. The response body lets your integration surface a clear message to the user or queue the request for next month.

JSON 429 Quota exceeded response
{
  "detail":          "Monthly API quota would be exceeded. Used 4900 of 5000 CVs this month; this request of 200 CVs would push you over the limit. Please contact hello@aptly.pro to increase your limit.",
  "current_usage":   4900,
  "hard_cap":        5000,
  "requested_count": 200,
  "billing_month":   "2026-05"
}

Validation errors (422)

When a request body fails validation (missing required fields, wrong types, malformed JSON), the API returns status 422 with a body whose detail field is an array of validation error objects, one per failed field.

JSON 422 Validation error response
{
  "detail": [
    {
      "loc":  ["body", "candidates", 0, "cv_text"],
      "msg":  "field required",
      "type": "value_error.missing"
    }
  ]
}

Limits and data retention

LimitValue
Maximum candidates per request 200
CV text used for scoring First 10,000 characters
Maximum active API keys 5 per organisation
Result retention (stateless screening jobs) 72 hours from submission
Webhook timeout 15 seconds per attempt
Webhook retry attempts 3 retries after initial attempt (4 total)
Monthly CV quota Configured per organisation as part of your API plan.

Data processing

CV text and job specifications submitted to the stateless screening endpoint (POST /api/v1/screen) are processed to generate scores and are not stored after the 72-hour retention window. Records created through the ATS integration endpoints persist in your Aptly workspace like any other app data until deleted in the app. Aptly does not use submitted content to train AI models. For full details, see our Privacy Policy and Data Processing Agreement.


Python example

A complete example using the requests library with polling until complete.

Python screen_candidates.py
import requests
import time

API_BASE = "https://api.aptly.pro"
API_KEY  = "aptly_your_key_here"

HEADERS = {
    "X-API-Key": API_KEY,
    "Content-Type": "application/json"
}

# 1. Submit the screening job
payload = {
    "job_spec": "Senior Python Developer, 5+ years FastAPI...",
    "candidates": [
        {"candidate_ref": "cand-001", "cv_text": "7 years Python, FastAPI, PostgreSQL..."},
        {"candidate_ref": "cand-002", "cv_text": "3 years Node.js, some Python exposure..."},
    ]
}

response = requests.post(f"{API_BASE}/api/v1/screen", json=payload, headers=HEADERS)
response.raise_for_status()
job_id = response.json()["job_id"]
print(f"Job submitted: {job_id}")

# 2. Poll until complete
while True:
    time.sleep(5)
    result = requests.get(
        f"{API_BASE}/api/v1/jobs/{job_id}",
        headers=HEADERS
    ).json()

    if result["status"] == "complete":
        for candidate in result["results"]:
            print(
                f"{candidate['candidate_ref']}: "
                f"{candidate['score']}%, {candidate['verdict']}"
            )
        break
    elif result["status"] == "failed":
        print("Job failed")
        break
    else:
        print(f"Status: {result['status']}, {result['processed_candidates']}/{result['total_candidates']}")

Node.js example

JavaScript screenCandidates.mjs (Node 18+, native fetch)
const API_BASE = "https://api.aptly.pro";
const API_KEY  = "aptly_your_key_here";

const headers = {
  "X-API-Key":      API_KEY,
  "Content-Type": "application/json"
};

async function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function screenCandidates() {
  // 1. Submit
  const submit = await fetch(`${API_BASE}/api/v1/screen`, {
    method:  "POST",
    headers,
    body: JSON.stringify({
      job_spec:   "Senior Python Developer, FastAPI, 5+ years...",
      candidates: [
        { candidate_ref: "cand-001", cv_text: "7 years Python, FastAPI..." },
        { candidate_ref: "cand-002", cv_text: "3 years Node.js, some Python..." }
      ]
    })
  });

  const { job_id } = await submit.json();
  console.log(`Job submitted: ${job_id}`);

  // 2. Poll
  while (true) {
    await sleep(5000);
    const res = await fetch(`${API_BASE}/api/v1/jobs/${job_id}`, { headers });
    const data = await res.json();

    if (data.status === "complete") {
      data.results.forEach(c =>
        console.log(`${c.candidate_ref}: ${c.score}%, ${c.verdict}`)
      );
      break;
    } else if (data.status === "failed") {
      console.error("Job failed"); break;
    }
    console.log(`Processing: ${data.processed_candidates}/${data.total_candidates}`);
  }
}

screenCandidates();

cURL examples

cURL Submit screening job from file
# Create payload.json first, then:
curl -X POST https://api.aptly.pro/api/v1/screen \
  -H "X-API-Key: aptly_your_key_here" \
  -H "Content-Type: application/json" \
  -d "@payload.json"
cURL Poll for results
curl https://api.aptly.pro/api/v1/jobs/YOUR_JOB_ID \
  -H "X-API-Key: aptly_your_key_here"
cURL List your API keys (uses JWT auth)
curl https://api.aptly.pro/api/v1/keys \
  -H "Authorization: Bearer YOUR_JWT_TOKEN"
On Windows using PowerShell, save your JSON payload to a file and use -d "@payload.json" to avoid shell escaping issues. Pass --ssl-no-revoke if you encounter SSL certificate errors.

Questions or integration issues?

Contact support →