Ir para o conteúdo principal
WipperozWipperoz
Explorar a documentação

Receber candidaturas a partir da sua própria página de carreiras

Atualizado

A página de candidatura alojada é o caminho sem integração: ligue para o applyLink e tudo o que acontece depois do clique é connosco. Esta página é para a conta que quer que a candidatura aconteça no seu próprio domínio. O candidato nunca sai da sua página de carreiras, o seu formulário envia para o seu servidor, e o seu servidor envia a candidatura à Wipperoz, que continua a construir o CV Virtual do candidato e a escrever-lhe para que o reclame.

O que precisa

Uma chave com o âmbito Receber candidaturas, criada em Orbit → Definições → Chaves de API. Um Administrador só a pode criar depois de aceitar o Aditamento de tratamento de dados da API: na página alojada, a Wipperoz recolhe o consentimento do candidato; na sua, é você quem o recolhe, e o aditamento é onde a sua conta assume isso. O âmbito não está implícito em ler e gerir vagas, e os outros âmbitos respondem 403 em todos os endpoints desta página.

export ORBIT_API_KEY="…uma chave com Receber candidaturas…"

O fluxo: quatro chamadas

1. Obtenha o texto de consentimento

curl "https://api.wipperoz.com/v1/consent-text?locale=pt" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
{
  "consentTextId": "orbit_api_apply",
  "version": "api_apply_v1",
  "locale": "pt",
  "text": "Estou me candidatando a esta vaga e concordo que a Wipperoz crie um Virtual CV a partir do PDF que estou enviando e permita que este empregador o leia para a minha candidatura. A Wipperoz vai me enviar por e-mail um link para reivindicar meu Virtual CV, e ele será meu aconteça o que acontecer com esta candidatura."
}

Mostre text tal e qual ao lado de uma caixa que começa desmarcada. Nunca o reformule, nunca o encurte, nunca marque a caixa de antemão. locale é en, es, fr ou pt; peça o da língua em que o candidato está a ler. O texto é servido pela API em vez de impresso aqui para que cada candidato veja o que está em vigor: guarde-o em cache uma hora, não até ao seu próximo deploy.

2. Reserve um carregamento e faça PUT do 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"
}

Depois envie o ficheiro para uploadUrl com um PUT simples, com exatamente bytes de comprimento:

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

bytes é o tamanho exato do ficheiro, até 10 MB, e o URL está assinado para esse comprimento e para application/pdf: outro comprimento ou outro tipo é recusado pelo armazenamento, não por nós. O URL vive cinco minutos e a reserva uma hora, e uma reserva usa-se uma só vez.

3. Envie a 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": "pt",
      "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 é o recibo: o consentTextId e a version que mostrou, e acceptedAt, ip e userAgent tal como o seu servidor os viu quando o candidato marcou a caixa. Registe-os no servidor no momento do envio; não os peça ao navegador.
  • lawfulBasis.attested é a atestação da sua conta, guardada tal e qual com a candidatura, de que o texto foi mostrado sem modificações, a aceitação foi registada com verdade e pode enviar os dados desta pessoa à Wipperoz.
  • Idempotency-Key é obrigatória aqui. Gere um UUID uma vez por submissão, ao renderizar o formulário, e reenvie o mesmo em cada nova tentativa: um navegador que reenvia e um servidor que esgota o tempo e tenta de novo acabam ambos na mesma candidatura.
  • phone, location, coverNote e answers (até 20 pares de pergunta e resposta) são opcionais. Campos desconhecidos respondem 400, como em toda esta API.

candidateId é o id que a rota de triagem aceita para este candidato.

4. Mostre ao candidato que o seu CV está a ser construído

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"
}

Consulte de poucos em poucos segundos a partir do seu servidor, e pare em qualquer coisa que não seja processing. cvStatus é um de:

Valor Significado
processing O PDF está a ser lido e o CV Virtual construído. Normalmente menos de um minuto
staged Construído, e o e-mail para o reclamar está a caminho. O candidato tem 90 dias para o reclamar
claimed O candidato reclamou-o, ou já tinha uma conta Wipperoz à qual foi adicionado. claimedAt diz quando
deleted O candidato eliminou-o, ou deixou-o expirar. A candidatura mantém-se
failed O PDF não pôde ser lido. status é withdrawn e reason é cv_unreadable: peça outro ficheiro ao candidato

A resposta nunca traz o conteúdo do CV, nem o nome nem o e-mail do candidato. É um estado para uma página que o candidato está a ver, não um registo para os seus sistemas.

O que as respostas significam

