Aller au contenu principal
WipperozWipperoz
Parcourir la documentation

Recevoir des candidatures depuis votre propre page carrières

Mis à jour

La page de candidature hébergée est la voie sans intégration : faites un lien vers applyLink et tout ce qui suit le clic est à nous. Cette page s’adresse au compte qui veut que la candidature se passe sur son propre domaine. Le candidat ne quitte jamais votre page carrières, votre formulaire poste vers votre serveur, et votre serveur envoie la candidature à Wipperoz, qui construit toujours le CV Virtuel du candidat et lui écrit pour qu’il le revendique.

Ce qu’il vous faut

Une clé portant la portée Recevoir des candidatures, créée dans Orbit → Paramètres → Clés d’API. Un Administrateur ne peut la créer qu’après avoir accepté l’Avenant de traitement des données pour l’API : sur la page hébergée, Wipperoz recueille le consentement du candidat ; sur la vôtre, c’est vous, et l’avenant est là où votre compte s’y engage. La portée n’est pas impliquée par lecture et gestion des offres, et les autres portées répondent 403 sur chaque endpoint de cette page.

export ORBIT_API_KEY="…une clé avec Recevoir des candidatures…"

Le flux : quatre appels

1. Récupérez le texte de consentement

curl "https://api.wipperoz.com/v1/consent-text?locale=fr" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
{
  "consentTextId": "orbit_api_apply",
  "version": "api_apply_v1",
  "locale": "fr",
  "text": "Je postule à ce poste et j'accepte que Wipperoz crée un Virtual CV à partir du PDF que j'envoie et permette à cet employeur de le lire pour ma candidature. Wipperoz m'enverra par e-mail un lien pour récupérer mon Virtual CV, qui restera le mien quoi qu'il advienne de cette candidature."
}

Affichez text mot pour mot à côté d’une case qui commence décochée. Ne le reformulez jamais, ne le raccourcissez jamais, ne pré-cochez jamais la case. locale vaut en, es, fr ou pt ; demandez celle de la langue dans laquelle lit le candidat. Le texte est servi par l’API plutôt qu’imprimé ici pour que chaque candidat voie la version en vigueur : mettez-le en cache une heure, pas jusqu’à votre prochaine mise en production.

2. Réservez un téléversement et faites un PUT du PDF

curl -X POST https://api.wipperoz.com/v1/uploads \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"bytes": 184320, "fileName": "jane-doe-cv.pdf"}'
{
  "uploadId": "0f5c2a9e-6b1d-4c3e-9a8f-2d7b1c4e5f60",
  "uploadUrl": "https://…/orbit/api-uploads/…?X-Amz-Signature=…",
  "expiresAt": "2026-10-13T09:05:00.000Z"
}

Envoyez ensuite le fichier vers uploadUrl par un simple PUT, d’exactement bytes octets :

curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/pdf" \
  --data-binary @jane-doe-cv.pdf

bytes est la taille exacte du fichier, jusqu’à 10 Mo, et l’URL est signée pour cette longueur et pour application/pdf : une autre longueur ou un autre type est refusé par le stockage, pas par nous. L’URL vit cinq minutes et la réservation une heure, et une réservation ne sert qu’une fois.

3. Envoyez la candidature

