Metadata-Version: 2.4
Name: recordorg-bluecollarverificationauth
Version: 1.1.0b1
Summary: Official Python SDK for Record Blue / Grey Collar Verification - open a verification session from your server
License-Expression: LicenseRef-Proprietary
Keywords: record,verification,blue-collar,grey-collar,sdk
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"

# recordorg-bluecollarverificationauth

Backend SDK for **Record Blue / Grey Collar Verification**, for Python. It runs on **your server** and does two things: opens a verification session for one click of "Send verification" in your platform, and later **hands you the results**. You hand the session token to your page, and the frontend SDK (`@recordorg/bluecollarverification`) opens Record's popup with it.

```
your server  --(this SDK, signed)-->  Record   POST /verify/verification/init  -->  { sessionToken }
your page    --(frontend SDK)------>  Record's popup, in an iframe, opened with the token
your server  --(this SDK, signed)-->  Record   GET /verify/events  (you pull the results, then acknowledge them)
```

The recruiter then picks or creates a job mandate in the popup and sends WhatsApp / SMS / voice verification to the candidates you passed. Campaigns and delivery status show up in the Record playground.

Python 3.9 or later. No runtime dependencies (standard library only).

## Install

```bash
pip install recordorg-bluecollarverificationauth
```

## Use

```python
import os
from bluecollarverificationauth import BlueCollarSDK

record = BlueCollarSDK(
    api_key=os.environ["RECORD_API_KEY"],        # the public key id: VFN_LIVE_... or VFN_TEST_...
    secret_key=os.environ["RECORD_SECRET_KEY"],  # VFN_SK_...: signs requests, never sent, never in the browser
)

# Your route: the recruiter clicked "Send verification" on these candidates (Flask shown).
@app.post("/api/record/session")
def create_session():
    candidates = load_selected_candidates(request)  # your own records; only name + phoneNumber (+ source) are forwarded

    result = record.init_verification({
        "workflowId": "WF_BG_...",                  # required: an active blue / grey collar workflow from your Record account
        "candidates": [{"name": c.full_name, "phoneNumber": c.phone} for c in candidates],
    })

    if not result.success:
        return {"code": result.error.code, "message": result.error.message}, 502

    return {"sessionToken": result.data["sessionToken"]}  # your page passes this to the frontend SDK's open()
```

The keys come from the Record playground (Settings, API keys). Use a `VFN_TEST_` pair while you build: it opens sessions in test mode.

Methods are `snake_case`. The dicts you pass in and the `data` you get back keep Record's own `camelCase` keys (`workflowId`, `phoneNumber`, `sessionToken`, `eventId`, ...), so they match the API and the frontend SDK exactly. Their shapes are described by exported `TypedDict`s for your type checker.

## `init_verification(payload)`

| Field | | |
| --- | --- | --- |
| `workflowId` | **required** | The Record workflow the verification runs under. It decides everything else. |
| `candidates` | **required**, 1 to 2000 | `name` and `phoneNumber` (8 to 20 characters, any usual format), optional `source: {"provider", "providerRecordId", "module"?}`. **The session can contact only these people.** |

That is the whole call: the recruiter enters the job details in the popup, so there is nothing else to pass.

Only `workflowId` and each candidate's `name`, `phoneNumber` and `source` are sent. Anything else on the dicts you pass (an email, a resume link) stays on your server.

It returns a `RecordResponse` and **does not raise** for a refused request:

```python
RecordResponse(success=True,  data={"sessionToken", "expiresAt", "expiresIn"}, status_code=201)
RecordResponse(success=False, error=RecordError(code, message, details), status_code=...)
```

A session token lasts **2 hours** (`expiresIn` is in seconds) and is the only thing your page ever holds. Open a fresh session for every click.

### Errors

