> ## Documentation Index
> Fetch the complete documentation index at: https://docs.meetdarwin.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# API recipes

> Working examples for the Darwin API: post handoff requests to Slack with a webhook, sync new leads to a CRM with updated_since, and export conversations to a data warehouse every night.

Three common jobs, each small enough to read in one sitting. They use Node 18 or later and no Darwin library; swap in your own CRM, warehouse or hosting.

## Send handoffs to Slack

When a visitor asks to talk to a person, post it to a Slack channel within seconds, with the page they were on and a link to the conversation.

**You need:** a Slack [incoming webhook URL](https://api.slack.com/messaging/webhooks) for the channel, and somewhere to run a small HTTPS function (a serverless function, a container or an existing server).

```javascript server.mjs theme={null}
import crypto from "node:crypto";
import http from "node:http";

const DARWIN_SECRET = process.env.DARWIN_WEBHOOK_SECRET; // whsec_... from Settings → API
const SLACK_URL = process.env.SLACK_WEBHOOK_URL;
const seen = new Set(); // use a database or cache in production

function verify(headers, body) {
  const id = headers["webhook-id"], ts = headers["webhook-timestamp"];
  if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(DARWIN_SECRET.replace(/^whsec_/, ""), "base64");
  const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64");
  return (headers["webhook-signature"] || "").split(" ").some((entry) => {
    const sig = entry.split(",")[1] || "";
    return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}

http
  .createServer((req, res) => {
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", async () => {
      if (req.method !== "POST" || !verify(req.headers, body)) return res.writeHead(400).end();
      res.writeHead(200).end(); // answer Darwin first
      const event = JSON.parse(body);
      if (event.type !== "handoff.requested" || seen.has(event.id)) return;
      seen.add(event.id);
      const c = event.data.object;
      if (!c) return;
      const who = [c.lead.first_name, c.lead.last_name].filter(Boolean).join(" ") || c.lead.email || "A visitor";
      await fetch(SLACK_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          text: `:raising_hand: ${who} asked for a person on ${c.page_path ?? "your site"}\n>${c.handoff?.reason ?? c.preview ?? ""}\n<https://app.meetdarwin.ai/admin/conversations|Open Conversations in Darwin>`,
        }),
      });
    });
  })
  .listen(process.env.PORT || 3000);
```

<Steps>
  <Step title="Deploy the function">
    Deploy it so it is reachable at an HTTPS address, for example `https://hooks.example.com/darwin`. Set `SLACK_WEBHOOK_URL` now; you'll get `DARWIN_WEBHOOK_SECRET` in the next step.
  </Step>

  <Step title="Add the endpoint in Darwin">
    In **Settings → API → Webhooks**, select **Add endpoint**, paste the address, choose only `handoff.requested`, and select **Add endpoint**. Copy the signing secret into `DARWIN_WEBHOOK_SECRET` and redeploy.
  </Step>

  <Step title="Test it">
    Select **Send test event** in the endpoint's menu. Darwin should report a `200`. The test event's type is `webhook.test`, so nothing is posted to Slack.

    **It worked if**, after someone asks the chat on your live site to talk to a person, a message appears in the channel within a few seconds.
  </Step>
</Steps>

The same pattern sends `meeting.booked` to a sales channel or `lead.captured` to an SDR queue: subscribe to the type and read `data.object`. See [Webhooks](/api-reference/webhooks) for every payload.

## Sync leads to a CRM

Copy every contact Darwin captures into your CRM every 15 minutes, and record Darwin's contact id back so later runs update the same record.

