From c8f7164678b212303a99583f670b3c99e7c67deb Mon Sep 17 00:00:00 2001 From: Shir Goldberg <3937986+shirgoldbird@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:30:58 +0200 Subject: [PATCH] docs: redirect /pricing and close the remaining agentic-readiness gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the /pricing redirect to deepl.com/pricing and works through the Is Agentic audit findings, keeping only the ones this repo can actually change. Two of the previous round's fixes turned out to be dead config. `head` is not a property of Mintlify's docs.json (the schema sets additionalProperties: false), so the Organization JSON-LD it carried never reached the rendered HTML: the live page has no contactPoint and no address anywhere in it. The root .css and .js files it referenced are auto-injected by Mintlify from the repo root, so they are unaffected. Removed `head` and enriched seo.organization instead, which is the only structured-data hook Mintlify exposes. It has no field for contactPoint or address, so that gap now needs Mintlify, not this repo. - New /AGENTS.md, served at /AGENTS.md and /AGENTS, with the when-to-use guidance the audit asked for: which job maps to which endpoint, when to reach for something else, base URLs, auth, the spec URLs, the error contract, and throttling. Also linked from the 404 page and from the markdown instructions - markdown.instructions, which Mintlify prepends to every page served as markdown, gains a "do not use it for" line, the RFC 9727 catalog, and the rate-limit conventions - Documented the response headers the API actually sends on errors: X-Trace-ID on all 22 error response components, Retry-After on the rate-limit response that every 429 and 529 uses, plus a table on the error handling page. Verified against api.deepl.com, which returns X-Trace-ID and a JSON body with a resolution link on a 403 - Surfaced docs/getting-started/auth in the navigation. Every DeepL API auth error links to it, but with seo.indexing "navigable" a page outside the navigation is left out of the sitemap and llms.txt, so agents could not discover the page the error told them to read - Short /openapi, /asyncapi and /agents redirects. The existing /openapi.json and /api/openapi.yaml redirects 404 in production because Mintlify routes any path with a file extension as an asset and never applies redirects to it - Flattened the two redirect chains, so no redirect points at another redirect Tests: scripts/agentic-readiness.test.mjs guards docs.json against unrecognized properties, redirect chains and dead destinations, the AGENTS.md sections and links, and the OpenAPI tool-definition and error-header invariants. The test workflow now runs on docs.json, AGENTS.md and the spec, not just scripts/. Verified: 28 tests pass, docs.json validates against mintlify.com/docs.json except two pre-existing deviations, openapi.json regenerates from the YAML byte-identically, /pricing and /openapi redirect correctly under `mint dev`, the custom CSS and JS are still injected, and `mint broken-links --check-anchors` reports the same 44 findings as main, all in pipeline/drafts or pre-existing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/test-scripts.yml | 8 + AGENTS.md | 155 +++++++++++++++++ api-reference/openapi.json | 120 ++++++++++++++ api-reference/openapi.yaml | 76 +++++++++ docs.json | 64 +++---- docs/best-practices/error-handling.mdx | 9 + scripts/agentic-readiness.test.mjs | 220 +++++++++++++++++++++++++ 7 files changed, 615 insertions(+), 37 deletions(-) create mode 100644 AGENTS.md create mode 100644 scripts/agentic-readiness.test.mjs diff --git a/.github/workflows/test-scripts.yml b/.github/workflows/test-scripts.yml index 7aa0f189..7feee7e0 100644 --- a/.github/workflows/test-scripts.yml +++ b/.github/workflows/test-scripts.yml @@ -4,11 +4,19 @@ on: pull_request: paths: - "scripts/**" + - "docs.json" + - "AGENTS.md" + - "api-reference/openapi.yaml" + - "api-reference/openapi.json" push: branches: - main paths: - "scripts/**" + - "docs.json" + - "AGENTS.md" + - "api-reference/openapi.yaml" + - "api-reference/openapi.json" jobs: test: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..c841ba8b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,155 @@ +--- +title: "DeepL API instructions for AI agents" +description: "When to call the DeepL API, which endpoint does which job, how to authenticate, and how to handle its errors and rate limiting." +--- + +DeepL SE builds Language AI products. This file tells an agent when to call the DeepL API, how to +authenticate, where the machine-readable specs are, and how to handle errors and throttling. + +- Documentation: https://developers.deepl.com +- Page index for agents: [/llms.txt](https://developers.deepl.com/llms.txt), or + [/llms-full.txt](https://developers.deepl.com/llms-full.txt) for the full text of every page +- Any documentation page is available as Markdown: append `.md` to its path, or request it with + `Accept: text/markdown` + +## When to use the DeepL API + +Call the DeepL API when a task needs one of these jobs. + +| **Job** | **Endpoint** | **Reference** | +| --- | --- | --- | +| Translate text strings between languages | `POST /v2/translate` | [Request translation](https://developers.deepl.com/api-reference/translate/request-translation) | +| Translate a whole document and keep its formatting (`.docx`, `.pptx`, `.xlsx`, `.pdf`, `.html`, `.xliff`, `.srt`, and more) | `POST /v2/document` | [Document translation](https://developers.deepl.com/api-reference/document/upload-and-translate-a-document) | +| Transcribe speech and translate it while someone is speaking | `POST /v3/voice/realtime`, then stream over the returned WebSocket URL | [Voice API](https://developers.deepl.com/docs/voice/overview) | +| Transcribe and translate a recorded audio file | `POST /v1/jobs/voice/translate` | [Translate an audio file](https://developers.deepl.com/api-reference/jobs-voice-translate/create-voice-translate-job) | +| Rewrite text in the same language: fix grammar, change tone, shorten or expand it | `POST /v2/write/rephrase`, `POST /v2/write/correct` | [Write API](https://developers.deepl.com/api-reference/improve-text/request-text-improvement) | +| Enforce specific terminology in a translation | `POST /v3/glossaries`, then pass the glossary ID | [Glossaries](https://developers.deepl.com/docs/customize/managing-glossaries) | +| Apply a house style, tone, or writing convention | `POST /v3/style_rules`, then pass the style ID | [Style rules](https://developers.deepl.com/docs/customize/using-style-rules) | +| Reuse translations a human has already approved | `POST /v3/translation_memories/import` | [Translation memories](https://developers.deepl.com/docs/customize/using-translation-memories) | +| Find out which languages and features are available before translating | `GET /v3/languages` | [Using the Languages API](https://developers.deepl.com/docs/languages/using-the-languages-api) | +| Check how much of an account's quota is used | `GET /v2/usage` | [Usage and quota](https://developers.deepl.com/api-reference/usage-and-quota/check-usage-and-limits) | +| Create, label, or limit API keys across an organization | `/v2/admin/*` | [Admin API](https://developers.deepl.com/docs/admin/overview) | + +Retrieve the list of supported languages from `/v3/languages` rather than hard-coding it. Language +support changes, and it differs per resource: a language available for text translation is not +necessarily available for speech. + +### When to use something else + +The DeepL API transforms text you supply. It does not generate new content, so send a request +somewhere else when the task is to write, summarize, answer, or classify. + +To translate inside a chat session rather than from code, use an MCP server instead of writing HTTP +calls yourself: + +- [DeepL MCP Server](https://developers.deepl.com/docs/getting-started/deepl-mcp-server) exposes + translation, text improvement, and glossaries as tools +- [Docs MCP Server](https://developers.deepl.com/docs/getting-started/docs-mcp-server) at + `https://developers.deepl.com/mcp` (Streamable HTTP, no authentication) searches and reads this + documentation + +## Base URLs and authentication + +| **Plan** | **Base URL** | +| --- | --- | +| DeepL API Pro | `https://api.deepl.com` | +| DeepL API Free | `https://api-free.deepl.com` | + +Free API keys end in `:fx`. Use the base URL that matches the key, since a Pro key does not work +against the Free host or the other way around. [Regional +endpoints](https://developers.deepl.com/docs/getting-started/regional-endpoints) are available for +accounts that need data residency in a specific region. + +Authenticate every request with a header: + +```http +Authorization: DeepL-Auth-Key +``` + +Never fabricate or guess an API key. Ask the user for one, or point them at the +[Quickstart](https://developers.deepl.com/docs/getting-started/quickstart). Keys belong in an +environment variable or a secret store, never in client-side code or a committed file. + +## Machine-readable API surface + +Read the specification instead of inferring request shapes from prose. + +| **Artifact** | **URL** | +| --- | --- | +| OpenAPI 3.0 specification (REST), YAML | https://developers.deepl.com/api-reference/openapi.yaml | +| OpenAPI 3.0 specification (REST), JSON | https://developers.deepl.com/api-reference/openapi.json | +| AsyncAPI specification (Voice WebSocket), YAML | https://developers.deepl.com/api-reference/voice/voice.asyncapi.yaml | +| AsyncAPI specification (Voice WebSocket), JSON | https://developers.deepl.com/api-reference/voice/voice.asyncapi.json | +| API catalog ([RFC 9727](https://www.rfc-editor.org/rfc/rfc9727.html)) | https://developers.deepl.com/.well-known/api-catalog | + +Every operation in the OpenAPI specification carries a unique `operationId`, a summary, a +description, typed request and response schemas, and typed error responses, so it can be converted +into function or tool definitions without hand-editing. + +## Error handling + +Every error carries a standard HTTP status code and a JSON body. Branch on the status code first, +then read the body. Full detail is in [Error +handling](https://developers.deepl.com/docs/best-practices/error-handling). + +Application errors put the message at the top level, and add a machine-readable `code` where one is +available: + +```json +{ + "message": "Value for 'target_lang' not supported." +} +``` + +Failures in DeepL's edge infrastructure, before a request reaches the API, nest the message under +`error` instead: + +```json +{ + "error": { + "message": "Bad Gateway." + } +} +``` + +Reading `body.message ?? body.error?.message` covers both shapes. Match on `code` or the status +code, never on `message`, which is written for humans and can change wording. + +| **Status** | **Meaning** | **What to do** | +| --- | --- | --- | +| `400` | The request itself is invalid | Fix the request. Do not retry | +| `403` | Authentication failed, or the key lacks the permission scope for this endpoint | Check the key and its scopes. Do not retry | +| `404` | The resource does not exist, or a document was already downloaded | Do not retry | +| `413` | The request is over the size limit | Split the payload. Do not retry | +| `429`, `529` | Too many requests in a short period | Retry with exponential backoff | +| `456` | The account quota is exhausted | Stop. Retrying cannot succeed until the quota is raised or the period resets | +| `500`, `503`, `504` | Temporary error in DeepL services | Retry with exponential backoff | + +Every response, including every error, carries an `X-Trace-ID` header that identifies the request in +DeepL's logs. Log it by default and include it in support requests. + +## Rate limits and throttling + +The service adjusts to the load on the system, so there is no fixed requests-per-second figure to +code against, and no `RateLimit` response headers to read. Throttle from the responses you get: + +- Retry `429` and `529` with exponential backoff and jitter +- Honor the `Retry-After` header, in seconds, when a response includes one, in preference to your own + backoff interval +- Cap how many requests you have in flight, and lower that cap while `429` responses continue +- Batch multiple strings into one `POST /v2/translate` call instead of one request per string, + staying inside the 128 KiB request size limit +- Treat `456` as a stop condition, and poll `GET /v2/usage` to see how close an account is to its + quota before you get there + +## Contact + +| **Purpose** | **Where** | +| --- | --- | +| API support and contact options | [Contact](https://developers.deepl.com/docs/resources/contact) | +| API status and incidents | https://status.deepl.com/?tab=api | +| Security reports | security@deepl.com ([policy](https://developers.deepl.com/SECURITY.md)) | +| Privacy and data handling | [Privacy](https://developers.deepl.com/docs/resources/privacy) | +| Plans and pricing | https://www.deepl.com/pricing | + +DeepL SE, Maarweg 165, 50825 Cologne, Germany. diff --git a/api-reference/openapi.json b/api-reference/openapi.json index 17a1102c..0fb65faa 100644 --- a/api-reference/openapi.json +++ b/api-reference/openapi.json @@ -6788,6 +6788,13 @@ "type": "string" }, "example": "501c3d93cc0c4f11ae2f60a226c2f0f0" + }, + "Retry-After": { + "description": "The number of seconds to wait before resending the request. Sent when DeepL can suggest a wait time. Honor it in preference to your own backoff interval; when it is absent, retry with exponential backoff and jitter.\n", + "schema": { + "type": "integer" + }, + "example": 5 } }, "parameters": { @@ -6877,6 +6884,11 @@ "responses": { "VoiceTranslateJobBadRequest": { "description": "Bad request. Check the error message and request parameters.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -6890,6 +6902,11 @@ }, "BadRequest": { "description": "Bad request. Please check error message and your parameters.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -6900,6 +6917,11 @@ }, "BadRequestGlossaries": { "description": "Bad request. Please check error message and your parameters.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -6924,6 +6946,11 @@ }, "Unauthorized": { "description": "Authorization failed. Please supply a valid `DeepL-Auth-Key` via the `Authorization` header.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -6934,6 +6961,11 @@ }, "Forbidden": { "description": "Authorization failed. Please supply a valid `DeepL-Auth-Key` via the `Authorization` header.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -6944,6 +6976,11 @@ }, "ForbiddenScoped": { "description": "Authorization failed. Please supply a valid `DeepL-Auth-Key` via the `Authorization` header. This error is also returned when the API key is scoped but does not include the scope required for this endpoint.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -6954,6 +6991,11 @@ }, "ForbiddenGlossaries": { "description": "Forbidden. The access to the requested resource is denied, because of insufficient access rights. This error is also returned when the API key is scoped but does not include the scope required for this endpoint.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -6964,6 +7006,11 @@ }, "NotFound": { "description": "The requested resource could not be found.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -6974,6 +7021,11 @@ }, "NotFound404DocTransDownload": { "description": "Trying to download a document using a non-existing document ID or the wrong document key will result in a 404 error. As stated above, documents can only be downloaded once before they are deleted from the server and their document ID is invalidated.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -6992,6 +7044,11 @@ }, "PayloadTooLarge": { "description": "The request size exceeds the limit.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -7002,6 +7059,11 @@ }, "URITooLong": { "description": "The request URL is too long. You can avoid this error by using a POST request instead of a GET request, and sending the parameters in the HTTP body.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -7019,6 +7081,11 @@ }, "UnsupportedMediaTypeGlossaries": { "description": "The requested entries format specified in the `Accept` header is not supported.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -7029,6 +7096,14 @@ }, "TooManyRequests": { "description": "Too many requests. Please wait and resend your request.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -7039,6 +7114,11 @@ }, "QuotaExceeded": { "description": "Quota exceeded. The character limit has been reached.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -7049,6 +7129,11 @@ }, "QuotaExceededGlossaries": { "description": "Quota exceeded. Too many requests were made to the glossary endpoints recently.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -7059,6 +7144,11 @@ }, "BadRequestSpokenTerms": { "description": "Bad request. Please check error message and your parameters.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -7072,6 +7162,11 @@ }, "ForbiddenSpokenTerms": { "description": "Forbidden. Authorization failed or feature not available for your plan.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -7085,6 +7180,11 @@ }, "QuotaExceededSpokenTerms": { "description": "Quota exceeded. Spoken Terms collection limit reached for your plan.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -7098,6 +7198,11 @@ }, "UnsupportedMediaType": { "description": "Unsupported Media Type. Must be application/json or application/x-www-form-urlencoded.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -7111,6 +7216,11 @@ }, "InternalServerError": { "description": "Internal error.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -7128,6 +7238,11 @@ }, "ServiceUnavailable": { "description": "Resource currently unavailable. Try again later.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { @@ -7145,6 +7260,11 @@ }, "ServiceUnavailable503DocTransDownload": { "description": "A 503 result will be returned if the user tries to download a translated document that is currently being processed and is not yet ready for download.\nPlease make sure to check that the document status is 'done' before trying to send a download request.", + "headers": { + "X-Trace-ID": { + "$ref": "#/components/headers/X-Trace-ID" + } + }, "content": { "application/json": { "schema": { diff --git a/api-reference/openapi.yaml b/api-reference/openapi.yaml index 01566352..e5702a65 100644 --- a/api-reference/openapi.yaml +++ b/api-reference/openapi.yaml @@ -4780,6 +4780,14 @@ components: schema: type: string example: 501c3d93cc0c4f11ae2f60a226c2f0f0 + Retry-After: + description: > + The number of seconds to wait before resending the request. Sent when DeepL can suggest a + wait time. Honor it in preference to your own backoff interval; when it is absent, retry + with exponential backoff and jitter. + schema: + type: integer + example: 5 parameters: CustomReportingTag: name: X-DeepL-Reporting-Tag @@ -4856,6 +4864,9 @@ components: responses: VoiceTranslateJobBadRequest: description: Bad request. Check the error message and request parameters. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -4864,12 +4875,18 @@ components: message: "/targets/0/type: Value is not valid" BadRequest: description: Bad request. Please check error message and your parameters. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' BadRequestGlossaries: description: Bad request. Please check error message and your parameters. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -4888,6 +4905,9 @@ components: Unauthorized: description: Authorization failed. Please supply a valid `DeepL-Auth-Key` via the `Authorization` header. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -4895,6 +4915,9 @@ components: Forbidden: description: Authorization failed. Please supply a valid `DeepL-Auth-Key` via the `Authorization` header. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -4903,6 +4926,9 @@ components: description: Authorization failed. Please supply a valid `DeepL-Auth-Key` via the `Authorization` header. This error is also returned when the API key is scoped but does not include the scope required for this endpoint. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -4911,12 +4937,18 @@ components: description: Forbidden. The access to the requested resource is denied, because of insufficient access rights. This error is also returned when the API key is scoped but does not include the scope required for this endpoint. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' NotFound: description: The requested resource could not be found. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -4926,6 +4958,9 @@ components: or the wrong document key will result in a 404 error. As stated above, documents can only be downloaded once before they are deleted from the server and their document ID is invalidated. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -4937,6 +4972,9 @@ components: message: Document not found PayloadTooLarge: description: The request size exceeds the limit. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -4945,6 +4983,9 @@ components: description: The request URL is too long. You can avoid this error by using a POST request instead of a GET request, and sending the parameters in the HTTP body. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -4954,30 +4995,47 @@ components: UnsupportedMediaTypeGlossaries: description: The requested entries format specified in the `Accept` header is not supported. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' TooManyRequests: description: Too many requests. Please wait and resend your request. + headers: + Retry-After: + $ref: '#/components/headers/Retry-After' + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' QuotaExceeded: description: Quota exceeded. The character limit has been reached. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' QuotaExceededGlossaries: description: Quota exceeded. Too many requests were made to the glossary endpoints recently. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' BadRequestSpokenTerms: description: Bad request. Please check error message and your parameters. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -4986,6 +5044,9 @@ components: message: "Bad request" ForbiddenSpokenTerms: description: Forbidden. Authorization failed or feature not available for your plan. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -4994,6 +5055,9 @@ components: message: "Forbidden. Authorization failed or feature not available for your plan." QuotaExceededSpokenTerms: description: Quota exceeded. Spoken Terms collection limit reached for your plan. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -5002,6 +5066,9 @@ components: message: "Spoken Terms quota exhausted" UnsupportedMediaType: description: Unsupported Media Type. Must be application/json or application/x-www-form-urlencoded. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -5010,6 +5077,9 @@ components: message: "Unsupported Content-Type" InternalServerError: description: Internal error. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -5018,6 +5088,9 @@ components: - $ref: '#/components/schemas/InfrastructureErrorResponse' ServiceUnavailable: description: Resource currently unavailable. Try again later. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: @@ -5028,6 +5101,9 @@ components: description: |- A 503 result will be returned if the user tries to download a translated document that is currently being processed and is not yet ready for download. Please make sure to check that the document status is 'done' before trying to send a download request. + headers: + X-Trace-ID: + $ref: '#/components/headers/X-Trace-ID' content: application/json: schema: diff --git a/docs.json b/docs.json index b4873789..43c0c479 100644 --- a/docs.json +++ b/docs.json @@ -37,6 +37,7 @@ "pages": [ "docs/getting-started/quickstart", "docs/getting-started/about", + "docs/getting-started/auth", "docs/getting-started/client-libraries" ] }, @@ -458,7 +459,7 @@ "404": { "redirect": false, "title": "Page not found", - "description": "This page does not exist. Humans: check out the [Quickstart](/docs/getting-started/quickstart) or the [API reference](/api-reference/translate/request-translation). Machines: try [/llms.txt](/llms.txt), [the docs MCP server](https://developers.deepl.com/mcp), or OpenAPI specs for [Text](/api-reference/openapi.yaml) and [Voice](/api-reference/voice/voice.asyncapi.yaml)." + "description": "This page does not exist. Humans: check out the [Quickstart](/docs/getting-started/quickstart) or the [API reference](/api-reference/translate/request-translation). Machines: try [/AGENTS.md](/AGENTS.md), [/llms.txt](/llms.txt), [the docs MCP server](https://developers.deepl.com/mcp), or OpenAPI specs for [Text](/api-reference/openapi.yaml) and [Voice](/api-reference/voice/voice.asyncapi.yaml)." } }, "redirects": [ @@ -516,7 +517,7 @@ }, { "source": "/api-reference/languages/retrieve-supported-languages-by-product", - "destination": "/api-reference/languages/retrieve-supported-languages-by-resource" + "destination": "/docs/languages/using-the-languages-api" }, { "source": "/api-reference/languages/retrieve-products", @@ -556,7 +557,7 @@ }, { "source": "/docs/api-reference/glossaries/glossaries", - "destination": "/api-reference/glossaries" + "destination": "/api-reference/glossaries/create-a-glossary" }, { "source": "/docs/api-reference", @@ -809,6 +810,22 @@ { "source": "/asyncapi.json", "destination": "/api-reference/voice/voice.asyncapi.json" + }, + { + "source": "/pricing", + "destination": "https://www.deepl.com/pricing" + }, + { + "source": "/openapi", + "destination": "/api-reference/openapi.yaml" + }, + { + "source": "/asyncapi", + "destination": "/api-reference/voice/voice.asyncapi.yaml" + }, + { + "source": "/agents", + "destination": "/AGENTS.md" } ], "integrations": { @@ -827,48 +844,20 @@ "vscode" ] }, - "head": [ - { - "tag": "link", - "attrs": { - "rel": "stylesheet", - "href": "/user-study-popup.css" - } - }, - { - "tag": "script", - "attrs": { - "src": "/user-study-popup.js", - "defer": true - } - }, - { - "tag": "script", - "attrs": { - "src": "/user-data.js", - "defer": true - } - }, - { - "tag": "script", - "attrs": { - "type": "application/ld+json" - }, - "children": "{\"@context\":\"https://schema.org\",\"@graph\":[{\"@type\":\"Organization\",\"@id\":\"https://www.deepl.com/#organization\",\"name\":\"DeepL\",\"legalName\":\"DeepL SE\",\"alternateName\":\"DeepL SE\",\"description\":\"DeepL SE is a Language AI company. Its developer products include the DeepL API for text, document, and real-time voice translation, and the DeepL Write API for text improvement.\",\"url\":\"https://www.deepl.com\",\"logo\":{\"@type\":\"ImageObject\",\"url\":\"https://static.deepl.com/img/logo/deepl-logo-blue.svg\"},\"email\":\"info@deepl.com\",\"faxNumber\":\"+49 221 95018653\",\"vatID\":\"DE349242045\",\"foundingDate\":\"2017\",\"address\":{\"@type\":\"PostalAddress\",\"streetAddress\":\"Maarweg 165\",\"postalCode\":\"50825\",\"addressLocality\":\"Cologne\",\"addressCountry\":\"DE\"},\"contactPoint\":[{\"@type\":\"ContactPoint\",\"contactType\":\"technical support\",\"url\":\"https://developers.deepl.com/docs/resources/contact\",\"areaServed\":\"Worldwide\",\"availableLanguage\":[\"en\"]},{\"@type\":\"ContactPoint\",\"contactType\":\"customer support\",\"url\":\"https://support.deepl.com/hc/en-us/requests/new\",\"areaServed\":\"Worldwide\"},{\"@type\":\"ContactPoint\",\"contactType\":\"sales\",\"url\":\"https://www.deepl.com/en/contact-us\",\"areaServed\":\"Worldwide\"},{\"@type\":\"ContactPoint\",\"contactType\":\"security\",\"email\":\"security@deepl.com\",\"areaServed\":\"Worldwide\",\"availableLanguage\":[\"en\"]}],\"sameAs\":[\"https://github.com/DeepL\",\"https://www.linkedin.com/company/deepl\",\"https://x.com/DeepLcom\",\"https://www.youtube.com/@DeepLcom\",\"https://en.wikipedia.org/wiki/DeepL_Translator\"]},{\"@type\":[\"SoftwareApplication\",\"WebAPI\"],\"@id\":\"https://developers.deepl.com/#deepl-api\",\"name\":\"DeepL API\",\"description\":\"REST and WebSocket APIs for translating text and documents, transcribing and translating speech in real time, improving text, and customizing output with glossaries, style rules, and translation memories.\",\"url\":\"https://developers.deepl.com\",\"applicationCategory\":\"DeveloperApplication\",\"operatingSystem\":\"Any\",\"provider\":{\"@id\":\"https://www.deepl.com/#organization\"},\"documentation\":\"https://developers.deepl.com/docs/getting-started/quickstart\",\"termsOfService\":\"https://www.deepl.com/pro-license\",\"privacyPolicy\":\"https://www.deepl.com/en/privacy\",\"offers\":[{\"@type\":\"Offer\",\"name\":\"DeepL API Free\",\"url\":\"https://www.deepl.com/en/pro#api\",\"price\":\"0\",\"priceCurrency\":\"EUR\",\"category\":\"free\"},{\"@type\":\"Offer\",\"name\":\"DeepL API Pro\",\"url\":\"https://www.deepl.com/en/pro#api\",\"category\":\"subscription\"}]}]}" - } - ], "markdown": { "instructions": [ "Use the DeepL API when a task needs machine translation or text improvement, including translating text strings, whole documents with formatting preservation, or transcribing and translating live speech. Preferred terminology and phrasing may be enforced using customizations (glossaries, style rules, and translation memories). Retrieve supported languages for each product from the `/v3/languages` endpoints.", - "Read the machine-readable API surface instead of inferring request shapes from prose: the REST spec is at https://developers.deepl.com/api-reference/openapi.yaml (also served as openapi.json) and the Voice WebSocket protocol is at https://developers.deepl.com/api-reference/voice/voice.asyncapi.yaml. These docs also expose an MCP server at https://developers.deepl.com/mcp (Streamable HTTP, no authentication).", + "Do not use the DeepL API to write, summarize, answer, or classify: it transforms text you supply. Full when-to-use guidance, mapped to endpoints, is at https://developers.deepl.com/AGENTS.md.", + "Read the machine-readable API surface instead of inferring request shapes from prose: the REST spec is at https://developers.deepl.com/api-reference/openapi.yaml (also served as openapi.json) and the Voice WebSocket protocol is at https://developers.deepl.com/api-reference/voice/voice.asyncapi.yaml. Both are listed in the RFC 9727 catalog at https://developers.deepl.com/.well-known/api-catalog. These docs also expose an MCP server at https://developers.deepl.com/mcp (Streamable HTTP, no authentication).", "Use https://api.deepl.com for Pro plans and https://api-free.deepl.com for the Free plan. Authenticate every request with the header `Authorization: DeepL-Auth-Key `. Never fabricate an API key: ask the user for one, or point them at https://developers.deepl.com/docs/getting-started/quickstart.", - "Errors use standard HTTP status codes with a JSON body containing a `message` field, plus a `code` field where available, and an `X-Trace-ID` response header that identifies the request in DeepL's logs. Log `X-Trace-ID` by default. Retry 429 and 5xx with exponential backoff. Do not retry 456, which means the account quota is exhausted, or 400, which means the request itself is invalid." + "Errors use standard HTTP status codes with a JSON body containing a `message` field, plus a `code` field where available, and an `X-Trace-ID` response header that identifies the request in DeepL's logs. Log `X-Trace-ID` by default. Retry 429 and 5xx with exponential backoff. Do not retry 456, which means the account quota is exhausted, or 400, which means the request itself is invalid.", + "There is no fixed request-per-second limit and no `RateLimit` response headers: the service adapts to load. Throttle from the responses instead, honoring `Retry-After` when a 429 or 529 includes it, and capping in-flight requests. See https://developers.deepl.com/docs/best-practices/error-handling." ] }, "seo": { "indexing": "navigable", "organization": { - "id": "https://www.deepl.com/", + "id": "https://www.deepl.com/#organization", "name": "DeepL", "legalName": "DeepL SE", "url": "https://www.deepl.com", @@ -878,7 +867,8 @@ "https://www.linkedin.com/company/deepl", "https://x.com/DeepLcom", "https://www.instagram.com/deeplhq/", - "https://www.youtube.com/@DeepLcom" + "https://www.youtube.com/@DeepLcom", + "https://en.wikipedia.org/wiki/DeepL_Translator" ] } } diff --git a/docs/best-practices/error-handling.mdx b/docs/best-practices/error-handling.mdx index bc810913..61a9d144 100644 --- a/docs/best-practices/error-handling.mdx +++ b/docs/best-practices/error-handling.mdx @@ -38,6 +38,15 @@ Failures that occur before a request reaches the API, in DeepL's edge infrastruc Handle both shapes in your parser. Reading `body.message ?? body.error?.message` covers every error the API can return, and keeps your client from crashing on a gateway error during an incident. +## Response headers on errors + +| **Header** | **When it's sent** | **What to do with it** | +| --- | --- | --- | +| `X-Trace-ID` | Every response, including every error | Log it. Include it in support requests so our team can find the request | +| `Retry-After` | On some `429` and `529` responses | Wait that many seconds before resending, in preference to your own backoff interval | + +The DeepL API does not return `RateLimit` headers, because there is no fixed per-second limit to report. Derive your pace from the responses instead, as described below. + ## Which errors to retry | **Status** | **Meaning** | **Retry?** | diff --git a/scripts/agentic-readiness.test.mjs b/scripts/agentic-readiness.test.mjs new file mode 100644 index 00000000..667df201 --- /dev/null +++ b/scripts/agentic-readiness.test.mjs @@ -0,0 +1,220 @@ +// Guards for the machine-readable surface of the docs: docs.json configuration, the redirects and +// agent instruction file that agents rely on, and the OpenAPI error contract. Zero dependencies: +// node --test scripts/*.test.mjs + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const read = (p) => readFileSync(join(root, p), 'utf8'); +const readJson = (p) => JSON.parse(read(p)); + +const docs = readJson('docs.json'); +const agentsMd = read('AGENTS.md'); +const openapi = readJson('api-reference/openapi.json'); +const openapiYaml = read('api-reference/openapi.yaml'); + +// Top-level properties accepted by https://mintlify.com/docs.json. The schema sets +// additionalProperties: false, so anything outside this list is silently dropped at build time +// rather than reported as an error. A "head" key used to sit here and never reached the page. +const MINTLIFY_TOP_LEVEL_KEYS = new Set([ + '$schema', 'api', 'appearance', 'background', 'banner', 'colors', 'contextual', 'description', + 'errors', 'favicon', 'fonts', 'footer', 'icons', 'integrations', 'interaction', 'logo', + 'markdown', 'metadata', 'name', 'navbar', 'navigation', 'public', 'redirects', 'search', 'seo', + 'styling', 'theme', 'thumbnails', 'variables', 'variations', +]); + +// Fields accepted under seo.organization, which is the only structured-data hook Mintlify exposes. +const MINTLIFY_ORGANIZATION_KEYS = new Set(['id', 'name', 'legalName', 'url', 'logo', 'sameAs']); + +const navigationPages = (() => { + const pages = new Set(); + const walk = (node, key) => { + if (typeof node === 'string') { + if (key === 'pages') pages.add(node); + } else if (Array.isArray(node)) node.forEach((child) => walk(child, key)); + else if (node && typeof node === 'object') { + for (const [k, v] of Object.entries(node)) walk(v, k); + } + }; + walk(docs.navigation); + return pages; +})(); + +// Routes Mintlify generates rather than files in this repo, plus wildcard redirect patterns. +const isGeneratedRoute = (target) => + target.includes(':page*') + || /^(llms\.txt|llms-full\.txt|mcp|sitemap\.xml)$/.test(target) + || target.startsWith('.well-known/') + || target.endsWith('rss.xml'); + +const resolves = (destination) => { + const target = destination.split('#')[0].replace(/^\//, ''); + return isGeneratedRoute(target) + || navigationPages.has(target) + || existsSync(join(root, `${target}.mdx`)) + || existsSync(join(root, target)); +}; + +test('docs.json uses only properties Mintlify recognizes', () => { + const unknown = Object.keys(docs).filter((k) => !MINTLIFY_TOP_LEVEL_KEYS.has(k)); + assert.deepEqual(unknown, [], `unknown docs.json keys are ignored at build time: ${unknown}`); + + const unknownOrg = Object.keys(docs.seo.organization) + .filter((k) => !MINTLIFY_ORGANIZATION_KEYS.has(k)); + assert.deepEqual(unknownOrg, [], `unknown seo.organization keys: ${unknownOrg}`); +}); + +test('every page in the navigation exists', () => { + const missing = [...navigationPages].filter((p) => !existsSync(join(root, `${p}.mdx`))); + assert.deepEqual(missing, []); +}); + +test('the page every auth error links to is in the navigation', () => { + // DeepL API 403 bodies point at /docs/getting-started/auth. With seo.indexing "navigable", a page + // outside the navigation is left out of the sitemap and llms.txt, so agents cannot discover it. + assert.ok(navigationPages.has('docs/getting-started/auth')); +}); + +test('redirect sources are unique and destinations resolve', () => { + const sources = docs.redirects.map((r) => r.source); + assert.equal(new Set(sources).size, sources.length, 'duplicate redirect source'); + + for (const { source, destination } of docs.redirects) { + assert.ok(source.startsWith('/'), `${source} must be rooted`); + if (destination.startsWith('http')) { + assert.ok(destination.startsWith('https://'), `${source} must redirect over https`); + continue; + } + assert.ok(destination.startsWith('/'), `${source} -> ${destination} must be rooted`); + assert.ok(resolves(destination), `${source} -> ${destination} has no target`); + } +}); + +test('no redirect points at another redirect', () => { + // A chain costs an extra hop, and agents that follow a single Location header land on a 3xx. + const bySource = new Map(docs.redirects.map((r) => [r.source, r.destination])); + const chains = docs.redirects + .filter((r) => bySource.has(r.destination)) + .map((r) => `${r.source} -> ${r.destination} -> ${bySource.get(r.destination)}`); + assert.deepEqual(chains, []); +}); + +test('/pricing redirects to the DeepL pricing page', () => { + const pricing = docs.redirects.find((r) => r.source === '/pricing'); + assert.ok(pricing, '/pricing redirect is missing'); + assert.equal(pricing.destination, 'https://www.deepl.com/pricing'); +}); + +test('agent-facing shortcuts avoid file extensions', () => { + // Mintlify routes any path with a file extension as an asset and never applies redirects to it, + // so a shortcut like /openapi.json cannot work. Extension-less sources can. + for (const source of ['/openapi', '/asyncapi', '/agents']) { + const redirect = docs.redirects.find((r) => r.source === source); + assert.ok(redirect, `${source} redirect is missing`); + assert.ok(!/\.[a-z0-9]+$/i.test(redirect.source), `${source} would be routed as an asset`); + } +}); + +test('markdown instructions tell agents when to use the API and where to read more', () => { + const instructions = docs.markdown.instructions.join('\n'); + assert.match(instructions, /Use the DeepL API when/); + assert.match(instructions, /Do not use the DeepL API to/); + assert.match(instructions, /developers\.deepl\.com\/AGENTS\.md/); + assert.match(instructions, /api-reference\/openapi\.yaml/); + assert.match(instructions, /DeepL-Auth-Key/); +}); + +test('AGENTS.md names its best-fit jobs and how to call the API', () => { + assert.match(agentsMd, /^## When to use the DeepL API$/m); + assert.match(agentsMd, /^### When to use something else$/m); + assert.match(agentsMd, /^## Base URLs and authentication$/m); + assert.match(agentsMd, /^## Machine-readable API surface$/m); + assert.match(agentsMd, /^## Error handling$/m); + assert.match(agentsMd, /^## Rate limits and throttling$/m); + assert.match(agentsMd, /Authorization: DeepL-Auth-Key/); +}); + +test('AGENTS.md links point at pages and files that exist', () => { + const links = [...agentsMd.matchAll(/https:\/\/developers\.deepl\.com(\/[^\s)>]*)/g)] + .map((m) => m[1].replace(/[.,`]+$/, '')); + assert.ok(links.length > 10, 'expected AGENTS.md to link into the docs'); + for (const link of links) { + assert.ok(resolves(link), `AGENTS.md links to ${link}, which does not exist`); + } +}); + +test('every OpenAPI operation is usable as a tool definition', () => { + const seen = new Map(); + for (const [path, item] of Object.entries(openapi.paths)) { + for (const [method, op] of Object.entries(item)) { + if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) continue; + const where = `${method.toUpperCase()} ${path}`; + assert.ok(op.operationId, `${where} has no operationId`); + assert.ok(!seen.has(op.operationId), `duplicate operationId ${op.operationId}`); + seen.set(op.operationId, where); + assert.ok(op.summary, `${where} has no summary`); + assert.ok(op.description, `${where} has no description`); + const errors = Object.keys(op.responses ?? {}).filter((c) => /^[45]/.test(c)); + assert.ok(errors.length > 0, `${where} documents no error responses`); + } + } +}); + +test('error responses document their JSON body and trace header', () => { + const responses = openapi.components.responses; + const headers = openapi.components.headers; + assert.ok(headers['X-Trace-ID'], 'X-Trace-ID header component is missing'); + assert.ok(headers['Retry-After'], 'Retry-After header component is missing'); + + for (const [name, response] of Object.entries(responses)) { + assert.ok( + response.content?.['application/json']?.schema, + `${name} does not document a JSON body; agents cannot parse an HTML error page`, + ); + assert.equal( + response.headers?.['X-Trace-ID']?.$ref, + '#/components/headers/X-Trace-ID', + `${name} does not document X-Trace-ID`, + ); + } + + assert.equal( + responses.TooManyRequests.headers['Retry-After'].$ref, + '#/components/headers/Retry-After', + 'the rate-limit response must document Retry-After so agents can self-throttle', + ); +}); + +test('every 429 and 529 uses the response that documents Retry-After', () => { + const rateLimited = []; + for (const [path, item] of Object.entries(openapi.paths)) { + for (const [method, op] of Object.entries(item)) { + if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) continue; + for (const code of ['429', '529']) { + const response = op.responses?.[code]; + if (response) rateLimited.push([`${method.toUpperCase()} ${path} ${code}`, response.$ref]); + } + } + } + assert.ok(rateLimited.length > 0, 'expected rate-limit responses in the spec'); + for (const [where, ref] of rateLimited) { + assert.match(ref ?? '', /#\/components\/responses\/(TooManyRequests|QuotaExceeded)/, where); + } +}); + +test('openapi.json is regenerated from openapi.yaml', () => { + // openapi.json is generated (see .github/workflows/update_openapi_json.yml), so header wiring + // added to the YAML has to be present in both files. + const count = (text, needle) => text.split(needle).length - 1; + for (const header of ['X-Trace-ID', 'Retry-After']) { + assert.equal( + count(openapiYaml, `#/components/headers/${header}'`), + count(read('api-reference/openapi.json'), `#/components/headers/${header}"`), + `${header} references differ between openapi.yaml and openapi.json`, + ); + } +});