Blue-IQ Capture API

Send a document, get back structured JSON with a confidence score on every field. One flow for every file: submit, then poll or take a webhook. Nothing parses on the request path, so a call never blocks and never trips a gateway timeout.

Base URLhttps://api.parsinglab.blue-iq.ai
EndpointWhat it does
POST /resume/parseSubmit one document. Returns a job_id.
GET /resume/job/{job_id}Poll a job until it reaches a terminal status.
POST /resume/upload-urlGet a presigned URL for a direct upload.
POST /resume/parse-uploadedParse a file already uploaded via that URL.
POST /resume/batchSubmit up to 200 documents in one request.
GET /resume/batch/{batch_id}Poll a batch.
POST /resume/{job_id}/retryRe-run a parse.
POST /resume/{job_id}/feedbackSend corrections back.
POST /webhooksRegister a delivery endpoint.
GET /webhooksList your endpoints.
DELETE /webhooks/{webhook_id}Remove one.
GET /healthService and dependency status.
01

Quickstart

Three calls: get a key, submit a file, poll until it is done.

Shell
# 1. Submit
curl -X POST "https://api.parsinglab.blue-iq.ai/api/v1/resume/parse" \
  -H "X-API-Key: rp_live_your_key" \
  -F "file=@resume.pdf"

# -> { "job_id": "01J3K...", "status": "processing",
#      "poll_url": "/api/v1/resume/job/01J3K..." }

# 2. Poll until status is terminal
curl "https://api.parsinglab.blue-iq.ai/api/v1/resume/job/01J3K..." \
  -H "X-API-Key: rp_live_your_key"

Generate a key in the dashboard. It is shown once, so copy it then. Use it only from your server.

02

Authentication

Every request carries your key in the X-API-Key header. There are no other auth schemes on the parsing endpoints.

Header
X-API-Key: rp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CodeMeaning
MISSING_API_KEYNo X-API-Key header was sent.
INVALID_API_KEYThe key is not recognised.
REVOKED_API_KEYThe key was revoked in the dashboard.
ACCOUNT_DEACTIVATEDThe workspace is disabled.
03

Parse a document

POST /api/v1/resume/parse with multipart/form-data and one file field. Accepts PDF, DOCX, RTF, PNG, JPG and TIFF up to 10 MB.

Shell
curl -X POST "https://api.parsinglab.blue-iq.ai/api/v1/resume/parse" \
  -H "X-API-Key: rp_live_your_key" \
  -F "file=@resume.pdf" \
  -F "force_textract=false"
FieldMeaning
fileThe document. Required.
force_textractSkip Tesseract and use AWS Textract for any OCR this file needs. Higher accuracy on hard scans, higher cost. Default false.

The response is immediate and never contains the parsed record:

JSON
{
  "job_id":   "01J3K5M2N4P6Q8R0S2T4U6V8W0",
  "status":   "processing",
  "poll_url": "/api/v1/resume/job/01J3K5M2N4P6Q8R0S2T4U6V8W0"
}
Every parse is asynchronous. This endpoint used to return the record inline for digital PDFs and DOCX. It no longer does, for any file type. If your integration reads data from the POST response, switch it to polling or a webhook. The old async_only flag is ignored.
04

Poll the job

GET /api/v1/resume/job/{job_id} until status is terminal. A sensible loop polls every 2 seconds and gives up after a couple of minutes.

StatusMeaning
processingStill working. The only non-terminal status. Poll again.
completedDone. data and confidence are populated.
partialDegraded. Some data recovered; check warnings before trusting it.
failedCould not parse. error explains why; error_code is the reason to branch on (NOT_A_RESUME: the file was empty or not a resume).
One request is not a poll loop. A typical parse takes 10-30 seconds, so a single poll straight after submitting will correctly say processing — that is not a failure and not an end state. Loop until you see a terminal status, and treat any status you do not recognise as non-terminal rather than as an end state.
Results expire after an hour. The jobs table carries a TTL, so a job_id is not a permanent handle and an old one returns JOB_NOT_FOUND — the document has to be submitted again. Persist the record on your side as soon as you receive it.
05

The parsed record

A completed job returns the record under data, per-section scores under confidence, and any caveats under warnings.

