Uploads

The multipart upload flow — idempotency via external_file_id, accepted content types, asynchronous processing states, and polling vs webhooks.

Documents enter a submission as uploads. An upload is a single multipart request that stores one file and queues it for processing. Uploads can be attached to a specific document slot of the submission's template (create_slot_upload, targeting a slot_id from the submission detail), or left unassigned and sorted into a slot later.

The multipart request

Send multipart/form-data with exactly two parts you care about:

  • external_file_id — a text part; your identifier for this file and its idempotency key.
  • file — the file bytes. Supply a filename and content type on the part.

Any other parts are ignored for forward compatibility.

# Unassigned upload
curl -X POST \
  https://api.aircredit.de/partner/v1/submissions/$SUBMISSION_ID/files \
  -H "Authorization: Bearer $AIRCREDIT_API_KEY" \
  -F "external_file_id=payslip-jan" \
  -F "file=@./payslip-january.pdf;type=application/pdf"

# Into a specific document slot
curl -X POST \
  "https://api.aircredit.de/partner/v1/submissions/$SUBMISSION_ID/document-slots/$SLOT_ID/files" \
  -H "Authorization: Bearer $AIRCREDIT_API_KEY" \
  -F "external_file_id=payslip-jan" \
  -F "file=@./payslip-january.pdf;type=application/pdf"
import { readFile } from "node:fs/promises";

const file = new File([await readFile("./payslip-january.pdf")], "payslip-january.pdf", {
  type: "application/pdf",
});

// Unassigned
await client.createUpload({ submissionId, externalFileId: "payslip-jan", file });

// Into a slot
await client.createSlotUpload({ submissionId, slotId, externalFileId: "payslip-jan", file });
with open("payslip-january.pdf", "rb") as f:
    part = ("payslip-january.pdf", f, "application/pdf")
    # Unassigned
    client.create_upload(submission_id=submission_id, external_file_id="payslip-jan", file=part)

with open("payslip-january.pdf", "rb") as f:
    part = ("payslip-january.pdf", f, "application/pdf")
    # Into a slot
    client.create_slot_upload(
        submission_id=submission_id, slot_id=slot_id, external_file_id="payslip-jan", file=part
    )
let bytes = tokio::fs::read("payslip-january.pdf").await?;

// Unassigned
client.create_upload()
    .submission_id(&submission_id)
    .external_file_id("payslip-jan")
    .file(bytes.clone(), "payslip-january.pdf", "application/pdf")
    .send().await?;

// Into a slot
client.create_slot_upload()
    .submission_id(&submission_id)
    .slot_id(&slot_id)
    .external_file_id("payslip-jan")
    .file(bytes, "payslip-january.pdf", "application/pdf")
    .send().await?;

Idempotency

Uploads are idempotent on external_file_id within a submission. If you re-send an external_file_id that already exists on the submission, AirCredit returns the existing upload with 200 OK and does not store the new bytes. A fresh store returns 202 Accepted.

This makes retries safe. If a request times out, resend it with the same external_file_id: you either created the upload (202) or you're getting back the one that already landed (200). Either way there's exactly one upload.

Tip

Pick external_file_ids that are stable and meaningful in your system (e.g. payslip-jan, id-front, bank-statement-q4). They flow through to evaluation results and webhook payloads, so they double as your correlation key.

Accepted content types

typeextension
application/pdf.pdf
image/jpeg.jpg
image/png.png
application/msword.doc
application/vnd.openxmlformats-officedocument.wordprocessingml.document.docx

The default maximum file size is 100 MiB. A file that is the wrong type or too large is accepted for asynchronous processing and then fails with a failure code (see below) rather than being rejected at request time — so always check the eventual upload_status.

Processing states

Processing is asynchronous. An upload moves through:

  • processing — stored and queued; the initial state of a 202 Accepted.
  • ready — processed successfully; file_id is now set and the file feeds evaluation.
  • failed — processing failed; failure carries a { code, message }.