curl -X POST https://api.wipperoz.com/v1/jobs/job_01J9A0B1C2EXAMPLE/applications \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7c1c4a6e-2b0f-4d2e-8f1a-9e3b5d7c2a10" \
  -d '{
    "name": "Jane Doe",
    "email": "jane@example.com",
    "phone": "+61 400 000 000",
    "location": "Sydney, NSW",
    "cv": {"uploadId": "0f5c2a9e-6b1d-4c3e-9a8f-2d7b1c4e5f60"},
    "coverNote": "I have run hiring pipelines on AWS for six years.",
    "consent": {
      "consentTextId": "orbit_api_apply",
      "version": "api_apply_v1",
      "locale": "fr",
      "acceptedAt": "2026-10-13T09:01:12.000Z",
      "ip": "203.0.113.42",
      "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) …"
    },
    "lawfulBasis": {"attested": true}
  }'
{
  "applicationId": "app_01JA2B3C4D5EXAMPLE",
  "candidateId": "app_01JA2B3C4D5EXAMPLE",
  "status": "received",
  "cvStatus": "processing",
  "job": {"id": "job_01J9A0B1C2EXAMPLE", "status": "published"}
}
  • consent est le reçu : le consentTextId et la version que vous avez affichés, et acceptedAt, ip et userAgent tels que votre serveur les a vus quand le candidat a coché la case. Enregistrez-les côté serveur à l’envoi ; ne les demandez pas au navigateur.
  • lawfulBasis.attested est l’attestation de votre compte, conservée mot pour mot avec la candidature, que le texte a été affiché sans modification, que l’acceptation a été enregistrée sincèrement et que vous pouvez envoyer les données de cette personne à Wipperoz.
  • Idempotency-Key est obligatoire ici. Générez un UUID une fois par soumission, au rendu du formulaire, et renvoyez le même à chaque nouvelle tentative : un navigateur qui resoumet et un serveur qui expire puis réessaie aboutissent tous deux à la même candidature.
  • phone, location, coverNote et answers (jusqu’à 20 paires question-réponse) sont facultatifs. Les champs inconnus répondent 400, comme partout sur cette API.

candidateId est l’id que la route de présélection accepte pour ce candidat.

4. Montrez au candidat que son CV est en construction

curl https://api.wipperoz.com/v1/applications/app_01JA2B3C4D5EXAMPLE \
  -H "Authorization: Bearer $ORBIT_API_KEY"
{
  "applicationId": "app_01JA2B3C4D5EXAMPLE",
  "jobId": "job_01J9A0B1C2EXAMPLE",
  "candidateId": "app_01JA2B3C4D5EXAMPLE",
  "status": "received",
  "cvStatus": "staged",
  "createdAt": "2026-10-13T09:01:14.000Z"
}

Interrogez toutes les quelques secondes depuis votre serveur, et arrêtez-vous à tout sauf processing. cvStatus vaut :

Valeur Signification
processing Le PDF est en cours de lecture et le CV Virtuel en construction. Généralement moins d’une minute
staged Construit, et l’e-mail de revendication est en route. Le candidat a 90 jours pour le revendiquer
claimed Le candidat l’a revendiqué, ou avait déjà un compte Wipperoz auquel il a été ajouté. claimedAt dit quand
deleted Le candidat l’a supprimé, ou l’a laissé expirer. La candidature reste
failed Le PDF n’a pas pu être lu. status vaut withdrawn et reason vaut cv_unreadable : demandez un autre fichier au candidat

La réponse ne porte jamais le contenu du CV, ni le nom ni l’e-mail du candidat. C’est un état pour une page que le candidat regarde, pas un dossier pour vos systèmes.

Ce que signifient les réponses

Réponse Signification
201 Reçue. Envoyez le candidat vers la page d’état
200 avec duplicate: true Cette personne a déjà postulé à ce poste ; la réponse porte l’applicationId existant. Ce n’est pas une erreur : une resoumission est la même candidature
409 job_not_open Le poste n’est pas publié. status dit ce qu’il est à la place : draft, closed ou expired
422 consent_invalid Le reçu manque ou est erroné, et message nomme le champ : une version de texte inconnue, un acceptedAt dans le futur, un ip ou un userAgent vide
422 attestation_required lawfulBasis.attested ne valait pas true
422 cv_rejected Le PDF a été lu et refusé : pas un CV, pas de texte, trop de pages. reason et detail sont pour vos journaux ; fix est écrit pour le candidat, montrez-le-lui
400 upload_invalid cv.uploadId est inconnu, expiré, déjà utilisé, ou rien n’y a été envoyé par PUT
429 daily_cap_reached Le budget quotidien de candidatures du compte est épuisé ; il se réinitialise à minuit UTC
429 rate_limited Trop de candidatures à ce poste en peu de temps ; réessayez dans une minute

