---
title: "Create a document and get an upload URL"
method: POST
path: "/documents"
tags: ["Documents"]
---

# Create a document and get an upload URL

`POST /documents`

## Request

- Base URL: `https://inkseal.net/api/v1`
- URL: `https://inkseal.net/api/v1/documents`
- Auth: HTTP bearer

Creates the document record and returns a presigned URL for the file itself.

The bytes do not travel through this API. Take `uploadUrl` from the response and issue a single `PUT` straight to it with the file as the raw request body, no authorization header, and two headers that must match what was signed exactly: `Content-Type` equal to the `contentType` you sent here, and `Content-Length` equal to `fileSize`. A mismatch on either one is rejected by storage, not by InkSeal. The URL is valid for 900 seconds (15 minutes) from the moment this call returns; after that, create a new document rather than reusing the id.

Nothing about the document is usable until the upload is handed back: call `POST /documents/{documentId}/confirm-upload` once the `PUT` succeeds. The document stays in `processing` with no page count until then.

The key the server signed is recorded on the document row, so `confirm-upload` binds to it and never trusts a key supplied by the caller. `objectKey` is returned for logging and support; it is not required by any later call.

Workspace content limits are workspace-wide rather than per-credential; a breach answers 429 with the code `workspace_limit_exceeded`, which is a different condition from request rate limiting and is not retryable on a timer.

Accepts an `Idempotency-Key`: a retry with the same key replays the original response rather than signing a second upload URL.

## Headers

- `Idempotency-Key` string

## Request body

- DocumentCreateRequest
  - `fileName` string, required: The file's name. Kept as the document's display name and used to derive the download name of the normalized PDF; it never becomes part of a storage path.
  - `contentType` 'application/pdf' | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' | 'application/msword' | 'application/rtf' | 'text/rtf' | 'application/vnd.oasis.opendocument.text' | 'application/vnd.openxmlformats-officedocument.presentationml.presentation' | 'text/plain' | 'text/markdown' | 'text/html' | 'image/png' | 'image/jpeg' | 'image/webp', required: The content type declared at creation. This is checked against the uploaded file's leading bytes at confirmation when the format has a reliable signature, so it has to be the truth. `text/html` accepts `.html` and `.htm` files up to 10 MB during conversion. The file must be self-contained and is rejected without alteration at the first forbidden construct: - Any `<script>` element, including inside SVG; any `on*` event attribute; or a `javascript:` or `vbscript:` URL. - Any `<iframe>`, `<frame>`, `<frameset>`, `<object>`, `<embed>`, `<applet>`, `<base>`, `<form>`, `<link>`, `<meta http-equiv>`, `<video>`, `<audio>`, `<track>`, or `<portal>` construct. - Any `src`, `href`, `srcset`, `poster`, `background`, `xlink:href`, `data`, `formaction`, or `ping` value unless it is empty, a bare fragment, or an allowed image data URL. Relative and scheme-relative references are external. - Any CSS `@import` or `@font-face`, any `url()` outside the allowed data-image rule, or any `image-set()` with an external member, in either `<style>` or a `style` attribute. - Any data URL except `image/png`, `image/jpeg`, `image/webp`, or `image/gif`; SVG images, fonts, media, and all other data types are rejected. Chromium independently disables JavaScript, runs offline, and aborts non-data and non-about requests. The normalized PDF follows the same 200-page limit and downstream workflow as every other source format.
  - `fileSize` integer, required: The file's exact size in bytes. It is signed into the upload URL, so the `PUT` must send a matching `Content-Length`.

## Response `201`

The document record was created and an upload URL was signed.

- object
  - `data` DocumentUpload, required: Where to put the file, and the id to use for everything after that.
    - `documentId` string, required: A document's public id: a 21-character URL-safe nanoid. Internal row ids and storage keys are never exposed.
    - `uploadUrl` string, uri, required: A presigned storage URL. `PUT` the file to it with the raw bytes as the body, `Content-Type` equal to the `contentType` you sent, and `Content-Length` equal to `fileSize`. Send no authorization header. Valid for 900 seconds.
    - `objectKey` string, required: The storage key the server signed, returned for logging and support. No later call needs it: `confirm-upload` reads the key from the document row.

## Other responses

- `400`: The request is malformed or the resource is in the wrong state for a synchronous check. Codes: `validation_error`, `invalid_body`, `invalid_parameter`, `invalid_idempotency_key`, `invalid_event_id`, `invalid_file`, `invalid_url`, `invalid_status`, `key_mismatch`, `no_signers`, `not_completed`, `envelope_exists`, `document_not_ready`, `upload_failed`.
- `401`: The credential is missing, malformed, unknown, expired, revoked, or past its rotation grace window. Code: `unauthorized`. Every authentication failure is deliberately indistinguishable.
- `403`: The credential is valid but may not do this. Codes: `insufficient_scope` (the route's scopes are not all held; `details` names `requiredScopes` and `missingScopes`), `forbidden` (the capability check on the resolved resource failed), `scope_escalation` (a grant wider than the caller's own was requested).
- `409`: The resource's state forbids this transition, or another request owns the work. Codes: `invalid_state`, `invalid_status`, `credential_state`, `limit_exceeded`, `envelope_missing`, `not_retryable`, `owner_unavailable`, `workspace_unavailable`, `detection_not_completed`, `document_in_use`, `version_conflict`, `document_processing`, `document_processing_failed`, `normalized_pdf_unavailable`, `pages_not_rendered`, `document_not_ready`, `idempotency_in_progress` (which also carries `Retry-After`), `payment_replayed` (a USDC payment signature presented twice), `payment_pending` (a USDC settlement whose outcome is not yet known).
- `422`: The request parsed but its content is not acceptable. Codes: `validation_error` (envelope configuration, with an `issues` array in `details`), `invalid_envelope`, `envelope_not_ready`, `document_page_limit`, `idempotency_key_reuse`.
- `429`: A limit was reached. Codes: `rate_limited` (the credential's per-bucket HTTP limit, or the per-workspace detection throttle), `workspace_limit_exceeded` (a workspace content or send limit; `details.limit` names it), `daily_budget_exceeded` (the workspace's daily detection budget). The four rate-limit headers are sent on this response and on no successful one, so a client cannot read its remaining budget without being refused. Retrying after `Retry-After` is always safe: a rate-limit refusal happens before the handler runs.
- `500`: An unexpected server failure. Codes: `internal_error`, `queue_failed`, `upload_failed`, `validation_failed`. Safe to retry, and safe to retry with the same `Idempotency-Key`: a 5xx is never pinned to a key.
- `503`: A dependency the request needs is unreachable and nothing was changed. Codes: `queue_unavailable` (a background job could not be enqueued; retry the call), `billing_unavailable` (Stripe would not open a checkout session; nothing was charged, retry the call), `service_unavailable` (the rate-limit store is down, so the request was refused before running).

---

[API](https://skmtc.dev/inkseal/apis/inkseal-api.md) · [All operations](https://skmtc.dev/inkseal/apis/inkseal-api/llms.txt) · [OpenAPI document](https://skmtc.dev/inkseal/apis/inkseal-api/revisions/c440c981d578?raw)
