Connect a form
Your website stays the source of the form. After it validates a submission, its server posts a signed copy to {BASE_PATH}/ingest (by default /ats/ingest). This page is the whole protocol, plus a client you can paste in.
Sign on the server, never in the browser. The ingest secret must stay on your web server (or Pages Function / Worker). Anyone holding it can submit applications.
The request#
- Build the payload
A JSON object describing the applicant and their answers (fields below). For each uploaded file, list its form field, name, type, size in bytes and the lowercase hex SHA-256 of its bytes in
files. - Sign it
Serialize the payload once and sign that exact string:
X-ATS-Timestamp: <unix seconds> X-ATS-Signature: hex( HMAC-SHA256( INGEST_SECRET, "<timestamp>.<sha256hex(payloadJson)>" ) )Only the payload JSON is signed; each file is bound to it by the SHA-256 listed in
files. - Send it as
multipart/form-dataOne text part named
payloadcontaining the JSON string, plus one file part per upload whose part name is the form field (for exampleresume).POST /ats/ingest HTTP/1.1 Host: ats.example.com Content-Type: multipart/form-data; boundary=... X-ATS-Timestamp: 1760450000 X-ATS-Signature: 5f0c…(64 hex chars) --... name="payload" {"externalId":"web-…","site":{…},"applicant":{…},"answers":[…],"files":[…]} --... name="resume"; filename="resume.pdf" <bytes> - Read the response
201with{ "applicationId": "…", "candidateId": "…", "duplicate": false }. If the sameexternalIdarrives again, you get the original ids back with"duplicate": true.
Rules the server enforces#
- Freshness: the timestamp must be within 5 minutes of the server's clock, either way. Re-sign each attempt, including retries.
- Signature first: headers are checked before the body is read; the signature is verified (in constant time) before anything in the payload is trusted.
- Files must match exactly: every file declared in
filesmust arrive under the same field with the same SHA-256 and size, and every file part must be declared. File name and content type are taken from the signed payload, not from the part headers. - Size: each file up to
MAX_FILE_BYTES(10 MiB by default); the whole request up to four times that plus 1 MiB; at most 20 files. - Replays: with an
externalId, a repeat is harmless and returns the original application. Without one, an identical signed request seen again within the window is refused with409(best effort, per process).
Payload fields#
The shape is IngestPayload in packages/core/src/api/types.ts; limits come from the server's validation schema.
| Field | Type | Notes |
|---|---|---|
externalId | string, optional | Up to 200 chars. Your stable id for this submission; makes retries idempotent. Always send one. |
source | string, optional | Up to 100 chars, e.g. "website". Defaults to "web". |
site | { id, name } | id: lowercase letters, digits and dashes, 1–80 chars. A new site id is added to Settings → Sites automatically with this name. |
formId | string, optional | Up to 100 chars. Which of your forms it came from. |
positionTitle | string, optional | Up to 200 chars. If an open job at that site has exactly this title, the application is linked to it. |
applicant.email | string | Valid email, up to 254 chars. Applications with the same email belong to the same candidate. |
applicant.firstName, .lastName | string | 1–100 chars each. |
applicant.phone, .city, .state | string, optional | Up to 40 / 100 / 100 chars. |
talentPoolConsent | boolean, optional | The applicant agreed to be considered for future openings. Once given, a later submission without it doesn't revoke it. |
answers | { title, rows: { key, label, value }[] }[] | The form, in the order the applicant saw it: up to 50 sections, 500 rows each. key 1–100 chars, label up to 500, value up to 20,000. |
files | { field, filename, contentType, size, sha256 }[], optional | field: letters, digits, _ and -, 1–60 chars; sha256: 64 lowercase hex chars. |
submittedAt | ISO 8601 string, optional | With a timezone offset. Defaults to the time it was received. |
Tips for answers:
- Use stable keys from your form (
first_name,emp1_title). Sensitive fields are matched by key: the defaultsconvicted*andwork_auth*mask keys such asconvicted_felonyorwork_auth_usuntil a recruiter reveals them. Change the list under Settings → Sensitive fields. - If a candidate already exists (same email), a new submission only fills empty profile fields; it never overwrites them, because anyone can type someone else's email into a public form. The new application always keeps exactly what was submitted.
Reference client (JavaScript)#
About 40 lines, no dependencies. Runs anywhere with Web Crypto, fetch and FormData: Node.js 18+, Cloudflare Workers and Pages Functions, Deno, Bun.
// Forward a validated form submission to the ATS ingest endpoint.
const enc = new TextEncoder();
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
const sha256 = async (bytes) => hex(await crypto.subtle.digest("SHA-256", bytes));
async function hmacHex(secret, message) {
const key = await crypto.subtle.importKey(
"raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
return hex(await crypto.subtle.sign("HMAC", key, enc.encode(message)));
}
/**
* @param {{ url: string, secret: string }} ats url like "https://ats.example.com/ats"
* @param {object} payload IngestPayload without `files`
* @param {{ field: string, file: File }[]} uploads
* @returns {Promise<{ applicationId: string, candidateId: string, duplicate: boolean }>}
*/
export async function sendToAts(ats, payload, uploads = []) {
const parts = [];
for (const { field, file } of uploads) {
const bytes = new Uint8Array(await file.arrayBuffer());
const meta = { field, filename: file.name || field, contentType: file.type || "application/octet-stream",
size: bytes.byteLength, sha256: await sha256(bytes) };
parts.push({ meta, bytes });
}
const json = JSON.stringify({ ...payload, files: parts.map((p) => p.meta) });
const ts = String(Math.floor(Date.now() / 1000));
const signature = await hmacHex(ats.secret, `${ts}.${await sha256(enc.encode(json))}`);
const body = new FormData();
body.set("payload", json);
for (const { meta, bytes } of parts) {
body.append(meta.field, new Blob([bytes], { type: meta.contentType }), meta.filename);
}
const res = await fetch(`${ats.url.replace(/\/+$/, "")}/ingest`, {
method: "POST",
body,
headers: { "X-ATS-Timestamp": ts, "X-ATS-Signature": signature },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`ATS ingest failed: ${res.status} ${await res.text()}`);
return res.json();
}Using it#
import { sendToAts } from "./ats-client.js";
const result = await sendToAts(
{ url: process.env.ATS_URL, secret: process.env.ATS_INGEST_SECRET },
{
externalId: submissionId, // the same id on every retry of this submission
source: "website",
site: { id: "main-office", name: "Main office" },
positionTitle: form.get("position") || undefined,
applicant: {
email: form.get("email"),
firstName: form.get("first_name"),
lastName: form.get("last_name"),
phone: form.get("phone") || undefined,
},
talentPoolConsent: form.get("talent_pool") === "yes",
answers: [
{ title: "About you", rows: [
{ key: "first_name", label: "First name", value: form.get("first_name") },
{ key: "work_auth_us", label: "Are you authorized to work in the U.S.?", value: form.get("work_auth_us") },
] },
],
submittedAt: new Date().toISOString(),
},
[{ field: "resume", file: form.get("resume") }].filter((u) => u.file && u.file.size > 0),
);A good externalId is the same for a retry of one submission and different for a genuinely new one. For example, render a hidden timestamp or random token into the form, then hash it together with the site id and the lowercased email. If your site should keep working while ATS is unreachable, catch the error, log it, and retry later with the same externalId.
On Cloudflare, a Pages Function on the same account can call env.ATS.fetch(…) through a Service Binding instead of fetch.
Test it with curl and openssl#
A quick end-to-end check from a shell. Set SECRET to your INGEST_SECRET.
SECRET='paste-your-ingest-secret'
URL='https://ats.example.com/ats/ingest'
cat > payload.json <<'EOF'
{"externalId":"curl-test-0001","source":"curl",
"site":{"id":"main-office","name":"Main office"},
"positionTitle":"Test role",
"applicant":{"email":"test.applicant@example.com","firstName":"Test","lastName":"Applicant"},
"answers":[{"title":"About you","rows":[{"key":"why","label":"Why us?","value":"Testing the integration."}]}]}
EOF
TS=$(date +%s)
PAYLOAD_SHA=$(openssl dgst -sha256 -r payload.json | cut -d' ' -f1)
SIG=$(printf '%s.%s' "$TS" "$PAYLOAD_SHA" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -sS "$URL" \
-H "X-ATS-Timestamp: $TS" \
-H "X-ATS-Signature: $SIG" \
-F "payload=<payload.json"-F "payload=<payload.json" sends the file's contents as a text part, byte for byte, so the hash matches. Run it twice: the second answer has "duplicate": true.
With a resume, declare it in the payload and attach it under the same field:
SIZE=$(wc -c < resume.pdf | tr -d ' ')
FILE_SHA=$(openssl dgst -sha256 -r resume.pdf | cut -d' ' -f1)
cat > payload.json <<EOF
{"externalId":"curl-test-0002","site":{"id":"main-office","name":"Main office"},
"applicant":{"email":"test.applicant@example.com","firstName":"Test","lastName":"Applicant"},
"answers":[],
"files":[{"field":"resume","filename":"resume.pdf","contentType":"application/pdf","size":$SIZE,"sha256":"$FILE_SHA"}]}
EOF
TS=$(date +%s)
PAYLOAD_SHA=$(openssl dgst -sha256 -r payload.json | cut -d' ' -f1)
SIG=$(printf '%s.%s' "$TS" "$PAYLOAD_SHA" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -sS "$URL" -H "X-ATS-Timestamp: $TS" -H "X-ATS-Signature: $SIG" \
-F "payload=<payload.json" -F "resume=@resume.pdf;type=application/pdf"Errors#
Errors are JSON: { "error": { "code": "…", "message": "…", "issues": [{ "path", "message" }] } }.
| Status | Code | Meaning |
|---|---|---|
| 401 | invalid_signature | Headers missing or malformed, timestamp outside the 5-minute window (check the sender's clock), or wrong signature. |
| 400 | validation_failed | Missing payload part, invalid JSON, or a field outside the limits (see issues). |
| 400 | file_mismatch | A declared file is missing or doesn't match its hash and size, or an undeclared file was sent. |
| 400 | bad_request | Unparseable multipart body, or too many parts. |
| 409 | replayed | The same signed request without an externalId arrived twice. |
| 413 | file_too_large, payload_too_large | A file is over MAX_FILE_BYTES, or the whole request is too big. |
| 415 | unsupported_media_type | Not multipart/form-data. |
Retry network errors and 5xx responses with the same externalId and a fresh signature. Don't retry 4xx responses unchanged.