> ## 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.

# Webhooks

> Get Darwin events (lead captured, handoff requested, meeting booked, conversation graded) posted to your URL, signed with Standard Webhooks. Events, payloads, verification, retries and redelivery.

<div className="darwin-location"><strong>Find it in Darwin</strong> Profile menu → Workspace settings → API → Webhooks <a href="https://app.meetdarwin.ai/admin/settings/api">Open this screen ↗</a></div>

Webhooks tell your systems the moment something happens in the chat: a visitor leaves their email, asks for a person, or books a meeting. Darwin sends each event as a signed `POST` to your HTTPS address and retries for about three days if your server doesn't answer.

Every event is something Darwin's server recorded, never something a browser claimed. Your own preview chats send no events; use **Send test event** to try an endpoint.

## Add an endpoint

<Tabs>
  <Tab title="In Darwin">
    <Steps>
      <Step title="Open Webhooks">
        Open **Settings → API**. Owners and admins see **Add endpoint** beside **Webhooks**.
      </Step>

      <Step title="Enter your URL">
        Select **Add endpoint** and enter an HTTPS **URL** on a public hostname, for example `https://example.com/webhooks/darwin`. It must answer `2xx` within 10 seconds.
      </Step>

      <Step title="Choose events">
        Under **Events**, keep **Choose events** and tick what you need. `lead.captured`, `handoff.requested` and `meeting.booked` are ticked for you. **All events** subscribes to every type, including ones Darwin adds later.
      </Step>

      <Step title="Copy the signing secret">
        Select **Add endpoint**. Darwin shows the endpoint's **Signing secret** (`whsec_…`). Store it with your receiver's other secrets. Owners and admins can see it again later with **Show signing secret**.
      </Step>

      <Step title="Send a test event">
        Open the endpoint's **⋯** menu and select **Send test event**. **It worked if** Darwin says **The endpoint answered 200** (or another `2xx`). Otherwise it shows why, for example `HTTP 401` or `No answer within 10 seconds.`
      </Step>
    </Steps>
  </Tab>

  <Tab title="With the API">
    Needs a key with `webhooks:write`.

    ```bash theme={null}
    curl https://api.meetdarwin.ai/v1/webhook_endpoints \
      -H "Authorization: Bearer $DARWIN_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
        "url": "https://example.com/webhooks/darwin",
        "events": ["lead.captured", "handoff.requested", "meeting.booked"],
        "description": "CRM sync"
      }'
    ```

    The `201` response is the endpoint, and only this response includes its `secret`:

    ```json theme={null}
    {
      "id": "we_7h3k9m2p4q8r1s5t",
      "object": "webhook_endpoint",
      "url": "https://example.com/webhooks/darwin",
      "description": "CRM sync",
      "events": ["handoff.requested", "lead.captured", "meeting.booked"],
      "status": "enabled",
      "disabled_reason": null,
      "failing_since": null,
      "created_at": "2026-10-12T09:30:00.000Z",
      "updated_at": "2026-10-12T09:30:00.000Z",
      "secret": "whsec_..."
    }
    ```

    Use `"events": ["*"]` for every type. Then send a test: `POST /v1/webhook_endpoints/we_…/test`. The response says whether your endpoint answered, with its `status`, `duration_ms` and the start of its `response`.
  </Tab>
</Tabs>

