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
{
"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.
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:
{
"scanId": "uuid",
"requestId": "uuid",
"status": "pending",
"message": "Scan queued. Poll GET /api/scan/{scanId} for results."
}
- Use
scanIdto poll for results viaGET /api/scan/{scanId} - Use
requestIdto correlate with webhooks (same ID in both the response and webhook payload)
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:
{
"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.
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:
{
"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:
{
"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)"
}
}
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.
{
"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 |
Tendrl