Docs / Surface / api

API Reference

All endpoints require a Bearer token: Authorization: Bearer <api-key>.

You can provide your own correlation ID by sending the X-Request-ID header, and Surface echoes it in the response and webhook payload. If omitted, one is generated automatically.

---

POST /api/scan#

Scan a file upload.

Request: multipart/form-data

Field Required Description
file Yes File to scan
profile_id No Scan profile ID to use (defaults to the profile linked to the API key)

Query params

Param Default Description
defer false Set to true to return immediately without waiting for results (async mode)

Response: see Understanding Results below.

---

POST /api/scan/payload#

Scan a raw text or binary payload for threats.

Request

json

{
  "payload": "string",
  "label": "optional name, e.g. agent-message.json",
  "encoding": "raw"
}
Field Required Description
payload Yes File content as raw text (default) or base64-encoded string (max decoded size 10 MB)
encoding No "raw" (default) or "base64" for binary payloads
label No Optional name for the payload, also used as a format hint when it has an extension (e.g. "test.php"). It is echoed back as name and shown in scan history; with no label, name is empty — a payload has no filename and none is invented

Response: same shape as file scan. See Understanding Results.

---

Async / Deferred Scanning#

For large files or high-throughput workflows, add ?defer=true to the scan request. Explicit ?defer=true requires a Standard or Pro plan; on Free and Starter it returns an error. Files larger than 25 MB are deferred automatically on every plan even without the flag.

bash

curl -X POST "https://app.tendrl.com/surface/api/scan?defer=true" \
  -H "Authorization: Bearer YOUR_KEY" \
  -F "file=@large_archive.zip"

Returns immediately with HTTP 202 Accepted:

json

{
  "scanId": "uuid",
  "requestId": "uuid",
  "status": "pending",
  "message": "Scan queued. Poll GET /api/scan/{scanId} for results."
}

If you've configured a webhook, results are also POSTed to your endpoint when the scan finishes.

---

Understanding Results#

Every scan returns a JSON response with a safety score and threat details:

json

{
  "requestId": "550e8400-e29b-41d4-a716-446655440000",
  "name": "report.pdf",
  "hash": "e3b0c44298fc1c149afbf4c8...",
  "size": 245760,
  "contentType": "application/pdf",
  "safetyScore": {
    "score": 95,
    "threatLevel": "Clean",
    "confidence": "High",
    "confidenceScore": 0.85,
    "confidenceReason": "3 engines analyzed; signature check clear; no rule matches",
    "primaryThreat": "No threats detected",
    "threatSummary": "No threats detected",
    "enginesUsed": ["Malware Signatures", "YARA", "Static Analysis"],
    "recommendedAction": "Allow",
    "coverage": "full"
  },
  "scanTimeMs": 1250,
  "scanType": "file",
  "timestamp": 1741500000
}

Key fields#

Field Description
requestId Correlation ID for this scan. Send X-Request-ID to provide your own
safetyScore.score 0-100 safety score. Higher is safer
safetyScore.threatLevel Clean (86-100), Informational (71-85), Suspicious (31-70), or Malicious (0-30). Two further values mean nothing was scanned: Rejected (the scan profile does not allow this file type) and Error (the scan could not run). Both carry recommendedAction: Block and an error message saying why. Treat an unrecognised value as unsafe rather than assuming it is clean.
safetyScore.confidence High, Medium, or Low: how certain the verdict is
safetyScore.confidenceScore Numeric confidence (0-1) for programmatic use
safetyScore.confidenceReason Human-readable explanation of the confidence level
safetyScore.primaryThreat Name of the detected threat, or "No threats detected"
safetyScore.threatSummary Brief description of what was found
safetyScore.enginesUsed Which scan engines analyzed this file
safetyScore.recommendedAction Allow, Review, or Block
safetyScore.suppressedEngines Present when an engine fired and your scan profile hides its findings. Names those engines. The verdict still reflects them — a profile controls what detail you see, not what the scanner weighed — so this is why a score can look higher than the findings shown explain. Enable the engine to see them.
safetyScore.coverage How far analysis reaches for this file's format: full, partial, or minimal. See How much a verdict is worth
safetyScore.coverageNote Present when coverage is not full: what is missing and why
safetyScore.cveFindings CVE records (id, description, CVSS, references) when a scan matches known vulnerabilities
hash SHA-256 hash of the scanned content
scanTimeMs Scan duration in milliseconds
scanType "file" or "payload"

Safety score ranges#

Score Range Threat Level Recommended Action
86-100 Clean Allow, safe to use
71-85 Informational Allow, minor findings worth noting
31-70 Suspicious Review before using
0-30 Malicious Block or quarantine

Scores combine signals from all engines. A file flagged by multiple independent engines scores lower than one flagged by a single check.

Tip

Don't want to think about numbers? Use recommendedAction; it is always one of three values: Allow, Review, or Block. No threshold logic required on your end.

How much a verdict is worth#

Not every format is analyzed to the same depth, and a verdict that hid that would be worth less than it appears. Every scan carries a coverage value saying how far analysis actually reached:

