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-..." }
}

The per-item error here is a plain string, not the nested { code, message, requestId } envelope every top-level API error uses — this array reports outcomes, not failures, and the call itself succeeded with 200. Parse it separately from your error handling.

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 a signed, five-minute, caller-bound path to fetch the bytes back (see below):

{
"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?exp=1790646000&sig=k3Qm9dHR1ZVNpZ25hdHVyZUhlcmU"
}
},
"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 from the contentUrl the metadata hands back.

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

contentUrl is signed, short-lived, and bound to you​

Every contentUrl arrives with exp and sig already on it. The signature is an HMAC over the credential, the attachment, your actor identity and the expiry, so the link can't be replayed against another attachment, handed to a colleague, or kept for later. It is valid for five minutes.

So:

  • Don't assemble the path yourself. Take contentUrl from a fresh metadata read. A /content request with a missing, tampered or expired exp/sig is rejected with 401.
  • Don't cache or share it. Once it expires, re-read the metadata for a new one.

The signature sits behind authentication rather than replacing it — the download request still carries your Authorization header, like every other call in this guide.

Downloading the bytes​

The download answers with a 302 to a short-lived presigned S3 URL; Oho never buffers the file.

Don't replay your Authorization header to S3

A presigned S3 GET carries its credentials in the query string, and S3 rejects a request that also sends an Authorization header. curl -L replays -H headers onto the redirect target, so curl -L -H "Authorization: ..." fails — as does any HTTP client that forwards headers across redirects (most do by default).

Follow the redirect without the Oho header, or use redirect=false below.

contentUrl is host-relative and already includes /openapi/v1, so join it to the host rather than to $OHO_BASE:

OHO_HOST="${OHO_BASE%/openapi/v1}"

CONTENT_URL=$(curl -sS "$OHO_BASE/credentials/$CRED/attachments/att_3kP9aQ4mN7pLrT2" \
-H "Authorization: Bearer $OHO_TOKEN" | jq -r '.data.attributes.contentUrl')

# Capture the redirect target with auth, then fetch it without.
S3_URL=$(curl -sS -o /dev/null -w '%{redirect_url}' "$OHO_HOST$CONTENT_URL" \
-H "Authorization: Bearer $OHO_TOKEN")
curl -sS "$S3_URL" -o first-aid-certificate.pdf

redirect=false — take the URL as JSON instead​

Clients that can't strip headers across a redirect should ask for the presigned URL directly:

curl -sS "$OHO_HOST$CONTENT_URL&redirect=false" -H "Authorization: Bearer $OHO_TOKEN"
{
"data": {
"url": "https://oho-attachments.s3.ap-southeast-2.amazonaws.com/attachments/...&X-Amz-Signature=...",
"expiresInSeconds": 3600
},
"meta": { "requestId": "..." }
}

Fetch data.url with no Oho headers at all. expiresInSeconds is the life of the S3 link, not of contentUrl — the two expire on different clocks, so use the URL promptly.

Legacy inline attachments

Files uploaded before attachment storage moved to S3 are held inline on the credential. Those come back as a plain 200 — the raw bytes with their original Content-Type and a Content-Disposition naming the file — with no redirect and nothing for redirect=false to return. They need the same signed contentUrl and the same bearer token; only the response shape differs. Handle both outcomes rather than assuming a redirect.

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​