Une requête refusée libère sa clé d’idempotence : une requête que vous corrigez peut être renvoyée avec la même. Un 201, ou un 200 avec duplicate: true, la garde 24 heures.

Le formulaire de référence

Une page, une page d’état et un serveur en Node ou en PHP. Chacun est assez complet pour tourner tel quel, lit la clé dans ORBIT_API_KEY et garde la clé côté serveur : le navigateur ne la voit jamais.

La page

Le serveur récupère le texte de consentement au rendu de la page et l’injecte là où se trouve {{consentText}} ; la case commence décochée. L’idempotencyKey caché est généré une fois par rendu, si bien qu’un navigateur qui resoumet le même formulaire rejoue la même candidature.

<form method="post" action="/apply/job_01J9A0B1C2EXAMPLE" enctype="multipart/form-data">
  <input type="hidden" name="idempotencyKey" value="{{idempotencyKey}}">

  <label>Full name <input name="name" required maxlength="200"></label>
  <label>Email <input name="email" type="email" required maxlength="254"></label>
  <label>Phone <input name="phone" type="tel" maxlength="50"></label>
  <label>CV (PDF, up to 10 MB) <input name="cv" type="file" accept="application/pdf" required></label>

  <fieldset>
    <legend>Consent</legend>
    <p id="consent-text">{{consentText}}</p>
    <label>
      <input type="checkbox" name="consent" value="yes" required>
      I agree
    </label>
  </fieldset>

  <button type="submit">Apply</button>
</form>

La page d’état interroge votre serveur, jamais l’API, et s’arrête à tout sauf processing :

<p id="message">We are building your Virtual CV…</p>
<script>
  const id = new URLSearchParams(location.search).get('id');
  const messages = {
    processing: 'We are building your Virtual CV…',
    staged: 'Your application is in. Check your email for the link to claim your Virtual CV.',
    claimed: 'Your application is in, and your Virtual CV is yours.',
    deleted: 'Your application is in. You deleted the Virtual CV we built, which is your right.',
    failed: 'We could not read that PDF. Please apply again with a different file.',
  };
  async function poll() {
    const answer = await (await fetch(`/status/${id}`)).json();
    document.getElementById('message').textContent = messages[answer.cvStatus] || messages.processing;
    if (answer.cvStatus === 'processing') setTimeout(poll, 3000);
  }
  poll();
</script>

Le serveur, en Node

// server.js — Node 20+, express and multer. form.html and status.html live in public/.
const express = require('express');
const multer = require('multer');
const crypto = require('node:crypto');
const fs = require('node:fs');

const BASE = 'https://api.wipperoz.com';
const headers = {Authorization: `Bearer ${process.env.ORBIT_API_KEY}`, 'Content-Type': 'application/json'};

const app = express();
// Behind a proxy, req.ip is then the first X-Forwarded-For entry: the visitor, not the proxy.
app.set('trust proxy', true);
app.use(express.static('public'));
const upload = multer({storage: multer.memoryStorage(), limits: {fileSize: 10 * 1024 * 1024}});

// The consent text, fetched at most once an hour. The API serves it so that every
// candidate sees the current wording; this server never stores or edits it.
let consent = {fetchedAt: 0, value: null};
async function consentText(locale) {
  if (Date.now() - consent.fetchedAt > 60 * 60 * 1000) {
    const res = await fetch(`${BASE}/v1/consent-text?locale=${locale}`, {headers});
    if (!res.ok) throw new Error(`consent-text ${res.status}`);
    consent = {fetchedAt: Date.now(), value: await res.json()};
  }
  return consent.value;
}

