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#

  1. 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.

  2. Sign it

    Serialize the payload once and sign that exact string:

    Text
    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.

  3. Send it as multipart/form-data

    One text part named payload containing the JSON string, plus one file part per upload whose part name is the form field (for example resume).

    Text
    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>
  4. Read the response

    201 with { "applicationId": "…", "candidateId": "…", "duplicate": false }. If the same externalId arrives 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 files must 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 with 409 (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.

FieldTypeNotes
externalIdstring, optionalUp to 200 chars. Your stable id for this submission; makes retries idempotent. Always send one.
sourcestring, optionalUp 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.
formIdstring, optionalUp to 100 chars. Which of your forms it came from.
positionTitlestring, optionalUp to 200 chars. If an open job at that site has exactly this title, the application is linked to it.
applicant.emailstringValid email, up to 254 chars. Applications with the same email belong to the same candidate.
applicant.firstName, .lastNamestring1–100 chars each.
applicant.phone, .city, .statestring, optionalUp to 40 / 100 / 100 chars.
talentPoolConsentboolean, optionalThe 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 }[], optionalfield: letters, digits, _ and -, 1–60 chars; sha256: 64 lowercase hex chars.
submittedAtISO 8601 string, optionalWith 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 defaults convicted* and work_auth* mask keys such as convicted_felony or work_auth_us until 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.

ats-client.js
// 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#

in your form handler, after validation
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.

Terminal
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:

Terminal
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" }] } }.

StatusCodeMeaning
401invalid_signatureHeaders missing or malformed, timestamp outside the 5-minute window (check the sender's clock), or wrong signature.
400validation_failedMissing payload part, invalid JSON, or a field outside the limits (see issues).
400file_mismatchA declared file is missing or doesn't match its hash and size, or an undeclared file was sent.
400bad_requestUnparseable multipart body, or too many parts.
409replayedThe same signed request without an externalId arrived twice.
413file_too_large, payload_too_largeA file is over MAX_FILE_BYTES, or the whole request is too big.
415unsupported_media_typeNot multipart/form-data.

Retry network errors and 5xx responses with the same externalId and a fresh signature. Don't retry 4xx responses unchanged.