Saltar al contenido principal
WipperozWipperoz
Explorar la documentación

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"}
}
  • consent es el recibo: el consentTextId y la version que mostraste, y acceptedAt, ip y userAgent tal 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.attested es 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-Key es 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, coverNote y answers (hasta 20 pares de pregunta y respuesta) son opcionales. Los campos desconocidos responden 400, 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) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[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 200 con duplicate: true y 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 cvStatus de la candidatura pasa a decir deleted. 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
WipperozWipperoz

Wipperoz es una plataforma interactiva de CV virtual centrada en el video, diseñada para reemplazar los currículums PDF tradicionales por perfiles dinámicos y compartibles.

© 2026 Wipperoz. Todos los derechos reservados

Desarrollado por epoqx.ai