| `error.code` | When |
| --- | --- |
| `MISSING_WORKFLOW_ID`, `MISSING_CANDIDATES`, `TOO_MANY_CANDIDATES`, `INVALID_CANDIDATE`, `INVALID_FORMAT` | The payload was wrong; nothing was sent. `message` names the field (for example `candidates[1].phoneNumber must be 8 to 20 characters`). |
| `VALIDATION_FAILED` | Record refused the payload. `details` lists each field: `[{"path": "body.workflowId", "message": ...}]`. Also the answer when `workflowId` is not an active blue / grey collar workflow of your key's organisation, mode and project ("workflowId does not match an active blue-collar workflow for this key"). |
| `UNAUTHORIZED` | Wrong or inactive key, wrong secret key, or your server's clock is more than 5 minutes off. |
| `FORBIDDEN`, `NOT_FOUND`, `RATE_LIMITED`, `SERVER_ERROR`, `REQUEST_FAILED` | Anything else Record answers with (`message` says what). Retry later for `RATE_LIMITED` and `SERVER_ERROR`. |
| `TIMEOUT`, `NETWORK_ERROR` | Record did not answer (`timeout` option, 30 s by default), or could not be reached. |
| `INVALID_RESPONSE` | Record answered without a session token. |

## Getting the results

Record does not call your server: **you pull the results**, whenever you like. As each candidate replies, cannot be reached or finishes, Record files an event under your API key. Read the events, store them, then acknowledge them so Record deletes them. Events are kept for **30 days**, and only candidates of sessions opened with your key produce them.

```python
# Simplest: poll, and let the SDK acknowledge what your handler stored.
def handle(e):
    if e["event"] == "candidate.completed":
        save_result(e["candidate"].get("source", {}).get("providerRecordId"), e["data"]["result"])

poller = record.poll_events(handle, on_error=lambda err, event=None: print("record events:", err))

# On shutdown:
poller.stop()
```

`poll_events` runs in a background (daemon) thread and calls your handler there, one event at a time. `poller.stop()` waits for the check in progress to finish; the poller is also a context manager (`with record.poll_events(handle) as poller: ...`).

Or by hand:

```python
res = record.get_events(limit=50)          # oldest first; reading does not remove them
if res.success:
    for e in res.data["events"]:
        save_result(e)
    record.acknowledge_events([e["eventId"] for e in res.data["events"]])  # now Record deletes them
```

**Delivery is at least once.** If acknowledging fails, or your handler raises (`poll_events` then leaves that event unacknowledged), the event comes again: use `eventId`, which is unique, to ignore one you already handled.

### The events

| `event` | When | `data` |
| --- | --- | --- |
| `candidate.accepted` | The candidate answered Yes on WhatsApp. | `{"via": "whatsapp"}` |
| `candidate.declined` | The candidate said no, on WhatsApp or on the call. | `{"via": "whatsapp" \| "call"}` |
| `candidate.failed` | The message could not be delivered. | `{"reason": ...}` |
| `candidate.completed` | A phone or video verification finished and was graded. | `{"via": "call" \| "video", "result": ...}` |

Every event also has `eventId`, `occurredAt`, `mode`, `campaignId`, `workflowId` and `candidate: {candidateId, name, phoneNumber, source?}`. `source` is the id you passed when opening the session, so you can match the event to your own record.

`result` is `{verdict, totalScore, isPartial, skills: [{skillName, score, maxScore}], channel, reportUrl}`: the verdict ("Good Fit", "Maybe" or "Not a Fit"; `None` when the candidate answered only some of the questions), the score out of 100, the score per skill, and `reportUrl`, the link to Record's result page for that candidate. It never contains what the candidate said.

