Notifications
Instead of polling every scan, let Urlyze call your endpoint when a scan finishes.
The scan.completed webhook
Once it is on, Urlyze sends a POST to your workspace's webhook URL for
every scan your workspace submits, whether through
POST /UrlCheck or POST /UrlCheck/batch:
POST https://your-endpoint.example.com/urlyze
Content-Type: application/json
X-Urlyze-Signature: sha256=<64 lower-case hex characters>
{
"type": "scan.completed",
"analyzeId": "3f6c0f2e-…",
"url": "https://login-example.com/verify",
"verdict": "Malicious",
"tags": ["sys:clickfix"],
"completedAt": "2026-10-02T12:00:00Z",
"resultPath": "/UrlCheck/3f6c0f2e-…"
}
| field | meaning |
|---|---|
type | always scan.completed for this event |
analyzeId | the id POST /UrlCheck or the batch answered
with |
url | the scanned URL |
verdict | one of the verdict values |
tags | the scan's tags |
completedAt | when the scan finished (UTC) |
resultPath | the path of the full result on
https://api.urlyze.io |
resultPath with
your own API key to read the scan. Anyone who learns your webhook URL can send you a request, so
nothing in the event is enough on its own to act on.
Turning it on
The event is opt-in. Set webhook.scanCompleted to true with
PUT /api/notifications/settings. The webhook URL must be an absolute
https URL. Set a signing secret as well: it is the only way to tell Urlyze's calls
from anyone else's.
PUT replaces the whole settings object (email, Slack, SIEM and webhook), so read
the current settings first and send them back with the one change:
curl -s https://api.urlyze.io/api/notifications/settings -H "X-Api-Key: YOUR_API_KEY" \
| jq '.webhook.scanCompleted = true | del(.updatedAt, .webhook.hasSecret, .siem.hasSecret)' \
| curl -X PUT https://api.urlyze.io/api/notifications/settings \
-H "Content-Type: application/json" -H "X-Api-Key: YOUR_API_KEY" -d @-
GETnever returns a secret, onlyhasSecret. In thePUT, leavesecretout to keep the stored one, send""to clear it, or send a new value to replace it.- Leaving
scanCompletedout keeps its stored value, so an older client that does not know the field cannot switch the event off. - A caller that may not change the workspace's settings gets
403.
Verifying the signature
X-Urlyze-Signature is sha256= followed by the lower-case hex
HMAC-SHA256 of the raw request body, keyed with your webhook secret. Compute it
over the bytes you received, before any JSON parsing, and compare in constant time:
// Node.js (Express): keep the raw body for this route
app.post('/urlyze', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.URLYZE_WEBHOOK_SECRET)
.update(req.body).digest('hex');
const got = req.get('X-Urlyze-Signature') ?? '';
if (got.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
queue.push(event.analyzeId); // fetch event.resultPath later, not inside this request
res.sendStatus(204);
});
# Python
import hmac, hashlib
def is_from_urlyze(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")
Delivery
- Answer
2xxquickly. Any other answer is retried, so do the work (fetching the result) after you respond. - Expect the same event more than once. A retry after a timeout can repeat
an event you already handled. Use
analyzeIdto ignore repeats. - A cached result sends no event. When a submission is answered from the
cache (
200,fromCache: true), no scan runs and nothing is sent. Read those results straight away withGET /UrlCheck/{analyzeId}. - Only your workspace's own submissions send an event, not every scan you can read.
This event is separate from the IOC push to your SIEM, which stays driven by confirmations; see IOC feeds.