Recibir candidaturas desde tu propia página de empleo
Actualizado
La página de postulación alojada es el camino sin integración: enlaza a applyLink y todo lo que pasa después del clic es nuestro. Esta página es para la cuenta que quiere que la postulación ocurra en su propio dominio. La persona candidata nunca sale de tu página de empleo, tu formulario envía a tu servidor, y tu servidor envía la candidatura a Wipperoz, que sigue construyendo su CV Virtual y le escribe para que lo reclame.
Qué necesitas
Una clave con el permiso Recibir candidaturas, creada en Orbit → Configuración → Claves de API. Un Administrador solo puede crearla después de aceptar la Adenda de tratamiento de datos de la API: en la página alojada, Wipperoz recoge el consentimiento de la persona candidata; en la tuya, lo recoges tú, y la adenda es donde tu cuenta asume eso. El permiso no va implícito en leer y gestionar empleos, y los demás permisos responden 403 en todos los endpoints de esta página.
export ORBIT_API_KEY="…una clave con Recibir candidaturas…"
El flujo: cuatro llamadas
1. Obtén el texto de consentimiento
curl "https://api.wipperoz.com/v1/consent-text?locale=es" \
-H "Authorization: Bearer $ORBIT_API_KEY"
{
"consentTextId": "orbit_api_apply",
"version": "api_apply_v1",
"locale": "es",
"text": "Me postulo a este puesto y acepto que Wipperoz cree un Virtual CV a partir del PDF que envío y permita que este empleador lo lea para mi candidatura. Wipperoz me enviará por correo un enlace para reclamar mi Virtual CV, y será mío pase lo que pase con esta candidatura."
}
Muestra text literalmente junto a una casilla que empiece sin marcar. Nunca lo reformules, nunca lo acortes, nunca marques la casilla de antemano. locale es en, es, fr o pt; pide el del idioma en que lee la persona candidata. El texto lo sirve la API en lugar de imprimirse aquí para que cada persona vea el vigente: cachéalo una hora, no hasta tu próximo despliegue.
2. Reserva una subida y haz PUT del 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"
}
Después envía el archivo a uploadUrl con un PUT normal, de exactamente bytes de longitud:
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @jane-doe-cv.pdf
bytes es el tamaño exacto del archivo, hasta 10 MB, y la URL está firmada para esa longitud y para application/pdf: otra longitud u otro tipo los rechaza el almacenamiento, no nosotros. La URL vive cinco minutos y la reserva una hora, y una reserva se usa una sola vez.
3. Envía la candidatura
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": "es",
"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"}
}
consentes el recibo: elconsentTextIdy laversionque mostraste, yacceptedAt,ipyuserAgenttal como los vio tu servidor cuando la persona marcó la casilla. Regístralos en el servidor al enviar; no se los pidas al navegador.lawfulBasis.attestedes la atestación de tu cuenta, guardada literalmente con la candidatura, de que el texto se mostró sin modificar, la aceptación se registró con veracidad y puedes enviar los datos de esta persona a Wipperoz.Idempotency-Keyes obligatoria aquí. Genera un UUID una vez por envío, al renderizar el formulario, y reenvía el mismo en cada reintento: un navegador que reenvía y un servidor que agota el tiempo y vuelve a intentarlo acaban en la misma candidatura.phone,location,coverNoteyanswers(hasta 20 pares de pregunta y respuesta) son opcionales. Los campos desconocidos responden400, como en toda esta API.
candidateId es el id que la ruta de evaluación acepta para esta persona.
4. Muestra a la persona candidata que su CV se está construyendo
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"
}
Consulta cada pocos segundos desde tu servidor, y para en cuanto veas algo distinto de processing. cvStatus es uno de:
| Valor | Significado |
|---|---|
processing |
El PDF se está leyendo y el CV Virtual, construyendo. Normalmente menos de un minuto |
staged |
Construido, y el correo para reclamarlo va en camino. La persona tiene 90 días para reclamarlo |
claimed |
La persona lo reclamó, o ya tenía una cuenta de Wipperoz a la que se añadió. claimedAt dice cuándo |
deleted |
La persona lo eliminó, o dejó que caducara. La candidatura se mantiene |
failed |
El PDF no se pudo leer. status es withdrawn y reason es cv_unreadable: pide a la persona otro archivo |
La respuesta nunca lleva el contenido del CV, ni el nombre ni el correo de la persona candidata. Es un estado para la página que la persona está mirando, no un registro para tus sistemas.
Qué significan las respuestas
| Respuesta | Significado |
|---|---|
201 |
Recibida. Envía a la persona a la página de estado |
200 con duplicate: true |
Esta persona ya se postuló a este puesto; la respuesta lleva el applicationId existente. No es un error: un reenvío es la misma candidatura |
409 job_not_open |
El puesto no está publicado. status dice qué es en su lugar: draft, closed o expired |
422 consent_invalid |
El recibo falta o está mal, y message nombra el campo: una versión del texto desconocida, un acceptedAt en el futuro, un ip o userAgent vacío |
422 attestation_required |
lawfulBasis.attested no era true |
422 cv_rejected |
El PDF se leyó y se rechazó: no es un CV, no tiene texto, demasiadas páginas. reason y detail son para tus registros; fix está escrito para la persona candidata, así que muéstraselo |
400 upload_invalid |
cv.uploadId es desconocido, caducó, ya se usó o no se hizo PUT de nada |
429 daily_cap_reached |
El presupuesto diario de candidaturas de la cuenta se agotó; se reinicia a medianoche UTC |
429 rate_limited |
Demasiadas candidaturas a este puesto en poco tiempo; vuelve a intentarlo en un minuto |
Una petición rechazada libera su clave de idempotencia, así que una petición que corriges se puede reintentar con la misma. Un 201, o un 200 con duplicate: true, la conserva 24 horas.
El formulario de referencia
Una página, una página de estado y un servidor en Node o en PHP. Cada uno está lo bastante completo como para ejecutarse tal cual, lee la clave de ORBIT_API_KEY y mantiene la clave en el servidor: el navegador nunca la ve.
La página
El servidor obtiene el texto de consentimiento al renderizar la página y lo inyecta donde está {{consentText}}; la casilla empieza sin marcar. El idempotencyKey oculto se genera una vez por renderizado, así que un navegador que reenvía el mismo formulario repite la misma candidatura.
<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 página de estado consulta a tu servidor, nunca a la API, y para en cuanto ve algo distinto de 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>
El servidor, 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) => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''})[c]);
}
app.listen(3000);
El servidor, 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.');
Añade personas a Mis candidatos desde tu sistema
POST /v1/candidates es la pantalla Importar CV por la API: una persona, un PDF, a Mis candidatos en lugar de a un puesto.
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": "es",
"lawfulBasis": {"attested": true}
}'
{"candidateId": "pool_01JA2B3C4D5EXAMPLE", "status": "queued", "cvStatus": "processing"}
Misma reserva de subida, misma atestación, misma regla de Idempotency-Key. No hay recibo de consentimiento, porque la persona no se postuló a nada: lo que atestas es que cuentas con una base legal para enviar su CV a Wipperoz, la misma atestación que pide Importar CV. sendClaimEmail (por defecto true) es si se le envía a la persona un enlace para reclamar su CV Virtual, y locale es el idioma de ese correo.
status |
Qué significa para ti |
|---|---|
queued |
Aceptada. El CV Virtual se está construyendo, y la persona aparece en Mis candidatos en cuanto está |
existing_account |
Esta persona ya tiene una cuenta de Wipperoz. Se la invitó a compartir su CV Virtual contigo en su lugar; no se construyó nada a partir de tu PDF |
suppressed |
Esta persona pidió a Wipperoz que no conserve su CV. No se guardó nada, y no se guardará, la envíe quien la envíe |
already_imported |
Tu cuenta ya importó a esta persona. La entrada existente se mantiene |
rejected |
El PDF se rechazó; rejection.reason y rejection.title dicen por qué |
cvStatus es processing para queued y none en el resto. El presupuesto diario es el de Importar CV, 100 personas al día por cuenta, API y pantalla juntas; pasado, la respuesta es 429 daily_cap_reached.
Reglas que mantener
- Una candidatura por persona y puesto. Un segundo envío con el mismo correo al mismo puesto responde
200conduplicate: truey el id de la primera candidatura; no se crea nada. - Wipperoz escribe a todas las personas que se postulan. El correo para reclamar es como la persona se entera de que existe un CV Virtual y de que es suyo; no se puede desactivar para las candidaturas. Para los candidatos sí, con
sendClaimEmail: false. - El CV Virtual es de la persona. Puede eliminarlo, o dejar que caduque sin reclamarlo, y el
cvStatusde la candidatura pasa a decirdeleted. El registro de la candidatura se mantiene. - Una persona que pidió que no se la conserve es
suppressed, la envíe quien la envíe, y las veces que haga falta. - El texto de consentimiento es de la API, no tuyo. Obténlo, muéstralo y nunca guardes una copia para editarla.
Referencia
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