Talqo Doc
Connect your candidate acquisition with Talqo in 2 ways: advanced API integration, or direct sharing of Talqo-generated job offer links (zero technical integration).
Choose your scenario
Choose your path: you can start with Talqo links, then move to API integration later.
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.
Quick API start
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 "linkedin_url=https://linkedin.com/in/jeandupont" \
-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.
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",
"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.
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.
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. |
linkedin_url | string (text) | — | Full URL (https://...). |
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 "linkedin_url=https://linkedin.com/in/jeandupont" \
-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
Returned status is not guaranteed to be "new". If AI auto-reject is enabled and CV score is below your configured threshold, the application is immediately set to "rejected":
| Case | data.status |
|---|---|
| Newly created application (nominal case) | "new" |
| AI auto-reject triggered | "rejected" |
| 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 | 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 extract text from CV: … | PDF/DOCX extraction error |
500 | AI scoring failed: … | AI scoring failure |
500 | Failed to upload CV: … | Supabase Storage upload failure |
Get an application
/api/job_applications/{application_id}Returns an application's status.
Example
curl "https://talqo.fr/api/job_applications/c3e5b1d6-4f77-4d3a-9e36-0e6fbbfb77c2" \
-H "X-API-Key: <YOUR_API_KEY>"200 response
{
"status": "success",
"data": {
"id": "c3e5b1d6-4f77-4d3a-9e36-0e6fbbfb77c2",
"candidate_id": "3d4b1c9b-1a2a-4f51-8a82-2a9f3a2b56aa",
"job_offer_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "in_progress",
"source": "careers.my-company.com",
"created_at": "2024-01-15T10:00:00Z",
"updated_at": "2024-01-15T11:00:00Z"
}
}Data schemas
JobOffer
interface JobOffer {
id: string; // uuid
title: string;
description: string; // Markdown (CommonMark / GFM)
location?: string | null;
job_type?: string | null;
published_at?: string | null; // ISO 8601
status?: string; // "open" | "closed" | "active" | "draft" | ...
updated_at?: string | null; // ISO 8601
}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;
linkedin_url?: string;
cover_letter?: string; // lettre de motivation (texte libre)
// source?: string // IGNORED: inferred from Origin / Referer
}CreateJobApplicationResponse
interface CreateJobApplicationResponse {
application_id: string; // uuid
candidate_id: string; // uuid
status: "new" | "rejected" | string; // "rejected" when AI auto-reject triggers
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.
Description Markdown format
The JobOffer.description field uses Markdown format (CommonMark + GFM). Use a dedicated library and avoid building 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 a successful POST /api/job_applications, the server performs the following:
- 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, CV text, company metadata, first pipeline step, subscription plan (auto-reject enabled or not).
- AI scoring generates
AI_resume_summary,rating,first_impression_text. - 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"in nominal case"rejected"if AI auto-reject is enabled andrating < auto_reject_threshold(job_application is also set to rejected and an automatic rejection email is scheduled.)
- Confirmation email to candidate (except AI auto-reject).
- 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: CV stays as binary file.
const out = new FormData();
for (const key of ['job_offer_id', 'first_name', 'last_name', 'email', 'phone', 'linkedin_url', 'cover_letter'] as const) {
const v = incoming.get(key);
if (typeof v === 'string' && v) out.append(key, v);
}
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, 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);
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'=>..., 'linkedin_url'=>..., 'cover_letter'=>...]
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.).
500 — AI scoring failed… / Failed to extract text from CV…Scanned/protected/corrupted CV may prevent extraction. OpenAI latency/error: retry. Contact support with UTC timestamp and application_id if created.
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 ≥ 30 seconds.
Performance
- Cache offers list (TTL 5–15 minutes).
- Pagination: per_page ≤ 50.
- Avoid aggressive Promise.all() (rate limits).
UX
- Show a loader during POST (can take several seconds).
- 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.