Upload and download documents
Attach files to a case or an estate item, follow them through the malware scan, and list and download them.
Documents are the files that make up a matter: the death certificate, the grant, valuations, bank statements, correspondence. Through the Cases API you can attach a file to a case or to one of its estate items, list what is there, and download any file once it has passed a malware scan.
The flow is the same for both kinds of document. This guide walks through it with case documents, then sets out the few ways estate item documents differ.
You will need an access token with update:case to upload and delete documents, and read:case to list and download them. See Authenticate with a machine-to-machine client.
All paths below are relative to your environment's base URL:
| Environment | API base URL |
|---|---|
| Sandbox | https://cases.api.testing.exizent.com |
| Production | https://cases.api.exizent.com |
How an upload works
A file does not travel through the API itself. Uploading takes three steps:
- Start the upload. Tell the API the file's name, type and category. It creates the document, with the status
Pending, and hands back a one-off upload URL and a set of form fields. - Send the file.
POSTthe file to that upload URL as a form, together with the fields you were given. The file goes straight to storage, so even a large one never passes through the API. - Wait for the scan. Every file is scanned for malware before anyone can download it. When the scan passes, the document becomes
Cleanand you can ask for a download URL.
There is nothing to call after step 2. The API notices the file arriving on its own.
sequenceDiagram
participant You as Your system
participant API as Cases API
participant Upload as Upload URL
You->>API: POST /cases/{caseId}/documents
API-->>You: documentId, uploadUrl, fields
You->>Upload: POST form: every field, then the file
Upload-->>You: 204 No Content
Note over API: Pending → Scanning → Clean
You->>API: GET /cases/{caseId}/documents/{documentId}/url
API-->>You: 425 while scanning, then 200 with a download URL
Step 1 — Start the upload
POST /cases/{caseId}/documents (reference)
curl --request POST \
--url https://cases.api.testing.exizent.com/cases/979989d9-4f3e-4253-9a1d-9f94adb00493/documents \
--header 'Authorization: Bearer {{access_token}}' \
--header 'Content-Type: application/json' \
--data '{
"fileName": "death-certificate.pdf",
"contentType": "application/pdf",
"category": "DeathCertificate"
}'| Field | Required | Notes |
|---|---|---|
fileName | Yes | The file's name, with its extension. It is shown in listings, and the file is downloaded under this name |
contentType | Yes | The file's media type, for example application/pdf. Either it or the extension of fileName must be on the accepted list. It comes back to you as the content-type field for step 2 |
category | Yes | What the document is. One of the values below |
Case document categories
category | Shown in Exizent as |
|---|---|
DeathCertificate | Death certificate |
ExecutorTermsOfService | Terms of service |
ExecutorLetterOfAdministration | Letter of administration |
GrantOfConfirmationOrProbate | Court grant documents |
CourtForm | Court form |
TaxForm | Tax form |
EstateDiscovery | Estate discovery |
AdditionalDocument | Additional document |
Other | Other |
Choose the category with care: there is no way to change it once the upload has started.
200 OK
{
"documentId": "5b0b6c1e-2f7a-4c3d-8e9f-a1b2c3d4e5f6",
"uploadUrl": "https://…/",
"fields": {
"key": "…",
"policy": "…",
"x-amz-algorithm": "…",
"x-amz-credential": "…",
"x-amz-date": "…",
"x-amz-security-token": "…",
"x-amz-signature": "…",
"content-type": "application/pdf",
"x-amz-server-side-encryption": "AES256"
}
}documentIdis the document's id from now on. Keep it.uploadUrlis where the file goes in step 2. It is on a different host from the API, it accepts one file, and it expires five minutes after it was issued.fieldsare the form fields to send with the file. Treat them as opaque: send every one exactly as you received it, and do not rely on which fields there are, because the set can change.
The document now exists with the status Pending, and it already appears in the case's document list.
The file type is checked at this point, not in step 2. A file is accepted when either its contentType or the extension of its fileName is on the accepted list; anything else is refused with 400 and an errors.contentType entry.
Requires update:case.
Uploading to an estate item
POST /cases/{caseId}/estateitems/{estateItemId}/documents (reference)
The body and the response are the same, with one difference: category is optional, defaults to Other, and takes the estate item categories.
category | Use it for |
|---|---|
Valuation | A valuation of the item |
Statement | A statement, such as a bank or investment statement |
Other | Anything else. The default |
A 404 means that the case does not exist, that the estate item is not on it, or that the item is archived or deleted.
Step 2 — Send the file
POST the file to uploadUrl as multipart/form-data:
- Every entry of
fieldsgoes in as a form field, with its name and value unchanged. Do not add fields of your own. - The file comes last, in a field named
file. - Send no
Authorizationheader. The upload URL carries its own permission. Your access token is not needed there, and it should never be sent to another host. - Send it within five minutes, before the upload URL expires.
The content-type field you were given is the contentType you declared in step 1. Sending every field unchanged is what keeps the two in agreement.
# Every field from step 1, then the file. Building this list by hand is error-prone:
# the script in "Putting it together" builds it from the step 1 response.
curl --request POST \
--url 'https://…/' \
--form-string 'key=…' \
--form-string 'policy=…' \
--form-string 'x-amz-algorithm=…' \
--form-string 'x-amz-credential=…' \
--form-string 'x-amz-date=…' \
--form-string 'x-amz-security-token=…' \
--form-string 'x-amz-signature=…' \
--form-string 'content-type=application/pdf' \
--form-string 'x-amz-server-side-encryption=AES256' \
--form '[email protected]'// Node 18 or later, as an ES module. `start` is the parsed response from step 1.
import { readFile } from "node:fs/promises";
const form = new FormData();
for (const [name, value] of Object.entries(start.fields)) {
form.append(name, value);
}
form.append("file", new Blob([await readFile("death-certificate.pdf")]), "death-certificate.pdf");
// No Authorization header: the upload URL carries its own permission.
const response = await fetch(start.uploadUrl, { method: "POST", body: form });
if (!response.ok) {
throw new Error(`Upload failed with ${response.status}: ${await response.text()}`);
}# `start` is the parsed response from step 1.
import requests
with open("death-certificate.pdf", "rb") as file:
response = requests.post(
start["uploadUrl"],
data=start["fields"], # every field, unchanged; requests sends these before the file
files={"file": ("death-certificate.pdf", file)},
)
response.raise_for_status()In curl, --form-string sends each value literally. Plain --form would treat a value that starts with @ or < as a file to read.
204 No Content with an empty body means the file has arrived. The document moves to Scanning shortly afterwards.
When the upload is refused
The upload URL answers errors with an XML body, not the JSON problem details the API uses. The body names the problem, so log it.
| Status | Usual cause | What to do |
|---|---|---|
403 | The upload URL has expired, or a field was changed, left out or added | Start again from step 1 |
400 | The file is empty, or larger than 25 MB | Check the size before you start an upload |
Starting again from step 1 creates a new document with a new documentId. The one you abandoned stays Pending. Either delete it, or leave it: a document that never receives a file is removed about an hour after its upload was started.
Step 3 — Wait until the document is ready
A document's status tells you where it is, and whether you can download it yet.
| Status | What it means | Download URL |
|---|---|---|
Pending | The upload has started, but the file has not arrived. A Pending document that never receives a file is removed about an hour after it was created | 425 — not yet |
Scanning | The file has arrived and is being scanned for malware. sizeBytes and uploadedAt are now filled in | 425 — not yet |
Uploaded | Treat it exactly like Scanning | 425 — not yet |
Clean | The scan found nothing. The document is ready, and this is final | 200 — the download URL |
Infected | The scan found malware. This is final | 409 — never |
ScanFailed | The scan could not reach a verdict on the file. This is final | 409 — never |
Only a Clean document can be downloaded.
stateDiagram-v2
[*] --> Pending: upload started
Pending --> Scanning: file arrives
Pending --> [*]: no file within about an hour
Scanning --> Clean
Scanning --> Infected
Scanning --> ScanFailed
The API does not push status changes to you, so poll for them. The simplest way is to ask for the download URL (see Download a document) and act on the status code:
425 Too Early— the document has not finished its scan. Wait and ask again, backing off: 2 seconds, then 4, 8, 16, and every 30 seconds after that.200 OK— it isClean, and the body is the download URL.409 Conflict— it isInfectedorScanFailed, and it will never be downloadable. Stop.
Most documents are Clean within a minute of the file arriving. A large file can take longer. If a document is still not ready after 15 minutes, contact support with its documentId.
When you are waiting on many documents at once, list them instead (see below) and check each one's status. That is one request per page rather than one per document.
An Infected or ScanFailed document stays on the case, listed with its status, until you delete it. To replace one, upload a clean copy as a new document.
List documents
GET /cases/{caseId}/documents (reference)
curl --request GET \
--url 'https://cases.api.testing.exizent.com/cases/979989d9-4f3e-4253-9a1d-9f94adb00493/documents?search=certificate&pageSize=2' \
--header 'Authorization: Bearer {{access_token}}'| Query parameter | Default | Notes |
|---|---|---|
search | Matches part of the file name, ignoring case. Up to 100 characters | |
pageId | 1 | The page to return. The first page is 1 |
pageSize | 25 | From 1 to 100 |
200 OK — here the search matches three documents, so with pageSize=2 this first page holds two, and rel=next points to the page with the third.
Link: </cases/979989d9-4f3e-4253-9a1d-9f94adb00493/documents?search=certificate&pageId=1&pageSize=2>; rel=first, </cases/979989d9-4f3e-4253-9a1d-9f94adb00493/documents?search=certificate&pageId=2&pageSize=2>; rel=next, </cases/979989d9-4f3e-4253-9a1d-9f94adb00493/documents?search=certificate&pageId=2&pageSize=2>; rel=last
X-Total-Count: 3[
{
"id": "5b0b6c1e-2f7a-4c3d-8e9f-a1b2c3d4e5f6",
"fileName": "death-certificate.pdf",
"contentType": "application/pdf",
"sizeBytes": 184320,
"status": "Clean",
"caseItemType": "Case",
"caseItemId": null,
"category": "DeathCertificate",
"createdAt": "2026-10-14T10:15:02.114Z",
"uploadedAt": "2026-10-14T10:15:05.870Z",
"updatedAt": "2026-10-14T10:15:09.402Z"
},
{
"id": "0d6f3a2b-9c1e-4b7d-a5f8-3e2c1b0a9d87",
"fileName": "marriage-certificate.jpg",
"contentType": "image/jpeg",
"sizeBytes": null,
"status": "Pending",
"caseItemType": "Case",
"caseItemId": null,
"category": "AdditionalDocument",
"createdAt": "2026-10-14T10:12:44.031Z",
"uploadedAt": null,
"updatedAt": "2026-10-14T10:12:44.031Z"
}
]- Newest first. Documents come back in order of
createdAt, newest first. There is no other order. - Every status is listed, including
Pending,InfectedandScanFailed. Deleted documents are not. - Paging.
X-Total-Countis the number of documents across all pages.Linkholds the URLs of thefirst,prev,nextandlastpages.firstandlastare always there;prevandnextonly when such a page exists.- The URLs are relative to the API base URL and keep your other query parameters. To read everything, follow
rel=nextuntil there is none.
| Field | Notes |
|---|---|
id | The document's id: the documentId from step 1 |
fileName | As given when the upload was started |
contentType | The file's media type |
sizeBytes | The file's size in bytes. null until the file has arrived |
status | See Step 3 |
caseItemType | What the document is held against: Case or EstateItem |
caseItemId | The estate item's id, for an estate item document. null for a case document |
category | The category it was uploaded with. A plain string: display a value you do not recognise as it is, because more categories may be added |
createdAt | When the upload was started |
uploadedAt | When the file arrived. null until then |
updatedAt | When the document last changed, for example its status |
Treat sizeBytes and uploadedAt as optional even on a Clean document. Occasionally the scan result is recorded before the file's details are.
The case's list holds the documents held against the case itself. It does not include estate item documents; see Find every document on a case.
Requires read:case.
Listing an estate item's documents
GET /cases/{caseId}/estateitems/{estateItemId}/documents (reference)
The same parameters, headers and response, for that one item's documents. A 404 means that the case does not exist, that the estate item is not on it, or that the item is archived or deleted.
Download a document
GET /cases/{caseId}/documents/{documentId}/url (reference)
curl --request GET \
--url https://cases.api.testing.exizent.com/cases/979989d9-4f3e-4253-9a1d-9f94adb00493/documents/5b0b6c1e-2f7a-4c3d-8e9f-a1b2c3d4e5f6/url \
--header 'Authorization: Bearer {{access_token}}'200 OK — the download URL, as a JSON string:
"https://…"Parse the body as JSON rather than stripping the quotes, because characters in the URL such as & may come back escaped.
Then fetch the URL itself:
- Send no
Authorizationheader. Like the upload URL, it carries its own permission. - Use it straight away. It expires five minutes after it was issued. Do not store download URLs; ask for a fresh one each time you need the file.
- The file comes back under its original name. The response's
Content-Dispositionheader gives thefileNamethe upload was started with, socurl --remote-name --remote-header-namesaves it asdeath-certificate.pdf.
curl --remote-name --remote-header-name --url '{{download_url}}'When the document cannot be downloaded, the API says why:
| Status | Meaning |
|---|---|
404 | No such document on the case, or it has been deleted |
409 | The document is Infected or ScanFailed, and it never will be downloadable |
425 | The document has not finished its malware scan. Try again shortly |
{
"title": "Document not ready",
"status": 425,
"detail": "Document is in status 'Scanning' and can be downloaded once its scan has finished.",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "Document unavailable",
"status": 409,
"detail": "Document is in status 'Infected' and cannot be downloaded.",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}For an estate item document, use GET /cases/{caseId}/estateitems/{estateItemId}/documents/{documentId}/url (reference). It also returns 404 when the estate item is archived or deleted.
Requires read:case.
Delete a document
DELETE /cases/{caseId}/documents/{documentId} (reference)
curl --request DELETE \
--url https://cases.api.testing.exizent.com/cases/979989d9-4f3e-4253-9a1d-9f94adb00493/documents/5b0b6c1e-2f7a-4c3d-8e9f-a1b2c3d4e5f6 \
--header 'Authorization: Bearer {{access_token}}'204 No Content — the document is gone from every list, and asking for its download URL now returns 404. This cannot be undone through the API.
- Any status. You can delete a document whatever its status, including a
Pendingone whose upload you abandoned. - Safe to retry. Deleting a document that is already deleted also returns
204.
For an estate item document, use DELETE /cases/{caseId}/estateitems/{estateItemId}/documents/{documentId} (reference).
Requires update:case.
Case documents and estate item documents
Everything in this guide applies to both. The steps, the statuses, the limits, the shape of a document, paging and search, and the scopes are all the same.
These are the differences:
| Case documents | Estate item documents | |
|---|---|---|
| Routes | /cases/{caseId}/documents | /cases/{caseId}/estateitems/{estateItemId}/documents |
category when starting an upload | Required | Optional, defaults to Other |
| Category values | DeathCertificate, ExecutorTermsOfService, ExecutorLetterOfAdministration, GrantOfConfirmationOrProbate, CourtForm, TaxForm, EstateDiscovery, AdditionalDocument, Other | Valuation, Statement, Other |
caseItemType and caseItemId | Case, and null | EstateItem, and the item's id |
404 | No such case, or no such document on it | Also when the estate item is not on the case, or is archived or deleted. This applies to all four routes, the list included |
| What the list holds | Documents held against the case itself | That one item's documents |
Some points to bear in mind:
- Categories belong to their owner. A case category sent to an estate item route, or the other way round, is refused with
400. - Archiving an estate item does not delete its documents. They cannot be reached through the API while the item is archived.
- A document belongs to one owner. It can only be reached through its own owner's routes; asking for it through any other owner's routes returns
404.
Find every document on a case
There is no single list of everything. To gather it all:
GET /cases/{caseId}/documentsfor the case's own documents.GET /cases/{id}and read theestateItems. Skip any withisArchived: true.GET /cases/{caseId}/estateitems/{estateItemId}/documentsfor each of the rest.
GET /cases/{id} and the estate item GET may also return a documents array. It is not part of the published API: it does not include documents uploaded through the routes in this guide, and nothing in it can be downloaded. Use the routes in this guide instead.
Putting it together
This script uploads a file to a case, waits for the scan, and downloads the file again. It needs curl and jq.
#!/usr/bin/env bash
set -euo pipefail
API=https://cases.api.testing.exizent.com
CASE_ID=979989d9-4f3e-4253-9a1d-9f94adb00493
FILE=death-certificate.pdf
TOKEN='{{access_token}}'
# 1. Start the upload.
start=$(curl --silent --show-error --fail-with-body --request POST \
--url "$API/cases/$CASE_ID/documents" \
--header "Authorization: Bearer $TOKEN" \
--header 'Content-Type: application/json' \
--data "$(jq -n --arg name "$(basename "$FILE")" \
'{fileName: $name, contentType: "application/pdf", category: "DeathCertificate"}')")
document_id=$(jq -r '.documentId' <<<"$start")
upload_url=$(jq -r '.uploadUrl' <<<"$start")
# 2. Send the file: every field unchanged, the file last, and no access token.
form=()
while IFS= read -r field; do
form+=(--form-string "$field")
done < <(jq -r '.fields | to_entries[] | "\(.key)=\(.value)"' <<<"$start")
curl --silent --show-error --fail-with-body --request POST \
--url "$upload_url" "${form[@]}" --form "file=@$FILE"
# 3. Wait for the scan: 425 means not yet, 200 means ready, anything else stops.
delay=2
deadline=$((SECONDS + 900))
while :; do
status=$(curl --silent --output response.json --write-out '%{http_code}' \
--url "$API/cases/$CASE_ID/documents/$document_id/url" \
--header "Authorization: Bearer $TOKEN")
case "$status" in
200) break ;;
425)
if (( SECONDS > deadline )); then
echo "Document $document_id is still being scanned. Contact support." >&2
exit 1
fi
sleep "$delay"
delay=$(( delay < 16 ? delay * 2 : 30 )) ;;
*)
echo "Download refused with $status:" >&2
cat response.json >&2
exit 1 ;;
esac
done
# 4. Download it under its original name, straight away.
curl --silent --show-error --fail --remote-name --remote-header-name \
--url "$(jq -r . response.json)"In short:
POST …/documentswith the file's name, type and category. Keep thedocumentId.- Within five minutes,
POSTthe file touploadUrl, with every field unchanged, the file last, and no access token. - Ask for the download URL:
425means wait and ask again,409means it never will be ready, and200means it is ready. - Fetch the download URL straight away, and ask for a new one the next time you need the file.
- Delete documents whose upload you abandoned, or let them expire.
Limits and rules
| File size | From 1 byte to 25 MB (26,214,400 bytes) |
| Upload URL | One file, within five minutes of being issued |
| Download URL | Valid for five minutes |
| Unused upload | Removed about an hour after it was started, if no file has arrived |
pageSize | From 1 to 100. Defaults to 25 |
search | Up to 100 characters |
Accepted files
A file is accepted when either its contentType or the extension of its fileName is on these lists. Both checks ignore case.
Content types: any image type (image/*), application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, text/xml, application/xml, text/csv, text/plain, application/vnd.ms-outlook, message/rfc822.
Extensions: .pdf, .doc, .docx, .xls, .xlsx, .csv, .txt, .xml, .msg, .eml, .png, .jpg, .jpeg, .gif, .bmp, .webp, .tif, .tiff, .heic, .heif.
If you do not know a file's type, declare application/octet-stream. It is accepted when the extension is on the list, and the content-type field you get back will match it.
Errors
| Status | Meaning |
|---|---|
400 | Validation failed: a required field is missing, the category is not one the owner accepts, or the file type is not accepted. The errors object names the field |
401 | Missing or expired access token |
403 | The token lacks update:case (to upload or delete) or read:case (to list or download) |
404 | No such case, estate item or document, or it belongs to another company. For estate item documents, also when the item is archived or deleted |
409 | Download URL only: the document is Infected or ScanFailed |
425 | Download URL only: the document has not finished its malware scan |
Validation failures come back as problem details:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"category": ["The Category field is required."]
}
}Errors from the upload URL in step 2 are different: see When the upload is refused.
Stuck on something? [email protected].
Updated about 4 hours ago

