CDR API Documentation


Token to cleaned file in three calls - or one, with the synchronous mode. Quickstarts in curl, C#, JavaScript and Python.


First cleaned document in under 5 minutes
Measured from a standing start - credentials, token, submit, download - following this page alone. No SDK to install; any HTTP client works.

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

  1. 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).
  2. Get a token - OAuth2 client credentials, scope cdr.api. Tokens last an hour; cache and reuse them.
  3. Sanitise - one call with ?wait=true returns the cleaned file link for most documents in seconds; larger files transition gracefully to a short poll.
All API requests must be made over HTTPS. Calls over plain HTTP will fail.

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)
Your client secret is shown once, at issue. Store it in a secret manager, never in source control - and if you believe it is compromised, rotate it immediately in the account portal. Rotation issues a new secret with a 7-day overlap by default, so a healthy rotation never causes downtime - and in a compromise you can revoke the old secret immediately instead of waiting the overlap out.

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 header Prefer: respond-sync) to your submit call. If the document reaches a terminal state within the sync budget (45 seconds by default), you get 200 OK with 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 a 202 instead - so always handle the 202 path even when you ask for a synchronous response. It is a slower answer, never a failure.
  • Asynchronous - submit without wait, receive 202 Accepted with a statusUrl, and poll at the suggested pollAfterSeconds cadence 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 Accepted carrying the freshest state seen. Your polling code path handles it identically, so ?wait=true is always safe to send - the answer arrives by the other route, it is never a worse answer.
A rejection is a final answer, not an error: a synchronous call whose document is rejected still returns 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: pendingprocessingcompleted, 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 file
  • 404 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 copy
  • 409 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 a 404: 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"
}
  • uploadUrl is 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 the requiredHeaders with the PUT; the link stops working at expiresAt, and abandoned uploads are cleaned up automatically.
  • maxSizeBytes is your plan's ceiling, checked when you submit by reference. An oversized file is refused with a 413 problem response carrying maxSizeBytes, and nothing is billed.
  • Optional integrity check: send a Content-MD5 header with the PUT and storage verifies the bytes it received - transfer corruption becomes an immediate, unbilled 400 at upload time instead of a billed rejected/invalid_file at processing time.
  • Any chunked uploader works: for multi-hundred-megabyte files over unreliable connections, tools like azcopy or the Azure SDKs can drive the same uploadUrl with retries and parallel blocks - plain curl -T is fine on a stable link.
  • After submit, everything is identical to a multipart submission: the same 202 envelope, the same statuses, the same usage headers and the same Idempotency-Key support. Large files take longer to process - keep polling at pollAfterSeconds; 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 pendingprocessing → 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 go30 requests/minute10× your plan's limit - polling never consumes your submission budget
Starter60 requests/minute
Growth120 requests/minute
Business180 requests/minute
Volume / EnterpriseCustom

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.

Do not assume the cleaned file has the extension you submitted. Read 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.

Maximum 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). Size limits apply to the uncompressed contents - an archive that is small on disk may still exceed the limit once expanded, and is refused with 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:

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.tech
API reference

Interactive OpenAPI documentation

Open API reference
Phone support

Available for Enterprise plan

+44 (0)20 8044 3221

The 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

Find us