Docs / Surface / webhooks
Webhooks
Webhooks let Surface push scan results to your server as soon as each scan finishes. Configure a webhook URL on any scan profile and Surface will POST the full result, so no polling is required. When the webhook has authentication configured, each request is signed with HMAC-SHA256 so you can cryptographically verify that it came from Surface.
Webhooks are configured per scan profile, under Scan Profiles. Choosing an Auth type other than None reveals the credential field for it.
Webhook payload
The POST body includes the requestId (matching the original scan response) for correlating results with your stored files, plus convenience booleans (isMalicious, isSuspicious) for simple branching logic.
{
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"file": {
"name": "upload.exe",
"size": 102400,
"hash": "sha256:abc123...",
"contentType": "application/x-msdownload"
},
"scanResult": {
"safetyScore": {
"score": 15,
"threatLevel": "Malicious",
"primaryThreat": "Trojan.Generic",
"recommendedAction": "Block"
}
},
"threatLevel": "Malicious",
"isMalicious": true,
"isSuspicious": false,
"timestamp": "2025-03-09T12:00:00Z",
"scanDuration": "2.1s"
}
Key fields:
| Field | Description |
|---|---|
requestId |
Correlation ID matching the original scan response |
file.name |
Original filename |
file.hash |
SHA-256 hash of the scanned content |
scanResult.safetyScore.score |
0-100 safety score (higher is safer) |
scanResult.safetyScore.threatLevel |
Clean, Informational, Suspicious, or Malicious |
scanResult.safetyScore.recommendedAction |
Allow, Review, or Block |
isMalicious |
true if the scan verdict is Malicious |
isSuspicious |
true if the scan verdict is Suspicious |
Signature verification
When a webhook has authentication configured, each request includes an X-Surface-Signature header containing an HMAC-SHA256 digest of the raw request body. Always verify this signature before trusting the payload.
The signature format is sha256=<hex digest>. Use a timing-safe comparison to prevent timing attacks.
The signing secret is not the full auth value you configured; it is the part after the first colon of that value. For example, if you configured an API Key auth value of apikey:secret123, the signature is computed with secret123. Use that substring as the secret in your verification code.
If the webhook auth type is None (or no auth value is set), no X-Surface-Signature header is sent and there is nothing to verify. Configure an auth value if you want signed deliveries.
Node.js verification example
const crypto = require('crypto');
function verifySurfaceWebhook(rawBody, secret, signatureHeader) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody) // rawBody must be the raw Buffer, not parsed JSON
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
The rawBody parameter must be the raw request buffer, not a parsed or re-serialized JSON object. Parsing and re-serializing can change whitespace or key order, which will cause the signature check to fail. Use express.raw({ type: 'application/json' }) or equivalent in your framework to capture the raw bytes.
Authentication options
In addition to signature verification, you can protect your webhook endpoint with an authentication header. Configure the auth type in your scan profile settings:
| Auth Type | What Surface sends |
|---|---|
| None | No auth headers, and no signature, since there is no secret to sign with |
| API Key | Authorization: Bearer your-key header |
| Bearer Token | Authorization: Bearer your-token header |
| Custom Header | A header name and value that you define |
Webhook credentials are encrypted at rest and are never displayed after you save them in the dashboard.
Example handler
Here is a complete Express.js handler that processes webhook results and takes action based on the verdict:
app.post('/webhooks/scan', (req, res) => {
const { requestId, file, isMalicious, isSuspicious, scanResult } = req.body;
if (isMalicious) {
quarantineFile(file.hash);
notifyAdmin(file.name, scanResult.safetyScore.primaryThreat);
} else if (isSuspicious) {
flagForReview(file);
} else {
approveFile(file);
}
res.status(200).json({ received: true });
});
Return any 2xx status to acknowledge receipt. Webhook delivery is fire-and-forget: failures are logged but not retried. If your endpoint is critical, use async scanning and poll GET /api/scan/{scanId} as a fallback.
The X-Surface-Request-ID header echoes the scan's requestId so you can correlate webhook deliveries with the original scan response (or with a record you stored when you submitted the scan).
Using webhooks with async scanning
Webhooks pair naturally with async scanning. Submit a scan with ?defer=true, get back a scanId and requestId immediately, and let the webhook deliver the full result when analysis completes. This avoids holding HTTP connections open for large files.
Tendrl