Failure codes you may see (new ones may be added — treat unknown codes by their family):

codemeaning
unsupported_content_typethe file's real type isn't accepted
file_too_largeover the size limit
unreadable_filecorrupt or not machine-readable
malware_detectedflagged by the malware scan
storage_failedtransient storage error
processing_failedgeneric processing failure
internal_errorunexpected server error

Managing uploads

Beyond creating uploads, you can list them (list_uploads), fetch one (get_upload), move one between slots (update_upload), and delete one (delete_upload):

# Poll one upload without fetching the whole submission.
curl https://api.aircredit.de/partner/v1/submissions/$SUBMISSION_ID/files/$UPLOAD_ID \
  -H "Authorization: Bearer $AIRCREDIT_API_KEY"

# Assign an unassigned upload to a document slot (null unassigns).
curl -X PATCH https://api.aircredit.de/partner/v1/submissions/$SUBMISSION_ID/files/$UPLOAD_ID \
  -H "Authorization: Bearer $AIRCREDIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "document_slot_id": "'$SLOT_ID'" }'

# Remove a wrong or rejected document before re-uploading.
curl -X DELETE https://api.aircredit.de/partner/v1/submissions/$SUBMISSION_ID/files/$UPLOAD_ID \
  -H "Authorization: Bearer $AIRCREDIT_API_KEY"
// Poll one upload without fetching the whole submission.
const upload = await client.getUpload({ submissionId, uploadId });
console.log(upload.uploadStatus);

// Assign an unassigned upload to a document slot (null unassigns).
await client.updateUpload({ submissionId, uploadId, documentSlotId: slotId });

// Remove a wrong or rejected document before re-uploading.
await client.deleteUpload({ submissionId, uploadId });
# Poll one upload without fetching the whole submission.
upload = client.get_upload(submission_id=submission_id, upload_id=upload_id)
print(upload.upload_status)

# Assign an unassigned upload to a document slot (None unassigns).
client.update_upload(submission_id=submission_id, upload_id=upload_id, document_slot_id=slot_id)

# Remove a wrong or rejected document before re-uploading.
client.delete_upload(submission_id=submission_id, upload_id=upload_id)
// Poll one upload without fetching the whole submission.
let upload = client.get_upload()
    .submission_id(&submission_id).upload_id(&upload_id)
    .send().await?.into_inner();
println!("{:?}", upload.upload_status);

// Assign an unassigned upload to a document slot (None unassigns).
client.update_upload()
    .submission_id(&submission_id).upload_id(&upload_id)
    .body_map(|b| b.document_slot_id(Some(slot_id.clone())))
    .send().await?;

// Remove a wrong or rejected document before re-uploading.
client.delete_upload()
    .submission_id(&submission_id).upload_id(&upload_id)
    .send().await?;

Deleting removes the stored bytes and frees the external_file_id for reuse on the submission; reassigning and deleting recompute the affected slot's status. Both are rejected with 409 evaluation_in_progress while an evaluation is queued or running — cancel or wait first.

The re-upload loop

Document slots carry a review status (missing, partial, complete, accepted, needs_review, rejected). When a reviewer rejects a document, the slot moves to rejected and a document_slot.updated event fires — no polling needed. The intended fix loop is: receive the event, delete the bad upload, upload the corrected file into the same slot, and start a new evaluation once everything is ready.

Polling vs webhooks

You learn an upload's outcome three ways:

  • Poll get_upload for a single file, or get_submission for the whole picture. Simple, but do it sparingly — a few times a minute at most.
  • Webhooks — subscribe and receive file.ready / file.failed the moment processing finishes, and document_slot.updated when a slot's review status changes. This is the production path; it's push-based, immediate, and doesn't waste calls. See Webhooks.

For evaluation outcomes (as opposed to individual files), the evaluation.* events carry the result summary; see Evaluations for the full result.

On this page