# CraftMyResume Business API > Assess a resume against a job's requirements and get evidence quoted from the resume. Assessments are asynchronous: start one, then receive a signed callback or check its status. Availability: only the free sandbox is open today. It accepts two fixed fake resumes (`fixture_id`) and charges nothing. Real resume processing is not open yet, so do not build anything that sends real resumes to this API. ## Rules for AI coding assistants - Keep the API key on the server, in an environment variable. Never put it in browser code, mobile code, URLs, logs or a repository. - Do not invent endpoints or fields. If something is not in this file or the OpenAPI spec, say it is unavailable. - A score supports a recruiter's review. Do not auto-reject or rank out candidates from a score alone. - Treat a null `score` as "not enough evidence", never as zero. - Show a warning when any requirement's `state` is `contradictory`. ## Basics - Base URL: `https://www.craftmyresume.org/api/business/v1` - Auth: `Authorization: Bearer $CMR_BUSINESS_KEY`. Create a sandbox key at https://www.craftmyresume.org/business/keys - OpenAPI spec: https://www.craftmyresume.org/business-openapi.json - Human docs: https://www.craftmyresume.org/developers/business-api ## Flow 1. `POST /assessments` with `{"fixture_id":"backend-evidenced"}` (or `backend-sparse`) and an `Idempotency-Key` header of 16 to 128 characters from `A-Z a-z 0-9 _ -`. Answers 202 with a job. 2. Wait for the signed callback, or poll `GET /jobs/{id}` no more than once every 5 seconds. 3. When `processing_state` is `succeeded`, call `GET /jobs/{id}/result`. Retrying with the same key and body returns the original job, so retries never create a second assessment. ```bash curl https://www.craftmyresume.org/api/business/v1/assessments \ -H "Authorization: Bearer $CMR_BUSINESS_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-12345-attempt-0001" \ -d '{"fixture_id":"backend-evidenced"}' ``` ## Endpoints | Method | Path | Key permission | | --- | --- | --- | | GET | /identity | jobs:read | | GET | /usage | usage:read | | POST | /assessments | assess:write | | GET | /jobs/{id} | jobs:read | | POST | /jobs/{id}/cancel | assess:write | | GET | /jobs/{id}/result | results:read | | DELETE | /jobs/{id}/content | content:delete | `/samples` routes also exist for older integrations. Use `/assessments` and `/jobs` for new work. ## Result - `status`: `scored` or `insufficient_evidence`. - `score`: 0 to 100, or null when weighted coverage is under 60%. - `coverage`: share of the rubric's weight with a usable answer. - `requirements[]`: `requirement_id`, `state`, `explanation`, `evidence[]`, `mandatory_status`. - `state`: `evidenced`, `partially_evidenced`, `contradictory` or `not_evidenced`. - `evidence[]`: `quote`, plus `start` and `end` offsets counted in Unicode code points, not bytes or UTF-16 units. ## Callbacks Add an address at https://www.craftmyresume.org/business/webhooks and press "Send test". It must be public HTTPS on port 443, and it receives events only after a test succeeds. Save the signing secret when it is shown, because it is shown once. ```json { "event_id": "9f1c0000-0000-4000-8000-000000000000", "type": "assessment.completed", "created_at": "2026-10-31T10:00:00.000Z", "data": { "job_id": "...", "status": "succeeded", "source": "api", "environment": "sandbox" } } ``` - `data.status`: `succeeded` or `failed`. `data.source`: `api` or `dashboard`. Dashboard events also carry `hiring_job_id` and `candidate_id`. - Events hold identifiers and status only. Fetch results with the API key. - Answer with a 2xx status within 10 seconds, and do the slow work afterwards. Redirects are not followed. - Failed deliveries retry six times in all, over about seven hours. Events can arrive more than once or out of order, so deduplicate on `event_id`. - A test event has `type` `webhook.test`. Answer it with 2xx and otherwise ignore it. - Always verify `X-CMR-Signature` against the raw body before parsing: ```js import { createHmac, timingSafeEqual } from "node:crypto"; function isGenuine(secret, signatureHeader, rawBody) { const parts = signatureHeader.split(",").map((p) => p.split("=")); const timestamp = Number(parts.find(([k]) => k === "t")?.[1]); if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > 300000) return false; const expected = Buffer.from(createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex")); return parts.some(([k, v]) => k === "v1" && v && Buffer.byteLength(v) === expected.length && timingSafeEqual(Buffer.from(v), expected)); } ``` The header can hold two `v1` values for 24 hours after a secret is replaced. Either one matching is valid. ## Errors `/assessments` and `/jobs` errors look like this: ```json { "error": { "code": "rate_limited", "message": "Assessment request could not be completed", "request_id": "b2b_abc", "retryable": true } } ``` | Status | Meaning | What to do | | --- | --- | --- | | 400 | Invalid request | Fix the request | | 401 | Invalid key | Check the key; do not retry | | 403 | Key lacks permission | Use a key with the permission in the table above | | 404 | Not found in this organization | Do not retry | | 409 | Key reused with a different body, or sample limit reached | Use a new key, or stop | | 410 | Result expired or deleted | Do not retry | | 429 | Rate limited | Wait for `Retry-After` | | 503 | Temporarily unavailable | Retry with backoff and the same `Idempotency-Key` | ## Limits - 60 requests per minute per organization, and ten lifetime sample runs per organization. - Two active sandbox keys, each expiring after 30 days. - Sample results expire after 24 hours.