Skip to content
jot.co.zm

Webhooks

Get notified the moment a link is created, with a verifiable signature on every delivery.

Webhooks require the Team plan or above. Today, one event type is delivered: link.created, fired the moment a link is created through either the dashboard or the API.

Register a webhook

POST /v1/webhooks — scope: write

{
  "targetUrl": "https://example.com/hooks/jot", // must be https://
  "eventTypes": ["link.created"]                 // non-empty, only "link.created" exists today
}
{
  "id": "...",
  "targetUrl": "https://example.com/hooks/jot",
  "eventTypes": ["link.created"],
  "signingSecret": "whsec_a1B2c3D4e5F6g7H8i9J0k1L2",
  "isActive": true,
  "createdAt": 1759276800
}

signingSecret is shown only here, once — store it immediately. There's no endpoint to retrieve it again later; if you lose it, delete the subscription and create a new one.

targetUrl must be https:// — an http:// target is rejected with 400. Errors: 403 if your plan is below Team.

List / remove subscriptions

GET /v1/webhooks (scope read) returns { "subscriptions": [...] } (without signingSecret — it's never returned again after creation). DELETE /v1/webhooks/:id (scope write) returns 204 No Content, or 404 if the subscription doesn't exist for your org.

What you receive

Every delivery is a POST to your targetUrl:

{
  "event": "link.created",
  "createdAt": 1759276800,
  "link": {
    "id": "...",
    "shortUrl": "https://jot.co.zm/aB3xQ",
    "shortCode": "aB3xQ",
    "destinationUrl": "https://example.com/launch"
  }
}
X-Jot-Signature: sha256=<hex-encoded HMAC-SHA256>
X-Jot-Timestamp: <unix seconds>

A failed delivery (anything other than a 2xx response) is retried up to 5 times with backoff, then moved to a dead-letter queue.

Verifying a delivery

The signature is an HMAC-SHA256 of the exact string ${timestamp}.${rawBody} — the timestamp, a literal ., then the raw JSON body bytes exactly as sent (not a re-serialization of the parsed JSON, which can reorder keys and break the signature). Compute the same HMAC with your signingSecret and compare it against the value after the sha256= prefix, using a constant-time comparison.

Putting the timestamp inside the signed content (not just alongside it) defends against replay without Jot needing a server-side nonce store — but only if you reject stale timestamps. Jot doesn't enforce a skew window on the sending side; check X-Jot-Timestamp is within a few minutes of your own clock before trusting a delivery.

const crypto = require("crypto");

function verifyJotWebhook(rawBody, signatureHeader, timestampHeader, signingSecret) {
  // Reject stale deliveries -- Jot signs with the timestamp but doesn't
  // enforce a skew window itself; that's on the receiver.
  const age = Math.abs(Date.now() / 1000 - Number(timestampHeader));
  if (age > 300) return false; // 5 minutes

  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(`${timestampHeader}.${rawBody}`)
    .digest("hex");

  const provided = signatureHeader.replace(/^sha256=/, "");
  const expectedBuf = Buffer.from(expected);
  const providedBuf = Buffer.from(provided);

  return expectedBuf.length === providedBuf.length && crypto.timingSafeEqual(expectedBuf, providedBuf);
}

Use rawBody as the exact bytes your HTTP framework received — most frameworks parse JSON before your handler sees it, so you may need a raw body middleware (e.g. Express's express.raw()) on this specific route.