# Bulk verification

> Queue up to 10,000 checks of one product in a single job. You get a job handle, never rows; results are read from the job or arrive by webhook.

Bulk jobs run many checks of one product in the background, four at a time. They are built for payout batches, portfolio re-verification and back-book remediation.

By design, a bulk request **never returns rows inline**. You receive a job handle, and per-row results are PII-free assertions you read from the job. That keeps 10,000 identifiers out of logs, chat transcripts and AI context windows.

## Create a job

`POST /v1/bulk`

**Body parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `product` | string | Yes | One of `nin`, `bvn`, `phone`, `business`, `account`, `signals`, `passport`, `drivers_license`, `voters_card`. Face checks are not available in bulk. |
| `items` | array of objects | Yes | 1 to 10,000 items. Each item is the request body you would send to that product endpoint. |
| `name` | string | No | A label for the dashboard, up to 120 characters. |

_cURL_

```bash
curl -X POST "http://localhost:4000/v1/bulk" \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
  "product": "account",
  "name": "Payout batch 42",
  "items": [
    {
      "account_number": "0123456789",
      "bank_code": "058",
      "match": {
        "name": "Clarke Griffin"
      }
    },
    {
      "account_number": "1234567890",
      "bank_code": "044",
      "match": {
        "name": "John Doe"
      }
    }
  ]
}'
```

_Node.js_

```javascript
const res = await fetch("http://localhost:4000/v1/bulk", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SPEEDMAP_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "product": "account",
    "name": "Payout batch 42",
    "items": [
      {
        "account_number": "0123456789",
        "bank_code": "058",
        "match": {
          "name": "Clarke Griffin"
        }
      },
      {
        "account_number": "1234567890",
        "bank_code": "044",
        "match": {
          "name": "John Doe"
        }
      }
    ]
  }),
});

const job = await res.json();
if (!res.ok) throw new Error(job.error.message);
console.log(job);
```

_Python_

```python
import os, requests

res = requests.post(
    "http://localhost:4000/v1/bulk",
    headers={
        "Authorization": f"Bearer {os.environ['SPEEDMAP_API_KEY']}",
    },
    json={
        "product": "account",
        "name": "Payout batch 42",
        "items": [
            {
                "account_number": "0123456789",
                "bank_code": "058",
                "match": {
                    "name": "Clarke Griffin"
                }
            },
            {
                "account_number": "1234567890",
                "bank_code": "044",
                "match": {
                    "name": "John Doe"
                }
            }
        ]
    },
    timeout=30,
)
res.raise_for_status()
print(res.json())
```

**202 Accepted**

```json
{
  "id": "job_1a2b3c4d5e6f7a8b",
  "object": "bulk_job",
  "product": "account",
  "name": "Payout batch 42",
  "status": "running",
  "total": 2500,
  "processed": 1180,
  "counts": {
    "verified": 1102,
    "not_found": 61,
    "failed": 17
  },
  "progress": 47,
  "mode": "sandbox",
  "created_at": "2026-10-19T09:41:07.512Z",
  "completed_at": null
}
```

Each item is validated and charged like a single request. A row that fails validation counts as `failed` without stopping the job. Every row gets its own idempotency key (`<job id>:<row index>`), so a row is never billed twice.

The whole job is checked against your entitlements when it is created: `403 not_entitled` if your workspace cannot use the product, `400 too_many_items` above 10,000 items.

## Track progress

`GET /v1/bulk/{id}`

`status` moves from `queued` to `running` to `completed`. `progress` is a percentage of `total`, and `counts` splits processed rows into `verified`, `not_found` and `failed`.

_cURL_

```bash
curl "http://localhost:4000/v1/bulk/job_1a2b3c4d5e6f7a8b" \
  -H "Authorization: Bearer sk_test_..."
```

_Node.js_

```javascript
const res = await fetch("http://localhost:4000/v1/bulk/job_1a2b3c4d5e6f7a8b", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.SPEEDMAP_API_KEY}`,
  },
});

const job = await res.json();
if (!res.ok) throw new Error(job.error.message);
console.log(job);
```

_Python_

```python
import os, requests

res = requests.get(
    "http://localhost:4000/v1/bulk/job_1a2b3c4d5e6f7a8b",
    headers={
        "Authorization": f"Bearer {os.environ['SPEEDMAP_API_KEY']}",
    },
    timeout=30,
)
res.raise_for_status()
print(res.json())
```

Instead of polling, subscribe to `bulk.completed`. It fires once, when every row has been processed, with the final job object:

**bulk.completed**

```json
{
  "id": "evt_5c2e9a0b1d3f4e67",
  "type": "bulk.completed",
  "mode": "sandbox",
  "created_at": "2026-10-19T09:52:40.118Z",
  "data": {
    "id": "job_1a2b3c4d5e6f7a8b",
    "object": "bulk_job",
    "product": "account",
    "name": "Payout batch 42",
    "status": "completed",
    "total": 2500,
    "processed": 2500,
    "counts": {
      "verified": 2391,
      "not_found": 87,
      "failed": 22
    },
    "progress": 100,
    "mode": "sandbox",
    "created_at": "2026-10-19T09:41:07.512Z",
    "completed_at": "2026-10-19T09:52:40.118Z"
  }
}
```

## Read results

`GET /v1/bulk/{id}/results`

One entry per processed row, in input order (`row` is 1-based). Entries are PII-free: a masked `subject`, `status` and `match`. To see a full record, retrieve the row's verification by `reference_id`; bulk rows are created through the bulk channel and never include `data`.

**200 OK**

```json
{
  "object": "list",
  "data": [
    {
      "row": 1,
      "reference_id": "spm_0c1d2e3f4a5b6c7d8e9f0a1b",
      "status": "verified",
      "match": true,
      "subject": "******6789"
    },
    {
      "row": 2,
      "reference_id": "spm_1d2e3f4a5b6c7d8e9f0a1b2c",
      "status": "verified",
      "match": false,
      "subject": "******7890"
    },
    {
      "row": 3,
      "reference_id": "spm_2e3f4a5b6c7d8e9f0a1b2c3d",
      "status": "not_found",
      "match": false,
      "subject": "******0000"
    }
  ]
}
```

## List jobs

`GET /v1/bulk` lists your jobs, newest first, with `limit` (up to 100, default 50) and the `before` cursor. See [Pagination](https://speedmap-delta.vercel.app/docs/pagination).

## CSV upload in the dashboard

Operations teams can run the same jobs without code: open **Dashboard → Bulk**, choose a product, and upload a CSV with one row per check and one column per request field. The job runs exactly like one created through the API, and its progress and per-row results appear in the dashboard.

## From an AI assistant

The [MCP server](https://speedmap-delta.vercel.app/docs/mcp) exposes `bulk_verify` and `get_bulk_job`. The assistant gets a job id, counts and a dashboard link, never the rows.

---

Source: https://speedmap-delta.vercel.app/docs/bulk