Coverage What ran Formats
full Dedicated ML model, signatures, threat feeds, static and behavioral analysis Windows executables (PE), Linux binaries (ELF)
partial ML model plus the full engine set, though detection varies with language and obfuscation Scripts, documents, archives, everything else
minimal Pattern rules and threat feeds only — no ML model exists for the format Java bytecode (.class), JARs and APKs, Mach-O binaries

A minimal scan never returns Clean. The best it reports is Informational (safety 85), because "we looked and found nothing" is not the same claim as "this is clean" when the format is one we cannot analyze deeply. Informational still means Allow — the cap changes what Surface claims, not what it blocks — and a real detection is never softened by it.

If you accept JARs, APKs or macOS binaries, treat Surface as one layer and pair it with runtime controls. Detection coverage explains what sits behind each tier.

Payload scan additional fields#

Payload scans include additional engine results for agentic/content security:

Field Description
codeExtraction Embedded code blocks found in text payloads. Returns blocksFound, indicators[], matches[] (one {indicator, excerpt} per indicator, quoting the code that matched), and dangerBlocks[]
promptInjection Prompt injection detection. Returns detected, risk level, and findings[] of {category, pattern, severity, match}. match is a short excerpt of the text the pattern fired on, with surrounding context; it is empty for the learned classifier, and for encoding-evasion findings it quotes the decoded or normalized text rather than the obfuscated bytes
sensitiveData Exposed credentials, API keys, or PII. Returns detected and findings[] of {type, category, severity, count, redacted} — only a redacted example is ever returned
toolCallAnalysis Suspicious tool or function call patterns. Returns detected, toolCalls count, and findings[] of {toolName, category, severity, reason, evidence}. evidence quotes the arguments that were flagged; it is omitted for exfiltration findings, whose arguments are the credential-bearing body itself

Payloads are not retained. The match, excerpt, evidence and redacted fields are the only pieces of a payload that persist in scan history, and each is capped at roughly 200 characters — enough to confirm or dispute a verdict, not to reconstruct the content.

Fractional numbers in every scan response (confidenceScore, entropy values, engine confidences) are rounded to two decimal places.

---

GET /api/scan/{scanId}#

Retrieve a deferred scan result by scan ID.

While the scan is in progress, returns a pending envelope:

json

{
  "scanId": "uuid",
  "requestId": "uuid",
  "status": "pending",
  "filename": "large_archive.zip",
  "createdAt": "2025-03-09T12:00:00Z"
}

When complete, the response is a top-level envelope with summary fields, and the full synchronous-style scan result nested under result:

json

{
  "scanId": "uuid",
  "requestId": "uuid",
  "status": "complete",
  "filename": "large_archive.zip",
  "fileHash": "e3b0c44298fc1c149afbf4c8...",
  "fileSize": 245760,
  "contentType": "application/zip",
  "safetyScore": 95,
  "threatLevel": "Clean",
  "primaryThreat": "No threats detected",
  "scanTimeMs": 1250,
  "creditsUsed": 1,
  "createdAt": "2025-03-09T12:00:00Z",
  "result": {
    "...": "full scan result, same shape as a synchronous scan (see Understanding Results)"
  }
}
Note

The polled completed response is not the same flat shape as a synchronous scan. The summary fields (safetyScore, threatLevel, …) sit at the top level, and the complete sync-style result (including the nested safetyScore object and engine details) is under the result key.

---

GET /api/account/history/{scan_id}#

Retrieve a previous scan result by ID.

---

GET /api/account/history#

Paginated scan history.

Query params

Param Default Description
limit 25 Results per page (max 100)
page 1 Page number

---

GET /api/account/usage#

Current month usage for the authenticated account.

json

{
  "scans_used": 43,
  "max_scans": 100,
  "scans_remaining": 57,
  "max_file_size_mb": 10,
  "plan_tier": "free",
  "reset_at": "2025-04-01T00:00:00Z",
  "daily_volume": { "dates": ["2025-03-08", "2025-03-09"], "counts": [12, 31] }
}

max_scans and max_file_size_mb come from your plan tier (Free starts at 100 scans/month and 10 MB; higher tiers raise both).

---

Rate Limits#

Resource Default
Scan writes per minute 30
Read endpoints per minute 60
Per-account plan rate limit 10 (Free) / 60 (Starter) / 200 (Standard) / 500 (Pro) requests per minute, applied in addition to the defaults above
Max file size Plan-tier dependent (10 MB on Free, up to 100 MB on Pro)

When a per-minute limit is reached the API returns 429 Too Many Requests. Monthly scan quotas depend on your plan; when exhausted, scan requests return 402. See tendrl.com/pricing for details.

---

Error codes#

Errors return an error message and a requestId. Most also carry fileType, profile, feature, current_plan or code fields relevant to that error — branch on the HTTP status first, and check code for the two cases below where the same status covers more than one reason.

Status Meaning
400 File type not allowed by the scan profile. The body also carries fileType, profile and allowedTypes
401 Missing or invalid API key
402 A usage quota is exhausted: the monthly scan limit, or (only via ?defer=true) async scanning on a plan that does not include it
403 An entitlement your plan does not include: webhooks, async scanning via ?defer=true on Free/Starter, audit log, analytics, custom roles, or blocked IPs. The body carries code: "plan_required", plus feature, current_plan and upgrade: true
413 File exceeds the size limit for your plan
429 Too many requests