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 filename hint for format detection",
"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 | Filename hint for format detection (e.g. "test.php") |
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. Files larger than 25 MB are deferred automatically 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"
},
"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) |
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.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.
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[], and dangerBlocks[] |
promptInjection |
Prompt injection detection. Returns detected, risk level, and findings[] |
sensitiveData |
Exposed credentials, API keys, or PII. Returns detected and findings[] |
toolCallAnalysis |
Suspicious tool or function call patterns. Returns detected, toolCalls count, and findings[] |
---
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 500 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
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized |
Missing or invalid API key |
| 402 | quota_exceeded |
Monthly scan limit reached |
| 413 | file_too_large |
File exceeds size limit |
| 415 | unsupported_type |
File type not allowed by scan profile |
| 429 | rate_limited |
Too many requests |
Tendrl