Talqo Doc
Connect your candidate acquisition with Talqo in 2 ways: API integration on your career site, or sharing Talqo-generated job offer links.
Choose your scenario
Choose your path: Talqo public link or API integration, depending on your context.
Introduction
This API allows an external career site (Webflow, WordPress, Wix, Bubble, Next.js, etc.) to list your company published job offers and create applications in Talqo ATS.
- Company identity is fully inferred from the API key — you never need to send company_id.
- CV is required and must be sent as a file in multipart/form-data (cv field, PDF or DOCX, ≤ 10 MB). Any other Content-Type is rejected with 415.
- The cover_letter field is optional (free text) and stored as-is; when omitted, an empty value is saved.
- Application source is inferred from Origin header (or Referer fallback) — any source field in request body is ignored.
- Implicit versioning (no /v1/ prefix). Any breaking change will be announced.
Available endpoints (X-API-Key)
Only these routes are exposed to external career sites. Pipeline, comments, and candidate tracking stay in the Talqo UI (not available via API).
| Method | Route | Purpose |
|---|---|---|
| GET | /api/job_offers | List published job offers (pagination, filters) |
| GET | /api/job_offers/{job_offer_id} | Job offer details by ID |
| POST | /api/job_applications | Create an application (multipart/form-data + CV required) |
GET/POST routes on /api/job_applications/{id}, /comments and /steps are not available — reserved for the Talqo ATS.
API path
List offers:
curl "https://talqo.fr/api/job_offers?status=open&per_page=20" \
-H "X-API-Key: <YOUR_API_KEY>"Create an application (multipart/form-data, resume sent as file):
curl -X POST "https://talqo.fr/api/job_applications" \
-H "X-API-Key: <YOUR_API_KEY>" \
-H "Origin: https://careers.my-company.com" \
-F "job_offer_id=550e8400-e29b-41d4-a716-446655440000" \
-F "first_name=Jean" \
-F "last_name=Dupont" \
-F "email=jean.dupont@example.com" \
-F "phone=+33123456789" \
-F "profile_link_linkedin=https://linkedin.com/in/jeandupont" \
-F "profile_link_github=https://github.com/jeandupont" \
-F "profile_link_portfolio=https://jeandupont.dev" \
-F "cover_letter=Je suis motive par ce poste et disponible rapidement." \
-F "cv=@/path/to/john_doe_resume.pdf;type=application/pdf"Authentication
All routes require a valid API key passed in an HTTP header.
Method 1 — X-API-Key header (recommended)
X-API-Key: your-secret-api-keyMethod 2 — Authorization: Bearer
Authorization: Bearer your-secret-api-keyWhere can I find my API key?
Talqo ATS interface → Company → API & Career Sites section.
Authentication errors
| Code | Case |
|---|---|
401 | Missing header, empty key, unknown key, or disabled key |
500 | Server configuration issue (missing SUPABASE_SERVICE_ROLE_KEY variable) |
Rate limiting
Each IP address is limited to 100 requests per minute across all Career Sites routes (fixed window). The limit is controlled by the CAREER_API_RATE_LIMIT_PER_IP environment variable.
If the limit is exceeded, the API returns 429 Too Many Requests with a Retry-After header:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
Retry-After: 37
Content-Type: application/json
{
"status": "error",
"data": null,
"message": "Too many requests. Please retry later."
}Error handling
All errors follow the same JSON format:
{
"status": "error",
"data": null,
"message": "Description lisible de l'erreur"
}Global HTTP codes
| Code | Meaning |
|---|---|
200 | OK |
201 | Resource created (application) |
400 | Invalid request (missing fields, malformed UUID, malformed multipart payload, closed offer, CV too large, etc.) |
401 | Missing or invalid API key |
403 | Resource belongs to another company |
404 | Resource not found |
415 | Unsupported Content-Type (use multipart/form-data on POST /api/job_applications) |
429 | Rate limit reached |
500 | Server/configuration error |
List job offers
/api/job_offersPaginated list of job offers for the company linked to the API key. Each offer’s description field is always Markdown (CommonMark + GFM).
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number (≥ 1) |
per_page | integer | 20 | Items per page (1 to 100, capped above 100) |
status | string | open | Status filter (open, closed, draft, etc.) |
location | string | — | Case-insensitive search (ILIKE %...%) |
job_type | string | — | Exact filter (CDI, CDD, Freelance, etc.) |
published_after | ISO 8601 | — | published_at >= published_after |
published_before | ISO 8601 | — | published_at <= published_before |
Example
curl "https://talqo.fr/api/job_offers?status=open&per_page=20" \
-H "X-API-Key: <YOUR_API_KEY>"200 response
{
"status": "success",
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Full Stack Developer",
"description": "# About the role\n\nWe are hiring...",
"location": "Paris, France",
"job_type": "CDI",
"salary": 55000,
"salary_currency": "EUR",
"show_salary": true,
"required_profile_links": ["linkedin", "github"],
"optional_profile_links": ["portfolio", "personal_website"],
"published_at": "2024-01-15T10:00:00Z",
"status": "open",
"updated_at": "2024-01-15T10:00:00Z"
}
],
"pagination": {
"page": 1,
"per_page": 20,
"total": 45,
"total_pages": 3
}
}Get a job offer
/api/job_offers/{job_offer_id}Returns job offer details. UUID is strictly validated before any DB query. The description field is always Markdown (CommonMark + GFM).
URL parameters
| Parameter | Type | Description |
|---|---|---|
job_offer_id | uuid | Strict format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. Any other format returns 400 immediately. |
Example
curl "https://talqo.fr/api/job_offers/550e8400-e29b-41d4-a716-446655440000" \
-H "X-API-Key: <YOUR_API_KEY>"Errors
| Code | Case |
|---|---|
400 | Invalid or missing UUID |
403 | The offer belongs to another company |
404 | Offer not found |
Create an application
/api/job_applicationsCreate an application from a career site. CV is required. Only multipart/form-data is supported. Any other Content-Type is rejected with 415. The HTTP response is returned as soon as the application is saved; AI scoring and emails are processed asynchronously by Talqo.
Request — multipart/form-data
Send flat fields in a FormData payload; the CV must be a real file in the cv field.
| Field | Type | Required | Notes |
|---|---|---|---|
job_offer_id | uuid (text) | ✅ | Offer must belong to your company and be in open or active status. |
first_name | string (text) | ✅ | trim() applied server-side. |
last_name | string (text) | ✅ | trim() applied server-side. |
email | string (text) | ✅ | Normalized with trim() + toLowerCase(). |
cv | file | ✅ | PDF or DOCX, ≤ 10 MB. See rules below. |
phone | string (text) | — | Free format. |
profile_link_{type} | string (text) | * | Profile links requested on the job offer. Use profile_link_'<type>' (e.g. profile_link_github). Required when the type is listed in the offer required_profile_links. |
cover_letter | string (text) | — | Free-text cover letter. Empty value when omitted. |
source | string | Ignored | Source is inferred from Origin or Referer header. |
CV file rules
- Required: missing or empty file ⇒ 400.
- Size ≤ 10 MB (above this limit ⇒ 400).
- Formats are autodetected by magic bytes (form Content-Type cannot be trusted):
- PDF (
%PDF) - DOCX (
PK…zip) - Unknown ⇒ fallback to PDF (not recommended)
- PDF (
- Any provided cv_url is ignored.
Example
curl -X POST "https://talqo.fr/api/job_applications" \
-H "X-API-Key: <YOUR_API_KEY>" \
-H "Origin: https://careers.my-company.com" \
-F "job_offer_id=550e8400-e29b-41d4-a716-446655440000" \
-F "first_name=Jean" \
-F "last_name=Dupont" \
-F "email=jean.dupont@example.com" \
-F "phone=+33123456789" \
-F "profile_link_linkedin=https://linkedin.com/in/jeandupont" \
-F "profile_link_github=https://github.com/jeandupont" \
-F "profile_link_portfolio=https://jeandupont.dev" \
-F "cover_letter=Je suis motive par ce poste et disponible rapidement." \
-F "cv=@/path/to/john_doe_resume.pdf;type=application/pdf"201 response — application created
HTTP/1.1 201 Created
Content-Type: application/json
{
"status": "success",
"data": {
"application_id": "c3e5b1d6-4f77-4d3a-9e36-0e6fbbfb77c2",
"candidate_id": "3d4b1c9b-1a2a-4f51-8a82-2a9f3a2b56aa",
"status": "new",
"created_at": "2024-01-15T10:00:00Z"
}
}200 response — existing application
If the same candidate (normalized email + first_name + last_name) already applied to the same offer, the API returns the existing application without creating a duplicate:
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "success",
"data": {
"application_id": "c3e5b1d6-4f77-4d3a-9e36-0e6fbbfb77c2",
"candidate_id": "3d4b1c9b-1a2a-4f51-8a82-2a9f3a2b56aa",
"status": "new",
"created_at": "2024-01-15T10:00:00Z"
},
"message": "Application already exists"
}About data.status
On a successful create (201), data.status is always "new". AI scoring and any auto-reject run afterwards in the background inside Talqo — the career site does not need to wait for that result:
| Case | data.status |
|---|---|
| Newly created application (nominal case) | "new" |
| Existing application returned as-is | current status |
Specific errors
| Code | message | Cause |
|---|---|---|
400 | job_offer_id is required | Missing field |
400 | first_name, last_name, and email are required | Incomplete candidate fields |
400 | cv file is required (multipart field "cv") | Missing CV file |
400 | CV validation failed: … | Empty file, > 10 MB, invalid format |
400 | Invalid multipart payload | Malformed multipart body |
400 | Missing required link: … | A link listed in the offer required_profile_links is missing or empty in the submission. |
400 | Invalid URL for link: … | Invalid URL for a profile_link_'<type>' field (must start with http:// or https://). |
400 | Job offer is not open for applications | Offer is not open/active |
403 | Job offer does not belong to your company | Offer belongs to another company |
404 | Job offer not found | Unknown job_offer_id |
415 | Unsupported Content-Type. Use multipart/form-data … | Sent as application/json (or other) instead of multipart |
500 | Failed to upload CV | Failed to upload CV to storage |
Data schemas
JobOffer
interface JobOffer {
id: string; // uuid
title: string;
description: string; // Markdown CommonMark+GFM (**bold**, *italic*, <u>underline</u>, - / 1. lists, [links](url))
location?: string | null;
job_type?: string | null;
salary?: number | null; // null when show_salary is false
salary_currency?: string | null; // EUR | USD | GBP | CHF — null when show_salary is false
show_salary?: boolean;
required_profile_links?: string[]; // e.g. ["linkedin", "github"]
optional_profile_links?: string[]; // e.g. ["portfolio", "behance"]
published_at?: string | null; // ISO 8601
status?: string; // "open" | "closed" | "active" | "draft" | ...
updated_at?: string | null; // ISO 8601
}Profile link enum (ProfileLinkType)
Allowed values in required_profile_links and optional_profile_links. Map each key to a human-readable label on your career site — never show the raw key (personal_website → "Personal website").
| API key | Form field | Suggested label (FR) | Suggested label (EN) |
|---|---|---|---|
linkedin | profile_link_linkedin | ||
github | profile_link_github | GitHub | GitHub |
gitlab | profile_link_gitlab | GitLab | GitLab |
bitbucket | profile_link_bitbucket | Bitbucket | Bitbucket |
portfolio | profile_link_portfolio | Portfolio | Portfolio |
CreateJobApplicationRequest
Request format: multipart/form-data.
// Transport: multipart/form-data only
// Flat fields in FormData, "cv" is a real file.
interface CreateJobApplicationMultipartFields {
job_offer_id: string; // uuid (text field)
first_name: string;
last_name: string;
email: string; // normalized to lowercase server-side
cv: File | Blob; // binary file (PDF or DOCX), <= 10 MB
phone?: string;
cover_letter?: string; // lettre de motivation (texte libre)
// Profile links (when configured on the job offer): profile_link_<type>
// See enum table "Profile link types" for all supported type keys and suggested labels.
}CreateJobApplicationResponse
interface CreateJobApplicationResponse {
application_id: string; // uuid
candidate_id: string; // uuid
status: "new" | string; // always "new" on 201; scoring/auto-reject run async after accept
created_at: string; // ISO 8601
}JobApplication
interface JobApplication {
id: string; // uuid
candidate_id: string; // uuid
job_offer_id: string; // uuid
status: string; // candidate_status
source?: string; // domaine (ex. "careers.my-company.com")
created_at: string; // ISO 8601
updated_at: string; // ISO 8601
}Enum candidate_status (common values)
| Value | Meaning |
|---|---|
new | New application (API default) |
in_progress | In internal review |
accepted | Accepted |
rejected | Rejected (including AI auto-reject) |
Other custom statuses may exist depending on company configuration.
Job offer descriptions: always Markdown
All job offer descriptions (JobOffer.description), in both list and detail endpoints, are official CommonMark + GFM Markdown — never pre-rendered HTML. Bold: **text** · Italic: *text* · Bold+italic: ***text*** · Underline: '<'u'>'text'</'u'>' (inline HTML; underline is not part of pure Markdown) · Bullet list: - item · Numbered list: 1. item · Links: [label](https://…). Use a dedicated Markdown library; do not build a custom regex parser.
Recommended libraries
| Stack | Packages |
|---|---|
| React / Next.js | react-markdown + remark-gfm |
| Vue | vue-markdown-render, markdown-it |
| Angular | ngx-markdown |
| Vanilla JS | marked, markdown-it |
| PHP | league/commonmark |
Application lifecycle
After POST /api/job_applications, the server first runs a fast hot path (validation → persist → 201), then deferred work (AI scoring, emails):
- IP / rate-limit validation (429 possible).
- API key validation (401 possible).
- Required-field + CV validation (binary read from multipart cv), size ≤ 10 MB, PDF/DOCX detection via magic bytes.
- Parallel retrieval: target offer + first pipeline step (offer / profile-link validation).
- Existing candidate detection via normalization (
email+first_name+last_name, lowercase and accent-stripped). - Upload CV to the resumes bucket. If candidate already exists, previous CVs are cleaned up.
- Deduplication: if an application already exists for
(candidate_id, job_offer_id), it is returned as-is (HTTP200). - Create
job_application(status"new",source= domain extracted from header,cover_letter= optional text (empty if omitted)). - Create the first step (
candidate_steps) :"awaiting_advance"with rating null (score filled later asynchronously)"rejected"if AI auto-reject is enabled andrating < auto_reject_threshold(job_application may become rejected and a rejection email is scheduled — only asynchronously after the 201.)
- AI scoring (deferred) then generates (outside the HTTP response)
AI_resume_summary,rating,first_impression_text. - Confirmation email to candidate (except AI auto-reject) — sent asynchronously after scoring.
- API log (api_logs): endpoint, HTTP status, latency, IP, user-agent.
Source detection
- Origin header → hostname.
- Otherwise Referer header → hostname.
- Otherwise default value "career_site".
Any source field sent in body is ignored.
Integration guides
Node / Next.js (server-side proxy) — multipart
// app/api/career/apply/route.ts
import { NextRequest, NextResponse } from 'next/server';
const TALQO_API_BASE = 'https://talqo.fr/api';
const TALQO_API_KEY = process.env.TALQO_API_KEY!;
export async function POST(request: NextRequest) {
const incoming = await request.formData();
// Rebuild FormData for Talqo: forward text fields + profile_link_* keys; CV stays binary.
const out = new FormData();
for (const [key, value] of incoming.entries()) {
if (key === 'cv') continue;
if (typeof value === 'string' && value) out.append(key, value);
}
const cv = incoming.get('cv');
if (!(cv instanceof File)) {
return NextResponse.json({ error: 'cv file is required' }, { status: 400 });
}
out.append('cv', cv, cv.name);
const res = await fetch(`${TALQO_API_BASE}/job_applications`, {
method: 'POST',
headers: {
// Do not set Content-Type manually: fetch adds correct multipart boundary.
'X-API-Key': TALQO_API_KEY,
'Origin': request.headers.get('origin') ?? '',
},
body: out,
});
const body = await res.json();
return NextResponse.json(body, { status: res.status });
}Browser JavaScript (via proxy) — multipart
// Send the file as-is to your proxy; proxy forwards it to talqo.fr.
// Lean payload, no unnecessary client CPU work.
async function submitApplication({ jobOfferId, firstName, lastName, email, phone, coverLetter, profileLinks, cvFile }) {
const form = new FormData();
form.append('job_offer_id', jobOfferId);
form.append('first_name', firstName);
form.append('last_name', lastName);
form.append('email', email.toLowerCase());
if (phone) form.append('phone', phone);
if (coverLetter) form.append('cover_letter', coverLetter);
// profileLinks: { github: 'https://...', portfolio: 'https://...' } from offer.required_profile_links / optional_profile_links
for (const [type, url] of Object.entries(profileLinks || {})) {
if (url) form.append(`profile_link_${type}`, url);
}
form.append('cv', cvFile, cvFile.name);
const res = await fetch('/api/career/apply', {
method: 'POST',
body: form, // Do not set Content-Type manually — browser manages boundary.
});
if (!res.ok) throw new Error((await res.json()).message);
return res.json();
}PHP / WordPress — multipart
function talqo_submit_application(string $job_offer_id, array $candidate, string $cv_path): array {
// WordPress: manually build multipart body to send CV as binary.
$boundary = wp_generate_password(24, false);
$eol = "\r\n";
$body = '';
$fields = array_merge(['job_offer_id' => $job_offer_id], $candidate);
// expected $candidate: first_name, last_name, email, phone, cover_letter,
// plus profile_link_<type> keys matching the job offer configuration (e.g. profile_link_linkedin).
foreach ($fields as $name => $value) {
if ($value === null || $value === '') continue;
$body .= "--{$boundary}{$eol}";
$body .= "Content-Disposition: form-data; name=\"{$name}\"{$eol}{$eol}";
$body .= $value . $eol;
}
$cv_bytes = file_get_contents($cv_path);
$filename = basename($cv_path);
$body .= "--{$boundary}{$eol}";
$body .= "Content-Disposition: form-data; name=\"cv\"; filename=\"{$filename}\"{$eol}";
$body .= "Content-Type: application/pdf{$eol}{$eol}";
$body .= $cv_bytes . $eol;
$body .= "--{$boundary}--{$eol}";
$response = wp_remote_post('https://talqo.fr/api/job_applications', [
'timeout' => 30,
'headers' => [
'X-API-Key' => getenv('TALQO_API_KEY'),
'Content-Type' => "multipart/form-data; boundary={$boundary}",
'Origin' => home_url(),
],
'body' => $body,
]);
if (is_wp_error($response)) {
return ['error' => $response->get_error_message()];
}
return json_decode(wp_remote_retrieve_body($response), true);
}Webflow
Webflow does not call third-party APIs directly. Recommended setup:
- Host a small proxy (Vercel/Cloudflare Workers/API Route) that receives Webflow multipart/form-data and relays it as-is to https://talqo.fr/api/job_applications with X-API-Key header.
- Configure the Webflow form webhook action to target this proxy.
Bubble.io
- Create an API Connector: POST https://talqo.fr/api/job_applications, X-API-Key header, Body type: Form-data (text fields + cv file field).
- Bubble File Uploader plugin provides the file object to inject directly into the cv field.
Wix
- Store TALQO_API_KEY in Wix Secrets.
- Create an HTTP endpoint in backend/http-functions.js that relays requests to Talqo API.
Troubleshooting
400 — cv file is required (multipart field "cv")You are using multipart/form-data but the file field is not named cv (e.g. resume, file) or is missing. Verify your HTTP client sends a File/Blob.
415 — Unsupported Content-Type. Use multipart/form-data …You are sending a Content-Type other than multipart/form-data (typically application/json). Re-encode the request as multipart and send CV as a binary file in cv field.
400 — CV validation failed: CV file too large…Maximum CV size: 10 MB. Compress the PDF before upload if needed.
400 — Invalid multipart payloadYou manually set Content-Type: multipart/form-data without boundary. Let your HTTP client generate the correct Content-Type automatically (curl -F, fetch(FormData), wp_remote_post...).
400 — Job offer is not open for applicationsOffer is draft, closed, paused, etc. Only open and active accept applications.
401 — API key is required / Invalid API keyMissing or misspelled X-API-Key header, revoked key in ATS (is_active=false), or extra spaces around key.
403 — …does not belong to your companyIdentifier exists but belongs to another company. Verify API key and UUID.
429 — Too many requestsRespect Retry-After header. Reduce redundant requests (proxy cache, etc.).
Scanned/protected/corrupted CV may prevent extraction (handled asynchronously after the 201). Contact support with UTC timestamp and application_id.
Best practices
Security
- Never expose X-API-Key in browser code.
- Use HTTPS only.
- Store the key in environment variables.
- Provide revocation/regeneration process.
Reliability
- Retry with exponential backoff on 429/5xx.
- Requests are idempotent per (candidate, job_offer).
- Client timeout ≥ 15–20 seconds for the application POST.
Performance
- Cache offers list (TTL 5–15 minutes).
- Pagination: per_page ≤ 50.
- Avoid aggressive Promise.all() (rate limits).
UX
- Show a loader during POST; the response arrives once the application is saved (AI scoring follows inside Talqo).
- Rewrite technical errors for end users.
- A confirmation email is automatically sent to candidate.
Need help?
Contact talqo.contact@gmail.com with endpoint, UTC timestamp and payload (without API key).
This page is not indexed by search engines. Share it only with your integrators.