Resposta Significado
201 Recebida. Envie o candidato para a página de estado
200 com duplicate: true Esta pessoa já se candidatou a esta vaga; a resposta traz o applicationId existente. Não é um erro: um reenvio é a mesma candidatura
409 job_not_open A vaga não está publicada. status diz o que é em vez disso: draft, closed ou expired
422 consent_invalid O recibo falta ou está errado, e message nomeia o campo: uma versão de texto desconhecida, um acceptedAt no futuro, um ip ou userAgent vazio
422 attestation_required lawfulBasis.attested não era true
422 cv_rejected O PDF foi lido e recusado: não é um CV, não tem texto, demasiadas páginas. reason e detail são para os seus registos; fix está escrito para o candidato, por isso mostre-lho
400 upload_invalid cv.uploadId é desconhecido, expirou, já foi usado ou nada foi enviado por PUT
429 daily_cap_reached O orçamento diário de candidaturas da conta esgotou-se; reinicia à meia-noite UTC
429 rate_limited Demasiadas candidaturas a esta vaga em pouco tempo; tente de novo daqui a um minuto

Um pedido recusado liberta a sua chave de idempotência, por isso um pedido que corrija pode ser repetido com a mesma. Um 201, ou um 200 com duplicate: true, mantém-na 24 horas.

O formulário de referência

Uma página, uma página de estado e um servidor em Node ou em PHP. Cada um está suficientemente completo para correr tal como está, lê a chave de ORBIT_API_KEY e mantém a chave no servidor: o navegador nunca a vê.

A página

O servidor obtém o texto de consentimento ao renderizar a página e injeta-o onde está {{consentText}}; a caixa começa desmarcada. O idempotencyKey escondido é gerado uma vez por renderização, por isso um navegador que reenvia o mesmo formulário repete a mesma 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>

A página de estado consulta o seu servidor, nunca a API, e para em qualquer coisa que não seja 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>

O servidor, em 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);

O servidor, em 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.');

Adicione pessoas a Meus candidatos a partir do seu sistema

POST /v1/candidates é o ecrã Importar CVs pela API: uma pessoa, um PDF, para Meus candidatos em vez de para uma vaga.

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": "pt",
    "lawfulBasis": {"attested": true}
  }'
{"candidateId": "pool_01JA2B3C4D5EXAMPLE", "status": "queued", "cvStatus": "processing"}

Mesma reserva de carregamento, mesma atestação, mesma regra de Idempotency-Key. Não há recibo de consentimento, porque a pessoa não se candidatou a nada: o que atesta é que dispõe de um fundamento jurídico para enviar o CV dela à Wipperoz, a mesma atestação que Importar CVs pede. sendClaimEmail (por predefinição true) é se a pessoa recebe por e-mail uma ligação para reclamar o seu CV Virtual, e locale é a língua desse e-mail.

status O que significa para si
queued Aceite. O CV Virtual está a ser construído, e a pessoa aparece em Meus candidatos assim que estiver
existing_account Esta pessoa já tem uma conta Wipperoz. Foi convidada a partilhar o seu CV Virtual consigo em vez disso; nada foi construído a partir do seu PDF
suppressed Esta pessoa pediu à Wipperoz que não conservasse o seu CV. Nada foi guardado, e nada será, seja quem for que a envie
already_imported A sua conta já importou esta pessoa. A entrada existente mantém-se
rejected O PDF foi recusado; rejection.reason e rejection.title dizem porquê

cvStatus é processing para queued e none nos restantes casos. O orçamento diário é o de Importar CVs, 100 pessoas por dia por conta, API e ecrã em conjunto; passado esse limite, a resposta é 429 daily_cap_reached.

Regras a manter

  • Uma candidatura por pessoa e por vaga. Um segundo envio com o mesmo e-mail à mesma vaga responde 200 com duplicate: true e o id da primeira candidatura; nada é criado.
  • A Wipperoz escreve a todos os candidatos. O e-mail para reclamar é como o candidato fica a saber que existe um CV Virtual e que é seu; não pode ser desligado para as candidaturas. Pode para os candidatos, com sendClaimEmail: false.
  • O CV Virtual é da pessoa. Pode eliminá-lo, ou deixá-lo expirar sem o reclamar, e o cvStatus da candidatura passa a dizer deleted. O registo da candidatura mantém-se.
  • Uma pessoa que pediu para não ser conservada é suppressed, seja quem for que a envie, e as vezes que forem.
  • O texto de consentimento é da API, não seu. Obtenha-o, mostre-o e nunca guarde uma cópia para a editar.

Referência

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

A Wipperoz é uma plataforma de CV virtual interativa com vídeo em primeiro lugar, criada para substituir currículos em PDF tradicionais por perfis dinâmicos e compartilháveis.

© 2026 Wipperoz. Todos os direitos reservados

Desenvolvido por epoqx.ai