Skip to main content

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.

Before you start

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 detailsPOST /fetch-requests
Ask many existing workers for the same credentials in one callPOST /fetch-requests/bulk — this guide
Attach a document you already hold (certificate, ID scan) to a credentialPOST /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).

What you're asking for

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.

Capped at 50 workers per call

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": "..." }
}
Verification arrives asynchronously

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-gatedcontentUrl 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:

  1. Create workers and credentials for everything you already know — see the Workers & Credentials tutorial.
  2. Attach the evidence you hold to those credentials with POST /credentials/{id}/attachments.
  3. 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.
  4. Track progress through the Fetch Requests tutorial and, for ongoing currency, subscribe to the credential.verified webhook.

Where to go next