**You need:** a key with `contacts:read` (and `contacts:write` if you write the CRM's id back), and a place to keep one timestamp between runs.

```javascript sync-contacts.mjs theme={null}
const API = "https://api.meetdarwin.ai/v1";
const headers = { Authorization: `Bearer ${process.env.DARWIN_API_KEY}`, "Content-Type": "application/json" };

export async function syncContacts(state, crm) {
  const started = new Date().toISOString(); // 1. note the time before reading
  let cursor = null;
  for (;;) {
    const url = new URL(`${API}/contacts`);
    url.searchParams.set("limit", "100");
    if (state.lastRun) url.searchParams.set("updated_since", state.lastRun);
    if (cursor) url.searchParams.set("starting_after", cursor);
    const res = await fetch(url, { headers });
    if (res.status === 429) {
      await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after")) * 1000));
      continue; // retry the same page
    }
    if (!res.ok) throw new Error(`Darwin ${res.status}: ${(await res.json()).code}`);
    const page = await res.json();
    for (const contact of page.data) {
      // 2. upsert by email; the same contact can arrive in two runs
      const crmId = await crm.upsertPerson({
        email: contact.email,
        firstName: contact.first_name,
        lastName: contact.last_name,
        company: contact.company,
        source: `Darwin (${contact.source})`,
      });
      // 3. optional: remember the CRM id in Darwin
      if (!contact.external_id) {
        await fetch(`${API}/contacts/${contact.id}`, {
          method: "PATCH",
          headers,
          body: JSON.stringify({ external_id: String(crmId) }),
        });
      }
    }
    if (!page.has_more) break;
    cursor = page.next_cursor;
  }
  state.lastRun = started; // 4. only after every page succeeded
}
```

Run it on a schedule (cron, a scheduled function, or your integration platform). The first run, with no `lastRun`, copies every contact.

* Writing `external_id` changes the contact, so it comes back once in the next run. The `if (!contact.external_id)` check makes that second pass a no-op.
* Each contact can have one `external_id`, unique in the workspace. Two contacts with the same id return `409` [`conflict`](/api-reference/errors#conflict).
* For the conversation behind a lead, list `GET /v1/conversations?has_lead=true&updated_since=…` and match on `contact`.
* If you'd rather not poll, subscribe a webhook to `lead.captured` and upsert the contact in `data.object`. Keep this job as a nightly safety net for any webhook you miss.

**It worked if** a test lead from your live chat appears in the CRM after the next run, and `GET /v1/contacts?email=…` shows the CRM's id as `external_id`.

## Export conversations to your warehouse nightly

Load every conversation, with its transcript and Darwin's grading, into BigQuery, Snowflake, Redshift or plain files each night.

**You need:** a key with `conversations:read`. Leave out `contacts:read` if the warehouse should not hold names and emails: lead and booker fields then come back `null` (transcripts still hold whatever visitors typed).

```javascript export-conversations.mjs theme={null}
import { createWriteStream } from "node:fs";

const API = "https://api.meetdarwin.ai/v1";
const headers = { Authorization: `Bearer ${process.env.DARWIN_API_KEY}` };

// A rolling three-day window: Darwin grades a conversation after it goes quiet,
// so re-export recent days and let the warehouse upsert by id.
const since = new Date(Date.now() - 3 * 86400_000).toISOString();
const file = createWriteStream(`conversations-${new Date().toISOString().slice(0, 10)}.ndjson`);

let cursor = null;
for (;;) {
  const url = new URL(`${API}/conversations`);
  url.searchParams.set("limit", "100");
  url.searchParams.set("created_since", since);
  url.searchParams.set("expand", "messages");
  if (cursor) url.searchParams.set("starting_after", cursor);
  const res = await fetch(url, { headers });
  if (res.status === 429) {
    await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after")) * 1000));
    continue;
  }
  if (!res.ok) throw new Error(`Darwin ${res.status}: ${(await res.json()).code}`);
  const page = await res.json();
  for (const conversation of page.data) file.write(JSON.stringify(conversation) + "\n");
  if (!page.has_more) break;
  cursor = page.next_cursor;
}
file.end();
```

Load the file into a table keyed on `id`, merging over yesterday's rows. Useful columns:

| Field | Use it for |
| - | - |
| `created_at`, `page_path`, `visitor.source`, `visitor.utm`, `visitor.ai_referred` | Where conversations come from |
| `outcome` (`meeting`, `lead`, `handoff`, `none`) | Results by page and source |
| `evaluation.intent`, `evaluation.buying_stage`, `evaluation.qualification_score` | Who is in market |
| `evaluation.understood`, `evaluation.failure_category`, `evaluation.recommended_improvement` | Where the agent needs better knowledge |
| `opening.experiment`, `opening.variant` | Which opening the visitor saw |
| `messages` | Full-text analysis |

Add `GET /v1/meetings?updated_since=…` and `GET /v1/analytics?range=7d` to the same job for meetings and daily funnel numbers.

**It worked if** the morning's file has one line per conversation from the last three days and yesterday's graded conversations have a non-null `evaluation`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.