Aptly Intelligence
API Reference
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.
| Mode | What it writes | Retention | Auth |
|---|---|---|---|
Stateless scoringPOST /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.
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.
X-API-Key: aptly_your_key_here
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:
- POST /api/v1/screen: submit your job spec and candidates, receive a
job_id - GET /api/v1/jobs/{job_id}: poll until
statusiscomplete, then read results
If you provide a webhook_url, step 2 is optional; results will be pushed to you when ready.
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..." } ] }'
{ "job_id": "3f8a2b1c-d4e5-4f67-89ab-cdef01234567", "status": "pending", "total_candidates": 1, "expires_at": "YYYY-MM-DDTHH:MM:SS.ssssss" }
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.
- aptly-api.http Open in VS Code with the REST Client extension
- aptly-api.postman_collection.json Import into Postman (Collection v2.1)
Base 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
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
| Field | Type | Required | Description |
|---|---|---|---|
| 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
| Field | Type | Description |
|---|---|---|
| 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). |
{ "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
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
| Parameter | Type | Description |
|---|---|---|
| job_id | string | The UUID returned by POST /api/v1/screen. |
Response
| Field | Type | Description |
|---|---|---|
| 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. |
List API keys
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.
{ "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
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)
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Required | A label for this key. Max 100 characters. e.g. Production, Staging, Integration test. |
{ "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
| Status | Meaning | Common 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
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
| Parameter | Type | Description |
|---|---|---|
| key_id | integer | The numeric id of the key to revoke, as returned by GET /api/v1/keys. |
Error responses
| Status | Meaning | Common 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 -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 -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 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
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
| Field | Type | Required | Description |
|---|---|---|---|
| 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
| Field | Type | Description |
|---|---|---|
| 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. |
{ "aptly_job_id": 92, "created": true, "source_system": "bullhorn_sf", "source_system_id": "00D8E0000008abcUAA", "external_id": "JOB-1042" }
Push candidates to 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
| Parameter | Type | Description |
|---|---|---|
| aptly_job_id | integer | Aptly's id for the job, as returned by POST /api/v1/ats/jobs. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| 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.
Response
The response is {"count": N, "results": [...]} with one result per submitted candidate, in order.
| Field | Type | Description |
|---|---|---|
| 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). |
{ "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
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
| Parameter | Type | Description |
|---|---|---|
| aptly_job_id | integer | Aptly's id for the job, as returned by POST /api/v1/ats/jobs. |
Job fields
| Field | Type | Description |
|---|---|---|
| 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
| Field | Type | Description |
|---|---|---|
| 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. |
{ "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" } ] }
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
scoredflag. Unscored applications carryscore0 andverdictnull. Always checkscoredbefore 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 type | Fires 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:
| Field | Type | Description |
|---|---|---|
| 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. |
{ "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 }
{ "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:
| Header | Value |
|---|---|
| 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.
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)
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
2xxquickly. - 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.
Request schema
Full schema for the POST /api/v1/screen request body.
{ "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.
{ "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.
{ "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 range | Verdict | Meaning |
|---|---|---|
| 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. |
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
completestatus - Your endpoint must respond with a
2xxstatus 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
{ "job_id": "3f8a2b1c-d4e5-4f67-89ab-cdef01234567", "status": "complete", "results": [ { "candidate_ref": "ats-candidate-8821", "score": 84, "verdict": "Strong shortlist", "match_reasons": ["..."], "gaps": ["..."] } ], "failed": [] }
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).
{ "detail": "Duplicate candidate_ref values found, all candidate_ref values must be unique within a request" }
| Status | Meaning | Common 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.
{ "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.
{ "detail": [ { "loc": ["body", "candidates", 0, "cv_text"], "msg": "field required", "type": "value_error.missing" } ] }
Limits and data retention
| Limit | Value |
|---|---|
| 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.
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
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
# 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 https://api.aptly.pro/api/v1/jobs/YOUR_JOB_ID \ -H "X-API-Key: aptly_your_key_here"
curl https://api.aptly.pro/api/v1/keys \ -H "Authorization: Bearer YOUR_JWT_TOKEN"
-d "@payload.json" to avoid shell escaping issues. Pass --ssl-no-revoke if you encounter SSL certificate errors.Questions or integration issues?
Contact support →