Webhooks
Receive real-time HTTP POST notifications when events happen in your workspace. Configure endpoints, choose which events to subscribe to, and verify signatures for secure delivery.
Available events
Subscribe to one or more of the following event types. Each event is delivered as an HTTP POST request to your configured endpoint URL. The complete, authoritative list — the exact payload contract for every event, and the names MASK has retired — is served by GET /api/public/events/schema.
| Event | Description |
|---|---|
| link.created | A new short link was created |
| link.updated | A link's destination or settings changed |
| link.deleted | A link was permanently deleted |
| page.published | A bio page was published |
| report.shared | A report left PRIVATE and became reachable outside the workspace |
| conversion.created | Your server recorded a conversion |
| analytics.spike | An unusual surge in clicks was detected |
| analytics.geo_anomaly | Clicks arrived from an unexpected geography |
| domain.verified | A custom domain DNS verification completed |
Creating webhook subscriptions
Register a webhook endpoint by providing a URL and the events you want to subscribe to. MASK generates the signing secret and returns it once, in the response to this call — it is stored encrypted and never shown again, so record it when you create the endpoint. Webhooks require a paid plan.
/api/webhookscurl -X POST https://mask.pk/api/webhooks \
-H "Content-Type: application/json" \
-d '{
"workspaceId": "ws_abc123",
"url": "https://your-app.com/webhooks/mask",
"events": ["link.created", "page.published", "conversion.created"]
}'{
"success": true,
"data": {
"id": "wh_abc123",
"url": "https://your-app.com/webhooks/mask",
"events": ["link.created", "page.published", "conversion.created"],
"secret": "whsec_...",
"enabled": true,
"createdAt": "2026-08-28T14:00:00Z"
}
}An event name MASK no longer offers is rejected with a 400 that names it, rather than being stored as a subscription that could never fire. You can list, update and delete webhooks using the following endpoints:
/api/webhooks— list all webhook subscriptions/api/webhooks/:id— remove a webhook subscriptionPayload format
Every webhook delivery sends a JSON POST request to your endpoint. The body is always the same three-key envelope: the event name, the time MASK sent it, and the event's own data.
Each data object is a closed contract, not a database row. It carries the fields listed for that event in GET /api/public/events/schema and nothing else, so a column added to MASK's own tables never starts arriving at your endpoint.
{
"event": "link.created",
"timestamp": "2026-08-28T10:00:00.000Z",
"data": {
"id": "lnk_def456",
"workspaceId": "ws_abc123",
"slug": "new-campaign",
"domain": "mask.bz",
"destinationUrl": "https://example.com/campaign",
"title": "Q1 campaign"
}
}{
"event": "report.shared",
"timestamp": "2026-08-28T15:22:10.000Z",
"data": {
"workspaceId": "ws_abc123",
"reportId": "rpt_abc123",
"title": "March performance",
"visibility": "TOKEN"
}
}Signature verification
Every delivery carries an X-Webhook-Signature header of the form t=<unix seconds>,v1=<hex digest>. The digest is HMAC-SHA256, keyed by your webhook secret, over the timestamp, a literal full stop, and the raw request body — that is, over {timestamp{'}'}.{rawBody{'}'}, not over the body alone. Signing the timestamp with the body is what stops a captured delivery being replayed later; reject a delivery whose t is further from your clock than you are willing to accept.
Verify against the raw bytes of the body. A framework that parses and re-serialises JSON for you will change the whitespace and the key order, and the signature will then never match.
Delivery headers
| Header | Description |
|---|---|
| X-Webhook-Signature | t=<unix seconds>,v1=<hex> — HMAC-SHA256 over timestamp.body |
| User-Agent | MASK-Webhooks/1.0 |
| X-Mask-Request-Id | An opaque correlation reference, when one is available. Quote it in a support request. |
import crypto from "crypto";
// MASK signs the timestamp, a full stop, then the raw body -- never the body alone.
function verifyMaskSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
String(header || "").split(",").map((p) => p.split("=", 2))
);
const timestamp = parts.t;
const received = parts.v1;
if (!timestamp || !received) return false;
// A signature stays valid forever unless you bound it. Reject stale deliveries.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
const a = Buffer.from(received, "hex");
const b = Buffer.from(expected, "hex");
// timingSafeEqual throws on a length mismatch, so compare lengths first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express -- express.raw(), so the bytes MASK signed are the bytes you verify.
app.post("/webhooks/mask", express.raw({ type: "application/json" }), (req, res) => {
const header = req.headers["x-webhook-signature"];
if (!verifyMaskSignature(req.body, header, process.env.MASK_WEBHOOK_SECRET)) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body.toString("utf8"));
console.log("Received", event.event, "at", event.timestamp);
// Acknowledge first, process afterwards.
res.status(200).json({ received: true });
});import hmac
import hashlib
import time
# MASK signs the timestamp, a full stop, then the raw body -- never the body alone.
def verify_mask_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
timestamp, received = parts.get("t"), parts.get("v1")
if not timestamp or not received:
return False
if abs(int(time.time()) - int(timestamp)) > tolerance:
return False
expected = hmac.new(
secret.encode(),
(timestamp + ".").encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(received, expected)
# Flask -- request.data is the raw body, which is what was signed.
@app.route("/webhooks/mask", methods=["POST"])
def handle_webhook():
header = request.headers.get("X-Webhook-Signature")
if not verify_mask_signature(request.data, header, WEBHOOK_SECRET):
return {"error": "Invalid signature"}, 401
event = request.get_json()
print("Received", event["event"], "at", event["timestamp"])
return {"received": True}, 200Retry policy
Your endpoint must respond with a 2xx status within 10 seconds; a slower response is abandoned as a timeout. A failed delivery is retried up to five times with exponential backoff, doubling from ten seconds and capped at thirty minutes:
| Attempt | Delay |
|---|---|
| 1st retry | 10 seconds |
| 2nd retry | 20 seconds |
| 3rd retry | 40 seconds |
| 4th retry | 80 seconds |
| 5th retry | 160 seconds, then capped at 30 minutes |
A 4xx is not retried. It is your endpoint saying it will not accept this delivery, so MASK marks it failed at once rather than repeating it — with two exceptions, 408 and 429, which mean “not now” rather than “not ever” and retry like a 5xx. A delivery whose outcome could not be established at all — a timeout or a reset connection, where the request may well have arrived — is recorded as UNKNOWN rather than failed, and is left for a person to judge instead of being repeated automatically. Deliveries and their outcomes are visible under Settings → Webhooks.
Best practices
Respond quickly. Return a 200 immediately and process the event asynchronously. Don't block the response on database writes or external API calls.
Verify signatures. Always validate X-Webhook-Signature against the raw body before processing any payload, using constant-time comparison, and reject a delivery whose t is too old.
Handle duplicates. Delivery is at-least-once: a response MASK never saw is retried, so one event can arrive twice. MASK does not send a delivery-id header, so deduplicate on the event name together with the identifier inside data — both are stable across every retry of one event.
Use HTTPS. Webhook URLs must use HTTPS. Plain HTTP endpoints are rejected when creating a subscription.