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-..." }
}
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.
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": "..." }
}
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
contentUrlfrom a fresh metadata read. A/contentrequest with a missing, tampered or expiredexp/sigis rejected with401. - 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.
Authorization header to S3A 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.
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:
- 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