Skip to main content

Native API quickstart

Use the native API for automation and for administration that the browser does not yet expose. This walkthrough assumes Bash, a ready Jiandu origin, curl, jq, openssl, and standard Unix file utilities. Run every snippet in the same Bash session and use a non-sensitive test document first.

The exact OpenAPI 3.1 contract is available from the running server at /api/v1/openapi.json, from this site as JSON, and as a browsable operation reference. Generate a client from the contract shipped with your Jiandu version; main can differ from an older installation.

Choose the exact origin

Start the shell with strict error handling, tracing disabled, private temporary files, and an EXIT cleanup. Keep the ordinary session open: it is the authority that revokes the temporary PAT even when a later command fails.

set -euo pipefail
set +x
umask 077

JIANDU_ORIGIN=''
JIANDU_CA=''
JIANDU_CURL_TRANSPORT=(--disable --noproxy '*' --connect-timeout 5 --max-time 60)
JIANDU_COOKIES=''
JIANDU_PAT_RESPONSE=''
JIANDU_HEADERS=''
JIANDU_CHUNK=''
JIANDU_TOKEN=''
JIANDU_TOKEN_ID=''
JIANDU_PAT_LABEL=''
JIANDU_PAT_CREATE_ATTEMPTED=false

jiandu_cookie_jar_has_cookie() {
[[ -s "${JIANDU_COOKIES}" ]] || return 1
awk -F '\t' '
/^#HttpOnly_/ && NF >= 7 { found = 1 }
!/^#/ && NF >= 7 { found = 1 }
END { exit !found }
' "${JIANDU_COOKIES}"
}

jiandu_cleanup() {
local original_status=$? cleanup_failed=0 cleanup_listing cleanup_path
trap - EXIT
trap '' HUP INT TERM
set +e
set +x
unset JIANDU_PASSWORD

if [[ "${JIANDU_PAT_CREATE_ATTEMPTED}" == true ]] \
&& jiandu_cookie_jar_has_cookie; then
if [[ -z "${JIANDU_TOKEN_ID}" && -n "${JIANDU_PAT_LABEL}" ]]; then
cleanup_listing="$(
curl "${JIANDU_CURL_TRANSPORT[@]}" --fail-with-body --silent --show-error \
--cookie "${JIANDU_COOKIES}" \
"${JIANDU_ORIGIN}/api/v1/personal-access-tokens" 2>/dev/null
)"
JIANDU_TOKEN_ID="$(
jq -er --arg label "${JIANDU_PAT_LABEL}" \
'[.[] | select(.label == $label)] |
if length == 1 then .[0].id else empty end' \
<<<"${cleanup_listing}" 2>/dev/null
)"
fi
if [[ -n "${JIANDU_TOKEN_ID}" ]]; then
if ! curl "${JIANDU_CURL_TRANSPORT[@]}" --fail-with-body --silent --show-error \
--request DELETE --cookie "${JIANDU_COOKIES}" \
--header "Origin: ${JIANDU_ORIGIN}" \
"${JIANDU_ORIGIN}/api/v1/personal-access-tokens/${JIANDU_TOKEN_ID}" \
>/dev/null 2>&1; then
printf 'cleanup failed: revoke PAT %s manually\n' "${JIANDU_TOKEN_ID}" >&2
cleanup_failed=1
fi
else
printf 'cleanup could not resolve the PAT; find and revoke label %s manually\n' \
"${JIANDU_PAT_LABEL}" >&2
cleanup_failed=1
fi
fi

if jiandu_cookie_jar_has_cookie && [[ "${cleanup_failed}" -eq 0 ]]; then
if ! curl "${JIANDU_CURL_TRANSPORT[@]}" --fail-with-body --silent --show-error \
--request DELETE --cookie "${JIANDU_COOKIES}" \
--header "Origin: ${JIANDU_ORIGIN}" \
"${JIANDU_ORIGIN}/api/v1/session" >/dev/null 2>&1; then
printf 'cleanup failed: revoke the temporary normal session manually\n' >&2
cleanup_failed=1
else
: >"${JIANDU_COOKIES}"
fi
elif jiandu_cookie_jar_has_cookie; then
printf 'temporary normal session retained so you can finish PAT cleanup manually\n' >&2
fi

