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

> ## Agent Instructions
> Leap's API is at https://api.tryleap.ai. Send the key in the x-api-key header or as Authorization: Bearer; keys start with leap_.
> Take model IDs and input schemas from GET /v1/models/{creator}/{name}, or from https://api.tryleap.ai/v1/public/models without a key. Never guess an input field: unknown fields are a 400.
> Model inputs take uploaded file IDs (file_...), never URLs.
> To give a coding agent the whole API, install the Leap skill: curl -fsSL https://www.tryleap.ai/install.sh | sh
> The full OpenAPI 3.1 spec is at https://www.tryleap.ai/docs/openapi.json.

# Webhooks

> Get a signed HTTPS request when a generation or batch finishes, for one run or for every run, and verify it with the Standard Webhooks libraries.

A webhook is a `POST` to your server when something happens: a generation succeeds, fails or is canceled, or a batch completes. Runs started from the API, the studio or MCP all send events to your workspace's endpoints. Use one for video and other long runs in a web app or a server, instead of polling. If your code can't receive requests from the internet, such as a script or a notebook, [long-poll](/docs/generations#long-poll) instead.

There are two ways to get them:

| | Use it when |
| - | - |
| A `webhook` URL on the request | You want to hear about this one run. Nothing to set up first. |
| A webhook endpoint | You want every run's events at one URL, filtered by type, with a secret per endpoint. |

## Hear about one run

Add `webhook` to the request. When the run ends, its event goes to that URL:

```bash theme={"theme":"css-variables"}
curl https://api.tryleap.ai/v1/generations \
  -H "x-api-key: $LEAP_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "model": "google/veo-3.1-fast",
    "input": { "prompt": "A heron lands on a still lake at dawn", "duration": 4 },
    "webhook": "https://example.com/webhooks/leap"
  }'
```

In a batch, give each request its own `webhook`. The batch's own `batch.completed` event goes to a per-request URL only when every request in the batch named the same one.

These events are signed with your workspace's default webhook secret. Read it once, with a key that has `generations:write`, and keep it with your other secrets. It's made the first time you read it.

```bash theme={"theme":"css-variables"}
curl https://api.tryleap.ai/v1/webhooks/default/secret -H "x-api-key: $LEAP_API_KEY"
```

```json theme={"theme":"css-variables"}
{
  "object": "webhook_secret",
  "secret": "whsec_DMlar1QHs8wCiHFLVgS15l9Crm0IK7kUDXDPN1lNDrg="
}
```

## Subscribe an endpoint

An endpoint gets the events of every run in your workspace, or only the types you list:

```bash theme={"theme":"css-variables"}
curl https://api.tryleap.ai/v1/webhook_endpoints \
  -H "x-api-key: $LEAP_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/leap",
    "events": ["generation.succeeded", "generation.failed"],
    "description": "Production renders"
  }'
```

```json theme={"theme":"css-variables"}
{
  "id": "we_5Tq8Lm2Xv9Kr4Wn7Bc1Yd3Hp",
  "object": "webhook_endpoint",
  "url": "https://example.com/webhooks/leap",
  "description": "Production renders",
  "events": ["generation.succeeded", "generation.failed"],
  "disabled": false,
  "disabled_reason": null,
  "created_at": "2026-10-04T18:20:03.441Z",
  "updated_at": "2026-10-04T18:20:03.441Z",
  "secret": "whsec_foO4NH7H4fkZ/TBkV6oqbUTlZgWKMsflfyzrjNDfrQs="
}
```

The `secret` is in this answer and when you rotate it, never again. Leave out `events`, or send `["*"]`, to get every type, including types added later. The URL must be `https`. A workspace can have up to 16 endpoints.

## Events

| Type | Sent when |
| - | - |
| `generation.succeeded` | A generation finished, and its `output` is ready. |
| `generation.failed` | A generation failed. Its `error.message` says why, and it isn't charged. |
| `generation.canceled` | A generation was canceled. |
| `batch.completed` | Every generation in a batch has a final status. |
| `webhook.test` | You sent a test to an endpoint. Its `data.object` is the endpoint. |

Every event has the same envelope. `data.object` is the generation exactly as `GET /v1/generations/{id}` returns it, or the batch for `batch.completed`:

```json theme={"theme":"css-variables"}
{
  "id": "evt_9Wc3Yb1Dq7Hm2Vx4Lr8Kp6Tn",
  "object": "event",
  "type": "generation.succeeded",
  "api_version": "2026-10-04",
  "created_at": "2026-10-04T18:24:12.031Z",
  "data": {
    "object": {
      "id": "gen_7Hq2mVx9Lr4Kp8Tn3Wc6Yb1D",
      "object": "generation",
      "model": "google/veo-3.1-fast",
      "status": "succeeded",
      "output": [
        {
          "type": "video",
          "url": "https://api.tryleap.ai/files/eyJrIjoibWVkaWEv...",
          "expires_at": "2026-10-05T18:24:12.004Z",
          "content_type": "video/mp4",
          "width": 1920,
          "height": 1080
        }
      ],
      "usage": { "cost_usd": "0.66" }
    }
  }
}
```

Output links in an event expire after 24 hours, like any other. Download the file when the event arrives, or read the generation later for fresh links.

## Verify the signature

Every webhook is signed following the [Standard Webhooks](https://www.standardwebhooks.com) spec, so you can check that it came from Leap and wasn't changed or replayed. Each request has three headers:

| Header | Value |
| - | - |
| `webhook-id` | The event's ID. The same on every retry, so use it to skip duplicates. |
| `webhook-timestamp` | When it was sent, in Unix seconds. Refuse anything more than 5 minutes off. |
| `webhook-signature` | `v1,` and a base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`, keyed with the base64 part of your secret after `whsec_`. During a secret rotation it holds two signatures, separated by a space. |

The Standard Webhooks libraries do all of that. Verify the raw body exactly as it arrived: parsing and re-serializing the JSON changes the bytes and breaks the signature.

<Tabs>
  <Tab title="TypeScript">
    ```bash theme={"theme":"css-variables"}
    npm install standardwebhooks
    ```

    ```ts theme={"theme":"css-variables"}
    // app/api/webhooks/leap/route.ts (Next.js)
    import { Webhook } from "standardwebhooks";

    const webhook = new Webhook(process.env.LEAP_WEBHOOK_SECRET!);

    export async function POST(request: Request) {
      const body = await request.text();

      let event;
      try {
        event = webhook.verify(body, Object.fromEntries(request.headers)) as {
          id: string;
          type: string;
          data: { object: { id: string; status: string } };
        };
      } catch {
        return new Response("Invalid signature", { status: 400 });
      }

      if (event.type === "generation.succeeded") {
        // Look up your record by event.data.object.id and save the output.
      }

      return new Response(null, { status: 204 });
    }
    ```
  </Tab>

  <Tab title="Python">
    ```bash theme={"theme":"css-variables"}
    pip install standardwebhooks
    ```

    ```python theme={"theme":"css-variables"}
    # Flask
    import os

    from flask import Flask, request
    from standardwebhooks.webhooks import Webhook

    app = Flask(__name__)
    webhook = Webhook(os.environ["LEAP_WEBHOOK_SECRET"])


    @app.post("/webhooks/leap")
    def leap_webhook():
        try:
            event = webhook.verify(request.get_data(), dict(request.headers))
        except Exception:
            return "Invalid signature", 400

        if event["type"] == "generation.succeeded":
            pass  # Look up your record by event["data"]["object"]["id"] and save the output.

        return "", 204
    ```
  </Tab>

  <Tab title="Node, no library">
    ```ts theme={"theme":"css-variables"}
    import { createHmac, timingSafeEqual } from "node:crypto";

    export function verifyLeapWebhook(secret: string, headers: Headers, body: string) {
      const id = headers.get("webhook-id") ?? "";
      const timestamp = headers.get("webhook-timestamp") ?? "";

      if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 5 * 60) {
        throw new Error("Webhook timestamp is too old or too new");
      }

      const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
      const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();

      const valid = (headers.get("webhook-signature") ?? "").split(" ").some((entry) => {
        const [version, signature = ""] = entry.split(",");
        const given = Buffer.from(signature, "base64");
        return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
      });

      if (!valid) throw new Error("Invalid webhook signature");

      return JSON.parse(body);
    }
    ```
  </Tab>
</Tabs>

## Answer fast, and expect retries

Answer with any `2xx` within 15 seconds, and do slow work, such as downloading a video, after you answer. Anything else counts as a failure: another status, a timeout, or a redirect, which isn't followed. Requests come from the user agent `Leap-Webhooks/1.0`.

A failed delivery is tried again on this schedule:

| Try | When |
| - | - |
| 1 | At once |
| 2 to 6 | 5 seconds, 5 minutes, 30 minutes, 2 hours and 5 hours after the previous try |
| 7 to 12 | Every 10 hours |

That's 12 tries in all, the last about 68 hours after the first, and none later than 72 hours. An endpoint that keeps failing for 72 hours is disabled, and its `disabled_reason` says why. Fix it, then turn it back on with `PATCH /v1/webhook_endpoints/{id}` and `{"disabled": false}`.

Delivery is at least once and in no particular order:

* The same event can arrive more than once. Skip any `webhook-id` you've already handled.
* An event can arrive after one that happened later. Trust the `status` in `data.object`, or read the generation for its latest state.
* `data.object` is read when each delivery is sent, so its output links last 24 hours from that delivery.

## Missed events

Events are kept for 30 days. List them to catch up after an outage, see how each delivery went, and send any one again:

```bash theme={"theme":"css-variables"}
# Recent events, newest first. Filter by type, comma-separated.
curl "https://api.tryleap.ai/v1/events?type=generation.succeeded,generation.failed&limit=20" -H "x-api-key: $LEAP_API_KEY"

# One event, and its deliveries: each try, with the status your server answered.
curl https://api.tryleap.ai/v1/events/$EVENT_ID -H "x-api-key: $LEAP_API_KEY"
curl https://api.tryleap.ai/v1/events/$EVENT_ID/deliveries -H "x-api-key: $LEAP_API_KEY"

# Deliver it again: to one endpoint with {"endpoint": "we_..."}, or else to every enabled
# endpoint that subscribes to its type and to the webhook URL its request named.
curl -X POST https://api.tryleap.ai/v1/events/$EVENT_ID/redeliver -H "x-api-key: $LEAP_API_KEY"
```

A redelivery answers `202` with the deliveries it queued. It keeps the event's `webhook-id`, so a receiver that skips duplicates handles it once.

A run whose webhook you never got can always be read with `GET /v1/generations/{id}`, so a job that reconciles with a [long-poll](/docs/generations#long-poll), or by listing your recent runs, catches anything that slipped through.

## Manage endpoints

| Request | What it does |
| - | - |
| `GET /v1/webhook_endpoints` | Lists your endpoints. |
| `GET /v1/webhook_endpoints/{id}` | Reads one. |
| `PATCH /v1/webhook_endpoints/{id}` | Changes its `url`, `events` or `description` (`null` clears it), or turns it off and on with `disabled`. |
| `DELETE /v1/webhook_endpoints/{id}` | Removes it. |
| `POST /v1/webhook_endpoints/{id}/rotate_secret` | Returns a new secret. The old one keeps signing alongside it for 24 hours, so you can deploy the new one without dropping events. Rotating again within those 24 hours drops the oldest. |
| `POST /v1/webhook_endpoints/{id}/test` | Sends a `webhook.test` event to that endpoint only, once, even if it's disabled, and answers with the delivery. Its `data.object` is the endpoint, never its secret. |

Tests and redeliveries share a budget of 100 an hour per workspace.


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