`reportUrl` opens without a sign-in and shows the **public version** of the report (the candidate's name is masked), so anyone who has the link can open it: treat it like a private link. It is also on every candidate's `result` in `get_campaign_results`, once that candidate has one. When a candidate has both a video and a call result, the link opens the more recent one, and so does `result`.

> A candidate who verifies by **video** produces a `candidate.completed` event too (`data["via"]` is `"video"`). If you ever miss one, `get_campaign_results` always has the result.

### `get_campaign_results(campaign_id)`

Where every candidate of a campaign stands now (`status`, `sentAt`, `completedAt`, `deliveryError`, and `result` once there is one), for both phone and video verifications. It reads Record's database, not the event queue, so you can ask again at any time: use it to catch up after downtime, to check a result you missed, or to read a video result. The `campaignId` is in every event, and in the popup's `onSuccess` data.

### Errors of the results calls

`get_events`, `acknowledge_events` and `get_campaign_results` return the same `RecordResponse` as `init_verification` and never raise. Besides the codes above: `INVALID_OPTIONS` (a `limit` outside 1 to 100, an empty or oversized `event_ids`), `MISSING_CAMPAIGN_ID`, and `SERVER_ERROR` with status `503` when results are temporarily unavailable: try again shortly (`poll_events` does). `NOT_FOUND` means the campaign is not one of your organisation's.

## Options

`BlueCollarSDK(api_key, secret_key, base_url=None, timeout=30)`

| Option | Default | |
| --- | --- | --- |
| `api_key` | required | Public key id. A secret key here is refused (`ValueError`). |
| `secret_key` | required | Signs requests. |
| `base_url` | built in, by the key's prefix (test or live) | The Record verification backend's `/api/v1` URL. Leave it out: the SDK knows Record's endpoints. |
| `timeout` | `30` | **Seconds** (the Node SDK takes milliseconds). Applies to each socket operation of a request. |

`poll_events(handler, *, interval=5, limit=None, on_error=None)`: `interval` is in **seconds** (at least 1), `limit` is 1 to 100 events per request, and `on_error(error, event=None)` is called when a check fails or your handler raises. The polling carries on.

`record.mode` is `"test"` for a `VFN_TEST_` key, otherwise `"live"`.

> **For maintainers:** the endpoints are the `LIVE_BASE_URL` / `TEST_BASE_URL` constants in `src/bluecollarverificationauth/client.py`. Both currently point at the deployed backend (`https://sdk-unified-playground-backend-350784069068.asia-south1.run.app/api/v1`). Change both together if the backend moves.

## Partner API (`RecordPartner`)

For a platform that runs the whole flow from its own backend (an ATS such as Randstad's): **no popup and no frontend SDK**. `RecordPartner` is the client for Record's Partner API: sync the skill taxonomy, upsert job mandates, create campaigns, read results and analytics, and receive webhooks. It uses the same key pair and the same request signing as `BlueCollarSDK`; the only new thing is the base path, `/api/v1/partner`.

> **Status:** written from Record's *Record API for Randstad: skills taxonomy to analytics* design document. Record's backend serves **skills** (`record.skills`), **job mandates** (`record.mandates`), **campaigns** (`record.campaigns`), the **pull queue** of results (`record.events`, `record.poll_events`), **webhooks** (`record.webhooks`, which push the same results) and **a candidate's result** (`record.results.candidate`, to re-read at any time), and the SDK is tested against that real code. Not served yet, so they answer `404` or `501 NOT_IMPLEMENTED`: stats and analytics, exports, `skills.usage`, `mandates.batch`, `mandates.versions`, `mandates.set_enrichment_questions` (send the whole mandate with `upsert`), `campaigns.cancel` (pause instead) and `campaigns.remove_candidate`. Those methods follow the document and are tested only against a stand-in server.

```python
import os
from bluecollarverificationauth import RecordPartner

record = RecordPartner(
    api_key=os.environ["RECORD_API_KEY"],        # VFN_LIVE_... or VFN_TEST_...
    secret_key=os.environ["RECORD_SECRET_KEY"],  # VFN_SK_...
    on_behalf_of="rec_1042",                     # optional: the ATS user, for per-recruiter stats and limits
)

# 1. A mandate, keyed on your own job id: resend the whole thing on every save.
res = record.mandates.upsert("JOB-88231", {
    "workflow_id": "WF_BG_...",
    "title": "Forklift Operator",
    "methods": ["data_enrichment", "skill_verification", "interest_collection"],
    "skills": [{"skill_id": "sk_36440", "verify": True}, {"name": "Reach truck", "pending": True}],
    "enrichment_questions": [{"key": "night_shift", "type": "yes_no", "text": "Open to night shifts?", "knockout": "no"}],
})                                               # 202: ready when the mandate.ready webhook arrives

# 2. A campaign: the first candidates, then more in further calls if there are many, then start it.
res = record.campaigns.create("JOB-88231", first_1000, name="Chennai walk-in list, week 41", start="manual")
if not res.success:
    print(res.error.code, res.error.message, res.error.request_id)
record.campaigns.add_candidates(res.data["id"], next_1000)
record.campaigns.start_sending(res.data["id"])

# 3. Results: webhooks push them (see "Receiving webhooks"); read one any time.
record.results.candidate("cand-42")
record.campaigns.stats("CMP_...")
```

Like the rest of the SDK, calls **return** a `RecordResponse` and do not raise for a refused request. The data keeps the Partner API's own `snake_case` keys (`workflow_id`, `external_id`, ...), exactly as in the API document. The answer also has `headers`, `res.etag`, `res.not_modified`, `res.retry_after`, `res.rate_limit`, and `res.error.request_id` on a failure.

| Area | What it does |
| --- | --- |
| `record.skills` | `meta`, `export_url`, `changes`, `domains`, `sub_domains`, `search`, `get`, `match` (up to 100 texts), `extract`, `request_skill`, `list_requests`, `usage` |
| `record.mandates` | `upsert`, `get`, `list`, `batch`, `questions`, `versions`, `archive`, `set_enrichment_questions` |
| `record.campaigns` | `create`, `add_candidates`, `remove_candidate`, `start_sending`, `pause`, `resume`, `cancel`, `get`, `list`, `candidates`, `report_link`, `stats` |
| `record.results` / `record.analytics` | `results.candidate(id, include_answers=)`, `analytics.mandate_stats`, `analytics.rollup`, `analytics.create_export` |
| `record.webhooks` | `create`, `list`, `get`, `update`, `delete`, `test`, `deliveries`, `replay`, `rotate_secret` |
| `record.events` / `record.poll_events` | the pull queue (kept 30 days) under the webhooks, and the background poller for it: the same `poll_events` as `BlueCollarSDK` |

### What the client does for you

- **Idempotency.** Every POST carries an `Idempotency-Key` (yours, or a fresh one). It stays the same on every retry of that call, so a retry never creates a second campaign. Pass your own `idempotency_key` to `campaigns.create` and `add_candidates` and a repeat after a crash adds nobody twice.
- **Retries.** A `429 RATE_LIMITED` waits for `Retry-After` and retries; so do 502, 503, 504 and a network error (with a short back-off). Every retry is signed afresh. A `COOLDOWN` (a long wait) is not waited for: it is handed back. `max_retries=3` and `max_retry_wait=60` can be changed; `max_retries=0` turns retrying off.
- **First-step checks only.** Before a call the SDK checks that required fields are there and are the right kind of thing (a mandate has a `workflow_id` and a `title`, a candidate a `name` and a `phone`, an id is not blank): a miss comes back as `VALIDATION_FAILED` with the field named, without a call. Every business rule (how many skills, which methods, how many candidates a call may carry, what a phone number may look like) is **Record's**: it can differ per organisation and change without a new SDK, so Record's answer (`VALIDATION_FAILED` or `LIMIT_EXCEEDED`, field by field, with a `request_id`) comes back unchanged. `validate=False` skips even the first step. The checks are public as `check_mandate`, `check_enrichment_questions` and `check_candidates`.
- **Pages and unchanged answers.** `iter_pages(record.mandates.list, status="ready")` follows `next_cursor`. Pass the last answer's `etag` as `if_none_match` and an unchanged answer is a cheap `304`.
- **The skills file.** `record.skills.export_url()` gives the signed download link (valid 15 minutes). The client never follows it, so your credentials are not sent to the download host: fetch it with a plain GET.

### Receiving webhooks

Register an endpoint once (needs the `webhooks:manage` scope). The answer holds the **webhook secret**: store it, it is shown once (lose it and call `rotate_secret`).

```python
res = record.webhooks.create(
    "https://ats.example.com/webhooks/record",
    ["candidate.completed", "candidate.declined", "mandate.ready"],   # or ["*"]
    mode="batch",                                                       # up to 100 events a POST; "single" sends one
)
WEBHOOK_SECRET = res.data["secret"]                                     # whsec_..., not the API secret
record.webhooks.test(res.data["id"])                                    # sends a webhook.test event now; res.data["delivered"] says if you answered 2xx
```

Event types: `candidate.accepted`, `candidate.declined`, `candidate.failed`, `candidate.completed` (the events `record.events` holds, under the same id: `eventId` there, `event_id` here) and `mandate.ready`, `mandate.failed`. Subscribing to a type Record does not publish yet is refused. Only campaigns made through this API push webhooks.

Each event is `{event_id, type, sequence, created_at, mode, data}`; `data` carries `campaign_id`, `mandate_external_id`, `candidate: {id, external_id, name}` (your own candidate id) and, for `candidate.completed`, `result` (`verdict`, `total_score`, `is_partial`, `skills`, `channel`, `report_url`). A `mandate.*` event's `data` is `{mandate_id, job_id, status, flow_version}`. A phone number is never sent.

Verify the **raw body** before parsing it, with the **webhook secret** (not the API secret). `verify_webhook` checks the `Record-Signature` header and the 5-minute window, and raises `WebhookVerificationError` (a `ValueError`) otherwise. While you rotate a secret, pass both: `verify_webhook([new, old], ...)`.

```python
from fastapi import FastAPI, HTTPException, Request
from bluecollarverificationauth import verify_webhook
from bluecollarverificationauth.partner import is_newer, unpack_events

app = FastAPI()

@app.post("/webhooks/record")
async def record_webhook(request: Request):
    body = await request.body()
    try:
        payload = verify_webhook(WEBHOOK_SECRET, request.headers, body)
    except ValueError:
        raise HTTPException(status_code=401)
    store_events(unpack_events(payload))   # a batch or a single event; dedupe on event_id
    return {"ok": True}                    # answer 2xx fast, work in a queue
```

Deliveries are not ordered and can repeat: dedupe on `event_id`, and apply an event only if `is_newer(event, last_sequence_you_stored)` (the `sequence` of one candidate or job rises, but is not 1, 2, 3). `sign_webhook(secret, body)` builds the header, to test your receiver.

What Record does when you do not answer 2xx: it tries again after 30 s, 2 min, 10 min, 30 min, 2 h, 6 h and 12 h (8 tries in about 21 hours), longer if you send `Retry-After`; a redirect counts as a failure. After that it gives up on that event for that endpoint, but it is still in `record.events` and `record.webhooks.replay(id, from_=..., to=...)` sends a period again (up to 30 days and 5,000 events). `record.webhooks.deliveries(id)` shows the last 7 days of attempts with the status each got (`iter_pages` pages it). Live endpoints need `https://`; a test key may use `http://`; addresses that are not on the public internet are refused. Up to 5 endpoints per organisation and mode, one per URL.

### What Record's backend does with them

- **A job is keyed on your own job id.** `mandates.upsert(job_id, {...})` creates it or brings it up to date (202; its questions are made in the background: `status` goes `preparing`, then `ready`). Send the whole mandate every time. `methods` may be left out if the questions, verified skills or `interest_collection` say what runs.
- **Limits are Record's, and it says which:** at most 10 skills, 3 verified and 5 enrichment questions (`LIMIT_EXCEEDED`); an unknown skill, a method without what it needs (`VALIDATION_FAILED`). Enrichment questions are `yes_no` and `voice` for now.
- **A campaign takes up to 1,000 candidates a call** (add more with `add_candidates`, 20,000 in all), needs an `Idempotency-Key` (the SDK adds one), leaves out numbers that are not phone numbers or are there twice (`rejected`, with a reason), and is not sent until `start="now"` or `start_sending`. One language per campaign.
- **Results** come through webhooks, or `events.list` / `poll_events`: each event carries the candidate's `source` (your own candidate id).

Needs a key whose scopes include what you call: `skills:read`, `skills:request`, `mandates:write`, `campaigns:write`, `results:read`, `webhooks:manage`. A new key has none: ask Record to set them (calls are refused with `SCOPE_MISSING` until then).

Matching free text to skills and extracting skills from a job description happen on Record's side (`skills.match`, `skills.extract`): the SDK carries no taxonomy and no matching code. A platform that wants local typeahead builds its own copy from `skills.export_url` and `skills.changes`.

## How requests are signed

Every call carries three headers: `x-api-key` (the public id), `x-timestamp` (milliseconds) and `x-signature`, the hex HMAC-SHA256 of `METHOD:<path and query>:<timestamp>:<exact body>` keyed with your secret key (for example `POST:/api/v1/verify/verification/init:<timestamp>:<exact JSON body>`, or `GET:/api/v1/verify/events?limit=10:<timestamp>:` with an empty body). The secret itself never leaves your server. Record refuses a request whose timestamp is more than 5 minutes off, and a signed request to open a session works once (a repeat is answered `401`), so a captured one cannot be replayed. The calls that read or acknowledge results are safe to repeat. The SDK signs a fresh request on every call.

## Security

- Keep `secret_key` in your server's environment. Never ship it to a browser, a mobile app or your repository.
- Send only the session token to your page. It is a bearer credential for 2 hours and lets its holder message the candidates of that one session, so do not log it or put it in shared URLs.
- Regenerate the key pair in the playground if a secret key is ever exposed. That ends every session opened with it.

## Develop

```bash
pip install -e ".[dev]"
pytest         # no network: a stand-in Record server over local HTTP
```