unset JIANDU_TOKEN JIANDU_PASSWORD
if ((cleanup_failed == 0)); then
for cleanup_path in "${JIANDU_PAT_RESPONSE:-}" "${JIANDU_COOKIES:-}" \
"${JIANDU_HEADERS:-}" "${JIANDU_CHUNK:-}" "${JIANDU_CA:-}"; do
if [[ -n "${cleanup_path}" ]] && ! rm -f -- "${cleanup_path}"; then
printf 'cleanup failed: private file was not removable: %s\n' \
"${cleanup_path}" >&2
cleanup_failed=1
fi
done
fi
if ((cleanup_failed != 0)); then
printf 'any remaining private cleanup files are retained; cookie=%s PAT-response=%s CA=%s\n' \
"${JIANDU_COOKIES:-unset}" "${JIANDU_PAT_RESPONSE:-unset}" \
"${JIANDU_CA:-unset}" >&2
fi
unset -f jiandu_api jiandu_cookie_jar_has_cookie 2>/dev/null || true

if ((original_status != 0)); then
exit "${original_status}"
fi
if ((cleanup_failed != 0)); then
exit 1
fi
set -e
return 0
}
trap jiandu_cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

For the recommended Compose evaluation, keep Jiandu behind Caddy and validate its local CA. Do not add --insecure:

export JIANDU_ORIGIN='https://jiandu.localhost'
JIANDU_CA="$(mktemp)"
docker compose -f examples/docker-compose/docker-compose.yml cp \
caddy:/data/caddy/pki/authorities/local/root.crt "${JIANDU_CA}" >/dev/null
JIANDU_CURL_TRANSPORT=(
--disable
--noproxy '*'
--connect-timeout 5
--max-time 60
--cacert "${JIANDU_CA}"
--resolve 'jiandu.localhost:443:127.0.0.1'
)

If you instead started the default source launcher on loopback, use:

export JIANDU_ORIGIN='http://127.0.0.1:8077'
JIANDU_CA=''
JIANDU_CURL_TRANSPORT=(--disable --noproxy '*' --connect-timeout 5 --max-time 60)

1. Create a narrow personal access token

PAT creation requires a non-PAT session; another PAT can never authorize it. A session created with the Owner recovery token is technically accepted, but reserve that credential for break-glass use and create routine automation under a named human account. The browser has no PAT-management screen yet.

Set the exact public origin, create a private cookie jar, and sign in with a local account. This example passes the password through standard input instead of placing it in the command line:

JIANDU_COOKIES="$(mktemp)"
read -r -p 'Jiandu username: ' JIANDU_USERNAME
if [[ ! "${JIANDU_USERNAME}" =~ ^[A-Za-z0-9._-]{3,64}$ ]]; then
printf 'username does not match Jiandu local-account syntax\n' >&2
exit 1
fi
read -r -s -p 'Jiandu password: ' JIANDU_PASSWORD; printf '\n'
printf '%s' "${JIANDU_PASSWORD}" |
jq -Rs --arg username "${JIANDU_USERNAME}" \
'{method:"password",username:$username,password:.}' |
curl "${JIANDU_CURL_TRANSPORT[@]}" --fail-with-body --silent --show-error \
--cookie-jar "${JIANDU_COOKIES}" \
--header "Origin: ${JIANDU_ORIGIN}" \
--header 'Content-Type: application/json' \
--data-binary @- "${JIANDU_ORIGIN}/api/v1/session" >/dev/null
unset JIANDU_PASSWORD
if ! jiandu_cookie_jar_has_cookie; then
printf 'login did not create a normal Jiandu session cookie\n' >&2
exit 1
fi

Create a uniquely labelled token limited to document reads and writes and explicitly expire it after one hour. The server default is 90 days and the maximum is 365 days, but a walkthrough does not need that exposure window. Omit write for inventory-only automation:

JIANDU_PAT_RESPONSE="$(mktemp)"
JIANDU_PAT_LABEL="API quickstart $(openssl rand -hex 8)"
JIANDU_PAT_EXPIRES_AT_MS="$((($(date +%s) + 3600) * 1000))"
JIANDU_PAT_CREATE_ATTEMPTED=true
jq -n --arg label "${JIANDU_PAT_LABEL}" \
--argjson expiresAtMs "${JIANDU_PAT_EXPIRES_AT_MS}" \
'{label:$label,scopes:["read","write"],expiresAtMs:$expiresAtMs}' |
curl "${JIANDU_CURL_TRANSPORT[@]}" --fail-with-body --silent --show-error \
--cookie "${JIANDU_COOKIES}" \
--header "Origin: ${JIANDU_ORIGIN}" \
--header 'Content-Type: application/json' \
--data-binary @- \
"${JIANDU_ORIGIN}/api/v1/personal-access-tokens" \
>"${JIANDU_PAT_RESPONSE}"
jq '{id:.token.id,label:.token.label,scopes:.token.scopes,expiresAtMs:.token.expiresAtMs}' \
"${JIANDU_PAT_RESPONSE}"

