Receive applications from your own careers page
Updated
The hosted apply page is the zero-integration path: link to applyLink and everything after the click is ours. This page is for the account that wants the application to happen on its own domain. The candidate never leaves your careers page, your form posts to your server, and your server sends the application to Wipperoz, which still builds the candidate’s Virtual CV and emails them to claim it.
What you need
A key with the Receive applications scope, created in Orbit → Settings → API keys. An Admin can only create one after accepting the API data-processing addendum: on the hosted page Wipperoz collects the candidate’s consent; on yours, you do, and the addendum is where your account takes that on. The scope is not implied by read and manage jobs, and the other scopes answer 403 on every endpoint on this page.
export ORBIT_API_KEY="…a key with Receive applications…"
The flow: four calls
1. Fetch the consent text
curl "https://api.wipperoz.com/v1/consent-text?locale=en" \
-H "Authorization: Bearer $ORBIT_API_KEY"
{
"consentTextId": "orbit_api_apply",
"version": "api_apply_v1",
"locale": "en",
"text": "I'm applying for this role, and I agree that Wipperoz will build a Virtual CV from the PDF I'm sending and let this employer read it for my application. Wipperoz will email me a link to claim my Virtual CV, and it's mine to keep whatever happens with this application."
}
Render text verbatim next to a checkbox that starts unticked. Never reword it, never shorten it, never pre-tick the box. locale is en, es, fr or pt; fetch the one the candidate is reading in. The text is served by the API rather than printed here so that every candidate sees the current one: cache it for an hour, not for a release.
2. Reserve an upload and PUT the 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"
}
Then send the file to uploadUrl with a plain PUT, exactly bytes long:
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @jane-doe-cv.pdf
bytes is the file’s exact size, up to 10 MB, and the URL is signed for that length and for application/pdf: a different length or type is refused by the storage, not by us. The URL lives five minutes and the slot an hour, and a slot is used once.
3. Send the application
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": "en",
"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"}
}
consentis the receipt: theconsentTextIdandversionyou showed, andacceptedAt,ipanduserAgentas your server saw them when the candidate ticked the box. Record them on the server at submit time; do not ask the browser for them.lawfulBasis.attestedis your account’s attestation, stored verbatim with the application, that the text was shown unmodified, the acceptance recorded truthfully, and that you may send this person’s details to Wipperoz.Idempotency-Keyis required here. Generate a UUID once per submission, when the form renders, and resend the same one on every retry: a browser that resubmits and a server that times out and tries again both land on the same application.phone,location,coverNoteandanswers(up to 20 question-and-answer pairs) are optional. Unknown fields answer400, as everywhere on this API.
candidateId is the id the screening route accepts for this applicant.
4. Show the candidate that their CV is being built
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"
}
Poll every few seconds from your server, and stop at anything but processing. cvStatus is one of:
| Value | Meaning |
|---|---|
processing |
The PDF is being read and the Virtual CV built. Usually under a minute |
staged |
Built, and the claim email is on its way. The candidate has 90 days to claim it |
claimed |
The candidate claimed it, or already had a Wipperoz account it was added to. claimedAt says when |
deleted |
The candidate deleted it, or let it expire. The application stays |
failed |
The PDF could not be read. status is withdrawn and reason is cv_unreadable: ask the candidate for a different file |
The answer never carries CV content, and never the candidate’s name or email. It is a status for a page the candidate is looking at, not a record for your systems.
What answers mean
| Answer | Meaning |
|---|---|
201 |
Received. Send the candidate to the status page |
200 with duplicate: true |
This person already applied to this role; the answer carries the existing applicationId. Not an error: a resubmission is the same application |
409 job_not_open |
The role is not published. status says what it is instead: draft, closed or expired |
422 consent_invalid |
The receipt is missing or wrong, and message names the field: an unknown text version, an acceptedAt in the future, an empty ip or userAgent |
422 attestation_required |
lawfulBasis.attested was not true |
422 cv_rejected |
The PDF was read and refused: not a CV, no text, too many pages. reason and detail are for your logs; fix is written for the candidate, so show it to them |
400 upload_invalid |
cv.uploadId is unknown, expired, already used, or nothing was PUT to it |
429 daily_cap_reached |
The account’s daily application budget is spent; it resets at midnight UTC |
429 rate_limited |
Too many applications to this role in a short window; try again in a minute |
A refused request releases its idempotency key, so a request you fix may be retried with the same one. A 201, or a 200 with duplicate: true, keeps it for 24 hours.
The reference form
A page, a status page, and a server in Node or in PHP. Each is complete enough to run as it is, reads the key from ORBIT_API_KEY, and keeps the key on the server: the browser never sees it.
The page
The server fetches the consent text when it renders the page and injects it where {{consentText}} is; the checkbox starts unticked. The hidden idempotencyKey is minted once per render, so a browser that resubmits the same form replays the same application.
<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>
The status page polls your server, never the API, and stops at anything but 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>
The server, in 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);
The server, in 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.');
Add people to My candidates from your system
POST /v1/candidates is the Import CVs screen over the API: one person, one PDF, into My candidates rather than against a role.
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": "en",
"lawfulBasis": {"attested": true}
}'
{"candidateId": "pool_01JA2B3C4D5EXAMPLE", "status": "queued", "cvStatus": "processing"}
Same upload slot, same attestation, same Idempotency-Key rule. There is no consent receipt, because the person did not apply to anything: what you attest is that you hold a lawful basis to send Wipperoz their CV, the same attestation Import CVs asks for. sendClaimEmail (default true) is whether the person is emailed a link to claim their Virtual CV, and locale is that email’s language.
status |
Meaning for you |
|---|---|
queued |
Accepted. The Virtual CV is being built, and the person appears in My candidates once it is |
existing_account |
This person already has a Wipperoz account. They were invited to share their Virtual CV with you instead; nothing was built from your PDF |
suppressed |
This person has asked Wipperoz not to hold their CV. Nothing was stored, and nothing will be, whoever sends them |
already_imported |
Your account already imported this person. The existing entry stands |
rejected |
The PDF was refused; rejection.reason and rejection.title say why |
cvStatus is processing for queued and none otherwise. The daily budget is the one Import CVs uses, 100 people a day per account, API and screen together; past it the answer is 429 daily_cap_reached.
Rules to keep
- One application per person per role. A second send for the same email on the same role answers
200withduplicate: trueand the first application’s id; nothing is created. - Wipperoz emails every applicant. The claim email is how the candidate learns that a Virtual CV exists and is theirs; it cannot be turned off for applications. It can for candidates, with
sendClaimEmail: false. - The person owns the Virtual CV. They may delete it, or let it expire unclaimed, and the application’s
cvStatusthen saysdeleted. The application record stays. - A person who asked not to be held is
suppressed, whoever sends them, and however many times. - The consent text is the API’s, not yours. Fetch it, show it, and never keep a copy to edit.
Reference
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