Reference
Webhooks
Register an HTTPS endpoint and we'll POST a signed JSON payload there whenever the bound account publishes new content. Cheaper, faster, and less rate-limited than polling.
Setup
- Open Settings → API access and pick the key you want to attach a webhook to.
- Click Manage webhooks on that key's card.
- Click Add endpoint, give us a public HTTPS URL, and check the events you want to receive.
- Copy the signing secret shown once on the next screen — your endpoint needs it to verify each delivery.
You can attach up to 5 webhook endpoints per API key.
Event catalog
Subscribe to whichever subset matters for your integration. Events scope to activity by the bound account — we never fan out unrelated platform-wide events to partners.
| Event | Status | What it means |
|---|---|---|
| jes.created | Live | The bound account published a new Jes. |
| jesclip.created | Live | The bound account published a new JesClip. |
| comment.created | Live | The bound account posted a new comment. |
| apikey.revoked | Live | The API key was revoked (self, admin, or auto). Clear your "connected" UI state. |
| jes.deleted | Reserved | Reserved — not yet emitted in v1. |
| jesclip.deleted | Reserved | Reserved — not yet emitted in v1. |
| comment.deleted | Reserved | Reserved — not yet emitted in v1. |
Envelope shape
Every delivery is JSON with this envelope, regardless of event type:
{
"event": "jes.created",
"id": "evt_<uuid-v4>", // unique per delivery; use for dedupe
"created_at": "2026-05-28T11:42:09.123Z",
"data": { ... } // shape varies by event — see below
}idisevt_+ a UUID v4 string. Always 40 characters. Save and dedupe on this id — deliveries are at-least-once, so the same envelope can arrive more than once during retries or replays.created_atis ISO-8601 with millisecond precision, UTC.datais the per-event payload — see the schemas below.
Per-event data schemas
jes.created & jesclip.created
// jes.created
"data": {
"jes": {
"id": "01HW5...",
"authorId": "01HW...",
"content": "Hello", // string | null
"type": "POST", // "POST" | "SHARE" | "QUOTE"
"visibility": "PUBLIC",
"isShare": false,
"originalJesId": null,
"jeHubId": null, // string | null
"createdAt": "2026-05-28T11:42:09.000Z"
}
}
// jesclip.created
"data": {
"jesclip": {
"id": "01HW5...",
"authorId": "01HW...",
"caption": "watch this", // string | null
"duration": 12, // seconds | null
"visibility": "PUBLIC",
"jeHubId": null,
"processingStatus":"PROCESSING", // "PROCESSING" | "READY" | "FAILED"
"createdAt": "2026-05-28T11:42:09.000Z"
}
}comment.created
"data": {
"comment": {
"id": "01HW5...",
"jesId": "01HW...", // the Jes this comment is on
"authorId": "01HW...",
"parentId": null, // string | null — the parent comment for replies
"content": "great point",
"createdAt": "2026-05-28T11:42:09.000Z"
}
}apikey.revoked
Fires once when the bound API key is revoked — self-revoke from settings, admin suspension, or the auto-revoke abuse threshold. After this event no further events from the key will fire (the key can no longer post). Use it to clear “connected to Jestha” UI state and prompt the user to reconnect.
"data": {
"apiKey": {
"id": "01HW5...",
"label": "poscos production",
"keyPrefix": "jes_live_a1b2c3d",
"revokedAt": "2026-05-28T11:42:09.000Z",
"revokedBy": "self", // "self" | "admin" | "auto"
"reason": null // string | null — present for admin / auto
}
}Verifying the signature
Each delivery includes these headers:
X-Jestha-Signature: t=1716889329,v1=6c57170dbd7f9ce061ac528a... X-Jestha-Event: jes.created X-Jestha-Delivery-Id: evt_01HW5ZQEXAMPLE...
The signature format is Stripe-style: t=<unix-timestamp>,v1=<hex-hmac>. The HMAC is HMAC-SHA256(secret, “<t>.<raw-body>”). To verify, recompute on your side and compare with a constant-time equality check.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.JESTHA_WEBHOOK_SECRET;
// Important: we need the RAW body bytes to recompute the HMAC.
app.post('/webhooks/jestha', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.header('X-Jestha-Signature') || '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return res.status(400).end();
const payload = req.body; // Buffer
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${t}.${payload.toString('utf8')}`)
.digest('hex');
const ok = expected.length === v1.length &&
crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'));
if (!ok) return res.status(401).end();
// Optional: reject deliveries with a stale timestamp to defend against replays.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) {
return res.status(401).end();
}
const body = JSON.parse(payload.toString('utf8'));
console.log('Got', body.event, body.id);
res.status(200).end();
});import hmac, hashlib, os, time, json
from flask import Flask, request, abort
SECRET = os.environ['JESTHA_WEBHOOK_SECRET'].encode()
app = Flask(__name__)
@app.post('/webhooks/jestha')
def jestha_webhook():
header = request.headers.get('X-Jestha-Signature', '')
parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
t, v1 = parts.get('t'), parts.get('v1')
if not t or not v1:
abort(400)
payload = request.get_data() # raw bytes
expected = hmac.new(SECRET, f"{t}.{payload.decode()}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(401)
if abs(time.time() - int(t)) > 300:
abort(401) # replay defence
body = json.loads(payload)
print('Got', body['event'], body['id'])
return '', 200Delivery, retries, and failures
We POST your endpoint with a 10-second timeout. Your endpoint should respond with any 2xx status to ack — typically 200 OK with an empty body.
- Non-2xx (or timeout, or connection error) is treated as a failure. We retry up to 5 attempts with exponential backoff (~1m, 2m, 4m, 8m, 16m).
- Each attempt writes a row to your endpoint's “recent deliveries” view in settings — status, HTTP code, attempt number.
- After 5 consecutive failures, we auto-disable the endpoint. Re-enable by removing and re-adding it once your receiver is healthy again.
Replay defence
The signature includes a Unix timestamp. We recommend rejecting deliveries where the timestamp is more than 5 minutes off your clock — that protects against an attacker replaying an intercepted payload long after the fact, even if they never recover the secret.
Test endpoint connectivity
From the webhook's row in settings, click Send test. We'll fire a synthetic webhook.test event with a small data payload — useful during setup to confirm your endpoint is reachable and your signature verification works.
Rotating the signing secret
Use POST /api/auth/api-keys/{keyId}/webhooks/{webhookId}/rotate to issue a new signing secret while keeping the previous one valid for a 24-hour overlap window. This enables zero-downtime deploys: roll the new secret out to your receivers first, then call rotate, and your receivers verify either secret during the overlap.
curl -X POST https://api.jestha.com/api/auth/api-keys/<keyId>/webhooks/<webhookId>/rotate \
-H "Cookie: <session>" # session-auth only
# 200 OK
# {
# "id": "<webhookId>",
# "secret": "whsec_<new>", // shown once, like at creation
# "secretPrefix": "whsec_<prefix>",
# "previousSecretExpiresAt": "2026-05-29T11:42:09.000Z"
# }During the 24-hour window, our worker signs with the new secret, but your receiver should accept either one. After expiry the old secret stops verifying. Pattern:
// Receiver — accept either secret during overlap const ok = verify(signature, body, t, CURRENT_SECRET) || (PREVIOUS_SECRET && verify(signature, body, t, PREVIOUS_SECRET));
Redelivery — replay missed events
If your receiver was down or buggy, you can ask us to replay deliveries from a time range. Useful for recovery after an incident; not a substitute for a healthy receiver.
curl -X POST https://api.jestha.com/api/auth/api-keys/<keyId>/webhooks/<webhookId>/redeliver \
-H "Cookie: <session>" \
-H "Content-Type: application/json" \
-d '{
"since": "2026-05-28T00:00:00Z",
"until": "2026-05-28T23:59:59Z", // optional, defaults to now
"eventTypes": ["jes.created"] // optional, defaults to all
}'
# 200 OK
# { "queued": 42, "skippedDuplicates": 3 }- We dedupe by envelope id — if the same event id has already been re-queued in this redelivery call, we skip the duplicate.
- We cap a single redelivery call at 1,000 events. For larger windows, issue multiple calls with narrower time slices.
- Each re-queued envelope keeps its original
idandcreated_at— your dedupe table will reject events you already processed.