JSON
{
  "job_id": "01J3K...",
  "status": "completed",
  "data": {
    "personal_info": { "full_name": "Jane Smith", "email": "jane@example.com",
                       "phone": "865-541-1111", "credentials": ["RN", "BSN"] },
    "experience": [
      { "company": "Fort Sanders Regional Medical Center",
        "role": "RN - Med Surg/Tele",
        "start_date": "01/2022", "end_date": "Present",
        "city": "Knoxville", "state": "TN", "state_id": "42",
        "profession": "RN", "profession_id": "1",
        "specialties": [
          { "name": "Med Surg/Tele", "specialty_id": "88", "confidence": 1.0 }
        ],
        "description": ["Charge nurse on a 30-bed telemetry unit"] }
    ],
    "education":      [{ "institution": "University of Tennessee",
                         "degree": "BSN", "graduation_year": 2021 }],
    "certifications": [{ "name": "BLS", "issued_date": "01/2024" }],
    "licenses":       [{ "license_type": "RN", "state": "TN", "is_compact": true }]
  },
  "confidence": { "overall": 0.9, "experience": 1.0, "catalog_mapping": 0.8 },
  "partial": false,
  "warnings": []
}

Reading the scores

Each specialty, profession and location resolves to a platform id with its own confidence. An unresolved id comes back null rather than a guess, so routing on specialty_id === null is a reliable review trigger.

FieldWhat it holds
dataThe record. Every section is present; empty ones are empty, not missing.
confidence.overall0-1 across the whole record.
confidence.catalog_mappingHow much of the record resolved to platform ids.
partialtrue when the parse degraded. Treat the record as reviewable.
warningsHuman-readable caveats, e.g. a duty list that looks short.

The record is cleaned before it is returned. An entry the resume repeats (a role, degree, certification, license, skill or bullet) appears once, keeping every value either copy held; entries that differ on dates or a license number stay separate. Clear typos are corrected, but names, employers, schools, places, emails, URLs and license numbers are never changed.

06

Large files

For anything near the request limit, upload straight to storage and hand back the key. Two calls: ask for a presigned URL, PUT the bytes, then parse it.

Shell
# 1. Ask for somewhere to put it
curl -X POST "https://api.parsinglab.blue-iq.ai/api/v1/resume/upload-url" \
  -H "X-API-Key: rp_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{"filename":"scan.pdf"}'

# 2. PUT the bytes at the returned URL, then:
curl -X POST "https://api.parsinglab.blue-iq.ai/api/v1/resume/parse-uploaded" \
  -H "X-API-Key: rp_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{"upload_id":"..."}'
07

Batch

POST /api/v1/resume/batch takes up to 200 files, or 60 MB across the whole request, whichever comes first. It returns 202 with a batch_id; poll GET /api/v1/resume/batch/{batch_id} for per-file status.

Shell
curl -X POST "https://api.parsinglab.blue-iq.ai/api/v1/resume/batch" \
  -H "X-API-Key: rp_live_your_key" \
  -F "files=@one.pdf" -F "files=@two.docx"
08

Webhooks

Register an endpoint and skip polling entirely. events is required and must list at least one event. The response includes hmac_secret once — it is never retrievable afterwards and there is no rotate endpoint, so store it before you close the response.

Shell
curl -X POST "https://api.parsinglab.blue-iq.ai/api/v1/webhooks" \
  -H "X-API-Key: rp_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://your-app.example.com/hooks/capture",
       "events":["parse.completed","parse.failed"]}'
EventFires when
parse.completedA single parse finished successfully.
parse.failedA single parse failed. Carries error and error_code (e.g. NOT_A_RESUME).
batch.completedEvery file in a batch reached a terminal status.
Register each endpoint once. The secret belongs to the registration, not to your account. Every active registration receives every event it subscribes to, each signed with its own secret — so if the same URL is registered twice, the secret you saved verifies only one of the two and the other fails every time. List your registrations with GET /api/v1/webhooks and delete strays with DELETE /api/v1/webhooks/{webhook_id}.

Verifying a delivery

Each request carries three headers:

HeaderValue
X-Signaturesha256=<hex> - see the signed message below. Not the body alone.
X-TimestampUnix seconds at send time. Part of the signed message.
X-EventThe event name from the table above.

The signed message is the timestamp, a literal dot, then the raw body — HMAC_SHA256(secret, X-Timestamp + "." + raw_body). Two details decide whether this works, and each one on its own causes every delivery to fail:

DetailWhy it matters
Include the timestamp prefixSigning the body alone never matches. The timestamp is what makes a captured delivery unreplayable.
Use the RAW body bytesCapture the body before any JSON middleware touches it. Re-serialising a parsed object changes the bytes (separators, key order), so the digest changes even with the correct secret.
JavaScript
// Node / Express - note express.raw, NOT express.json
const crypto = require("crypto");

