Talqo Doc

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

Base URL
https://talqo.fr
Auth
X-API-Key
Format
JSON · UTF-8

List offers:

bash
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):

bash
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)

http
X-API-Key: your-secret-api-key

Method 2 — Authorization: Bearer

http
Authorization: Bearer your-secret-api-key
Never expose the API key in browser code. Use a server-side proxy (API Route, Cloudflare Worker, Wix Secrets, etc.) and store the key in an environment variable.

Where can I find my API key?

Talqo ATS interface → Company → API & Career Sites section.

Authentication errors

CodeCase
401Missing header, empty key, unknown key, or disabled key
500Server 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
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:

json
{
  "status": "error",
  "data": null,
  "message": "Description lisible de l'erreur"
}

Global HTTP codes

CodeMeaning
200OK
201Resource created (application)
400Invalid request (missing fields, malformed UUID, malformed multipart payload, closed offer, CV too large, etc.)
401Missing or invalid API key
403Resource belongs to another company
404Resource not found
415Unsupported Content-Type (use multipart/form-data on POST /api/job_applications)
429Rate limit reached
500Server/configuration error

List job offers

GET/api/job_offers

Paginated list of job offers for the company linked to the API key.

Query parameters

ParameterTypeDefaultDescription
pageinteger1Page number (≥ 1)
per_pageinteger20Items per page (1 to 100, capped above 100)
statusstringopenStatus filter (open, closed, draft, etc.)
locationstringCase-insensitive search (ILIKE %...%)
job_typestringExact filter (CDI, CDD, Freelance, etc.)
published_afterISO 8601published_at >= published_after
published_beforeISO 8601published_at <= published_before
Default sorting: published_at DESC. Offers without publication date are listed last.

Example

bash
curl "https://talqo.fr/api/job_offers?status=open&per_page=20" \
  -H "X-API-Key: <YOUR_API_KEY>"

200 response

