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: oconsentTextIde aversionque mostrou, eacceptedAt,ipeuserAgenttal 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,coverNoteeanswers(até 20 pares de pergunta e resposta) são opcionais. Campos desconhecidos respondem400, 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) => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''})[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
200comduplicate: truee 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
cvStatusda candidatura passa a dizerdeleted. 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