A workspace can have 16 endpoints. A new endpoint receives events that happen after it was created; for older ones, read [`GET /events`](#catch-up-with-the-event-log).

## Events

| Type | Sent when | `data.object` is |
| - | - | - |
| `lead.captured` | A visitor gave their name and email in the chat | The contact, plus `data.conversation` |
| `handoff.requested` | A visitor asked to talk to a person (once per conversation) | The conversation, with its transcript |
| `meeting.booked` | A meeting was booked from the chat | The meeting |
| `meeting.rescheduled` | A meeting booked from the chat moved to a new time | The meeting |
| `meeting.canceled` | A meeting booked from the chat was canceled | The meeting |
| `conversation.started` | A visitor sent their first message. High volume: subscribe only if you mirror every chat. | The conversation |
| `conversation.completed` | A conversation went quiet and Darwin graded it: intent, buying stage and score | The conversation, with its transcript and `evaluation` |
| `experiment.started` | An opening test started showing variants to visitors | The experiment |
| `experiment.winner_found` | An opening test found a winner | The experiment |
| `knowledge.source_failed` | A page the agent answers from could not be read | The knowledge page, plus `data.error` |

`data.object` has the same shape as the REST API's answer for that object, so code that reads `GET /conversations/{id}` also reads the webhook. Webhooks always include names and emails: an endpoint exists to get the lead into your CRM.

## What Darwin sends

```http theme={null}
POST /webhooks/darwin HTTP/1.1
Content-Type: application/json
User-Agent: Darwin-Webhooks/1.0 (+https://docs.meetdarwin.ai/api-reference/webhooks)
webhook-id: evt_2b8f0c1d6e4a9937
webhook-timestamp: 1760261400
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

The body is one event:

| Field | Meaning |
| - | - |
| `id` | The event id. Also sent as `webhook-id`. |
| `object` | Always `event` |
| `type` | One of the types above |
| `created_at` | When it happened |
| `api_version` | The [API version](/api-reference/introduction#versioning) the payload follows |
| `data` | `object`, and for some types `conversation` or `error` |

Darwin builds the payload a few seconds after the event and keeps it: a retry or redelivery sends the same body. For the latest state, read the object from the API by its id.

### Example payloads

<AccordionGroup>
  <Accordion title="lead.captured">
    ```json theme={null}
    {
      "id": "evt_2b8f0c1d6e4a9937",
      "object": "event",
      "type": "lead.captured",
      "created_at": "2026-10-12T09:30:00.000Z",
      "api_version": "2026-10-12",
      "data": {
        "object": {
          "id": "ct_4f8k2m9q1x7z3c5v",
          "object": "contact",
          "email": "jordan@example.com",
          "first_name": "Jordan",
          "last_name": "Lee",
          "name": "Jordan Lee",
          "company": null,
          "phone": null,
          "external_id": null,
          "attributes": {},
          "identity_verified": false,
          "source": "chat",
          "first_seen_at": "2026-10-12T09:27:41.000Z",
          "last_seen_at": "2026-10-12T09:30:00.000Z",
          "created_at": "2026-10-12T09:30:00.000Z",
          "updated_at": "2026-10-12T09:30:00.000Z"
        },
        "conversation": {
          "id": "conv_c-1760261261000-k3j9",
          "object": "conversation",
          "page_url": "https://example.com/pricing",
          "page_path": "/pricing",
          "outcome": "lead",
          "message_count": 6,
          "preview": "Do you offer annual billing for teams of 20?",
          "contact": "ct_4f8k2m9q1x7z3c5v",
          "lead": { "first_name": "Jordan", "last_name": "Lee", "email": "jordan@example.com", "captured_at": "2026-10-12T09:30:00.000Z" },
          "visitor": { "device": "desktop", "country": "US", "source": "google", "utm": {}, "ai_referred": false },
          "test": false
        }
      }
    }
    ```

    The conversation here is shortened; it carries every field of a conversation, without the transcript.
  </Accordion>

  <Accordion title="handoff.requested and conversation.completed">
    ```json theme={null}
    {
      "id": "evt_9c0d2e7f1a3b5c6d",
      "object": "event",
      "type": "handoff.requested",
      "created_at": "2026-10-12T10:02:13.000Z",
      "api_version": "2026-10-12",
      "data": {
        "object": {
          "id": "conv_c-1760262900000-p2r8",
          "object": "conversation",
          "page_url": "https://example.com/support",
          "outcome": "handoff",
          "handoff": { "requested_at": "2026-10-12T10:02:13.000Z", "reason": "Wants to change the billing contact on their account" },
          "lead": { "first_name": "Sam", "last_name": null, "email": "sam@example.com", "captured_at": "2026-10-12T10:01:50.000Z" },
          "evaluation": null,
          "messages": [
            { "id": "m1", "role": "user", "text": "Can I talk to someone about our invoice?", "created_at": "2026-10-12T10:01:02.000Z", "tools": [] },
            { "id": "m2", "role": "assistant", "text": "I've passed this to the team. They'll email you at sam@example.com.", "created_at": "2026-10-12T10:02:13.000Z", "tools": ["asked for a person"] }
          ]
        }
      }
    }
    ```

    `conversation.completed` has the same shape. Its `evaluation` is filled in: `intent`, `buying_stage`, `qualification_score` and `meeting_intent` (0 to 10), `understood`, `failure_category`, `abandonment_reason` and `recommended_improvement`. `conversation.started` carries the conversation without `messages`.
  </Accordion>

  <Accordion title="meeting.booked, meeting.rescheduled and meeting.canceled">
    ```json theme={null}
    {
      "id": "evt_5e6f7a8b9c0d1e2f",
      "object": "event",
      "type": "meeting.booked",
      "created_at": "2026-10-12T11:15:00.000Z",
      "api_version": "2026-10-12",
      "data": {
        "object": {
          "id": "mtg_n4f8a2b1c9d0e7f6a5b4c3d2e1f0a9b8c",
          "object": "meeting",
          "status": "booked",
          "verified": true,
          "provider": "calendly",
          "meeting_type": "30 minute demo",
          "host": "Alex Rivera",
          "start_at": "2026-10-14T16:00:00.000Z",
          "end_at": "2026-10-14T16:30:00.000Z",
          "booked_at": "2026-10-12T11:15:00.000Z",
          "reschedules": 0,
          "join_url": "https://zoom.us/j/0000000000",
          "reschedule_url": "https://calendly.com/reschedulings/...",
          "cancel_url": "https://calendly.com/cancellations/...",
          "conversation": "conv_c-1760267400000-x8m2",
          "contact": "ct_7q2w9e4r1t6y3u8i",
          "invitee": { "name": "Priya Shah", "email": "priya@example.com" },
          "updated_at": "2026-10-12T11:15:00.000Z",
          "test": false
        }
      }
    }
    ```

    `verified` is `true` when the calendar provider confirmed the booking. On `meeting.rescheduled`, `start_at`, `end_at` and `reschedules` change; on `meeting.canceled`, `status` is `canceled`.
  </Accordion>

  <Accordion title="experiment.started and experiment.winner_found">
    ```json theme={null}
    {
      "id": "evt_1a2b3c4d5e6f7a8b",
      "object": "event",
      "type": "experiment.winner_found",
      "created_at": "2026-10-12T12:00:00.000Z",
      "api_version": "2026-10-12",
      "data": {
        "object": {
          "id": "exp_4c1f0a2b9d3e7a10",
          "object": "experiment",
          "name": "Pricing page opening",
          "status": "winner_found",
          "pages": ["/pricing"],
          "metric": "bookings",
          "winner": "var_...",
          "variants": [
            { "id": "var_...", "object": "variant", "name": "Current", "control": true, "headline": "Questions about pricing?", "results": { "views": 1840, "rate": 0.021, "chance_beats_current": null } }
          ]
        }
      }
    }
    ```

    Shortened. The experiment has the shape of `GET /experiments/{id}`.
  </Accordion>

  <Accordion title="knowledge.source_failed">
    ```json theme={null}
    {
      "id": "evt_8b7a6f5e4d3c2b1a",
      "object": "event",
      "type": "knowledge.source_failed",
      "created_at": "2026-10-12T03:10:00.000Z",
      "api_version": "2026-10-12",
      "data": {
        "object": {
          "id": "src_142",
          "object": "knowledge_source",
          "url": "https://example.com/docs/security",
          "section": "/docs/",
          "title": "Security",
          "status": "failed",
          "error": "The page answered 404.",
          "characters": 5120,
          "chunks": 4,
          "read_at": "2026-10-05T03:10:00.000Z",
          "created_at": "2026-09-20T14:00:00.000Z"
        },
        "error": "The page answered 404."
      }
    }
    ```

    A page that fails keeps its last good content, so the agent keeps answering from it until you fix or remove it.
  </Accordion>

  <Accordion title="webhook.test">
    ```json theme={null}
    {
      "id": "evt_0f1e2d3c4b5a6978",
      "object": "event",
      "type": "webhook.test",
      "created_at": "2026-10-12T09:31:00.000Z",
      "api_version": "2026-10-12",
      "data": {
        "object": {
          "endpoint": "we_7h3k9m2p4q8r1s5t",
          "message": "This is a test event from Darwin. Your endpoint works if it answered 2xx."
        }
      }
    }
    ```

    Sent only by **Send test event**. It is signed like a real event, sent once and not retried, and does not appear in `GET /events`.
  </Accordion>
</AccordionGroup>

If the object no longer exists when Darwin builds the payload, `data.object` is `null`. If the person was [erased](/api-reference/privacy), `data` is `{ "erased": true }`.

## Verify the signature

Darwin signs every request the [Standard Webhooks](https://www.standardwebhooks.com/) way, so you can use an existing library or a few lines of your own. Always verify before you trust a payload: anyone can send a `POST` to your URL.

1. Take the **raw request body** exactly as received. Parsing and re-serialising the JSON changes the bytes and breaks the signature.
2. Reject the request if `webhook-timestamp` is more than five minutes from your clock.
3. Build the signed content: `` `${webhook-id}.${webhook-timestamp}.${body}` ``.
4. Compute HMAC-SHA256 over it. The key is your signing secret **without** the `whsec_` prefix, base64-decoded.
5. Base64-encode the result and compare it, in constant time, with each `v1,` entry in `webhook-signature`. The header can hold several entries separated by spaces (during a [secret rotation](#rotate-the-signing-secret)); accept if any matches.

<CodeGroup>
  ```javascript Node (Express) theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const secret = process.env.DARWIN_WEBHOOK_SECRET; // whsec_...

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

  const app = express();
  app.post("/webhooks/darwin", express.raw({ type: "application/json" }), (req, res) => {
    const body = req.body.toString("utf8");
    if (!verify(req.headers, body)) return res.status(400).send("bad signature");
    const event = JSON.parse(body);
    res.sendStatus(200); // answer first, work after
    queueForProcessing(event); // your own job queue
  });
  app.listen(3000);
  ```

  ```python Python (Flask) theme={null}
  import base64, hashlib, hmac, json, os, time
  from flask import Flask, request, abort

  secret = os.environ["DARWIN_WEBHOOK_SECRET"]  # whsec_...
  key = base64.b64decode(secret.removeprefix("whsec_"))
  app = Flask(__name__)

  def verify(headers, body: bytes) -> bool:
      msg_id = headers.get("webhook-id")
      timestamp = headers.get("webhook-timestamp")
      signatures = headers.get("webhook-signature", "")
      if not msg_id or not timestamp or abs(time.time() - int(timestamp)) > 300:
          return False
      signed = f"{msg_id}.{timestamp}.".encode() + body
      expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
      return any(
          entry.startswith("v1,") and hmac.compare_digest(entry[3:], expected)
          for entry in signatures.split(" ")
      )

  @app.post("/webhooks/darwin")
  def darwin_webhook():
      body = request.get_data()  # raw bytes
      if not verify(request.headers, body):
          abort(400)
      event = json.loads(body)
      queue_for_processing(event)  # your own job queue
      return "", 200
  ```
</CodeGroup>

### Use a Standard Webhooks library

Any Standard Webhooks library verifies Darwin's signatures, including Svix's. Pass the raw body, the headers and the secret as Darwin shows it:

<CodeGroup>
  ```javascript Node (svix) theme={null}
  import { Webhook } from "svix";

  const wh = new Webhook(process.env.DARWIN_WEBHOOK_SECRET); // whsec_...
  // Throws if the signature or timestamp is wrong.
  const event = wh.verify(rawBody, {
    "webhook-id": req.headers["webhook-id"],
    "webhook-timestamp": req.headers["webhook-timestamp"],
    "webhook-signature": req.headers["webhook-signature"],
  });
  ```

  ```python Python (svix) theme={null}
  from svix.webhooks import Webhook

  wh = Webhook(os.environ["DARWIN_WEBHOOK_SECRET"])  # whsec_...
  event = wh.verify(raw_body, request.headers)  # raises if invalid
  ```
</CodeGroup>

## Answer quickly

* Answer with any `2xx` status within **10 seconds**. Anything else counts as a failure: a `3xx` (Darwin does not follow redirects), a `4xx`, a `5xx`, a timeout or a refused connection.
* Answer first and do slow work (CRM calls, Slack posts) afterwards in a queue. A receiver that waits on another service is the most common cause of timeouts.
* Darwin reads at most the first 64 KB of your answer and keeps the first 500 characters in the delivery log. Don't return anything secret.

## Handle duplicates and order

* **At least once.** Darwin may deliver the same event more than once, for example when your server handled it but the answer was lost. `webhook-id` (the event `id`) is the same on every retry and redelivery. Record the ids you've processed and skip repeats.
* **No ordering.** Events can arrive out of order, especially after retries. A `meeting.rescheduled` can arrive before its `meeting.booked`. Use `created_at`, or the object's `updated_at`, to decide which is newer, or read the current object from the API.

## Retries

If a delivery fails, Darwin tries again after:

| Retry | Wait after the previous attempt |
| - | - |
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | 12 hours |
| 7 | 24 hours |
| 8 | 24 hours |

That is nine attempts over about three days. After the last one the delivery is marked **Failed**. You can still send it again by hand for 30 days.

### Endpoints that keep failing are switched off

If an endpoint has failures and not one successful delivery for **three days**, and is still failing, Darwin switches it off and shows why under its address in **Settings → API**: *Deliveries have failed for three days without one success. Fix the receiver, then switch the endpoint back on.* An endpoint that answers `410 Gone` is switched off at once: that is how a receiver (Zapier, for one) says the subscription is over. Its badge turns from **Failing** to **Off**, and `GET /webhook_endpoints/{id}` shows `"status": "disabled"` with a `disabled_reason`.

While an endpoint is off, Darwin does not queue new events for it. To recover:

1. Fix your receiver and check it with **Send test event**.
2. Open the endpoint's **⋯** menu and select **Switch on** (or `PATCH` it with `{"status": "enabled"}`). Deliveries that were still retrying continue.
3. Send the events you missed: use **Send again** in **Deliveries**, or read [`GET /events`](#catch-up-with-the-event-log) for the gap and redeliver them.

You can also select **Switch off** yourself, for example during maintenance. Events stay in the event log for 30 days.

## Check deliveries and send again

Open the endpoint's **⋯** menu and select **Deliveries**. Each row shows the event type, whether it was **Delivered**, is **Retrying** or **Failed**, the number of attempts, your server's last HTTP status or error, and the next try. **Send again** puts that delivery back in the queue; it goes out within a few seconds.

The endpoint row also summarises this week: how many were delivered, failed and are retrying.

With the API:

```bash theme={null}
# Failed deliveries for one endpoint
curl "https://api.meetdarwin.ai/v1/webhook_endpoints/we_7h3k9m2p4q8r1s5t/deliveries?status=failed" \
  -H "Authorization: Bearer $DARWIN_API_KEY"

# Send one event again to every enabled endpoint subscribed to its type...
curl -X POST https://api.meetdarwin.ai/v1/events/evt_2b8f0c1d6e4a9937/redeliver \
  -H "Authorization: Bearer $DARWIN_API_KEY"

# ...or to one endpoint
curl -X POST https://api.meetdarwin.ai/v1/events/evt_2b8f0c1d6e4a9937/redeliver \
  -H "Authorization: Bearer $DARWIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"endpoint": "we_7h3k9m2p4q8r1s5t"}'
```

The redeliver response says how many deliveries were `queued`. Only enabled endpoints subscribed to the event's type receive it.

## Catch up with the event log

Every event from the last 30 days is in `GET /events` (scope `events:read`), newest first. It is the pull twin of webhooks: use it after downtime, or instead of webhooks if you would rather poll.

```bash theme={null}
curl "https://api.meetdarwin.ai/v1/events?type=lead.captured,meeting.booked&created_since=2026-10-10T00:00:00Z&limit=100" \
  -H "Authorization: Bearer $DARWIN_API_KEY"
```

Each item is the same event body a webhook sends.

## Rotate the signing secret

Open the endpoint's **⋯** menu, select **Rotate signing secret** and confirm. Darwin shows the new secret once. For the next **24 hours** every delivery carries two signatures, one per secret, so your receiver keeps working while you switch it to the new secret. After 24 hours only the new secret signs.

With the API: `POST /v1/webhook_endpoints/{id}/rotate_secret` returns the endpoint with its new `secret`.

## Requirements for your URL

* **HTTPS** on a **public hostname**. Plain HTTP, IP addresses, `localhost` and `.local` or `.internal` names are refused, and so is a hostname that resolves to a private network address.
* No username or password before the host. To authenticate requests, verify the signature, or put a token in the path or query string.
* Darwin does not publish a fixed list of sending IP addresses. Verify signatures rather than allowlisting IPs.

## Change or remove an endpoint

* Change its URL, events or description with `PATCH /v1/webhook_endpoints/{id}`.
* **Delete** in the **⋯** menu (or `DELETE /v1/webhook_endpoints/{id}`) stops all deliveries and removes the endpoint's delivery log.

## Troubleshooting

<AccordionGroup>
  <Accordion title="Every delivery fails with a signature error on my side">
    Check that you verify the raw body, not parsed and re-serialised JSON, and that you base64-decode the secret after removing `whsec_`. Frameworks that parse JSON automatically need a raw-body route, like `express.raw` above.
  </Accordion>

  <Accordion title="Redirects are not followed">
    Your URL answered with a `3xx`. Use the final address, including `https://` and the exact path, often with or without a trailing slash.
  </Accordion>

  <Accordion title="No answer within 10 seconds">
    Answer `200` before doing the work. Check that your server is reachable from the public internet and not behind a login or firewall.
  </Accordion>

  <Accordion title="I don't get events for my test chats">
    Preview chats in Darwin never send events. Use **Send test event**, or have someone use the chat on your live website.
  </Accordion>
</AccordionGroup>

[Send handoffs to Slack →](/api-reference/recipes#send-handoffs-to-slack) · [Sync leads to a CRM →](/api-reference/recipes#sync-leads-to-a-crm)


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