The raw 64-character accessToken exists only in that creation response. Extract it without printing it. Keep the cookie jar and response mode-private until the EXIT cleanup has revoked the PAT; the helper function supplies the bearer to curl over standard input instead of exposing it in the process argument list:

JIANDU_TOKEN="$(
jq -er '.accessToken | select(type == "string" and test("^[0-9a-f]{64}$"))' \
"${JIANDU_PAT_RESPONSE}"
)"
JIANDU_TOKEN_ID="$(
jq -er '.token.id | select(type == "string" and test("^pat_0[0-7][0-9abcdefghjkmnpqrstvwxyz]{25}$"))' \
"${JIANDU_PAT_RESPONSE}"
)"
jiandu_api() {
printf 'header = "Authorization: Bearer %s"\n' "${JIANDU_TOKEN}" |
curl "${JIANDU_CURL_TRANSPORT[@]}" --config - "$@"
}

Keep JIANDU_TOKEN only for this shell session, move a production bearer directly into the automation client's protected secret store, and revoke the PAT when the job ends. Do not commit, log, email, or place it in a URL.

2. Make an authenticated request

jiandu_api --fail-with-body --silent --show-error \
--header 'Accept: application/json' \
"${JIANDU_ORIGIN}/api/v1/documents?query=tax&limit=25" |
jq '{documents:.items,nextCursor,warnings}'

The token scope is only a ceiling. Jiandu still applies the human principal's current roles and each document's access policy. Supplying cookie and bearer credentials together is rejected.

3. Follow opaque cursors

List responses contain items and a nullable nextCursor. The cursor is bound to the query, filters, principal, and authorization scope. Do not parse it or reuse it with different inputs:

JIANDU_CURSOR=''
while :; do
JIANDU_URL="${JIANDU_ORIGIN}/api/v1/documents?query=tax&limit=100"
if [[ -n "${JIANDU_CURSOR}" ]]; then
JIANDU_CURSOR_ESCAPED="$(jq -rn --arg value "${JIANDU_CURSOR}" '$value|@uri')"
JIANDU_URL="${JIANDU_URL}&cursor=${JIANDU_CURSOR_ESCAPED}"
fi
JIANDU_PAGE="$(jiandu_api --fail-with-body --silent --show-error "${JIANDU_URL}")"
jq -r '.items[] | [.id,.title] | @tsv' <<<"${JIANDU_PAGE}"
JIANDU_CURSOR="$(jq -r '.nextCursor // empty' <<<"${JIANDU_PAGE}")"
[[ -n "${JIANDU_CURSOR}" ]] || break
done

4. Upload one original with TUS

Jiandu implements TUS 1.0 creation, checksum, expiration, and termination. Discover the negotiated TUS version, extensions, and checksum algorithms first:

curl "${JIANDU_CURL_TRANSPORT[@]}" --include --request OPTIONS \
"${JIANDU_ORIGIN}/api/v1/uploads"

OPTIONS does not advertise Jiandu's request-body bound. The current server accepts at most 8 MiB in one PATCH; that is a transfer-unit limit, not a whole-document limit. The loop below uses 4 MiB chunks so an interruption retries only bounded work.

Create a session for a non-empty test PDF with a stable, caller-owned idempotency key:

JIANDU_FILE='./sample.pdf'
if [[ ! -f "${JIANDU_FILE}" || -L "${JIANDU_FILE}" ]]; then
printf 'sample must be a regular, non-symlink file: %s\n' "${JIANDU_FILE}" >&2
exit 1
fi
JIANDU_LENGTH="$(wc -c <"${JIANDU_FILE}" | tr -d ' ')"
if [[ "${JIANDU_LENGTH}" -eq 0 ]]; then
printf 'sample must not be empty\n' >&2
exit 1
fi
JIANDU_FILENAME="$(basename "${JIANDU_FILE}" | base64 | tr -d '\n')"
JIANDU_FILETYPE="$(printf 'application/pdf' | base64 | tr -d '\n')"
JIANDU_IDEMPOTENCY_KEY="$(openssl rand -hex 16)"
JIANDU_HEADERS="$(mktemp)"