app.post("/hooks/capture",
  express.raw({ type: "application/json" }),   // req.body stays a Buffer
  (req, res) => {
    const ts  = req.get("X-Timestamp");
    const sig = req.get("X-Signature");

    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);

    const expected = "sha256=" + crypto
      .createHmac("sha256", process.env.BLUEIQ_WEBHOOK_SECRET)
      .update(ts + ".")           // <-- the timestamp prefix
      .update(req.body)           // <-- the RAW bytes
      .digest("hex");

    const ok = expected.length === sig.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
    if (!ok) return res.sendStatus(401);

    const event = JSON.parse(req.body.toString());   // parse AFTER verifying
    res.sendStatus(202);                             // ack fast, work async
  });
Python
# Python / FastAPI
import hashlib, hmac, time

@app.post("/hooks/capture")
async def capture(request: Request):
    raw = await request.body()               # raw bytes, before any parsing
    ts  = request.headers["X-Timestamp"]
    if abs(time.time() - int(ts)) > 300:
        raise HTTPException(400, "stale delivery")

    message  = f"{ts}.".encode() + raw
    expected = "sha256=" + hmac.new(
        SECRET.encode(), message, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, request.headers["X-Signature"]):
        raise HTTPException(401, "invalid signature")

    event = json.loads(raw)
    return Response(status_code=202)
A 4xx is never retried. We treat any non-5xx reply as delivered, so a 401 from a signature mismatch discards that event permanently — nothing is queued for later. Alert on rejections rather than only logging them, and keep a reconcile pass that polls for any job_id you never got a delivery for. Return 5xx if you do want a retry (≈2s, 5s, 10s).
Deliveries can arrive before your own bookkeeping settles, and the same event can arrive more than once. Persist the job_id from the submit response first, make the handler idempotent on job_id, and tolerate an unknown job_id (upsert, or buffer and retry the lookup) instead of dropping it. Retries of one event reuse a single timestamp and signature, so the signature is a usable dedupe key.
09

Retry & feedback

POST /api/v1/resume/{job_id}/retry re-runs a parse, for a job that failed on something transient. Repeated retries return RETRY_LIMIT_REACHED.

POST /api/v1/resume/{job_id}/feedback sends corrections back and returns 202. Corrections a reviewer makes are what improve extraction over time, so it is worth wiring up if you have a review step.

10

Errors

Every error returns the same shape, with a stable machine-readable error_code. Branch on the code, not the message; hint is safe to show to end users.

JSON
{
  "error": {
    "status_code": 415,
    "error_code": "UNSUPPORTED_FILE_TYPE",
    "detail": "Unsupported file extension '.txt'.",
    "hint": "This file type is not supported...",
    "request_id": "a1b2c3d4-..."
  }
}
CodeHTTPMeaning
MISSING_API_KEY401No X-API-Key header on the request.
INVALID_API_KEY401The key is not recognised.
REVOKED_API_KEY401The key was revoked in the dashboard.
ACCOUNT_DEACTIVATED403The workspace is disabled.
FILE_TOO_LARGE413Over the 10 MB per-file limit.
UNSUPPORTED_FILE_TYPE415Not a PDF, DOCX, RTF, PNG, JPG or TIFF.
SERVICE_UNAVAILABLE503Storage was briefly unavailable. Request a new upload URL and retry.
NOT_A_RESUME422The file is empty, or it is not a resume. One code for both.
EMPTY_BATCH422A batch request with no files.
BATCH_TOO_LARGE413Over 200 files or 60 MB in one batch.
JOB_NOT_FOUND404Unknown job_id, or the result has expired.
BATCH_NOT_FOUND404Unknown batch_id.
WEBHOOK_NOT_FOUND404Unknown webhook_id.
RETRY_LIMIT_REACHED429This job has been retried too many times.
EXTRACTION_FAILED422No text could be read from the file.
OCR_FAILED422OCR could not read the scan.
PARSE_FAILED500The parse stage failed after extraction.
VALIDATION_ERROR422A field on the request did not validate.
Wrong document? One code. NOT_A_RESUME covers both an empty file and a file that is not a resume (a job description, cover letter, invoice...). A 0-byte upload gets it straight back from the submit as 422. Anything else is checked after its text is read, so it arrives as a failed job whose poll response and parse.failed webhook carry error_code: "NOT_A_RESUME". Retrying the same file will not help.
11

Limits

LimitValue
File size10 MB per document
Batch size200 files per request
Batch payload60 MB per request
FormatsPDF, DOCX, RTF, PNG, JPG, TIFF
Job resultsExpire on a TTL - store what you need

GET /api/v1/health reports service status and dependency health (DynamoDB, S3, the async worker) if you want to monitor it.