Webhooks
Webhooks are available on Starter and above. Setting a webhook URL on a profile while you are on the Free plan is refused with a 403 (code: "plan_required").
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. Each request is signed with HMAC-SHA256 using a signing secret unique to the profile, 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. The Signing secret is what you verify X-Surface-Signature with; Surface never sends 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": "abc123...",
"contentType": "application/x-msdownload"
},
"scanResult": {
"safetyScore": {
"score": 15,
"threatLevel": "Malicious",
"primaryThreat": "Trojan.Generic",
"recommendedAction": "Block",
"coverage": "full"
}
},
"threatLevel": "Malicious",
"isMalicious": true,
"isSuspicious": false,
"isRisky": 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, Risky or Malicious, or Rejected / Error when nothing was scanned |
scanResult.safetyScore.recommendedAction |
Allow, Review, or Block |
scanResult.safetyScore.coverage |
full, partial, or minimal — how far analysis reached for this format (details) |
isMalicious |
true if the scan verdict is Malicious |
isSuspicious |
true if the scan verdict is Suspicious |
isRisky |
true if the verdict is Risky: an agent tool call blocked for what it would do, not for malware. isMalicious is false for these |
Signature verification#
Each request includes an X-Surface-Signature header containing an HMAC-SHA256 digest of the raw request body, keyed with the profile's signing secret. Always verify this signature before trusting the payload.
The signature format is sha256=<hex digest>. Use a constant-time comparison to prevent timing attacks.
The signing secret#
Surface generates a signing secret for each profile when you first save a webhook URL on it. It starts with whsec_; use the whole string, prefix included, as the HMAC key. Surface never sends the secret with a delivery, so it cannot leak through anything that logs request headers between Surface and your endpoint.
- Find it: open the profile under Scan Profiles. In the Webhook section, Signing secret shows the secret masked; choose Reveal or Copy.
- Rotate it: choose Rotate and confirm. Deliveries are signed with the new secret immediately and the old one stops verifying, so update your receiver right away. Rotate if the secret may have been exposed.
- API:
GET /api/account/profiles/{id}/webhook-secretreturns it andPOST /api/account/profiles/{id}/webhook-secret/rotatereplaces it. Both need permission to edit profiles. Profile responses only ever include a masked hint,webhook_signing_secret_masked(for examplewhsec_…3f9a).
The signing secret is separate from the credential you set under Auth type. That credential is sent on every delivery so your endpoint can authenticate the request; it does not sign anything. Verify signatures with the signing secret only.
Use Test webhook on the profile to send a signed sample payload and check your verification code.
The rawBody below must be the raw request bytes, 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. Configure your framework to give you the unparsed body for this route.
Python#
def verify_surface_webhook(raw_body: bytes, secret: str, signature_header: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header or "")
Node.js#
const crypto = require('crypto');
function verifySurfaceWebhook(rawBody, secret, signatureHeader) {
const expected = Buffer.from(
'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
);
const received = Buffer.from(signatureHeader || '');
// timingSafeEqual throws on a length mismatch, so check that first.
return received.length === expected.length && crypto.timingSafeEqual(expected, received);
}
Go#
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func verifySurfaceWebhook(rawBody []byte, secret, signatureHeader string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signatureHeader))
}
The Python, JavaScript, and Go SDKs include the same check as verify_webhook_signature / verifyWebhookSignature / VerifyWebhookSignature; pass them the signing secret.
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. Deliveries are still signed with the profile's signing secret |
| 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 and signing secrets are encrypted at rest. Credentials are never displayed after you save them in the dashboard; the signing secret is shown only when you reveal or copy it.
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.
An 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