Webhooks
Get the finished request posted to your server instead of polling for it.
Pass webhook_url on a request, or set a default for the account at watermark.ws/developers, and the API posts the finished request to it as JSON:
{
"id": "evt_1234_done",
"object": "event",
"type": "request.succeeded",
"created": 1791738000,
"data": { "id": 1234, "object": "request", "status": "succeeded", "result": { "…": "…" } }
}
Event types: request.succeeded, request.failed, request.aborted. Answer with any 2xx within 10 seconds; anything else is retried 3 times with backoff (about 4, 5 and 7 seconds apart). Use the event id to ignore a repeat.
Verifying the signature
Every delivery carries a Watermark-Signature header:
Watermark-Signature: t=1791738000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
v1 is the HMAC-SHA256 of "{t}.{raw body}", keyed with your account's webhook secret (whsec_…, shown in the Developers area). Compare it in constant time, and refuse a t more than 5 minutes old so a recorded delivery cannot be replayed.
PHP
[$t, $v1] = sscanf($_SERVER['HTTP_WATERMARK_SIGNATURE'], 't=%d,v1=%s');
$body = file_get_contents('php://input');
$valid = hash_equals(hash_hmac('sha256', "{$t}.{$body}", $secret), $v1) && abs(time() - $t) <= 300;
Node.js
const crypto = require('crypto');
const [, t, v1] = req.headers['watermark-signature'].match(/^t=(\d+),v1=([0-9a-f]+)$/);
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)) && Math.abs(Date.now() / 1000 - t) <= 300;
Python
import hmac, hashlib, re, time
t, v1 = re.match(r"t=(\d+),v1=([0-9a-f]+)", signature_header).groups()
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
valid = hmac.compare_digest(expected, v1) and abs(time.time() - int(t)) <= 300
Webhook URLs follow the same rules as sources: public http or https addresses only.