app.get('/apply/:jobId', async (req, res) => {
  const text = await consentText('en');
  // One key per rendered form: a browser that resubmits replays the same application.
  const idempotencyKey = crypto.randomUUID();
  res.send(
    fs.readFileSync('public/form.html', 'utf8')
      .replace('{{consentText}}', escapeHtml(text.text))
      .replace('{{idempotencyKey}}', idempotencyKey),
  );
});

app.post('/apply/:jobId', upload.single('cv'), async (req, res) => {
  if (req.body.consent !== 'yes') return res.status(400).send('Please tick the consent box.');
  if (!req.file) return res.status(400).send('Please attach your CV as a PDF.');

  // The receipt, as this server saw it. Never ask the browser for any of these.
  const receipt = {
    consentTextId: 'orbit_api_apply',
    version: 'api_apply_v1',
    locale: 'en',
    acceptedAt: new Date().toISOString(),
    ip: req.ip,
    userAgent: req.get('user-agent') || '',
  };

  // 1. Reserve an upload slot, then PUT the PDF to it. The URL is signed for the
  //    exact byte length, which fetch sets from the buffer.
  const slot = await (await fetch(`${BASE}/v1/uploads`, {
    method: 'POST',
    headers,
    body: JSON.stringify({bytes: req.file.size, fileName: req.file.originalname}),
  })).json();
  const put = await fetch(slot.uploadUrl, {
    method: 'PUT',
    headers: {'Content-Type': 'application/pdf'},
    body: req.file.buffer,
  });
  if (!put.ok) return res.status(502).send('The upload failed. Please try again.');

  // 2. Send the application. The Idempotency-Key came from the form, so a retry
  //    of the same submission lands on the same application.
  const answer = await fetch(`${BASE}/v1/jobs/${req.params.jobId}/applications`, {
    method: 'POST',
    headers: {...headers, 'Idempotency-Key': req.body.idempotencyKey},
    body: JSON.stringify({
      name: req.body.name,
      email: req.body.email,
      phone: req.body.phone || undefined,
      cv: {uploadId: slot.uploadId},
      consent: receipt,
      lawfulBasis: {attested: true},
    }),
  });
  const body = await answer.json();

  if (answer.status === 201 || answer.status === 200) {
    return res.redirect(`/status.html?id=${encodeURIComponent(body.applicationId)}`);
  }
  if (body.error?.code === 'cv_rejected') {
    return res.status(422).send(escapeHtml(body.error.fix)); // written for the candidate
  }
  console.error('application refused', body.error);
  return res.status(500).send('Something went wrong. Please try again in a minute.');
});

// The status page polls this. It proxies the API so that the key stays here.
app.get('/status/:id', async (req, res) => {
  const answer = await fetch(`${BASE}/v1/applications/${encodeURIComponent(req.params.id)}`, {headers});
  res.status(answer.status).json(await answer.json());
});

