API publique des offres d'emploi
Construisez votre propre site d'emploi (par exemple avec Next.js sur Vercel) en vous appuyant sur votre pipeline de recrutement Kit. Listez les postes publiés et recevez des candidatures via une API REST publique, un SDK TypeScript officiel et un modèle Next.js en un clic.
Pourquoi c’est important
Le portail carrière hébergé par Kit et le widget intégrable couvrent la plupart des besoins. Mais si vous voulez un contrôle total sur le design (un site carrière sur mesure, une page de destination dédiée par poste ou un site d’emploi assorti à votre produit), l’API publique des offres d’emploi vous permet de lire vos offres publiées et d’envoyer les candidatures directement dans votre pipeline Kit, pendant que Kit continue de gérer la présélection, les étapes, les entretiens et la communication avec les candidats.
Il existe aussi un SDK TypeScript officiel et un modèle Next.js déployable en un clic, pour livrer un site d’emploi sur mesure en quelques minutes ; vous pouvez aussi passer outre les deux et appeler directement les points de terminaison REST ci-dessous avec n’importe quel client HTTP.
Découvrez un exemple chez un client avec le site carrière sur mesure de Fourthwall. La présentation couvre aussi le modèle Next.js de Kit.
Clés API
Créez une paire de clés sous Recrutement → Portail carrière → Clés API publiques. Chaque paire comprend :
-
Clé publiable (
pk_…) : peut être embarquée sans risque dans un navigateur. Elle peut lire les offres publiées et soumettre des candidatures, rien de plus, et n’expose jamais les données des candidats. Avant de pouvoir soumettre des candidatures ou demander des téléversements présignés, vous devez configurer au moins une protection anti-bot (une liste d’origines autorisées ou votre propre widget Cloudflare Turnstile), faute de quoi ces requêtes sont rejetées avec403 bot_protection_required. La lecture des offres fonctionne sans cette configuration. -
Clé secrète (
sk_…) : réservée à un usage côté serveur (par exemple une Server Action Next.js). Elle contourne les vérifications navigateur d’origine et de Turnstile. Ne l’exposez jamais dans du code côté client. Aucune des deux clés ne peut lire les données personnelles des candidats.
La clé secrète n’est affichée qu’une seule fois, à sa création ou à sa rotation. Vous pouvez effectuer sa rotation à tout moment depuis la page de paramètres de la clé ; l’ancienne clé secrète cesse de fonctionner immédiatement.
Authentifiez chaque requête avec un en-tête bearer :
Authorization: Bearer sk_your_secret_key
Points de terminaison
URL de base : https://startupkit.app ou https://app.startupkit.app (valeur par défaut du SDK ; les deux servent la même API), ou votre domaine carrière personnalisé.
Lister les offres publiées
GET /api/public/v1/jobs?department=&location=&employment_type=&remote=&locale=&page=&per_page=
Retourne uniquement les postes publiés de votre compte.
{
"data": [
{
"id": "JdK2hQ8…",
"title": "Senior Rails Developer",
"department": "Engineering",
"location": "Poznań, Poland",
"employment_type": "full_time",
"remote": true,
"location_display": "Poznań, Poland · Remote (Poland, EU)",
"city": "Poznań",
"region": null,
"country_code": "PL",
"remote_regions": ["PL", "EU"],
"published_at": "2026-06-01T12:00:00Z",
"url": "https://careers.yourco.com/JdK2hQ8…",
"apply_url": "https://careers.yourco.com/JdK2hQ8…/apply",
"salary": { "min": 120000, "max": 160000, "currency": "USD", "period": "YEAR" }
}
],
"pagination": { "current_page": 1, "total_pages": 3, "total_count": 42, "per_page": 20 }
}
L’id est le jeton public de l’offre : utilisez-le pour les points de terminaison de détail et de candidature.
Le champ employment_type prend l’une des valeurs full_time, part_time, b2b, contract ou internship. Vous pouvez transmettre cette même valeur au point de terminaison de liste pour filtrer les offres publiées.
Localisation et postes en télétravail
Affichez location_display. C’est le libellé de lieu prêt à l’emploi : la localisation, la mention « Remote » et les pays où les candidats en télétravail peuvent être basés, chacun énoncé une seule fois (Poznań, Poland · Remote (Poland, EU), Berlin, Remote). Passez locale=de, fr, es ou pl pour l’obtenir dans cette langue ; par défaut, il est en anglais.
Ne concaténez pas location et remote vous-même. location est du texte libre, tel que le recruteur l’a saisi, et peut déjà contenir « Remote » : c’est ainsi qu’un site d’emploi finit par l’afficher deux fois.
Les champs structurés servent aux filtres, au tri et au balisage Google for Jobs :
| Champ | Description |
|---|---|
city, region
|
Le bureau ou la ville du poste, lorsqu’ils sont connus. Sinon null. |
country_code |
Pays de ce lieu au format ISO 3166-1 alpha-2 (PL), ou null. |
remote |
true lorsque le poste peut être exercé à distance. |
remote_regions |
Où les candidats en télétravail peuvent être basés : codes ISO 3166-1 alpha-2, plus EU pour tout État membre de l’UE. Vide lorsque le poste n’est pas en télétravail ou n’impose aucune restriction. |
Récupérer une offre et son formulaire de candidature
GET /api/public/v1/jobs/:public_token
Retourne l’offre, accompagnée d’un application_form décrivant précisément les champs et questions à afficher, la mention de consentement à présenter, si le CV est obligatoire ainsi que les types et la taille de fichier acceptés, et si Turnstile est requis.
{
"id": "JdK2hQ8…",
"title": "Senior Rails Developer",
"description_html": "<p>We're hiring…</p>",
"accepting_applications": true,
"stages": [{ "name": "Application Review", "type": "application_form" }],
"application_form": {
"fields": [
{ "name": "cover_letter", "type": "textarea", "label": "Cover letter", "required": false }
],
"questions": [
{ "key": "why_us", "type": "text", "prompt": "Why do you want to join?", "required": true, "max_length": 2000 }
],
"consent_disclosure_html": "<p>By applying you agree…</p>",
"resume": {
"required": false,
"content_types": ["application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
"max_byte_size": 10485760
},
"turnstile": { "required": false, "sitekey": null }
}
}
Les indicateurs required sont appliqués côté serveur. Lorsque resume.required vaut true, une candidature sans resume_signed_id est rejetée avec 422 validation_failed, et il en va de même pour tout champ ou question marqué required: true resté sans réponse. Si vous générez le formulaire dynamiquement à partir de ce schéma, respectez ces indicateurs pour éviter aux candidats un rejet après avoir tout rempli.
Téléverser un CV (URL présignée)
Les CV sont téléversés directement vers le stockage : ils ne transitent jamais par votre serveur (ce qui évite les limites de taille de corps des environnements serverless).
POST /api/public/v1/direct_uploads
{ "blob": { "filename": "cv.pdf", "byte_size": 102400, "checksum": "<base64 MD5>", "content_type": "application/pdf" } }
{
"signed_id": "eyJf…",
"direct_upload": { "url": "https://…s3…", "headers": { "Content-Type": "application/pdf", "Content-MD5": "…" } }
}
Envoyez les octets du fichier en PUT vers direct_upload.url avec les headers retournés, puis transmettez le signed_id comme resume_signed_id au moment de soumettre la candidature.
Soumettre une candidature
POST /api/public/v1/jobs/:public_token/applications
{
"application": {
"email": "[email protected]",
"first_name": "Ada",
"last_name": "Lovelace",
"phone": "+1 555 0100",
"responses": { "cover_letter": "…", "why_us": "…" },
"resume_signed_id": "eyJf…"
},
"turnstile_token": "<token>"
}
Retourne 201 avec une confirmation minimale, sans données personnelles :
{ "id": "app_9fQ…", "status": "submitted", "job": "JdK2hQ8…", "submitted_at": "2026-06-11T09:30:00Z" }
Le turnstile_token n’est nécessaire que pour les soumissions depuis un navigateur (pk_) lorsque Turnstile est configuré sur la clé ; les appels côté serveur (sk_) s’en passent.
Vivier de talents
Toutes les personnes qui apprécient votre entreprise ne correspondent pas à un poste ouvert aujourd’hui. Le vivier de talents recueille ces profils (e-mail, LinkedIn, éventuellement un CV) pour que vous puissiez les inviter à postuler quand le bon poste s’ouvre. Les entrées arrivent non vérifiées : Kit envoie un lien de confirmation par e-mail, et personne n’entre dans votre vivier par ce point de terminaison tant qu’il n’a pas cliqué dessus. (Les administrateurs peuvent aussi importer des CV directement, une porte distincte, avec ses propres règles.)
Une inscription au vivier de talents, c’est quelqu’un qui vous demande de conserver ses coordonnées sans poste auquel postuler : elle exige donc un consentement explicite, une case effectivement cochée, et non une simple mention que vous lui affichez. Kit rejette une inscription publique qui s’en dispense. Un import par un administrateur consigne à la place une attestation de base légale, puisque personne n’est là pour cocher quoi que ce soit.
Lire le formulaire d’inscription
GET /api/public/v1/talent_pool
{
"accepting_signups": true,
"consent": {
"required": true,
"disclosure_html": "<p>Keep my details on file for 24 months…</p>",
"retention_months": 24,
"privacy_policy_url": "https://yourco.com/privacy"
},
"fields": [
{ "name": "email", "required": true },
{ "name": "linkedin_url", "required": false },
{ "name": "resume_signed_id", "required": false }
],
"resume": {
"required": false,
"content_types": ["application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
"max_byte_size": 10485760
},
"turnstile": { "required": false, "sitekey": null }
}
Affichez consent.disclosure_html comme libellé d’une case à cocher qui démarre décochée. Il s’agit de votre propre texte de consentement : modifiez-le sous Recrutement → Paramètres → Consentement, en même temps que retention_months, qui détermine à partir de quand Kit anonymise les entrées que personne n’a renouvelées.
Rejoindre le vivier de talents
POST /api/public/v1/talent_pool/entries
{
"talent_pool_entry": {
"email": "[email protected]",
"linkedin_url": "https://linkedin.com/in/ada",
"resume_signed_id": "eyJf…",
"consent": true,
"consent_ip_address": "203.0.113.7"
},
"turnstile_token": "<token>"
}
Retourne 201 avec une confirmation sans données personnelles :
{ "id": "tpe_9fQ…", "status": "pending_verification", "submitted_at": "2026-06-11T09:30:00Z" }
Les CV empruntent le même téléversement présigné que les candidatures : demandez un signed_id, envoyez les octets en PUT, puis transmettez-le comme resume_signed_id.
Consigner qui a consenti
Kit conserve un justificatif de consentement avec chaque entrée : le texte exact affiché, l’horodatage et l’adresse IP de la personne qui a accepté. C’est sur ce dernier champ que les intégrations côté serveur se trompent, sans que rien ne le signale.
Lors d’une soumission depuis un navigateur avec une clé pk_, Kit observe lui-même l’adresse IP et ignore tout consent_ip_address que vous envoyez, car une clé embarquée dans le JavaScript de la page ne doit pas pouvoir rédiger son propre justificatif.
Lors d’une soumission depuis votre serveur avec une clé sk_, l’adresse IP que Kit voit est celle de votre serveur, pas celle du candidat. Envoyez la vraie dans consent_ip_address. Dans une Server Action Vercel, c’est le premier saut de x-forwarded-for :
import { headers } from "next/headers";
const forwarded = (await headers()).get("x-forwarded-for");
const consentIp = forwarded?.split(",")[0]?.trim();
Cette valeur est déclarée par vous, elle n’est pas prouvée : Kit enregistre ce que vous envoyez dès lors que cela s’analyse comme une adresse IP, et rejette tout le reste avec 422 invalid_consent_ip. Omettez-la si vous l’ignorez : Kit ne consigne alors aucune adresse IP, ce qui constitue une lacune assumée plutôt qu’un justificatif désignant la mauvaise machine.
Statistiques des offres
Le tableau de bord Recrutement → Analytique du portail carrière de Kit (vues, visiteurs uniques, sources de trafic, campagnes UTM, pays, appareils, pages d’entrée et l’entonnoir de la vue à la candidature) se remplit aussi depuis votre propre site. Votre site signale ce que font les visiteurs ; Kit l’enregistre exactement comme les visites du portail carrière hébergé, si bien que chaque graphique et l’onglet statistiques de chaque offre fonctionnent sans rien changer.
Tant que votre site n’envoie pas d’événements, le tableau de bord affiche un rappel dès que l’une de vos clés API a été utilisée sur la période sélectionnée.
Les événements viennent du navigateur du visiteur, avec une clé publiable (pk_). Kit lit l’adresse IP (masquée), l’appareil et le pays du visiteur dans cette requête : relayer les événements par votre serveur enregistrerait chaque visiteur comme étant votre serveur. Les clés secrètes sont refusées avec publishable_key_required. Si la clé a une liste d’origines autorisées, ajoutez-y l’origine de votre site.
Envoyer des événements
POST /api/public/v1/events
{
"events": [
{
"name": "job.viewed",
"job": "abc123",
"url": "https://careers.acme.com/jobs/abc123?utm_source=linkedin",
"referrer": "https://www.linkedin.com/",
"time": "2026-09-22T10:00:00.000Z"
}
]
}
| Événement | Quand | job |
|---|---|---|
job_board.viewed |
La page de liste des offres est affichée | Sans objet |
job.viewed |
Une page d’offre est affichée | Obligatoire |
application.started |
Le visiteur interagit pour la première fois avec le formulaire de candidature | Obligatoire |
application.submitted |
Une candidature a été acceptée | Obligatoire |
talent_pool.joined |
Une inscription au vivier de talents a été acceptée | Sans objet |
-
jobest l’idde l’offre (jeton public) retourné parGET /jobs. -
urlest la page où l’événement s’est produit (location.href) etreferrervautdocument.referrer. Le premier événement d’une visite fixe sa page d’entrée, son domaine référent et sa campagneutm_*. Uneurlsur un autre hôte que l’Originde la requête est ignorée, et un référent venant de votre propre site compte comme du trafic direct. -
timeest facultatif. Kit ne le conserve que s’il tombe dans la dernière minute ; sinon il enregistre l’heure d’arrivée de l’événement. - Envoyez jusqu’à 20 événements par requête. Les robots d’indexation sont écartés silencieusement.
La réponse est 202 Accepted avec un verdict par événement, si bien qu’un jeton mal saisi n’écarte pas le reste du lot :
{ "accepted": 1, "rejected": [{ "index": 1, "code": "unknown_job" }] }
Les codes par événement sont unknown_event, job_required et unknown_job.
Le SDK TypeScript (à partir de la version 0.5) encapsule tout cela dans createTracker. Il regroupe les événements, les envoie à la fermeture de l’onglet, ne fait rien pendant le rendu côté serveur et ne lève jamais d’exception :
import { createTracker } from "@startupkit-app/jobs";
const tracker = createTracker({ publishableKey: process.env.NEXT_PUBLIC_STARTUPKIT_PUBLISHABLE_KEY });
tracker.jobBoardViewed();
tracker.jobViewed(job.id);
tracker.applicationStarted(job.id);
tracker.applicationSubmitted(job.id);
tracker.talentPoolJoined();
Erreurs
Les erreurs sont retournées dans une enveloppe cohérente :
{ "error": { "code": "validation_failed", "message": "Email can't be blank", "fields": { "email": ["can't be blank"] } } }
| Statut | Code | Signification |
|---|---|---|
| 401 | invalid_key |
Clé API manquante ou invalide |
| 403 | bot_protection_required |
La clé publiable (pk_) n’a ni liste d’origines autorisées ni Turnstile configuré |
| 403 | origin_not_allowed |
Origine du navigateur absente de la liste autorisée de la clé |
| 403 | publishable_key_required |
Événements de statistiques envoyés avec une clé secrète (sk_) |
| 404 | not_found |
Offre introuvable ou non publiée |
| 409 | already_applied |
Cette adresse e-mail a déjà postulé à cette offre |
| 409 | already_in_talent_pool |
Cette adresse e-mail figure déjà dans le vivier de talents |
| 422 | validation_failed |
Champs de candidature invalides, y compris un CV obligatoire manquant ou un champ ou une question obligatoire sans réponse (voir fields) |
| 422 | consent_required |
Inscription au vivier de talents reçue sans case de consentement cochée |
| 422 | invalid_consent_ip |
consent_ip_address n’est pas une adresse IP valide |
| 422 | turnstile_failed |
Échec de la vérification Turnstile |
| 422 | too_many_events |
Plus de 20 événements de statistiques dans une même requête |
| 422 |
invalid_content_type / file_too_large / invalid_byte_size
|
Téléversement de CV refusé |
Suivi des statuts via webhooks
Pour suivre les candidatures après leur soumission, configurez des webhooks sortants. Les événements pertinents incluent application.submitted, application.advanced et application.rejected, ainsi que job_posting.published/paused/closed. Les payloads de candidature contiennent à la fois l’id numérique et le prefix_id de l’API (app_…), ainsi que le public_token de l’offre, pour corréler les événements webhook avec les données de l’API.
SDK et modèle Next.js
Deux points de départ officiels et open source s’appuient sur le contrat REST ci-dessus. Utilisez l’un des deux, ou passez outre et appelez directement les points de terminaison avec n’importe quel client HTTP.
-
SDK TypeScript :
@startupkit-app/jobs. Un client typé et sans dépendances (fetchnatif, ESM + CJS, Node ≥ 20.19) qui tourne sous Node, dans les navigateurs et les runtimes edge. Installez-le avecnpm install @startupkit-app/jobs, puis :import { createClient } from "@startupkit-app/jobs"; const kit = createClient({ secretKey: process.env.KIT_SECRET_KEY }); const page = await kit.listJobs({ department: "Engineering", remote: true }); const job = await kit.getJob(page.data[0].id); const { signed_id } = await kit.uploadFile(resumeFile); await kit.apply(job.id, { email: "[email protected]", resume_signed_id: signed_id });Dans le code navigateur, passez
publishableKey(pk_…) au lieu desecretKey. Autres méthodes :allJobs()itère de façon asynchrone sur toutes les pages,createUpload()offre un contrôle de plus bas niveau sur le téléversement présigné, etgetTalentPool()/joinTalentPool()couvrent l’inscription au vivier de talents décrite ci-dessus, etcreateTracker()envoie les statistiques des offres. Les réponses non-2xx lèventKitApiError(avec.codeet.fields) ; les échecs qui n’atteignent jamais l’API lèventKitNetworkError. Le client utilise par défaut l’URL de basehttps://app.startupkit.app; passezbaseUrlpour la remplacer par un domaine carrière personnalisé. -
Modèle Next.js :
nextjs-job-board. Un site carrière prêt pour la production (Next.js App Router, Server Components + Server Actions, ISR avec revalidation par tag) que vous pouvez forker ou déployer en un clic. Il génère le formulaire de candidature dynamiquement à partir du schéma de l’API, effectue des téléversements de CV présignés directement vers le stockage, émet du JSON-LD schema.orgJobPosting(prêt pour Google for Jobs) et revalide optionnellement de façon instantanée via des webhooks. Définissez une seule variable d’environnement,STARTUPKIT_SECRET_KEY(votre clésk_…), et déployez :Démo en ligne : nextjs-job-board-orcin.vercel.app. Variables d’environnement optionnelles :
STARTUPKIT_BASE_URL(par défauthttps://app.startupkit.app),NEXT_PUBLIC_TURNSTILE_SITE_KEY,KIT_WEBHOOK_SIGNING_SECRETetNEXT_PUBLIC_COMPANY_NAME.
Les deux consomment le contrat ci-dessus, vous pouvez donc aussi développer avec n’importe quel framework via du simple HTTP. Un site d’emploi personnalisé typique enchaîne quatre appels : lister les offres, récupérer une offre avec son formulaire, demander un téléversement présigné pour le CV et soumettre la candidature. Comme il s’agit de simple JSON sur HTTPS, la même intégration fonctionne côté serveur (clé sk_) comme dans le navigateur (clé pk_ avec une protection anti-bot configurée).
Convention pour les pages d’offres externes
Pour recevoir les visiteurs des liens Kit publiés sur les sites d’emploi, votre site doit proposer les détails des postes à {careers_homepage}/jobs/{public_token}. Utilisez l’id du poste fourni par l’API (le jeton public), et non l’identifiant numérique de la base de données ni un slug composé uniquement du titre. Pour une page d’accueil à https://example.com/careers, l’URL détaillée est https://example.com/careers/jobs/senior-engineer-abc123. Si vous déployez le modèle Next.js sous /careers, configurez basePath dans Next.js en conséquence.
Activez la page d’accueil externe et Afficher les offres sur votre site carrière externe dans les paramètres du portail carrière uniquement lorsque ces pages fonctionnent. Les comptes existants conservent les pages Kit tant qu’ils n’activent pas l’option.
Les URL publiques habituelles des offres Kit suivent le réglage des pages externes lorsque le poste est public et accepte les candidatures. Cela comprend les liens partagés, ceux des sites d’emploi et l’url du poste renvoyée par l’API publique. Ajoutez destination=kit pour afficher explicitement l’offre sur Kit. Désactiver l’option permet aux mêmes URL publiques d’afficher à nouveau la page Kit. Les intégrations embarquées, les réponses JSON et les routes de candidature Kit restent sur Kit. Les paramètres de requête et les fragments de l’URL de la page d’accueil ne sont pas repris dans le chemin externe du poste.
Utilisez l’apply_url de l’API pour le formulaire de candidature Kit : elle reste toujours sur Kit. Si ce champ manque dans une ancienne réponse de l’API, ajoutez /apply uniquement si url utilise un domaine carrière Kit actif ou une route Kit valide sous /careers/:slug/:public_token. Conservez les paramètres de requête. Si url pointe vers un domaine d’entreprise non vérifié, mettez à jour l’intégration API et utilisez apply_url plutôt que de construire un lien de candidature. Kit transmet utm_source, utm_medium, utm_campaign, utm_content, utm_term et une locale explicitement demandée. Conservez les paramètres de campagne dans le lien menant de la page externe au formulaire de candidature afin de préserver l’attribution. Utilisez destination=kit pour revenir à la description du poste sur Kit. Vous pouvez aussi utiliser votre propre page de candidature utilisant l’API. Le modèle possède sa propre page /jobs/{public_token}/apply.
Les anciens liens se terminant par #apply restent compatibles : le navigateur ouvre la route de candidature dédiée de Kit avant de suivre une redirection externe. Les autres visites HTML habituelles mènent à la page externe de l’offre. Sans JavaScript, la page de transition propose Voir l’offre et Postuler ici. Les requêtes HEAD redirigent directement vers la page externe. Pour les nouveaux liens de candidature, utilisez apply_url ou /{public_token}/apply. Les candidats connectés consultent les descriptions des postes dans leur portail candidat, avec un lien de retour à leurs candidatures.
Conservez cette route lors d’une refonte du site. Un jeton public introuvable doit renvoyer 404 ; un poste fermé doit clairement indiquer que les candidatures sont closes. Testez un véritable lien de site d’emploi et le parcours de candidature avant d’activer les redirections. Les destinations doivent utiliser HTTPS sur un domaine d’entreprise vérifié. Kit ne consulte jamais le site externe pour le valider.
Limites de débit
Les soumissions de candidatures sont limitées à 10/heure par IP et les requêtes de téléversement à 30/heure par IP et 300/heure par clé, en plus des limites de débit globales de l’API. Les clés navigateur sont en outre protégées par leur liste d’origines autorisées et, en option, par Turnstile.
Les événements de statistiques sont limités à 60 requêtes/minute et 600/heure par IP, et à 10 000 requêtes/heure par clé. Ils échappent à la limite d’API par clé, qui plafonnerait sinon un site très fréquenté partagé par tous ses visiteurs.
Les inscriptions au vivier de talents sont limitées à 100/heure par clé. Celles qui viennent d’un navigateur (pk_) sont en outre soumises à 5/heure par IP ; celles qui viennent d’un serveur (sk_) ne le sont pas, car elles parviennent toutes à Kit depuis la même adresse de sortie et un plafond par IP briderait tout votre site plutôt qu’une seule personne.