CDR API Documentation
Token to cleaned file in three calls - or one, with the synchronous mode. Quickstarts in curl, C#, JavaScript and Python.
Getting started
The Red Eagle Tech Content Disarm and Reconstruction (CDR) API sanitises documents to remove potential security threats while preserving document usability. Most integrations are live within the hour.
Base URL
https://api.cdr.redeagle.tech/v1
The 60-second integration
- Get credentials - sign up at account.redeagle.tech, buy pay-as-you-go credit or a plan, and your API client id and secret are issued on the spot (the secret is shown once - store it in your secret manager).
- Get a token - OAuth2 client credentials, scope
cdr.api. Tokens last an hour; cache and reuse them. - Sanitise - one call with
?wait=truereturns the cleaned file link for most documents in seconds; larger files transition gracefully to a short poll.
Authentication
The CDR API uses OAuth2 client credentials: exchange your client id and secret for a short-lived bearer token, then send it on every request. There is nothing to sign - and if your integration runs on a workflow platform that cannot perform a token exchange, API keys are available as an alternative credential.
| Setting | Value |
|---|---|
| Token endpoint | https://identity.redeagle.tech/connect/token |
| Grant type | client_credentials |
| Scope | cdr.api |
| Token lifetime | 1 hour - cache the token and reuse it until it expires |
| Usage | Authorization: Bearer <access_token> header on every API call |
Getting a token (curl)
TOKEN=$(curl -s https://identity.redeagle.tech/connect/token \
-d grant_type=client_credentials \
-d client_id="$CDR_CLIENT_ID" \
-d client_secret="$CDR_CLIENT_SECRET" \
-d scope=cdr.api | jq -r .access_token)
API keys - for integration platforms
Some places an integration runs cannot perform an OAuth token exchange - workflow platforms such as Zapier, Power Automate, n8n and Make, or a quick curl test. For those, create an API key on the same account portal page as your OAuth client and send it on every call. OAuth client-credentials authentication remains the recommended default for server integrations.
| Setting | Value |
|---|---|
| Header | X-Api-Key: <key> on every API call (Authorization: Bearer <key> is also accepted) |
| Where to create | Account portal - API credentials - API keys; the key is shown once, at creation |
| Access | The document API only, as your organisation - the same metering, plan and rate limits as a bearer token; account administration always requires OAuth |
| Lifetime | Non-expiring; revoke in the portal - revocation takes effect within a minute |
| Rotation | Create a second key, repoint the integration, revoke the old one - no downtime |
Sanitising with an API key (curl)
curl -s "https://api.cdr.redeagle.tech/v1/documents?filename=report.docx&wait=true" \
-H "X-Api-Key: $CDR_API_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary @report.docx
Create one key per platform, labelled with where it lives - then a credential that leaks, or a platform you stop using, can be revoked on its own without touching anything else. Platforms typically attach the key to every request automatically, including the cleaned-file download: the storage layer simply ignores an X-Api-Key header on the pre-signed link, so the download still works (an Authorization header there would be rejected - one of the reasons X-Api-Key is the canonical form).
Synchronous & asynchronous modes
Every submission runs through the same pipeline; you choose how to wait for it.
- Synchronous - add
?wait=true(or the headerPrefer: respond-sync) to your submit call. If the document reaches a terminal state within the sync budget (45 seconds by default), you get200 OKwith the full result in one round trip. Small documents typically clean in under 3 seconds (measured July 2026 on a warm service: median 1.9 s, 95th percentile 7.0 s). The first submission after an idle period can be markedly slower, and if it exceeds the budget you get a202instead - so always handle the202path even when you ask for a synchronous response. It is a slower answer, never a failure. - Asynchronous - submit without
wait, receive202 Acceptedwith astatusUrl, and poll at the suggestedpollAfterSecondscadence until the status is terminal. - Graceful transition - a synchronous call that can't finish inside the budget (for example a large file) transitions to a normal
202 Acceptedcarrying the freshest state seen. Your polling code path handles it identically, so?wait=trueis always safe to send - the answer arrives by the other route, it is never a worse answer.
200 OK - the envelope's status tells you what happened.
API endpoints
Submit document
POST /v1/documents · POST /v1/documents?wait=true
Submit a document for sanitisation as multipart form data with a single file part.
Request
curl -s -X POST "https://api.cdr.redeagle.tech/v1/documents?wait=true" \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 2b7e9c14-unique-per-attempt" \
-F "file=@invoice.pdf"
Drop ?wait=true for a purely asynchronous submit. The Idempotency-Key header is optional but recommended on any submit your code might retry.
Response - asynchronous (202 Accepted)
{
"id": "a1b2c3d4-e5f6-7890-abcd-1234567890ab",
"status": "pending",
"submittedAt": "2026-07-21T14:22:31Z",
"statusUrl": "https://api.cdr.redeagle.tech/v1/documents/a1b2c3d4-e5f6-7890-abcd-1234567890ab",
"pollAfterSeconds": 2
}
Response - synchronous completion (200 OK)
{
"id": "a1b2c3d4-e5f6-7890-abcd-1234567890ab",
"status": "completed",
"outcome": "cleaned",
"modifications": ["rebuilt"],
"submittedAt": "2026-07-21T14:22:31Z",
"completedAt": "2026-07-21T14:22:33Z",
"downloadUrl": "https://...",
"downloadExpiresAt": "2026-07-22T14:22:33Z",
"statusUrl": "https://api.cdr.redeagle.tech/v1/documents/a1b2c3d4-e5f6-7890-abcd-1234567890ab"
}
Accepted submissions also carry live usage headers for your plan.
Idempotency
Send an Idempotency-Key header (any unique value up to 200 characters) with a submit and a retry of the same request returns the original submission instead of creating - and billing - a second one. Replayed responses carry Idempotency-Replayed: true. Use it on every submit your code might retry after a network failure.
Check status
GET /v1/documents/{id}
Returns the same envelope, reflecting the document's current state: pending → processing → completed, rejected or failed (see the response taxonomy). Status polling has its own generous rate-limit budget - it never consumes your submission allowance.
Download sanitised document
GET /v1/documents/{id}/download
Redirects (302) to a time-limited link for the cleaned file. The link is valid for 24 hours from completion (downloadExpiresAt in the envelope tells you exactly when it stops working).
This endpoint accepts your bearer token and is the simplest path for API clients - HTTP libraries drop the Authorization header automatically when following the cross-origin redirect. The envelope's downloadUrl is the same underlying signed link directly: if you fetch it yourself, send it without your bearer token (the storage layer rejects requests carrying both a signed link and an Authorization header).
302 Found- follow the redirect to download the file404 Not Found- the link has expired; cleaned files are deleted after the 24-hour retention window and cannot be recovered, so resubmit the original if you still need a cleaned copy409 Conflict- the document is not in a completed state; the response body is the envelope, so you can see why
Delete a document early
DELETE /v1/documents/{id}
Removes the cleaned file before its 24-hour window expires. Returns 204 No Content, and is idempotent - deleting an already-deleted document succeeds rather than erroring, so a retry is always safe.
- A document still being processed cannot be deleted:
409 DocumentNotDeletable. Wait for a terminal state and retry. - The document's status stays readable afterwards - you keep the audit trail, you just lose the file. The envelope carries
deletedAt. - Downloading a deleted document returns
410 DocumentDeleted, which is deliberately distinct from a404: the document existed and you deleted it, rather than never existing or belonging to someone else.
For automatic cleanup instead, submit with deleteAfterDownload: true - the file is removed shortly after your first download begins, with a grace window so a transfer that dies mid-stream can still be retried.
Account & balance
GET /v1/account
Your live entitlement snapshot - the programmatic "where do I stand?" check between submissions.
{
"organisationId": "9f8e7d6c-5b4a-3210-fedc-0987654321ba",
"organisationName": "Example Ltd",
"planType": "payg",
"status": "active",
"monthlyAllowance": 0,
"periodStart": "2026-07-01T00:00:00Z",
"periodEnd": "2026-08-01T00:00:00Z",
"periodUsedSubmissions": 42,
"creditBalancePence": 4970,
"pricePerDocumentPence": 15,
"rateLimitPerMinute": 30,
"maxFileSizeBytes": 104857600,
"maxUploadSizeBytes": 262144000
}
Tier plans additionally include periodRemainingSubmissions; pricePerDocumentPence appears on pay-as-you-go only. creditBalancePence is present on every plan - prepaid credit stays spendable even if a subscription later lapses. maxFileSizeBytes is the direct multipart ceiling and maxUploadSizeBytes your plan's ceiling via the staged upload flow - read these rather than hard-coding the published numbers, so a plan change or a limit increase needs no code change from you.
Large files - the staged upload flow
Multipart submits carry files up to 100 MB. Beyond that (up to your plan ceiling - 250 MB on Pay-as-you-go and Starter, 450 MB on Growth and above), stage the file first: create an upload, PUT the bytes straight to storage, then submit by reference. The API is never in the data path for the bytes, which is what makes the larger sizes possible.
The three-call recipe
# 1. Create an upload slot
UPLOAD=$(curl -s -X POST https://api.cdr.redeagle.tech/v1/uploads \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"fileName": "board-pack.pdf"}')
UPLOAD_ID=$(echo "$UPLOAD" | jq -r .uploadId)
UPLOAD_URL=$(echo "$UPLOAD" | jq -r .uploadUrl)
# 2. PUT the bytes straight to storage (no bearer token - the link is the auth)
curl -sS -X PUT "$UPLOAD_URL" \
-H "x-ms-blob-type: BlockBlob" \
-T board-pack.pdf
# 3. Submit by reference - same envelope, statuses and billing as any submit
curl -s -X POST https://api.cdr.redeagle.tech/v1/documents \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"uploadId\": \"$UPLOAD_ID\"}" | jq
The upload envelope (201 Created)
{
"uploadId": "u_9f2c4b1a0e8d47c6a3b5d9e1f2a4c6b8",
"uploadUrl": "https://...",
"method": "PUT",
"requiredHeaders": { "x-ms-blob-type": "BlockBlob" },
"maxSizeBytes": 471859200,
"expiresAt": "2026-07-25T18:22:31Z"
}
uploadUrlis a time-limited, single-file, write-only link. It accepts one file's bytes and nothing else - it cannot read, list or touch anything. Send therequiredHeaderswith thePUT; the link stops working atexpiresAt, and abandoned uploads are cleaned up automatically.maxSizeBytesis your plan's ceiling, checked when you submit by reference. An oversized file is refused with a413problem response carryingmaxSizeBytes, and nothing is billed.- Optional integrity check: send a
Content-MD5header with thePUTand storage verifies the bytes it received - transfer corruption becomes an immediate, unbilled400at upload time instead of a billedrejected/invalid_fileat processing time. - Any chunked uploader works: for multi-hundred-megabyte files over unreliable connections, tools like
azcopyor the Azure SDKs can drive the sameuploadUrlwith retries and parallel blocks - plaincurl -Tis fine on a stable link. - After submit, everything is identical to a multipart submission: the same
202envelope, the same statuses, the same usage headers and the sameIdempotency-Keysupport. Large files take longer to process - keep polling atpollAfterSeconds; the status endpoint's separate rate-limit budget means polling never crowds out submissions. - Don't modify a staged file after submitting it. The submission records the file it accepted; if the bytes change between submit and processing, the document terminates
rejected/invalid_file.
Idempotency key vs integrity check - which protects what?
They look similar - "why not just hash the file and use that as the key?" - but they answer different questions. The Idempotency-Key identifies an attempt: did this request already happen? The Content-MD5 identifies the bytes: did the file arrive intact? A content hash can't do the key's job, because submitting the same document twice on purpose (a nightly re-clean, the same template for two clients) is two legitimate attempts with identical bytes - only a per-attempt key can tell deliberate resubmission from an accidental retry.
Idempotency-Key (any submit) |
Content-MD5 (staged upload) |
|
|---|---|---|
| The question it answers | "Did this request already happen?" | "Did the bytes arrive intact?" |
| You send | Any value unique to the attempt (a fresh UUID is ideal), as a header on the submit | The MD5 of the file, as a header on the PUT |
| Network timeout, then retry | The retry replays the original answer (Idempotency-Replayed: true) - one document, one charge |
Doesn't help - the retry is a new request as far as billing is concerned |
| File corrupted in transit | Doesn't help - the corrupted bytes process, and terminate as a billed rejected/invalid_file |
Caught at upload time: an immediate, unbilled 400 - fix and re-PUT for free |
| Same document, submitted twice on purpose | Two keys, two attempts - both process and bill, as intended | Same hash both times - irrelevant, it only checks transport |
| Use it when | Always - on every submit your code might retry | Whenever you stage a file, and especially over unreliable links |
Response taxonomy
Every document moves pending → processing → one of three terminal states. The distinction matters for billing:
| Status | Meaning | Billing |
|---|---|---|
completed |
The file was sanitised. outcome is cleaned and modifications lists what was done (e.g. rebuilt). Download the result within 24 hours. |
Billed |
rejected |
The file's own properties stopped it. reason is one of threat_detected (with threatNames - no file is released), password_protected, invalid_file, unsupported_file_type, file_too_large or policy_violation. |
Billed - blocking a dangerous file is the product working; the analysis is the work you paid for |
failed |
We could not process the file. reason is timeout or internal_error. |
Automatically credited back - if we fail, you don't pay, and you don't need to ask |
Usage & quotas
Every accepted submission response carries live usage headers for your plan, so your integration always knows where it stands without an extra call:
| Plan | Headers on accepted submissions |
|---|---|
| Monthly plans | X-CDR-Usage-Used, X-CDR-Usage-Allowance, X-CDR-Usage-Remaining |
| Pay as you go | X-CDR-Credit-Balance-Pence - the exact balance after this document's charge |
The headers are part of the contract, not a best-effort extra: they are computed inside the same request that accepted the document - the very values the billing decision just used - so they ride every accepted submission and your integration can rely on them. What GET /v1/account adds is an answer on demand: check your balance whenever you want - before a large batch, on a schedule, or from a reconciliation job - without having to submit a document to find out.
When an allowance or balance runs out the API hard-stops with a structured refusal (see below) - it never silently bills beyond what you bought.
Errors & refusals
Every non-2xx response on /v1 is an RFC 9457 problem document (Content-Type: application/problem+json). Branch on the code member - it is stable and machine-readable; detail is the human-readable message and may be reworded. The type URI identifies the same code as a stable anchor. Refusal-specific fields (balance, price, period end, size ceiling) ride as extension members:
{
"type": "https://api.cdr.redeagle.tech/docs/errors#insufficient-credit",
"title": "Insufficient credit",
"status": 402,
"detail": "The organisation's prepaid credit balance is insufficient for this submission.",
"code": "InsufficientCredit",
"creditBalancePence": 5,
"pricePerDocumentPence": 15,
"helpUrl": "https://account.redeagle.tech"
}
Plan & account refusals:
| HTTP | Code | Meaning |
|---|---|---|
| 402 | InsufficientCredit |
Pay-as-you-go balance can't cover the document - top up in the portal (or enable auto top-up) |
| 403 | CustomerSuspended |
The account is suspended - contact support |
| 403 | NotProvisioned |
The organisation has no active CDR plan - buy one in the portal |
| 429 | QuotaExceeded |
The monthly allowance is used up - includes Retry-After (seconds until the period resets); upgrade to continue sooner |
| 429 | RateLimited |
Too many requests this minute - back off briefly and retry |
Submission validation problems:
| HTTP | Code | Cause & remedy |
|---|---|---|
| 400 | NoFileProvided |
The multipart body had no file part (or it was empty) - check the form field name is exactly file |
| 400 | UnsupportedFileType |
The file's extension isn't in the supported formats list - convert it, or talk to us about the format |
| 413 | FileTooLargeForPlan |
Over the lane or plan ceiling - the problem body carries maxSizeBytes; a multipart refusal also carries acceptedByLane: "uploads", meaning the staged upload flow takes this file. Nothing is billed |
| 404 | UploadNotFound |
The uploadId doesn't exist for your organisation, or the abandoned upload was cleaned up - create a fresh upload and re-stage |
| 409 | UploadAlreadySubmitted |
Each staged upload is submittable exactly once - if this was a retry, the first submission is the live one (use Idempotency-Key to get replays instead of conflicts) |
| 409 | DocumentNotDeletable |
A DELETE arrived while the document was still processing - wait for a terminal state and retry |
| 410 | DocumentDeleted |
The cleaned file was deleted - by you, or by deleteAfterDownload. Distinct from 404 on purpose: the submission record - status, outcome, timestamps - is still readable via GET /v1/documents/{id}; only the cleaned file itself is gone |
Rejection reasons (terminal rejected status - billed):
A rejection is the analysis working, delivered through the normal envelope rather than an HTTP error. The reason values are stable:
| Reason | What it means | What to do |
|---|---|---|
threat_detected |
Malicious or dangerous content was found; threatNames lists what. No file is released |
Don't resubmit the same bytes. Treat the source with suspicion - this is the outcome you integrated for |
password_protected |
The document is encrypted, so its content cannot be analysed or rebuilt | Remove the password protection and resubmit |
invalid_file |
The file could not be parsed as a valid document of its declared type - typically structurally corrupt or truncated, and (staged uploads) content that changed between submit and processing. Not every damaged file lands here: a document that still parses may well rebuild successfully instead | Re-export from the source application; for staged uploads, re-stage and resubmit without touching the blob |
unsupported_file_type |
The actual content isn't a format we process, whatever the extension says | Check what the file really is; convert to a supported format |
file_too_large |
The file exceeded a processing limit after acceptance | Reduce the file's size, or upgrade for the larger ceilings |
policy_violation |
The file contains content the sanitisation policy refuses to pass through | If this is unexpected for a legitimate document, contact support with the document id |
Other statuses:
| Status Code | Description |
|---|---|
| 401 | Unauthorized - missing, expired or invalid bearer token; missing, invalid or revoked API key |
| 403 | Forbidden - the token lacks the cdr.api scope |
| 404 | Not Found - unknown document id (or an expired download link) |
| 409 | Conflict - download requested before the document completed (body is the envelope) |
| 500 | Internal Server Error (problem+json, code InternalError, with a requestId to quote at support) - something went wrong on our end; any accepted document that fails as a result is automatically credited back |
Rate limits
Rate limits scale with plan size and are published in full - the same numbers your GET /v1/account response reports:
| Plan | Submissions | Status reads |
|---|---|---|
| Pay as you go | 30 requests/minute | 10× your plan's limit - polling never consumes your submission budget |
| Starter | 60 requests/minute | |
| Growth | 120 requests/minute | |
| Business | 180 requests/minute | |
| Volume / Enterprise | Custom |
Being throttled returns a structured RateLimited refusal and is harmless - back off briefly and retry. Deliberately engineering around throttling (for example by spreading traffic across credentials) is a breach of the terms of service.
Supported file formats
Our CDR service supports a wide range of document and image formats commonly used in business environments. The following table lists all currently supported file types:
| File Category | File Type | File Extensions |
|---|---|---|
| Documents | Microsoft Word 97-2003 | DOC, DOT |
| Microsoft Word 2007 and later | DOCX, DOCM, DOTX, DOTM |
|
| Spreadsheets | Microsoft Excel 97-2003 | XLS |
| Microsoft Excel 2007 and later | XLSX, XLSB, XLSM, XLTX, XLTM, XLAM |
|
| Presentations | Microsoft PowerPoint 97-2003 | PPT, PPS, POT, PPA |
| Microsoft PowerPoint 2007 and later | PPTX, PPTM, POTX, POTM, PPAM, PPSX, PPSM |
|
| Other | Adobe PDF (all versions) | PDF |
| Adobe FDF | FDF |
|
| Rich text and OpenDocument | Rich Text Format | RTF |
| OpenDocument | ODT, ODS, ODP |
|
| Images | JPEG | JPG, JPEG |
| PNG | PNG |
|
| GIF | GIF |
|
| Bitmap | BMP |
|
| TIFF | TIF, TIFF |
|
| WebP | WEBP |
|
| Web and vector | Scalable Vector Graphics | SVG |
| HTML | HTML, HTM |
|
| Archives | ZIP | ZIP |
| Gzip | GZ |
Formats returned in a different format
A few formats cannot be rebuilt in their original form. Rather than refuse them, we convert them - a viewable PDF is more use to you than a file you cannot open at all. When this happens the completed document carries "converted" in its modifications array and an outputFormat field, and the download is named with the new extension.
outputFormat when naming or routing the download.
| Submitted | Returned as |
|---|---|
SVG, HTML, HTM |
PDF |
ODS, ODP |
PDF |
GZ |
ZIP |
Everything else is returned in the format it was submitted in. ODT, RTF, WEBP and ZIP are all rebuilt in place.
Archives
A ZIP is accepted only if every file inside it is itself a supported type. One unsupported member rejects the whole archive with unsupported_file_type; one infected member rejects it with threat_detected. A clean archive comes back as a single rebuilt archive.
file_too_large after processing rather than at upload. Our CDR service is regularly updated to support additional file formats. If you require processing for a file type not listed here, please contact our support team to discuss your requirements.
Code examples
Each example gets a token, sanitises a document synchronously (falling back to polling automatically), downloads the cleaned file, and shows the balance check. All were run against the live API in July 2026.
# 1. Token (cache it - valid one hour)
TOKEN=$(curl -s https://identity.redeagle.tech/connect/token \
-d grant_type=client_credentials \
-d client_id="$CDR_CLIENT_ID" \
-d client_secret="$CDR_CLIENT_SECRET" \
-d scope=cdr.api | jq -r .access_token)
# 2. Sanitise synchronously (200 = done; 202 = still processing, poll statusUrl)
curl -s -X POST "https://api.cdr.redeagle.tech/v1/documents?wait=true" \
-H "Authorization: Bearer $TOKEN" \
-F "file=@invoice.pdf" | jq
# 3. Or async: submit, then poll the statusUrl the envelope gives you
RESP=$(curl -s -X POST https://api.cdr.redeagle.tech/v1/documents \
-H "Authorization: Bearer $TOKEN" -F "file=@invoice.pdf")
STATUS_URL=$(echo "$RESP" | jq -r .statusUrl)
while curl -s -H "Authorization: Bearer $TOKEN" "$STATUS_URL" | jq -e '.status == "pending" or .status == "processing"' >/dev/null; do sleep 2; done
curl -s -H "Authorization: Bearer $TOKEN" "$STATUS_URL" | jq
# 4. Fetch the cleaned file (302 to a 24-hour link; -L follows it)
curl -sL -H "Authorization: Bearer $TOKEN" "$STATUS_URL/download" -o invoice.cleaned.pdf
# 5. Where do I stand? (live balance / allowance / period)
curl -s -H "Authorization: Bearer $TOKEN" https://api.cdr.redeagle.tech/v1/account | jq
// C# (.NET 8) - token, synchronous sanitise, poll fallback, download
using System.Net.Http.Headers;
using System.Net.Http.Json;
var http = new HttpClient();
// 1. Token (cache until expiry)
var tokenResponse = await http.PostAsync("https://identity.redeagle.tech/connect/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = Environment.GetEnvironmentVariable("CDR_CLIENT_ID")!,
["client_secret"] = Environment.GetEnvironmentVariable("CDR_CLIENT_SECRET")!,
["scope"] = "cdr.api",
}));
var token = (await tokenResponse.Content.ReadFromJsonAsync<TokenReply>())!.access_token;
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// 2. Sanitise synchronously
using var form = new MultipartFormDataContent
{
{ new ByteArrayContent(await File.ReadAllBytesAsync("invoice.pdf")), "file", "invoice.pdf" }
};
var submit = await http.PostAsync("https://api.cdr.redeagle.tech/v1/documents?wait=true", form);
var doc = (await submit.Content.ReadFromJsonAsync<DocumentEnvelope>())!;
// 200 -> terminal now; 202 -> poll statusUrl until it is
while (doc.status is "pending" or "processing")
{
await Task.Delay(TimeSpan.FromSeconds(doc.pollAfterSeconds ?? 2));
doc = (await http.GetFromJsonAsync<DocumentEnvelope>(doc.statusUrl))!;
}
if (doc.status == "completed")
{
// The /download endpoint 302s to a short-lived link; the bearer is
// stripped automatically on the cross-origin redirect.
var cleaned = await http.GetByteArrayAsync(doc.statusUrl + "/download");
await File.WriteAllBytesAsync("invoice.cleaned.pdf", cleaned);
}
else
{
Console.WriteLine($"{doc.status}: {doc.reason}"); // rejected (billed) or failed (auto-credited)
}
record TokenReply(string access_token);
record DocumentEnvelope(string id, string status, string? outcome, string? reason,
string statusUrl, string? downloadUrl, int? pollAfterSeconds);
// JavaScript (Node 20+) - token, synchronous sanitise, poll fallback, download
import fs from "node:fs/promises";
const IDENTITY = "https://identity.redeagle.tech";
const API = "https://api.cdr.redeagle.tech";
// 1. Token (cache until expiry)
const tokenRes = await fetch(`${IDENTITY}/connect/token`, {
method: "POST",
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.CDR_CLIENT_ID,
client_secret: process.env.CDR_CLIENT_SECRET,
scope: "cdr.api",
}),
});
const { access_token } = await tokenRes.json();
const auth = { Authorization: `Bearer ${access_token}` };
// 2. Sanitise synchronously
const form = new FormData();
form.append("file", new Blob([await fs.readFile("invoice.pdf")]), "invoice.pdf");
let res = await fetch(`${API}/v1/documents?wait=true`, { method: "POST", headers: auth, body: form });
let doc = await res.json();
// 200 -> done; 202 -> poll
while (doc.status === "pending" || doc.status === "processing") {
await new Promise(r => setTimeout(r, (doc.pollAfterSeconds ?? 2) * 1000));
doc = await (await fetch(doc.statusUrl, { headers: auth })).json();
}
if (doc.status === "completed") {
// The /download endpoint 302s to a short-lived link; fetch drops the
// bearer automatically on the cross-origin redirect.
const cleaned = await fetch(`${doc.statusUrl}/download`, { headers: auth });
await fs.writeFile("invoice.cleaned.pdf", Buffer.from(await cleaned.arrayBuffer()));
} else {
console.log(`${doc.status}: ${doc.reason}`); // rejected (billed) / failed (auto-credited)
}
# Python (3.10+) - token, synchronous sanitise, poll fallback, download
import os, time, requests
IDENTITY = "https://identity.redeagle.tech"
API = "https://api.cdr.redeagle.tech"
# 1. Token (cache until expiry)
token = requests.post(f"{IDENTITY}/connect/token", data={
"grant_type": "client_credentials",
"client_id": os.environ["CDR_CLIENT_ID"],
"client_secret": os.environ["CDR_CLIENT_SECRET"],
"scope": "cdr.api",
}).json()["access_token"]
auth = {"Authorization": f"Bearer {token}"}
# 2. Sanitise synchronously (200 = done; 202 = poll)
with open("invoice.pdf", "rb") as fh:
doc = requests.post(f"{API}/v1/documents?wait=true",
headers=auth, files={"file": ("invoice.pdf", fh)}).json()
while doc["status"] in ("pending", "processing"):
time.sleep(doc.get("pollAfterSeconds", 2))
doc = requests.get(doc["statusUrl"], headers=auth).json()
if doc["status"] == "completed":
# /download 302s to a short-lived link; requests drops the bearer on the redirect
cleaned = requests.get(doc["statusUrl"] + "/download", headers=auth)
open("invoice.cleaned.pdf", "wb").write(cleaned.content)
else:
print(doc["status"], doc.get("reason")) # rejected (billed) / failed (auto-credited)
Build with AI
The API publishes machine-readable maps of itself, so a coding assistant can integrate it without you pasting documentation around:
https://api.cdr.redeagle.tech/llms.txt- the concise AI-facing map of the APIhttps://api.cdr.redeagle.tech/llms-full.txt- the full reference, including the sharp edges and the design rationalehttps://api.cdr.redeagle.tech/v1/openapi.json- the OpenAPI specification
Or paste this prompt into your assistant and go:
Integration prompt
Integrate the Red Eagle CDR API (base https://api.cdr.redeagle.tech/v1) into my
application. Read https://api.cdr.redeagle.tech/llms-full.txt for the full
contract first. The facts that matter:
- Auth: OAuth2 client credentials at https://identity.redeagle.tech/connect/token,
scope cdr.api. Tokens last an hour - cache and reuse, never mint per call.
- Submit: POST /v1/documents, multipart field "file". Add ?wait=true - small
files come back 200 completed in one round trip; a 202 means poll the
envelope's statusUrl at its pollAfterSeconds cadence until terminal.
- Files over 100 MB: POST /v1/uploads -> PUT the bytes to uploadUrl with its
requiredHeaders -> POST /v1/documents with {"uploadId": "..."}.
- Send an Idempotency-Key header on any submit that might be retried.
- Terminal states: "completed" (fetch via GET /v1/documents/{id}/download with
the bearer, or use downloadUrl WITHOUT the bearer - never send both);
"rejected" (a billed, final analysis verdict - branch on reason and surface
threatNames to the user); "failed" (our fault, automatically credited).
- Errors are RFC 9457 problem+json: branch on the stable "code" member and
honour Retry-After on 429s. Download links expire 24 hours after completion -
collect cleaned files promptly.
Write idiomatic code for my stack, with tests, including the poll loop and
rejected/failed handling.
The assistant-readable files and this page describe the same contract - if you ever find them disagreeing, trust the API's own /llms.txt and tell us.
Limitations
- File Size: 250 MB per document on Pay-as-you-go and Starter, 450 MB on Growth and above; files over 100 MB are submitted via the staged-upload flow (custom limits at Enterprise)
- Rate Limits: per plan - see rate limits
- Storage: transient by design - submitted content is deleted once processing completes, cleaned files are retained for 24 hours (delete them sooner via the API, or opt in to delete-after-download), and download links are valid for 24 hours from completion. The service is a processing pipeline, not a storage service: keep your own copies of originals and collect cleaned files promptly
- Formats: limited to the file types listed in the supported file formats section
Support
If you need assistance with the CDR API, you can:
Email support
UK business hours, 09:00–17:30 Mon–Fri (excl. England & Wales bank holidays). Paid plans: first response within four business hours - two business hours for production-impacting faults on Business and Volume. Pay-as-you-go: within one business day
support@redeagle.techThe machine-readable spec is always at https://api.cdr.redeagle.tech/v1/openapi.json.
Ready to get started?
Create your account, add credit or pick a plan, and make your first API call in minutes.
Get started now