Webhooks
Most operations in the Prezent API are asynchronous — POST /api/v1/autogenerations
returns a callback_id you then poll. Webhooks let you skip the
polling: subscribe an HTTPS URL once, and Prezent will deliver a signed
HTTPS POST every time a matching event occurs.
Use webhooks when you want push-style integration into your own backend (queue jobs into your worker, update a deal in your CRM, fan events out over your own broker). Keep polling for low-volume CLIs, scripts, or environments where you can't expose a public HTTPS endpoint.
Full request/response schemas for the subscription-management endpoints live in the interactive API Reference under the Webhooks tag.
At a glance
| Property | Value |
|---|---|
| Scope | Per API key — each key manages its own subscriptions |
| Transport | HTTPS POST, JSON body |
| Signature | X-Prezent-Signature: t=<unix>,v1=<hex> (HMAC-SHA256, Stripe-style) |
| Retries | 7 attempts over a 21-hour window |
| Auto-disable | 50 consecutive failures flips status to disabled |
| Manage via | /api/v1/webhook-subscriptions REST resource |
| Max subscriptions per key | 25 (soft cap — contact us for more) |
Authentication for management endpoints
All /api/v1/webhook-subscriptions/* endpoints require the same Bearer
token used for the rest of the API:
Authorization: Bearer YOUR_API_KEY
See Getting Started → Authentication for details. Subscriptions are scoped to the API key that created them — a key can only list, read, modify, or delete its own subscriptions.
Event catalog
Each delivered payload's type is one of the codes below. Subscribe
with events: ["*"] to receive everything, or list specific types to
filter at the source.
| Event type | Fires when |
|---|---|
autogeneration.completed | An AutoGenerator job reaches status=success. |
autogeneration.failed | An AutoGenerator job reaches status=failed. |
template_conversion.completed | A template-conversion run reaches status=success. |
template_conversion.failed | A template-conversion run reaches status=failed. |
webhook.test | Emitted only by POST /api/v1/webhook-subscriptions/{id}/test. |
New event types are strictly additive — we'll never repurpose an
existing code. You can safely ignore unknown type values; we
recommend logging them so we can flag a misconfiguration.
Quickstart
1. Create a subscription
curl -X POST https://api.prezent.ai/api/v1/webhook-subscriptions \
-H "Authorization: Bearer $PREZENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/prezent",
"events": ["autogeneration.completed", "autogeneration.failed"],
"description": "Push completions into our deal-room worker"
}'
The response contains the HMAC secret, returned once:
{
"success": true,
"data": {
"id": "whsub_3f7a9b1c8d2e4f5a6b7c8d9e0f1a2b3c",
"url": "https://hooks.example.com/prezent",
"events": ["autogeneration.completed", "autogeneration.failed"],
"status": "active",
"secret": "whsec_4mZkV8t9oFp...truncated",
"secret_prefix": "whsec_",
"created_at": "2026-05-25T10:00:00Z",
"updated_at": "2026-05-25T10:00:00Z"
}
}
Store secret immediately — it will not be returned again. Reads only
show the secret_prefix (first 6 chars). If you lose it, call
POST /api/v1/webhook-subscriptions/{id}/rotate-secret.
2. Verify connectivity
curl -X POST https://api.prezent.ai/api/v1/webhook-subscriptions/whsub_.../test \
-H "Authorization: Bearer $PREZENT_API_KEY"
We immediately POST a synthetic webhook.test event to your endpoint
and return the HTTP status + response body verbatim. Use this to
confirm signing verification works before real traffic flows.
3. Receive events
Every delivery is a JSON POST shaped like:
{
"id": "evt_8c2a1f7e6d3b4a5c9e0f8d7b6a5c4d3e",
"type": "autogeneration.completed",
"api_version": "1.0",
"created_at": "2026-05-25T10:05:42Z",
"data": {
"callback_id": "ag_5f7a9b1c8d2e4f5a",
"report_id": "rpt_2c9e0f8d7b6a5c4d",
"status": "success",
"outputs": { "...": "..." }
}
}
with HTTP headers:
POST /prezent HTTP/1.1
Content-Type: application/json
User-Agent: Prezent-Webhooks/1.0
X-Prezent-Event: evt_8c2a1f7e6d3b4a5c9e0f8d7b6a5c4d3e
X-Prezent-Event-Type: autogeneration.completed
X-Prezent-Signature: t=1716545142,v1=8d9a...c4f
X-Prezent-Delivery: whdl_4f5a6b7c8d9e0f1a2b3c4d5e
X-Prezent-Attempt: 1
4. Verify the signature
The signature header is Stripe-compatible:
X-Prezent-Signature: t=<unix_timestamp>,v1=<hex_hmac_sha256>
The signed string is "{t}.{raw_body}" (decimal timestamp, a dot, then
the raw request body, byte-for-byte). Compute
HMAC-SHA256(secret, signed_string), hex-encode, and compare to v1.
Use a constant-time comparison.
We strongly recommend rejecting requests whose t is more than 5
minutes off your local clock — this prevents replay of an
intercepted payload.
- cURL (debug only)
- Python (Flask)
- Node.js (Express)
# This is a one-liner for ad-hoc verification only.
# Production code should use a real HTTP server framework.
SECRET="whsec_..."
RAW='{"id":"evt_...","type":"autogeneration.completed",...}'
TS=1716545142
EXPECTED=$(printf '%s.%s' "$TS" "$RAW" \
| openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
echo "expected v1=$EXPECTED"
import hmac, hashlib, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = b"whsec_..." # from the create / rotate response
TOLERANCE_SECONDS = 5 * 60
@app.post("/prezent")
def receive():
header = request.headers.get("X-Prezent-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
ts = int(parts["t"])
except (KeyError, ValueError):
abort(400)
if abs(time.time() - ts) > TOLERANCE_SECONDS:
abort(400, "timestamp out of tolerance")
raw = request.get_data()
signed = f"{ts}.".encode() + raw
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
abort(401, "bad signature")
event = request.get_json()
# event["type"] -> "autogeneration.completed" etc.
# event["data"] -> per-event payload
process(event)
return ("", 200)
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = 'whsec_...';
const TOLERANCE_SECONDS = 5 * 60;
// IMPORTANT: capture the raw body — JSON parsing mangles whitespace
// and breaks the signature.
app.post('/prezent', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.headers['x-prezent-signature'] || '';
const parts = Object.fromEntries(
header.split(',').filter(p => p.includes('=')).map(p => p.split('='))
);
const ts = parseInt(parts.t, 10);
if (!Number.isFinite(ts)) return res.status(400).send('bad t');
if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) {
return res.status(400).send('timestamp out of tolerance');
}
const signed = `${ts}.${req.body.toString('utf8')}`;
const expected = crypto.createHmac('sha256', SECRET).update(signed).digest('hex');
const provided = Buffer.from(parts.v1 || '', 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
if (provided.length !== expectedBuf.length
|| !crypto.timingSafeEqual(provided, expectedBuf)) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
process(event);
res.status(200).end();
});
Delivery semantics
- Acceptable responses. Any
2xxstatus within 10 seconds counts as a successful delivery. We do not parse your response body — it is captured for the dashboard only. - At-least-once. Network blips can cause the same event to be
delivered more than once. Each delivery carries an immutable
X-Prezent-Eventheader andidin the body — deduplicate on those. - Order is not guaranteed. A retry can land after the next event.
Use
created_atif you need a wall-clock anchor. - Retries. A failed delivery is retried at +1m, +5m, +30m, +2h, +6h, and +18h. After 21 hours, the event is dropped to a dead-letter queue and surfaced on the dashboard.
- Auto-disable. After 50 consecutive failures we flip the
subscription to
status: "disabled"and stop attempting delivery. Patch back tostatus: "active"once you've fixed the endpoint. - Idempotency on your side. Subscribers must be idempotent: a successful response on a duplicate is the cheapest way to handle retries.
URL requirements
For security and reliability we enforce the following at create + update time:
https://only.http://URLs are rejected with400 INVALID_URL_SCHEME.- The hostname must resolve to a public IP. Private (RFC 1918,
127.0.0.0/8,169.254.0.0/16, IPv6 ULA, link-local) addresses are rejected with400 INVALID_URL_PRIVATE_HOST. This protects you and us from SSRF. - Ports
22, 23, 25, 3306, 5432, 6379, 9200, 11211, 27017are rejected with400 INVALID_URL_PORT.
Managing subscriptions
| Method | Path | What it does |
|---|---|---|
POST | /api/v1/webhook-subscriptions | Create a new subscription. Returns the HMAC secret once. |
GET | /api/v1/webhook-subscriptions | List your active subscriptions (paginated). |
GET | /api/v1/webhook-subscriptions/{id} | Read a single subscription. The secret is never returned — only secret_prefix. |
PATCH | /api/v1/webhook-subscriptions/{id} | Update url, events, description, or status. |
DELETE | /api/v1/webhook-subscriptions/{id} | Soft-delete the subscription. |
POST | /api/v1/webhook-subscriptions/{id}/rotate-secret | Issue a fresh HMAC secret. Old secret is invalidated immediately. |
POST | /api/v1/webhook-subscriptions/{id}/test | Send a synthetic webhook.test event to your URL and return the response verbatim. |
See the API Reference for full request/response schemas.
Pausing a subscription
A common operational pattern is pausing deliveries during maintenance:
curl -X PATCH https://api.prezent.ai/api/v1/webhook-subscriptions/whsub_.../ \
-H "Authorization: Bearer $PREZENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "disabled" }'
Events that occur while disabled are not retroactively delivered
once you re-enable. For replay/backfill, fall back to
GET /api/v1/autogenerations/{callback_id} polling for the gap.
Rotating the secret
curl -X POST https://api.prezent.ai/api/v1/webhook-subscriptions/whsub_.../rotate-secret \
-H "Authorization: Bearer $PREZENT_API_KEY"
The new secret is returned once in the response. The old secret
stops verifying as soon as the rotation completes — there is no grace
window. If you need overlap, stand up a second subscription on a
distinct path, switch over, then delete the old one.
Errors
All endpoints on this page return the standard error envelope. Webhook-specific codes:
400 INVALID_URL_SCHEME—urlis nothttps://.400 INVALID_URL_PRIVATE_HOST—urlresolves to a private IP.400 INVALID_URL_PORT—urluses a disallowed port.400 INVALID_EVENT_TYPE—eventscontains an unknown type. Use["*"]or names from the catalog.404 WEBHOOK_SUBSCRIPTION_NOT_FOUND— id not found, or owned by a different API key.
See the full catalog in Developer Guide → Error codes.
See also
- AutoGenerator API — the operation whose completions you'll most often subscribe to.
- Template Converter API — same idea for converted decks.
- Developer Guide — auth, error envelope, pagination, idempotency.