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

# LuaWebhook

> Webhook class for an HTTP endpoint your code handles, with declared event schemas, HMAC signing, and platform event subscriptions

`LuaWebhook` defines an HTTP endpoint that runs your `execute` function outside any conversation and returns its result to the caller. Once pushed and deployed, the webhook answers `POST https://webhook.heylua.ai/<agentId>/<name>`; the webhook ID works in place of the name. A [webhook](/concepts/webhooks) can also subscribe to platform events, which the platform delivers to `execute` without passing through the public URL. To have the model react to an event instead of your code, use a [trigger](/reference/sdk/luatrigger).

*Verified against lua-cli 3.33.0.*

```ts theme={null}
import { LuaWebhook } from 'lua-cli';
```

## Quick example

```ts src/webhooks/OrderPaidWebhook.ts theme={null}
import { LuaWebhook, Data } from 'lua-cli';
import { z } from 'zod';

// The compiler reads this value at build time, so it can't come from env().
const SIGNING_SECRET = 'lua-whsec-2026-09-a1b2c3';
// The platform doesn't apply this schema; execute does, with safeParse.
const bodySchema = z.object({ orderNumber: z.string(), amount: z.number() });

export default new LuaWebhook({
  name: 'order-paid',
  description: 'Marks an order as paid when the payment provider confirms it',
  secret: SIGNING_SECRET,
  bodySchema,
  execute: async (event) => {
    const parsed = bodySchema.safeParse(event.body);
    if (!parsed.success) return { ok: false, error: 'invalid body' };
    const { orderNumber, amount } = parsed.data;
    const page = await Data.get('orders', { orderNumber: { $eq: orderNumber } }, 1, 1);
    const order = page.data[0];
    if (!order) return { ok: false, error: 'order not found', orderNumber };
    await Data.update('orders', order.id, { ...order.data, status: 'paid', amount });
    return { ok: true, orderNumber };
  },
});
```

Run it locally with a JSON object holding any of `query`, `headers`, and `body`.

```bash theme={null}
lua test webhook --name order-paid --input '{"body":{"orderNumber":"A-1001","amount":42}}'
```

```text Output theme={null}
…
✅ Webhook execution successful!

Webhook returned: Object — fields: ok, error, orderNumber
Output:
{ ok: false, error: 'order not found', orderNumber: 'A-1001' }
✨ Webhook works locally. To test in production:
   Trigger via HTTP:  `curl -X POST <your webhook URL>`
   Inspect logs:      `lua logs --type webhook --name order-paid --limit 5`
```

## Constructor

```ts theme={null}
new LuaWebhook(config: LuaWebhookConfig)
```

<ParamField path="name" type="string" required>
  Server-side identifier and the last segment of the URL. Lowercase with hyphens; `lua compile` warns `Webhook name should be URL-safe (lowercase, hyphens only)` otherwise. Also `--name` for `lua push webhook` and `--webhook-name` for `lua webhooks`.
</ParamField>

<ParamField path="description" type="string" required>
  One or two sentences shown in listings.
</ParamField>

<ParamField path="querySchema" type="ZodType">
  Zod schema describing the query string. The platform doesn't apply it; only the unit-test method [`execute(query, headers, body)`](#execute) does.
</ParamField>

<ParamField path="headerSchema" type="ZodType">
  Zod schema describing the request headers, with lowercase names as keys. Not applied by the platform.
</ParamField>

<ParamField path="bodySchema" type="ZodType">
  Zod schema describing the body. Not applied by the platform: `event.body` arrives unvalidated, so call `bodySchema.safeParse(event.body)` in `execute` to refuse a malformed request.
</ParamField>