json
{
  "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

GET/api/job_offers/{job_offer_id}

Returns job offer details. UUID is strictly validated before any DB query.

URL parameters

ParameterTypeDescription
job_offer_iduuidStrict format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. Any other format returns 400 immediately.

Example

bash
curl "https://talqo.fr/api/job_offers/550e8400-e29b-41d4-a716-446655440000" \
  -H "X-API-Key: <YOUR_API_KEY>"

Errors

CodeCase
400Invalid or missing UUID
403The offer belongs to another company
404Offer not found
The company_id field is never exposed to career sites.

Create an application

POST/api/job_applications

Create an application from a career site. CV is required. Only multipart/form-data is supported. Any other Content-Type is rejected with 415.

Why multipart? The CV is sent as raw binary. Lighter payloads, simpler client-side encoding, and no extra server-side transformation.

Request — multipart/form-data

Send flat fields in a FormData payload; the CV must be a real file in the cv field.

FieldTypeRequiredNotes
job_offer_iduuid (text)Offer must belong to your company and be in open or active status.
first_namestring (text)trim() applied server-side.
last_namestring (text)trim() applied server-side.
emailstring (text)Normalized with trim() + toLowerCase().
cvfilePDF or DOCX, ≤ 10 MB. See rules below.
phonestring (text)Free format.
linkedin_urlstring (text)Full URL (https://...).
cover_letterstring (text)Free-text cover letter. Empty value when omitted.
sourcestringIgnoredSource is inferred from Origin or Referer header.
Do not set Content-Type: multipart/form-data manually. Your HTTP client (curl -F, fetch(FormData), wp_remote_post...) automatically adds the required boundary.

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)
  • Any provided cv_url is ignored.

Example

bash
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
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
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":

Casedata.status
Newly created application (nominal case)"new"
AI auto-reject triggered"rejected"
Existing application returned as-iscurrent status

Specific errors

CodemessageCause
400job_offer_id is requiredMissing field
400first_name, last_name, and email are requiredIncomplete candidate fields
400cv file is required (multipart field "cv")Missing CV file
400CV validation failed: …Empty file, > 10 MB, invalid format
400Invalid multipart payloadMalformed multipart body
400Job offer is not open for applicationsOffer is not open/active
403Job offer does not belong to your companyOffer belongs to another company
404Job offer not foundUnknown job_offer_id
415Unsupported Content-Type. Use multipart/form-data …Sent as application/json (or other) instead of multipart
500Failed to extract text from CV: …PDF/DOCX extraction error
500AI scoring failed: …AI scoring failure
500Failed to upload CV: …Supabase Storage upload failure
Latency : this route can take several seconds (PDF extraction, AI scoring, storage upload, inserts). Plan a client timeout ≥ 30 s.

Get an application

GET/api/job_applications/{application_id}

Returns an application's status.

Unlike /api/job_offers/{id}, this endpoint does not pre-validate UUID format with regex. A malformed identifier usually returns 404.

Example

bash
curl "https://talqo.fr/api/job_applications/c3e5b1d6-4f77-4d3a-9e36-0e6fbbfb77c2" \
  -H "X-API-Key: <YOUR_API_KEY>"

200 response

json
{
  "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

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

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

typescript
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

typescript
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)

ValueMeaning
newNew application (API default)
in_progressIn internal review
acceptedAccepted
rejectedRejected (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

StackPackages
React / Next.jsreact-markdown + remark-gfm
Vuevue-markdown-render, markdown-it
Angularngx-markdown
Vanilla JSmarked, markdown-it
PHPleague/commonmark

Application lifecycle

After a successful POST /api/job_applications, the server performs the following:

  1. IP / rate-limit validation (429 possible).
  2. API key validation (401 possible).
  3. Required-field + CV validation (binary read from multipart cv), size ≤ 10 MB, PDF/DOCX detection via magic bytes.
  4. Parallel retrieval: target offer, CV text, company metadata, first pipeline step, subscription plan (auto-reject enabled or not).
  5. AI scoring generates AI_resume_summary, rating, first_impression_text.
  6. Existing candidate detection via normalization (email + first_name + last_name, lowercase and accent-stripped).
  7. Upload CV to the resumes bucket. If candidate already exists, previous CVs are cleaned up.
  8. Deduplication: if an application already exists for (candidate_id, job_offer_id), it is returned as-is (HTTP 200).
  9. Create job_application (status "new", source = domain extracted from header, cover_letter = optional text (empty if omitted)).
  10. Create the first step (candidate_steps) :
    • "awaiting_advance" in nominal case
    • "rejected" if AI auto-reject is enabled and rating < auto_reject_threshold (job_application is also set to rejected and an automatic rejection email is scheduled.)
  11. Confirmation email to candidate (except AI auto-reject).
  12. API log (api_logs): endpoint, HTTP status, latency, IP, user-agent.
Important: only the first step is created automatically. Following steps are managed manually by recruiters in the ATS.

Source detection

  1. Origin header → hostname.
  2. Otherwise Referer header → hostname.
  3. Otherwise default value "career_site".

Any source field sent in body is ignored.

Integration guides

Node / Next.js (server-side proxy) — multipart

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

Never call https://talqo.fr directly from a public frontend: your API key would be exposed. Always use a server-side proxy.
javascript
// 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

php
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:

  1. 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.
  2. 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

400cv 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.

415Unsupported 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.

400CV validation failed: CV file too large…

Maximum CV size: 10 MB. Compress the PDF before upload if needed.

400Invalid multipart payload

You 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...).

400Job offer is not open for applications

Offer is draft, closed, paused, etc. Only open and active accept applications.

401API key is required / Invalid API key

Missing or misspelled X-API-Key header, revoked key in ATS (is_active=false), or extra spaces around key.

403…does not belong to your company

Identifier exists but belongs to another company. Verify API key and UUID.

429Too many requests

Respect Retry-After header. Reduce redundant requests (proxy cache, etc.).

500AI 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.