Bulk Import & Attachments
The programmatic counterpart to the Import a Spreadsheet UI flow. Where the spreadsheet importer brings a whole workforce in through Oho's template, this guide does the same job from your own code: ask many workers for the credentials you don't hold in a single call, and push the documents you do hold straight onto their credentials.
Read API Basics first — it covers base URLs, the Authorization header, IDs, the
response envelope, and error shapes that every step here relies on. The examples assume $OHO_BASE
and $OHO_TOKEN are set. If you're bringing people in from scratch, the
Workers & Credentials tutorial shows how to create workers and
credentials one at a time first, and the Fetch Requests tutorial covers the
full lifecycle of a single request (tracking, reminders, resend, cancel).
When to use which
| You want to… | Use |
|---|---|
| Ask a single existing worker for credential details | POST /fetch-requests |
| Ask many existing workers for the same credentials in one call | POST /fetch-requests/bulk — this guide |
| Attach a document you already hold (certificate, ID scan) to a credential | POST /credentials/{id}/attachments |
A fetch request emails each worker a secure upload link; whatever they supply lands on their profile, verified where possible. An attachment is a file you upload yourself against a credential you've already created. Most onboarding flows use both: create the workers and credentials you know about, attach the evidence you hold, and fan out fetch requests for everything still missing.
Bulk fetch requests
POST /fetch-requests/bulk fans out one fetch request per worker in a single call. The workers are
supplied at the top level as a list, and every other field is applied uniformly to all of them.
Each created request gets its own cap_<id> and authorizes against the captureRequest entity, so
your token needs access to it.
curl -sS -X POST "$OHO_BASE/fetch-requests/bulk" \
-H "Authorization: Bearer $OHO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"workers": [
{ "id": "wkr_V1StGXR8Z5jdHi6B" },
{ "externalId": "WD-100482", "sourceSystem": "workday" },
{ "externalId": "WD-100483", "sourceSystem": "workday" }
],
"requestedCredentialTypes": ["WWCC_NSW", "FIRST_AID"],
"credentialGuidingTexts": {
"FIRST_AID": "Please upload your current HLTAID011 certificate, not the wallet card."
},
"message": "Welcome to the team — please confirm a couple of credentials so we can get you started.",
"expiryDays": 14
}'
Each worker reference is exactly one of id (a wkr_<id>) or externalId (your upstream HRIS
identifier, narrowed with sourceSystem where it could be ambiguous) — the same shape used
throughout the API. credentialGuidingTexts is keyed by credential type and shown to the worker on
their upload page; expiryDays sets how long each secure link stays valid (default 14).
Send exactly one of requestedCredentialTypes (the flat list shown here) or a structured
requirements block — for AND/OR groups, exemptions, or ban checks — not both. See
Create fetch request for the requirements shape, and
List supported credential types for the live list of type codes.
Reading the response
The call returns 200 with a per-worker result array. Partial failures don't roll back successful
sibling creates — a bad reference in one entry doesn't stop the rest from being sent, so always
inspect each item rather than relying on the HTTP status alone.
{
"data": [
{
"success": true,
"workerId": "wkr_V1StGXR8Z5jdHi6B",
"requestId": "cap_7Qmost4nB2kZ1xPa"
},
{
"success": true,
"workerId": "wkr_8c2Kf9aQ4mN7pLrT",
"requestId": "cap_9aZ1xKp0LmQ4nR8t"
},
{
"success": false,
"workerId": "WD-100483",
"error": "Worker has no properties: ..."
}
],
"meta": { "total": 3, "requestId": "f3c1a0e2-..." }
}
Match each result back to your input by workerId — on success that's the resolved wkr_<id>; on
failure it echoes the id or externalId you supplied. For successful entries, requestId is the
created fetch request — track it with GET /fetch-requests/{requestId} or list
them with List fetch requests. Retry only the entries where success is
false. Common per-worker failures are a worker that can't be resolved or one with no email address
on file to send the link to.
Bulk is capped at 50 workers — more returns 400, as does an empty workers list. Split larger
imports into batches of 50. There's no all-or-nothing guarantee across a batch, so make your retry
logic idempotent on your side: resending to a worker simply issues another request.
Attachments
When you already hold a document — a scanned certificate, an ID, a registration letter — upload it
directly against the credential rather than asking the worker for it. Attachments live under a
credential, so create the credential first (see Create credential) and
use its cred_<id>:
export CRED="cred_8x2Kf9aQ4mN7pLrT"
Uploads accept JPEG, PNG, WebP, HEIC/HEIF, and PDF, up to 10 MB per file. Anything else
returns 400 (unsupported mimeType or a size error).
Upload a file
POST /credentials/{credentialId}/attachments accepts the file two ways. Use whichever fits your
client; both return 201 with the stored attachment's metadata and a synthetic att_<id>.
Multipart form upload — stream the raw file, best for anything large. The form field must be
named file:
curl -sS -X POST "$OHO_BASE/credentials/$CRED/attachments" \
-H "Authorization: Bearer $OHO_TOKEN" \
-F "file=@./first-aid-certificate.pdf"
JSON + base64 — convenient when your client already serialises everything as JSON. Provide the
bytes as a base64-encoded data field alongside fileName and mimeType:
curl -sS -X POST "$OHO_BASE/credentials/$CRED/attachments" \
-H "Authorization: Bearer $OHO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"fileName": "first-aid-certificate.pdf",
"mimeType": "application/pdf",
"data": "JVBERi0xLjcKJ..."
}'
The response carries the file's metadata. source is api for files uploaded this way, and
contentUrl is the (auth-gated) path to fetch the bytes back:
{
"data": {
"id": "att_3kP9aQ4mN7pLrT2",
"type": "credentialAttachment",
"attributes": {
"fileName": "first-aid-certificate.pdf",
"mimeType": "application/pdf",
"sizeBytes": 248913,
"source": "api",
"uploadedAt": "2026-06-29T02:31:55Z",
"contentUrl": "/openapi/v1/credentials/cred_8x2Kf9aQ4mN7pLrT/attachments/att_3kP9aQ4mN7pLrT2/content"
}
},
"meta": { "requestId": "..." }
}
Oho runs an automatic check on each upload, but it doesn't block the response — the verification
block is absent from the upload response and is filled in shortly afterwards. Re-fetch the
attachment (below) to read it once it's ready: it reports whether the file matched the credential's
expected type (matchesExpectedType), a confidencePercent, the detectedType, reasoning, and
any concerns.
Full reference: Upload an attachment.
List, fetch, and download
The list and single-metadata reads return the same attributes as the upload response — including
verification once it's ready — but never the file bytes. Fetch those separately from
contentUrl.
# Metadata for every attachment on the credential
curl -sS "$OHO_BASE/credentials/$CRED/attachments" -H "Authorization: Bearer $OHO_TOKEN"
# Metadata for one attachment
curl -sS "$OHO_BASE/credentials/$CRED/attachments/att_3kP9aQ4mN7pLrT2" -H "Authorization: Bearer $OHO_TOKEN"
# Download the raw bytes
curl -sS "$OHO_BASE/credentials/$CRED/attachments/att_3kP9aQ4mN7pLrT2/content" \
-H "Authorization: Bearer $OHO_TOKEN" \
-o first-aid-certificate.pdf
The download endpoint returns the raw file with its original Content-Type and a
Content-Disposition set to the original filename. It is auth-gated — contentUrl is not a
signed, shareable link, so every download must carry your bearer token.
References: List attachments · Get attachment · Download content.
Delete
Deleting an attachment is permanent — it removes the file from the credential and cannot be undone (unlike the soft deletes used elsewhere in the API).
curl -sS -X DELETE "$OHO_BASE/credentials/$CRED/attachments/att_3kP9aQ4mN7pLrT2" \
-H "Authorization: Bearer $OHO_TOKEN" -i # 204, or 404 if the attachment isn't found
See Delete attachment.
A typical import flow
Putting it together, a programmatic onboarding usually runs in this order:
- Create workers and credentials for everything you already know — see the Workers & Credentials tutorial.
- Attach the evidence you hold to those credentials with
POST /credentials/{id}/attachments. - Fan out fetch requests for everything still missing with
POST /fetch-requests/bulk, in batches of 50, inspecting the per-worker results and retrying failures. - Track progress through the Fetch Requests tutorial and, for ongoing
currency, subscribe to the
credential.verifiedwebhook.
Where to go next
- Import a Spreadsheet — the no-code version of this flow
- Fetch Requests tutorial — the full lifecycle of a single request
- Workers & Credentials tutorial — create the workers and credentials first
- API Reference — every endpoint, field, and query parameter