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

# Completion webhooks

> Get notified when a run finishes, with signed, verifiable deliveries

Instead of polling, let Leme call you. Pass a `callbackUrl` when [creating a run](/api/runs#create-a-run) and Leme sends a signed `POST` to that URL when the run reaches a final state — or pauses for approval.

```json theme={null}
{
  "input": { "message": "Qualify this lead." },
  "callbackUrl": "https://example.com/hooks/leme"
}
```

The URL must be public HTTPS on the default port. URLs pointing at private networks are rejected with `422 callback_url_invalid`.

## Events

| Event                  | Sent when                           |
| ---------------------- | ----------------------------------- |
| `run.completed`        | The run finished successfully       |
| `run.failed`           | The run failed                      |
| `run.canceled`         | The run was canceled                |
| `run.waiting_approval` | The run paused for a human approval |

The payload is intentionally thin — identifiers and status, never the agent's output:

```json theme={null}
{
  "type": "run.completed",
  "timestamp": "2026-07-07T18:00:00Z",
  "data": {
    "runId": "run_id",
    "agentId": "agent_id",
    "sessionId": "session_id",
    "status": "completed",
    "origin": "api",
    "metadata": { "leadId": "4821" }
  }
}
```

When you receive it, fetch the result with [`GET /api/v1/runs/:id`](/api/runs#follow-the-run). This keeps sensitive content off your webhook endpoint and guarantees you always read the freshest state.

## Verify the signature

Every delivery is signed following the [Standard Webhooks](https://www.standardwebhooks.com) specification, the same scheme used by OpenAI and Svix. Three headers accompany the request:

| Header              | Content                                               |
| ------------------- | ----------------------------------------------------- |
| `webhook-id`        | Unique delivery ID, stable across retries             |
| `webhook-timestamp` | Unix timestamp (seconds) of the send                  |
| `webhook-signature` | One or more `v1,<base64>` signatures, space-separated |

First, fetch your project's signing secret (requires a `read_write` token):

```bash theme={null}
curl https://app.leme.ai/api/v1/project/webhook-signing-secret \
  -H "Authorization: Bearer leme_pat_v1_..."
```

Then verify with any Standard Webhooks library:

<CodeGroup>
  ```js Node theme={null}
  import { Webhook } from "standardwebhooks"

  const webhook = new Webhook(secret) // "whsec_..."

  // Throws if the signature is invalid or too old
  webhook.verify(rawBody, {
    "webhook-id": req.headers["webhook-id"],
    "webhook-timestamp": req.headers["webhook-timestamp"],
    "webhook-signature": req.headers["webhook-signature"],
  })
  ```

  ```python Python theme={null}
  from standardwebhooks import Webhook

  webhook = Webhook(secret)  # "whsec_..."

  # Raises if the signature is invalid or too old
  webhook.verify(raw_body, {
      "webhook-id": headers["webhook-id"],
      "webhook-timestamp": headers["webhook-timestamp"],
      "webhook-signature": headers["webhook-signature"],
  })
  ```
</CodeGroup>

<Warning>
  Always verify against the **raw request body**, before any JSON parsing.
  Reject deliveries whose signature does not match.
</Warning>

## Rotate the secret

If the secret leaks — or on your regular rotation schedule — mint a new one:

```bash theme={null}
curl -X POST https://app.leme.ai/api/v1/project/webhook-signing-secret \
  -H "Authorization: Bearer leme_pat_v1_..."
```

The previous secret keeps signing for 24 hours so you can roll over without dropping deliveries. During that window `webhook-signature` carries two signatures — a delivery is valid if **any** of them matches, which every Standard Webhooks library handles for you.

## Retries and reliability

Deliveries are **at least once**. If your endpoint does not answer `2xx` within 15 seconds, Leme retries: after 1 minute, 5 minutes, 30 minutes, and 2 hours. After five failed attempts the delivery is marked exhausted.

Because retries can overlap with your processing, make your handler idempotent — deduplicate on `webhook-id`.

You can inspect delivery state at any time on the run resource:

```json theme={null}
{
  "callback": {
    "url": "https://example.com/hooks/leme",
    "lastStatus": "delivered",
    "attempts": 1
  }
}
```

## Best practices

* **Answer fast.** Acknowledge with `2xx` immediately and process asynchronously; the 15-second timeout includes your handler.
* **Deduplicate on `webhook-id`.** Retries reuse the same ID.
* **Don't trust the payload alone.** Verify the signature, then fetch the run via the API for the authoritative state.
* **Watch for `exhausted`.** If deliveries exhaust, your endpoint was down for hours — poll the runs you have in flight to catch up.
