Skip to main content
WipperozWipperoz
Browse the docs

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

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"}
}
  • consent is the receipt: the consentTextId and version you showed, and acceptedAt, ip and userAgent as 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.attested is 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-Key is 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, coverNote and answers (up to 20 question-and-answer pairs) are optional. Unknown fields answer 400, 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) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[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 200 with duplicate: true and 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 cvStatus then says deleted. 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
WipperozWipperoz

Wipperoz is a video-first interactive virtual CV platform designed to replace traditional PDF resumes with dynamic, shareable profiles.

© 2026 Wipperoz. All rights reserved

Developed by epoqx.ai