Webhooks
Pellucid POSTs every analysis result to your endpoint, signed with HMAC-SHA256 so you can trust the body before parsing it.
When an analysis finishes, Pellucid loads every enabled webhook target, builds a payload shaped for that target’s kind (generic, slack, discord), signs the body if the target has a shared secret, and fires a five-second-timeout POST. Failures are logged and skipped — there is no retry queue in this round.
Register a target
Use the integrations API or the web app under /integrations. The minimum is a label and a URL.
curl -X POST http://localhost:8000/api/v1/integrations/webhooks \
-H "Content-Type: application/json" \
-d '{
"label": "Production receiver",
"url": "https://your.app/hooks/pellucid",
"kind": "generic",
"secret": "a-long-random-string"
}'Once registered, fire a test ping to verify the receiver responds and the signature header lines up:
curl -X POST http://localhost:8000/api/v1/integrations/webhooks/wh_01J.../test
# → { "status": 200, "error": null }The analysis.completed payload
Pellucid sends one POST per registered, enabled target. The body is always JSON. The shape depends on the registered kind:
kind: “generic”
The canonical Pellucid payload. Stable fields are versioned via schema_version; bumping the version is a breaking change. The linear and jira sub-objects are deliberately flat so bridge tools (Zapier, n8n, custom scripts) can map fields without inspecting the rest of the payload.
{
"schema_version": "1",
"kind": "analysis.completed",
"document": {
"id": "doc_01HZ...",
"title": "Vehicle Telematics SRS",
"url": "/analyze/doc_01HZ..."
},
"stats": {
"total_findings": 42,
"by_severity": { "low": 5, "medium": 20, "high": 15, "critical": 2 },
"clarity_score": 73
},
"top_findings": [
{
"text": "user-friendly",
"type": "vague_term",
"severity": "high",
"rationale": "Subjective adjective with no measurable acceptance criterion."
}
],
"linear": {
"title": "Pellucid: Vehicle Telematics SRS — 42 findings (clarity 73)",
"comment": "Pellucid found 42 findings in 'Vehicle Telematics SRS'..."
},
"jira": {
"summary": "Pellucid: Vehicle Telematics SRS — 42 findings (clarity 73)",
"description": "Pellucid found 42 findings in 'Vehicle Telematics SRS'..."
}
}kind: “slack”
A Block Kit body suitable for an incoming-webhook URL. Header → severity counts → Open in Pellucid button. No emojis in user-visible text.
kind: “discord”
Discord { content, embeds: [...] }. Same severity counts, rendered as a description block.
Signing
When a webhook has a shared secret, Pellucid signs the request body with HMAC-SHA256 and sends:
X-Pellucid-Timestamp: <unix seconds>
X-Pellucid-Delivery: <delivery id>
X-Pellucid-Signature: sha256=<lower-case hex digest>The HMAC key is your shared secret. Pellucid stores it encrypted at rest, decrypts it only for dispatch, and signs the exact bytes <timestamp>.<raw body>. Reject stale timestamps and store recent delivery ids to protect against replay.
Store the same shared secret in your receiver’s secret manager. The API never returns that secret after registration.
Verifying — Python
A minimal FastAPI receiver. The same pattern works in Flask or Django — what matters is reading the raw body before any framework parses it.
import hmac
import hashlib
import os
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
PELLUCID_SECRET: bytes = os.environ["PELLUCID_HOOK_SECRET"].encode("utf-8")
def verify_signature(secret: bytes, body: bytes, timestamp: str, header: str) -> bool:
"""Constant-time compare against HMAC_SHA256(secret, timestamp.body)."""
signed = timestamp.encode("ascii") + b"." + body
expected = "sha256=" + hmac.new(secret, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
@app.post("/hooks/pellucid")
async def receive(
request: Request,
x_pellucid_signature: str | None = Header(default=None),
x_pellucid_timestamp: str | None = Header(default=None),
x_pellucid_delivery: str | None = Header(default=None),
):
body = await request.body() # raw, not parsed JSON
if x_pellucid_signature is None or x_pellucid_timestamp is None:
raise HTTPException(401, "missing signature")
# Reject stale timestamps and dedupe x_pellucid_delivery in production.
if not verify_signature(
PELLUCID_SECRET, body, x_pellucid_timestamp, x_pellucid_signature
):
raise HTTPException(401, "bad signature")
payload = await request.json()
if payload.get("kind") == "analysis.completed":
handle_completion(payload)
return {"ok": True}
def handle_completion(payload: dict) -> None:
title = payload["document"]["title"]
findings = payload["stats"]["total_findings"]
print(f"[pellucid] {title}: {findings} findings")Sanity-check the verification offline against Pellucid’s own helper:
import hmac, hashlib, json
# Echoes apps/api/app/services/webhook_dispatcher.py
def sign_body(secret: bytes, body: bytes, timestamp: str) -> str:
signed = timestamp.encode("ascii") + b"." + body
return "sha256=" + hmac.new(secret, signed, hashlib.sha256).hexdigest()
secret = b"<shared secret>"
payload = {"schema_version": "1", "kind": "ping"}
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
timestamp = "1778400000"
print(sign_body(secret, body, timestamp))
# → sha256=...
# Compare to the X-Pellucid-Signature header on a real delivery.Verifying — Node.js
An Express receiver. The critical bit is mounting express.raw() so req.body is a Buffer — not a parsed object. Stringifying a parsed JSON body will produce different bytes than what Pellucid signed.
import express, { type Request, type Response } from "express";
import crypto from "node:crypto";
const app = express();
// IMPORTANT: raw body for the hook route. Express's json parser would
// strip whitespace and re-order keys, breaking the HMAC.
app.use("/hooks/pellucid", express.raw({ type: "application/json" }));
const PELLUCID_SECRET = Buffer.from(process.env.PELLUCID_HOOK_SECRET ?? "", "utf8");
function verifySignature(
secret: Buffer,
body: Buffer,
timestamp: string,
header: string,
): boolean {
const signed = Buffer.concat([Buffer.from(timestamp + "."), body]);
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(signed).digest("hex");
// Lengths must match for timingSafeEqual; otherwise we'd leak length.
if (expected.length !== header.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}
app.post("/hooks/pellucid", (req: Request, res: Response) => {
const sig = req.header("x-pellucid-signature");
const timestamp = req.header("x-pellucid-timestamp");
if (!sig || !timestamp) return res.status(401).send("missing signature");
const body = req.body as Buffer;
if (!verifySignature(PELLUCID_SECRET, body, timestamp, sig)) {
return res.status(401).send("bad signature");
}
const payload = JSON.parse(body.toString("utf8"));
if (payload.kind === "analysis.completed") {
console.log(`[pellucid] ${payload.document.title}: ${payload.stats.total_findings} findings`);
}
res.json({ ok: true });
});
app.listen(3001);For Next.js Route Handlers, read the body via request.text() and pass it to createHmac directly:
import { NextResponse } from "next/server";
import crypto from "node:crypto";
const SECRET = Buffer.from(process.env.PELLUCID_HOOK_SECRET ?? "", "utf8");
export async function POST(request: Request) {
const sig = request.headers.get("x-pellucid-signature");
const timestamp = request.headers.get("x-pellucid-timestamp");
if (!sig || !timestamp) return new NextResponse("missing signature", { status: 401 });
const body = await request.text();
const signed = timestamp + "." + body;
const expected =
"sha256=" + crypto.createHmac("sha256", SECRET).update(signed).digest("hex");
if (expected.length !== sig.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return new NextResponse("bad signature", { status: 401 });
}
const payload = JSON.parse(body);
// ... do something with payload
return NextResponse.json({ ok: true });
}Delivery semantics
- At-most-once. No retry queue today. Treat each delivery as a one-shot — if your receiver is down, Pellucid will move on.
- Five-second timeout. Receivers should respond fast. Do your work in a background job and return 2xx synchronously.
- Order is best-effort. If two analyses on the same document complete back-to-back, you may receive the second delivery before the first. The
document.urlreflects the latest state when you fetch it. - Idempotency hint. Use
X-Pellucid-Deliveryto dedupe deliveries at the receiver.
On the roadmap
- Per-target retry queue with exponential backoff and a delivery log endpoint.
- Inbound webhooks for triggering analyses from external events (e.g. GitHub PR open).
- Signature versioning (the
v1scheme above will be aliased assha256=...indefinitely).