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.