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.
https://api.parsinglab.blue-iq.ai| Endpoint | What it does |
|---|---|
| POST /resume/parse | Submit one document. Returns a job_id. |
| GET /resume/job/{job_id} | Poll a job until it reaches a terminal status. |
| POST /resume/upload-url | Get a presigned URL for a direct upload. |
| POST /resume/parse-uploaded | Parse a file already uploaded via that URL. |
| POST /resume/batch | Submit up to 200 documents in one request. |
| GET /resume/batch/{batch_id} | Poll a batch. |
| POST /resume/{job_id}/retry | Re-run a parse. |
| POST /resume/{job_id}/feedback | Send corrections back. |
| POST /webhooks | Register a delivery endpoint. |
| GET /webhooks | List your endpoints. |
| DELETE /webhooks/{webhook_id} | Remove one. |
| GET /health | Service and dependency status. |
Quickstart
Three calls: get a key, submit a file, poll until it is done.
# 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.
Authentication
Every request carries your key in the X-API-Key header. There are no other auth schemes on the parsing endpoints.
X-API-Key: rp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx| Code | Meaning |
|---|---|
| MISSING_API_KEY | No X-API-Key header was sent. |
| INVALID_API_KEY | The key is not recognised. |
| REVOKED_API_KEY | The key was revoked in the dashboard. |
| ACCOUNT_DEACTIVATED | The workspace is disabled. |
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.
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"| Field | Meaning |
|---|---|
| file | The document. Required. |
| force_textract | Skip 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:
{
"job_id": "01J3K5M2N4P6Q8R0S2T4U6V8W0",
"status": "processing",
"poll_url": "/api/v1/resume/job/01J3K5M2N4P6Q8R0S2T4U6V8W0"
}data from the POST response, switch it to polling or a webhook. The old async_only flag is ignored.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.
| Status | Meaning |
|---|---|
| processing | Still working. The only non-terminal status. Poll again. |
| completed | Done. data and confidence are populated. |
| partial | Degraded. Some data recovered; check warnings before trusting it. |
| failed | Could not parse. error explains why; error_code is the reason to branch on (NOT_A_RESUME: the file was empty or not a resume). |
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.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.The parsed record
A completed job returns the record under data, per-section scores under confidence, and any caveats under warnings.
{
"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.
| Field | What it holds |
|---|---|
| data | The record. Every section is present; empty ones are empty, not missing. |
| confidence.overall | 0-1 across the whole record. |
| confidence.catalog_mapping | How much of the record resolved to platform ids. |
| partial | true when the parse degraded. Treat the record as reviewable. |
| warnings | Human-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.
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.
# 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":"..."}'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.
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"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.
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"]}'| Event | Fires when |
|---|---|
| parse.completed | A single parse finished successfully. |
| parse.failed | A single parse failed. Carries error and error_code (e.g. NOT_A_RESUME). |
| batch.completed | Every file in a batch reached a terminal status. |
GET /api/v1/webhooks and delete strays with DELETE /api/v1/webhooks/{webhook_id}.Verifying a delivery
Each request carries three headers:
| Header | Value |
|---|---|
| X-Signature | sha256=<hex> - see the signed message below. Not the body alone. |
| X-Timestamp | Unix seconds at send time. Part of the signed message. |
| X-Event | The 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:
| Detail | Why it matters |
|---|---|
| Include the timestamp prefix | Signing the body alone never matches. The timestamp is what makes a captured delivery unreplayable. |
| Use the RAW body bytes | Capture 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. |
// 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 / 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)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).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.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.
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.
{
"error": {
"status_code": 415,
"error_code": "UNSUPPORTED_FILE_TYPE",
"detail": "Unsupported file extension '.txt'.",
"hint": "This file type is not supported...",
"request_id": "a1b2c3d4-..."
}
}| Code | HTTP | Meaning |
|---|---|---|
| MISSING_API_KEY | 401 | No X-API-Key header on the request. |
| INVALID_API_KEY | 401 | The key is not recognised. |
| REVOKED_API_KEY | 401 | The key was revoked in the dashboard. |
| ACCOUNT_DEACTIVATED | 403 | The workspace is disabled. |
| FILE_TOO_LARGE | 413 | Over the 10 MB per-file limit. |
| UNSUPPORTED_FILE_TYPE | 415 | Not a PDF, DOCX, RTF, PNG, JPG or TIFF. |
| SERVICE_UNAVAILABLE | 503 | Storage was briefly unavailable. Request a new upload URL and retry. |
| NOT_A_RESUME | 422 | The file is empty, or it is not a resume. One code for both. |
| EMPTY_BATCH | 422 | A batch request with no files. |
| BATCH_TOO_LARGE | 413 | Over 200 files or 60 MB in one batch. |
| JOB_NOT_FOUND | 404 | Unknown job_id, or the result has expired. |
| BATCH_NOT_FOUND | 404 | Unknown batch_id. |
| WEBHOOK_NOT_FOUND | 404 | Unknown webhook_id. |
| RETRY_LIMIT_REACHED | 429 | This job has been retried too many times. |
| EXTRACTION_FAILED | 422 | No text could be read from the file. |
| OCR_FAILED | 422 | OCR could not read the scan. |
| PARSE_FAILED | 500 | The parse stage failed after extraction. |
| VALIDATION_ERROR | 422 | A field on the request did not validate. |
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.Limits
| Limit | Value |
|---|---|
| File size | 10 MB per document |
| Batch size | 200 files per request |
| Batch payload | 60 MB per request |
| Formats | PDF, DOCX, RTF, PNG, JPG, TIFF |
| Job results | Expire 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.