Metadata-Version: 2.4
Name: recordorg-bluecollarverificationauth
Version: 1.0.0b4
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.

## 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
```
