Documentation menu

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.

EventDescription
link.createdA new short link was created
link.updatedA link's destination or settings changed
link.deletedA link was permanently deleted
page.publishedA bio page was published
report.sharedA report left PRIVATE and became reachable outside the workspace
conversion.createdYour server recorded a conversion
analytics.spikeAn unusual surge in clicks was detected
analytics.geo_anomalyClicks arrived from an unexpected geography
domain.verifiedA 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.

POST/api/webhooks
Request
curl -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"]
  }'
Response: 201 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:

GET/api/webhooks— list all webhook subscriptions
DELETE/api/webhooks/:id— remove a webhook subscription

Payload 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.

link.created delivery
{
  "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"
  }
}
report.shared delivery
{
  "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

HeaderDescription
X-Webhook-Signaturet=<unix seconds>,v1=<hex> — HMAC-SHA256 over timestamp.body
User-AgentMASK-Webhooks/1.0
X-Mask-Request-IdAn opaque correlation reference, when one is available. Quote it in a support request.
Node.js signature verification
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 });
});
Python signature verification
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}, 200

Retry 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:

AttemptDelay
1st retry10 seconds
2nd retry20 seconds
3rd retry40 seconds
4th retry80 seconds
5th retry160 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.