function escapeHtml(s) {
  return s.replace(/[&<>"']/g, (c) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[c]);
}

app.listen(3000);

Le serveur, en PHP

<?php
// apply.php — plain PHP 8 with curl, no framework. form.html and status.html sit beside it;
// in status.html, poll apply.php?status= instead of /status/.
// GET renders the form, POST sends the application, GET ?status=<id> proxies the status.
const BASE = 'https://api.wipperoz.com';
const JOB_ID = 'job_01J9A0B1C2EXAMPLE';

function api(string $method, string $path, ?array $body = null, array $extraHeaders = []): array
{
    $ch = curl_init(BASE . $path);
    $options = [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => array_merge(
            ['Authorization: Bearer ' . getenv('ORBIT_API_KEY'), 'Content-Type: application/json'],
            $extraHeaders,
        ),
        CURLOPT_RETURNTRANSFER => true,
    ];
    if ($body !== null) {
        $options[CURLOPT_POSTFIELDS] = json_encode($body);
    }
    curl_setopt_array($ch, $options);
    $raw = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    return [$status, json_decode($raw, true) ?? []];
}

// The consent text, cached in a file for an hour. The API serves it so that every
// candidate sees the current wording; this server never stores or edits it for longer.
function consentText(string $locale): array
{
    $cache = sys_get_temp_dir() . "/orbit-consent-$locale.json";
    if (is_file($cache) && filemtime($cache) > time() - 3600) {
        return json_decode(file_get_contents($cache), true);
    }
    [$status, $text] = api('GET', "/v1/consent-text?locale=$locale");
    if ($status !== 200) {
        throw new RuntimeException("consent-text $status");
    }
    file_put_contents($cache, json_encode($text));
    return $text;
}

session_start();

if (isset($_GET['status'])) {
    // The status page polls this. It proxies the API so that the key stays here.
    [$status, $body] = api('GET', '/v1/applications/' . rawurlencode($_GET['status']));
    http_response_code($status);
    header('Content-Type: application/json');
    echo json_encode($body);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    // One key per rendered form, kept in the session: a browser that resubmits replays
    // the same application instead of making a second one.
    $_SESSION['idempotencyKey'] = bin2hex(random_bytes(16));
    $text = consentText('en');
    echo strtr(file_get_contents(__DIR__ . '/form.html'), [
        '{{consentText}}' => htmlspecialchars($text['text'], ENT_QUOTES),
        '{{idempotencyKey}}' => $_SESSION['idempotencyKey'],
    ]);
    exit;
}

if (($_POST['consent'] ?? '') !== 'yes') {
    http_response_code(400);
    exit('Please tick the consent box.');
}
if (empty($_FILES['cv']['tmp_name'])) {
    http_response_code(400);
    exit('Please attach your CV as a PDF.');
}

// The receipt, as this server saw it. Behind a proxy the visitor is the first
// X-Forwarded-For entry, otherwise REMOTE_ADDR. Never ask the browser for any of these.
$forwarded = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
$receipt = [
    'consentTextId' => 'orbit_api_apply',
    'version' => 'api_apply_v1',
    'locale' => 'en',
    'acceptedAt' => gmdate('Y-m-d\TH:i:s\Z'),
    'ip' => $forwarded !== '' ? trim(explode(',', $forwarded)[0]) : $_SERVER['REMOTE_ADDR'],
    'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
];

// 1. Reserve an upload slot, then PUT the PDF to it, exactly as many bytes as reserved.
$size = filesize($_FILES['cv']['tmp_name']);
[, $slot] = api('POST', '/v1/uploads', ['bytes' => $size, 'fileName' => $_FILES['cv']['name']]);
$ch = curl_init($slot['uploadUrl']);
curl_setopt_array($ch, [
    CURLOPT_PUT => true,
    CURLOPT_INFILE => fopen($_FILES['cv']['tmp_name'], 'rb'),
    CURLOPT_INFILESIZE => $size,
    CURLOPT_HTTPHEADER => ['Content-Type: application/pdf'],
    CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);
$putStatus = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($putStatus !== 200) {
    http_response_code(502);
    exit('The upload failed. Please try again.');
}

// 2. Send the application, with the Idempotency-Key from the session.
$application = [
    'name' => $_POST['name'],
    'email' => $_POST['email'],
    'cv' => ['uploadId' => $slot['uploadId']],
    'consent' => $receipt,
    'lawfulBasis' => ['attested' => true],
];
if (($_POST['phone'] ?? '') !== '') {
    $application['phone'] = $_POST['phone'];
}
[$status, $body] = api('POST', '/v1/jobs/' . JOB_ID . '/applications', $application, [
    'Idempotency-Key: ' . $_SESSION['idempotencyKey'],
]);

if ($status === 201 || $status === 200) {
    header('Location: /status.html?id=' . rawurlencode($body['applicationId']));
    exit;
}
if (($body['error']['code'] ?? '') === 'cv_rejected') {
    http_response_code(422);
    exit(htmlspecialchars($body['error']['fix'])); // written for the candidate
}
error_log('application refused: ' . json_encode($body['error'] ?? $body));
http_response_code(500);
exit('Something went wrong. Please try again in a minute.');

Ajoutez des personnes à Mes candidats depuis votre système

POST /v1/candidates est l’écran Importer des CV via l’API : une personne, un PDF, dans Mes candidats plutôt que sur un poste.

curl -X POST https://api.wipperoz.com/v1/candidates \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: crm-person-88213" \
  -d '{
    "name": "Jane Doe",
    "email": "jane@example.com",
    "cv": {"uploadId": "0f5c2a9e-6b1d-4c3e-9a8f-2d7b1c4e5f60"},
    "sendClaimEmail": true,
    "locale": "fr",
    "lawfulBasis": {"attested": true}
  }'
{"candidateId": "pool_01JA2B3C4D5EXAMPLE", "status": "queued", "cvStatus": "processing"}

Même réservation de téléversement, même attestation, même règle d’Idempotency-Key. Il n’y a pas de reçu de consentement, parce que la personne n’a postulé à rien : ce que vous attestez, c’est que vous disposez d’une base légale pour envoyer son CV à Wipperoz, la même attestation qu’Importer des CV demande. sendClaimEmail (par défaut true) indique si la personne reçoit par e-mail un lien pour revendiquer son CV Virtuel, et locale est la langue de cet e-mail.

status Ce que cela signifie pour vous
queued Acceptée. Le CV Virtuel est en construction, et la personne apparaît dans Mes candidats dès qu’il l’est
existing_account Cette personne a déjà un compte Wipperoz. Elle a été invitée à partager son CV Virtuel avec vous à la place ; rien n’a été construit à partir de votre PDF
suppressed Cette personne a demandé à Wipperoz de ne pas conserver son CV. Rien n’a été stocké, et rien ne le sera, quel que soit l’expéditeur
already_imported Votre compte a déjà importé cette personne. L’entrée existante reste
rejected Le PDF a été refusé ; rejection.reason et rejection.title disent pourquoi

cvStatus vaut processing pour queued et none sinon. Le budget quotidien est celui d’Importer des CV, 100 personnes par jour et par compte, API et écran confondus ; au-delà, la réponse est 429 daily_cap_reached.

Les règles à garder

  • Une candidature par personne et par poste. Un second envoi avec le même e-mail sur le même poste répond 200 avec duplicate: true et l’id de la première candidature ; rien n’est créé.
  • Wipperoz écrit à chaque candidat. L’e-mail de revendication est la façon dont le candidat apprend qu’un CV Virtuel existe et lui appartient ; il ne peut pas être désactivé pour les candidatures. Il le peut pour les candidats, avec sendClaimEmail: false.
  • Le CV Virtuel appartient à la personne. Elle peut le supprimer, ou le laisser expirer sans le revendiquer, et le cvStatus de la candidature dit alors deleted. Le dossier de candidature reste.
  • Une personne qui a demandé à ne pas être conservée est suppressed, quel que soit l’expéditeur, et autant de fois qu’il faut.
  • Le texte de consentement est celui de l’API, pas le vôtre. Récupérez-le, affichez-le, et ne gardez jamais une copie pour la modifier.

Référence

Read the consent textGET /v1/consent-text Start a CV uploadPOST /v1/uploads Send an applicationPOST /v1/jobs/{jobId}/applications Read an application's statusGET /v1/applications/{applicationId} Add a candidate you hold a CV forPOST /v1/candidates
WipperozWipperoz

Wipperoz est une plateforme de CV virtuel interactive axée sur la vidéo, conçue pour remplacer les CV PDF traditionnels par des profils dynamiques et partageables.

© 2026 Wipperoz. Tous droits réservés

Développé par epoqx.ai