jiandu_api --fail-with-body --silent --show-error --dump-header "${JIANDU_HEADERS}" \
--request POST \
--header "Origin: ${JIANDU_ORIGIN}" \
--header 'Tus-Resumable: 1.0.0' \
--header "Upload-Length: ${JIANDU_LENGTH}" \
--header "Upload-Metadata: filename ${JIANDU_FILENAME},filetype ${JIANDU_FILETYPE}" \
--header "Idempotency-Key: ${JIANDU_IDEMPOTENCY_KEY}" \
"${JIANDU_ORIGIN}/api/v1/uploads" >/dev/null

JIANDU_LOCATION="$(awk 'tolower($1)=="location:" {print $2}' "${JIANDU_HEADERS}" | tr -d '\r')"
if [[ ! "${JIANDU_LOCATION}" =~ ^/api/v1/uploads/upl_0[0-7][0-9abcdefghjkmnpqrstvwxyz]{25}$ ]]; then
printf 'server returned an unexpected upload Location: %s\n' "${JIANDU_LOCATION}" >&2
exit 1
fi
JIANDU_CHUNK_BYTES=$((4 * 1024 * 1024))
JIANDU_CHUNK_INDEX=0
JIANDU_OFFSET=0
JIANDU_CHUNK="$(mktemp)"

while [[ "${JIANDU_OFFSET}" -lt "${JIANDU_LENGTH}" ]]; do
dd if="${JIANDU_FILE}" of="${JIANDU_CHUNK}" \
bs="${JIANDU_CHUNK_BYTES}" skip="${JIANDU_CHUNK_INDEX}" count=1
JIANDU_CHUNK_LENGTH="$(wc -c <"${JIANDU_CHUNK}" | tr -d ' ')"
JIANDU_CHECKSUM="$(openssl dgst -sha256 -binary "${JIANDU_CHUNK}" | base64 | tr -d '\n')"

jiandu_api --fail-with-body --silent --show-error --dump-header "${JIANDU_HEADERS}" \
--max-time 600 \
--request PATCH \
--header "Origin: ${JIANDU_ORIGIN}" \
--header 'Tus-Resumable: 1.0.0' \
--header "Upload-Offset: ${JIANDU_OFFSET}" \
--header "Upload-Checksum: sha256 ${JIANDU_CHECKSUM}" \
--header 'Content-Type: application/offset+octet-stream' \
--data-binary "@${JIANDU_CHUNK}" \
"${JIANDU_ORIGIN}${JIANDU_LOCATION}"

JIANDU_EXPECTED_OFFSET=$((JIANDU_OFFSET + JIANDU_CHUNK_LENGTH))
JIANDU_REPORTED_OFFSET="$(
awk 'tolower($1)=="upload-offset:" {print $2}' "${JIANDU_HEADERS}" | tr -d '\r'
)"
if [[ "${JIANDU_REPORTED_OFFSET}" != "${JIANDU_EXPECTED_OFFSET}" ]]; then
printf 'server reported unexpected Upload-Offset: %s\n' "${JIANDU_REPORTED_OFFSET}" >&2
exit 1
fi
JIANDU_OFFSET="${JIANDU_EXPECTED_OFFSET}"
JIANDU_CHUNK_INDEX=$((JIANDU_CHUNK_INDEX + 1))
done

rm -f "${JIANDU_CHUNK}"

Each successful PATCH returns 204 and advances Upload-Offset only after that chunk is durable. Recover an interrupted transfer with HEAD, resume from the returned offset, and reuse the original idempotency key when retrying session creation. Do not invent a new upload while the existing status is recoverable.

Poll finalization status with GET on the session. This loop makes at most 120 attempts, sleeping one second between nonterminal responses, and fails on every terminal state that did not accept the original. Each request also has the 60-second transport deadline set above:

