Skip to content

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

  • POST with Content-Type: application/json
  • X-Lacudelph-Signature: hex-encoded HMAC-SHA256 of the raw body using your stored secret
  • X-Lacudelph-Event: event-type string
  • X-Lacudelph-Attempt: integer attempt counter (1, 2, 3)
  • Retry schedule: [immediate, +2min, +15min] on 5xx / network failure; Retry-After on 429/503 is honoured
  • After 3 failures the attempt is marked failed; visible in /org/settings with 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)
end

Idempotency

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.

cross-turn reasoning · rendered live© 2026 · proprietary