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.

Webhook settings on a scan profile: URL, auth type and token, additional headers, and the masked signing secret with Reveal, Copy and Rotate 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.

json

{
  "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.

Not your auth header

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#

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#

javascript

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#

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:

javascript

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 });
});
Info

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.