JIANDU_UPLOAD_STATUS=''
for _ in {1..120}; do
JIANDU_UPLOAD_STATUS="$(
jiandu_api --fail-with-body --silent --show-error \
--header 'Tus-Resumable: 1.0.0' \
"${JIANDU_ORIGIN}${JIANDU_LOCATION}"
)"
JIANDU_UPLOAD_STATE="$(jq -er '.state' <<<"${JIANDU_UPLOAD_STATUS}")"
case "${JIANDU_UPLOAD_STATE}" in
accepted) break ;;
failed|canceled|expired)
jq '{uploadId,state,error}' <<<"${JIANDU_UPLOAD_STATUS}" >&2
exit 1
;;
open|received|finalizing) sleep 1 ;;
*) printf 'unknown upload state: %s\n' "${JIANDU_UPLOAD_STATE}" >&2; exit 1 ;;
esac
done
if [[ "${JIANDU_UPLOAD_STATE}" != accepted ]]; then
printf 'upload did not reach accepted after 120 polling attempts\n' >&2
exit 1
fi
JIANDU_DOCUMENT_ID="$(jq -er '.documentId' <<<"${JIANDU_UPLOAD_STATUS}")"
JIANDU_TASK_ID="$(jq -er '.taskId' <<<"${JIANDU_UPLOAD_STATUS}")"
jq '{uploadId,state,offset,length,documentId,taskId}' <<<"${JIANDU_UPLOAD_STATUS}"

A documentId and taskId mean the original and durable work were accepted, not that processing is complete. Poll the task to an asserted terminal state:

JIANDU_TASK_STATE=''
for _ in {1..180}; do
JIANDU_TASK="$(
jiandu_api --fail-with-body --silent --show-error \
"${JIANDU_ORIGIN}/api/v1/tasks/${JIANDU_TASK_ID}"
)"
JIANDU_TASK_STATE="$(jq -er '.state' <<<"${JIANDU_TASK}")"
case "${JIANDU_TASK_STATE}" in
ready|ready_with_warnings|needs_review) break ;;
failed|canceled|index_failed)
jq '{id,state,documentId,progress,error}' <<<"${JIANDU_TASK}" >&2
exit 1
;;
queued|processing|indexing) sleep 1 ;;
*) printf 'unknown task state: %s\n' "${JIANDU_TASK_STATE}" >&2; exit 1 ;;
esac
done
case "${JIANDU_TASK_STATE}" in
ready|ready_with_warnings|needs_review)
jq '{id,state,documentId,progress,error}' <<<"${JIANDU_TASK}"
;;
*) printf 'task did not reach a terminal state after 180 polling attempts\n' >&2; exit 1 ;;
esac

needs_review and ready_with_warnings are successful but require a person to inspect the document; they are not equivalent to an unqualified ready.

5. Preserve revisions with If-Match

Document detail responses carry a strong ETag. Send that exact value in If-Match for corrections and lifecycle mutations. Jiandu returns 412 when another writer has published a newer revision; fetch the new detail, reconcile deliberately, and retry with its ETag. Never substitute * or a weak validator.

Idempotency and revision preconditions solve different problems:

  • reuse one Idempotency-Key when retrying the same logical command after an ambiguous network failure;
  • never reuse that key for different semantic input; and
  • use If-Match to prove which current revision a mutation intends to replace.

6. Handle problem responses

Handler-level request failures normally use RFC 9457 application/problem+json. Switch on the stable code, retain requestId for operator correlation, and follow retry:

{
"type": "https://jiandu.org/docs/references/problem-codes?code=intake.duplicate",
"title": "This document is already in the library.",
"status": 409,
"instance": "urn:uuid:0f2c9d24-6a6b-4f8b-9f5b-1a2c3d4e5f60",
"code": "intake.duplicate",
"requestId": "0f2c9d24-6a6b-4f8b-9f5b-1a2c3d4e5f60",
"retry": "do_not_retry"
}

Retry only retry_with_backoff failures and honor bounded server guidance such as Retry-After. Do not retry permanent input, authorization, or precondition failures unchanged. A batch item error is different: the request can succeed with 200 or 202 while an individual item reports its own bounded error.

Use the problem-code field guide for current meanings and safe actions. Keep an unknown-code branch: the OpenAPI operation pages describe common response envelopes and statuses but do not yet associate every reachable code with every operation. Transport middleware, timeouts, method mismatches, and asynchronous task or batch-item failures may use other shapes, so also retain a safe unknown-error branch.

7. Revoke credentials and remove temporary files

A PAT cannot revoke itself or manage credentials. The retained normal session lets the EXIT cleanup revoke the uniquely labelled PAT first, then revoke the normal session and remove the CA, cookie jar, raw creation response, headers, and chunk. Run it explicitly to turn any cleanup failure into a visible failure now; the trap already does the same after an earlier error or interrupt:

jiandu_cleanup
trap - HUP INT TERM
unset JIANDU_TOKEN_ID JIANDU_USERNAME JIANDU_PAT_LABEL JIANDU_PAT_EXPIRES_AT_MS
unset JIANDU_CA JIANDU_CURL_TRANSPORT JIANDU_ORIGIN JIANDU_COOKIES