<ParamField path="secret" type="string">
  HMAC-SHA256 signing key. When set, every direct request must carry a valid `x-lua-signature` header (see [Signing](#signing)). It must be a string literal or a constant the compiler can resolve. Rotate it by changing the value and deploying again; set `''` to turn signing off on the next push.
</ParamField>

<ParamField path="execute" type="(event: LuaWebhookEvent) => Promise<any>" required>
  Your handler. For a direct request its return value is serialized as the JSON response body. No end user is in scope: call `User.get(userId)` with an ID from the payload, or send with [`Channels.send`](/reference/sdk/channels).
</ParamField>

The constructor throws when `name` is empty or blank: ``LuaWebhook requires a non-empty `name` (used as the server-side identifier).``

`lua compile` fails with `Webhook must have an execute function` when `execute` is missing, and with ``Webhook `secret` must be a string literal or a compile-time-resolvable constant (a runtime expression such as `process.env.X` cannot be read at compile time)`` when it can't read the secret.

## Event

`execute` receives one `LuaWebhookEvent`. The type marks `query`, `headers`, and `body` optional; `timestamp` is always set.

<ResponseField name="query" type="Record<string, any>">
  Parsed query string; `{}` when there is none.
</ResponseField>

<ResponseField name="headers" type="Record<string, any>">
  Request headers with lowercase keys.
</ResponseField>

<ResponseField name="body" type="any">
  Parsed JSON body as sent; `bodySchema` isn't applied to it. For a subscribed platform event it is `{ eventType, ...payload }`.
</ResponseField>

<ResponseField name="timestamp" type="string">
  ISO 8601 time the request was received.
</ResponseField>

<ResponseField name="execution" type="{ eventId: string; executionId: string; attempt: number }">
  Set on every deployed run for a subscribed platform event, absent for a direct request, and not on the type. `eventId` is stable across retries of one event, `executionId` changes per attempt, and `attempt` starts at 1.
</ResponseField>

<Info>
  Deployed runs only. `event.execution` is `undefined` under `lua test webhook`; read it through a widened type, `(event as typeof event & { execution?: { eventId: string; executionId: string; attempt: number } }).execution`.
</Info>

## Signing

When `secret` is set, the platform rejects a direct request before running any code unless it carries the signature header.

```text theme={null}
x-lua-signature: sha256=<hex HMAC-SHA256 of the raw request body, keyed with secret>
```

The digest is computed over the exact bytes the caller sent and compared in constant time. A missing header, a missing body, a malformed value, or a mismatch each return `401` with `Invalid webhook signature`. Sign the bytes you send: re-serializing JSON changes key order and whitespace and invalidates the signature. Subscribed platform events don't pass through the public URL and aren't signed.

## Delivery

|                        | Direct request                                                                                                                                                                                | Subscribed platform event                                                                      |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Invoked by             | `POST` to the webhook URL                                                                                                                                                                     | The platform, on each event the webhook subscribes to                                          |
| Response to the caller | `200` with the return value of `execute`; `401` bad signature; `404` unknown or deactivated webhook; `500` `Internal server error` when `execute` throws (your error text goes to `lua logs`) | None                                                                                           |
| Retries                | None; the caller applies its own policy                                                                                                                                                       | 3 attempts in total, 60 seconds apart, only after a failure                                    |
| `event.execution`      | Absent                                                                                                                                                                                        | `{ eventId, executionId, attempt }` on every deployed run                                      |
| Delivery guarantee     | Once, synchronously in the request                                                                                                                                                            | At least once, from a durable queue. An event over 50,000 characters runs once without retries |

The retry policy is the platform's; there is no per-webhook setting. `event.execution.eventId` stays the same across the attempts of one event, so write the handler to be idempotent; see [About webhooks](/concepts/webhooks).

<Warning>
  A subscribed event can run your handler more than once. Key side effects on `event.execution.eventId` so a retry is a no-op.
</Warning>

## Event subscriptions

List the events a webhook can subscribe to, then subscribe a pushed webhook by name.

```bash theme={null}
lua webhooks list-events
lua webhooks subscribe --webhook-name order-paid --event message.delivered
lua webhooks unsubscribe --webhook-name order-paid --event message.delivered
```

```text Output theme={null}
============================================================
📡 Available event types
============================================================

  • message.received
  • message.sent
  • message.delivered
  • message.read
  • message.failed
  • message.played

============================================================
…
```

For a `message.*` event, `event.body` is `{ eventType, ...payload }` where the payload is the platform's record of that message delivery.&#x20;

## Methods

### getSecret()

Returns the signing key, or `undefined`. Never log it.

```ts theme={null}
webhook.getSecret(): string | undefined
```

**Errors** — none.

### getName()

Returns `name`.

```ts theme={null}
webhook.getName(): string
```

**Errors** — none.

### getDescription()

Returns `description`.

```ts theme={null}
webhook.getDescription(): string
```

**Errors** — none.

### execute()

Validates each part against its schema, builds the event, and runs your handler. Only your unit tests call it: the platform and `lua test webhook` pass the event to the compiled handler directly, so the validation errors below never occur in a deployed webhook.

```ts theme={null}
webhook.execute(query?: Record<string, any>, headers?: Record<string, any>, body?: any): Promise<any>
```

<ParamField path="query" type="Record<string, any>">
  Query parameters. Defaults to `{}`.
</ParamField>

<ParamField path="headers" type="Record<string, any>">
  Headers. Defaults to `{}`.
</ParamField>

<ParamField path="body" type="any">
  Request body.
</ParamField>

**Returns** — whatever your handler returns.

**Example**

```ts theme={null}
const result = await webhook.execute({}, {}, { orderNumber: 'A-1001', amount: 42 });
```

**Errors** — `Query parameter validation failed: <ZodError>`, `Header validation failed: <ZodError>`, or `Body validation failed: <ZodError>` when a schema rejects its part. This method is the only place they are raised.

## Types

`LuaWebhookConfig` is an exported type. `LuaWebhookEvent` isn't exported by name; derive it from the config.

```ts theme={null}
import type { LuaWebhookConfig } from 'lua-cli';

type LuaWebhookEvent = Parameters<LuaWebhookConfig['execute']>[0];
```

## See also

* [About webhooks](/concepts/webhooks)
* [Handle a webhook](/build/handle-a-webhook)
* [LuaTrigger](/reference/sdk/luatrigger)
* [lua webhooks](/reference/cli/webhooks)
* [Channels](/reference/sdk/channels)
