docs
Webhooks
Configure a webhook URL in /org/settings. When a participant interview completes, Lacudelph POSTs a JSON payload to your URL signed with HMAC-SHA256 of the body using a per-org secret generated when you save. Two event types fire today; both share the same delivery contract.
Delivery contract
POSTwithContent-Type: application/jsonX-Lacudelph-Signature: hex-encoded HMAC-SHA256 of the raw body using your stored secretX-Lacudelph-Event: event-type stringX-Lacudelph-Attempt: integer attempt counter (1, 2, 3)- Retry schedule:
[immediate, +2min, +15min]on 5xx / network failure;Retry-Afteron 429/503 is honoured - After 3 failures the attempt is marked
failed; visible in/org/settingswith a manual retry button
Event: interview.completed
Fires once per real participant interview at the moment the takeaway is finalised. Skipped for refinement / design / preview interviews and for demo-tier briefs.
{
"event": "interview.completed",
"emittedAt": "2026-05-07T14:22:18.420Z",
"org": { "id": "<org-uuid>" },
"brief": { "id": "<brief-uuid>", "name": "Customer success — churn root-cause" },
"interview": {
"id": "<interview-uuid>",
"status": "completed",
"startedAt": "2026-05-07T14:08:33.901Z",
"endedAt": "2026-05-07T14:22:11.078Z",
"takeawayMd": "## What you said you were trying to figure out\n...",
"extractionStateJson": "{\"per_objective\":{...}}",
"transcript": [
{ "index": 0, "role": "host", "text": "..." },
{ "index": 1, "role": "participant", "text": "..." }
]
}
}Event: interview.objective_completed
Fires the turn an objective newly crosses completeness ≥ 0.8 — so a customer can build a real-time pipeline rather than wait for the terminal event. Same headers + signature contract.
{
"event": "interview.objective_completed",
"emittedAt": "2026-05-07T14:14:02.118Z",
"org": { "id": "<org-uuid>" },
"brief": { "id": "<brief-uuid>", "name": "..." },
"interview": {
"id": "<interview-uuid>",
"status": "active",
"startedAt": "2026-05-07T14:08:33.901Z",
"turnIndex": 7
},
"objective": {
"id": "decision_turn_moment",
"label": "The decision turn moment",
"completeness": 0.83,
"confidence": 0.71
}
}Verifying the signature
Read the raw body BEFORE parsing it. Compute HMAC-SHA256(secret, body) hex; compare against X-Lacudelph-Signature with a constant-time comparator. If it doesn’t match, reject the request — don’t trust the payload.
Node.js / TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(req: Request, body: string, secret: string): boolean {
const sig = req.headers.get("x-lacudelph-signature") ?? "";
const expected = createHmac("sha256", secret).update(body).digest("hex");
if (sig.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Python
import hmac, hashlib
def verify(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)Ruby
require "openssl"
def verify(body, signature, secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
Rack::Utils.secure_compare(signature, expected)
endIdempotency
Receivers should treat the interview.id + event tuple as the idempotency key. The same event may arrive multiple times if a network blip causes a retry to win after the original eventually delivered. Deduplicate on (event, interview.id) for interview.completed and (event, interview.id, objective.id) for interview.objective_completed.
Testing
Site admins can fire a synthetic test webhook from /admin → Send test webhook. The payload uses a clearly fake interview id (test-<timestamp>) so it filters out of any production join.
Ticket templates (Linear / Notion / Asana)
Most teams want one ticket per cohort, with the strongest finding pinned and weaker findings as comments. Below are minimal receivers for each tool — each accepts the verified payload, picks out the top pattern from the takeaway, and creates a ticket. None of these are official integrations; copy them into your own webhook receiver.
Linear (one issue per completed cohort)
// POST handler at /api/lacudelph-webhook
import { LinearClient } from "@linear/sdk";
const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY! });
const TEAM_ID = process.env.LINEAR_TEAM_ID!;
export async function POST(req: Request) {
const body = await req.text();
// ... verify HMAC (see "Verifying the signature" above)
const evt = JSON.parse(body);
if (evt.event !== "interview.completed") return Response.json({ ok: true });
const titleStem = evt.brief.name.slice(0, 60);
await linear.createIssue({
teamId: TEAM_ID,
title: `[research] ${titleStem} · ${evt.interview.id.slice(0, 8)}`,
description: evt.interview.takeawayMd
+ `\n\n---\nFull transcript: https://lacudelph.com/t/${evt.interview.id}`,
labelIds: [/* your "research-completed" label id */],
});
return Response.json({ ok: true });
}Notion (one row per finding, in a database)
// POST handler — appends one row to a Notion DB per completion.
// The "Finding" property holds the takeaway markdown; "Brief" is a
// select; "Interview" is a URL back to the takeaway.
import { Client } from "@notionhq/client";
const notion = new Client({ auth: process.env.NOTION_TOKEN! });
const DB = process.env.NOTION_RESEARCH_DB!;
export async function POST(req: Request) {
const body = await req.text();
// ... verify HMAC ...
const evt = JSON.parse(body);
if (evt.event !== "interview.completed") return Response.json({ ok: true });
await notion.pages.create({
parent: { database_id: DB },
properties: {
Name: { title: [{ text: { content: evt.brief.name + " · " + evt.interview.id.slice(0, 8) } }] },
Brief: { select: { name: evt.brief.name } },
Started: { date: { start: evt.interview.startedAt } },
Source: { url: `https://lacudelph.com/t/${evt.interview.id}` },
},
children: [
{ object: "block", type: "paragraph",
paragraph: { rich_text: [{ text: { content: evt.interview.takeawayMd.slice(0, 1900) } }] } }
],
});
return Response.json({ ok: true });
}Asana (one task per completion, mapped to a project)
// POST handler — uses Asana's REST API directly to avoid SDK churn.
const ASANA_PAT = process.env.ASANA_PERSONAL_ACCESS_TOKEN!;
const PROJECT_ID = process.env.ASANA_PROJECT_ID!;
export async function POST(req: Request) {
const body = await req.text();
// ... verify HMAC ...
const evt = JSON.parse(body);
if (evt.event !== "interview.completed") return Response.json({ ok: true });
await fetch("https://app.asana.com/api/1.0/tasks", {
method: "POST",
headers: {
authorization: `Bearer ${ASANA_PAT}`,
"content-type": "application/json",
},
body: JSON.stringify({
data: {
projects: [PROJECT_ID],
name: `${evt.brief.name} · ${evt.interview.id.slice(0, 8)}`,
notes: evt.interview.takeawayMd
+ `\n\nSource: https://lacudelph.com/t/${evt.interview.id}`,
},
}),
});
return Response.json({ ok: true });
}Slack (DM the channel; great for low-volume cohorts)
// Single-channel webhook (no OAuth required; uses an Incoming Webhook URL).
const SLACK_HOOK = process.env.SLACK_INCOMING_WEBHOOK!;
export async function POST(req: Request) {
const body = await req.text();
// ... verify HMAC ...
const evt = JSON.parse(body);
if (evt.event !== "interview.completed") return Response.json({ ok: true });
await fetch(SLACK_HOOK, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
blocks: [
{ type: "header", text: { type: "plain_text", text: `${evt.brief.name} · interview completed` } },
{ type: "section", text: { type: "mrkdwn",
text: evt.interview.takeawayMd.split("\n").slice(0, 12).join("\n").slice(0, 2900) } },
{ type: "actions", elements: [
{ type: "button", text: { type: "plain_text", text: "Open in Lacudelph" },
url: `https://lacudelph.com/t/${evt.interview.id}` },
] },
],
}),
});
return Response.json({ ok: true });
}Filtering by event type
If you only want completion events, drop interview.objective_completed at the receiver — both events ship to the same URL. Inverse: if you want a real-time pipeline, the objective-completed event lets you route mid-session signal without waiting for the takeaway.