# DocHatch API Base URL: https://dochatch.io API base: https://dochatch.io/v1 Docs: https://dochatch.io/docs/errors OpenAPI: https://dochatch.io/v1/openapi.json ## Auth modes - Anonymous publishing: omit Authorization header. Links are permanent (no expiry). - API key: Authorization: Bearer dcl_live_... - Agent key flow: POST /v1/auth/device with email, poll GET /v1/auth/device/{poll_token} ## Agent key flow — REQUIRES a human step (cannot be completed by an agent alone) 1. POST /v1/auth/device {email, label?} -> {poll_token, poll_interval, expires_in} 2. A 6-digit code is EMAILED to the address. A HUMAN must read that email and complete POST /v1/auth/verify {poll_token, device_code} with the emailed 6-digit code. This is an out-of-band human step — an agent cannot do it. 3. Poll GET /v1/auth/device/{poll_token} -> {status}. status stays "pending" until the human completes the verify step above. Once verified, the poll returns {status: "verified", api_key: "dcl_live_..."} exactly once. Do not poll silently until expiry: if status is "pending", prompt the human to check their email and enter the 6-digit code. The poll_token expires in 600s. ## Three-call anonymous publish (minimum path) 1. POST /v1/files {filename, content_type} -> {file_id, upload_url, share_url, manage_token} content_type must be "text/html" or "text/markdown". Store manage_token immediately — it is shown exactly twice (init + publish) and cannot be recovered. 2. PUT {upload_url} -- body: raw file bytes Content-Type header REQUIRED: must match the content_type declared in step 1 (text/html or text/markdown). Omitting it returns 400. 3. POST /v1/files/{file_id}/publish {manage_token} -> {share_url, is_live: true, manage_token, manage_token_warning} The manage_token from step 1 is REQUIRED in the request body. The publish response echoes manage_token again alongside a warning. This is the last time the server surfaces it — store it now if you have not already. The share_url is a permanent link. It is not live until step 3 completes. PUT replay semantics: step 2 is idempotent on identical content, so a retry is safe and resolved by comparing the bytes you re-send: - Completed upload, re-PUT the SAME bytes -> 200 {uploaded: true} (no re-write). - Completed upload, PUT DIFFERENT bytes -> 409 upload_conflict (a completed upload cannot be overwritten). - Interrupted upload (bytes never fully received the first time) -> still retryable; just PUT again. To safely retry a flaky PUT, re-send the exact same bytes — never substitute different content. ## Downloading a password-protected file (recipient journey) If you have a share link /d/{slug} and the password, you can download the raw bytes: 1. POST /v1/files/{slug}/verify-password {password} -> {ok: true, session_token, expires_in: 3600, file_id} On a wrong password this returns 401 unauthorized. 2. GET /v1/files/{file_id}/download with header Authorization: Bearer {session_token} -> 200 with the original file bytes. The session_token is scoped to DOWNLOAD of that one file only. It expires in 3600s and cannot edit or delete the file (PATCH/DELETE require the owner manage_token or an API key). Unprotected files need no token — GET the download URL directly. ## Supported formats - text/html (.html, .htm) -- rendered as live interactive page; JavaScript runs - text/markdown (.md, .markdown) -- rendered as formatted rich text ## Manage endpoints (require manage_token or API key) - GET /v1/files/{file_id} -- non-secret status for a file you own -> {id, slug, is_live, has_password, content_type, created_at, published_at} Owner Bearer required (mt_... or dcl_live_...). Never returns the manage token, password hash, or sha256; has_password is a boolean. Not public — without owner auth, file IDs could be enumerated, so unauthenticated calls get 401. - DELETE /v1/files/{file_id} -- permanently delete; slug retired and never reused - PATCH /v1/files/{file_id} {password?, filename?} -- update metadata - GET /v1/files/{file_id}/download -- download the raw file bytes. Auth: owner Bearer (mt_... or dcl_live_...) OR, for a password-protected file, the download-only session_token from POST /v1/files/{slug}/verify-password (see the recipient journey above). The session_token authorizes DOWNLOAD of that one file only. Unprotected files need no token. ## Reporting abuse (anonymous, no auth) POST /v1/report {slug, reason, detail?} -> 201 {message} reason is one of: phishing, malware, illegal, harassment, spam, other (any other value -> 400). detail is optional plain text, truncated to 500 chars. Accepts JSON or form-urlencoded. Reporters are anonymous; the reporter IP is hashed before storage. Unknown slug -> 404. ## Limits Rate limit is on REQUESTS per minute, not publishes. Anonymous: 10 MB per file, 10 requests/min (per IP) on /v1/* API routes API key: 25 MB per file, 60 requests/min (per key) on /v1/* API routes Page routes (/d/*, /p/*, /manage/*): 60 requests/min per IP (separate bucket; HTML 429 response) ## Idempotency Idempotency-Key header accepted on POST /v1/files and POST /v1/files/{id}/publish. Same key within 24 hours returns original response without creating a duplicate. Idempotency-Key must be 255 characters or fewer. ## Error envelope { "error": { "code": "...", "message": "...", "retry_after": N, "docs_url": "..." } } Read the machine-readable code at error.code — it is a stable enum (see the OpenAPI Error schema for the full set); docs_url is the human explainer page. On rate_limit_exceeded: wait retry_after seconds (also sent as the Retry-After header). On unauthorized: request API key.