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

# Webhooks

> HTTP endpoints your code handles, how Lua delivers requests and platform events to them, and how they differ from triggers

A webhook is an HTTP endpoint on your agent that runs your code when something happens outside a conversation: a payment settles, an order ships, a ticket changes state. It exists because a [tool](/concepts/skills-and-tools) runs only when the model calls it mid-conversation, and many events have no conversation to belong to.

## How a webhook handles a request

You define a webhook with `new LuaWebhook({ name, description, execute })` and register it on the [agent](/concepts/agents)'s `webhooks` array. After `lua push webhook` and a deploy, Lua serves it at `https://webhook.heylua.ai/<agentId>/<webhookId>`, and also at `https://webhook.heylua.ai/<agentId>/<webhook-name>`; `lua webhooks view` lists both identifiers.

A `POST` to that URL calls `execute` with one event object, `{ query, headers, body, timestamp }`, exactly as the request arrived. The optional zod schemas (`querySchema`, `headerSchema`, `bodySchema`) describe the shape you expect; the platform doesn't apply them, so a request they would reject still reaches `execute`, and you refuse it there with `schema.safeParse`. Whatever `execute` returns is the HTTP response body, with status 200; a handler that throws answers 500 `Internal server error`, and your error text goes to `lua logs`, not to the caller. The request is handled synchronously and Lua never retries it; the caller receives your return value and applies its own retry policy, as Stripe, Shopify, and GitHub all do.

```ts src/webhooks/OrderShippedWebhook.ts theme={null}
import { LuaWebhook, User } from 'lua-cli';

export default new LuaWebhook({
  name: 'order-shipped',
  description: 'Tell the customer their order is on its way',
  async execute({ body }) {
    const user = await User.get(body.customerId);
    if (!user) return { received: true, notified: false };
    await user.send([{ type: 'text', text: `Order ${body.orderNumber} has shipped.` }]);
    return { received: true, notified: true };
  },
});
```

A webhook runs with no conversational end user, so `User.get()` with no argument has nobody to return. Store the Lua user ID in the external system when you create the order or payment, and pass it to `User.get(userId)` in the handler. From there the handler can send a message, change stored data, start a [workflow](/concepts/workflows), or hand the event to the model with `Agents.invoke`. The [execution contexts](/concepts/execution-contexts) page lists what else is available.

## Verifying who is calling

A webhook URL is public. Set `secret` on the definition and Lua runs `execute` only for requests that carry `x-lua-signature: sha256=<hex>`, the HMAC-SHA256 of the raw request body keyed with that secret. A missing, malformed, or wrong signature is answered with 401 `Invalid webhook signature` and your code never runs. The secret must be a string literal or a constant the compiler can read; `lua compile` fails on a runtime expression rather than deploy an unsigned webhook. Vendors that sign with their own scheme (Stripe's `stripe-signature`, GitHub's `x-hub-signature-256`) never send `x-lua-signature`, so leave `secret` unset for them and verify their header inside `execute`. The [LuaWebhook reference](/reference/sdk/luawebhook) has the full signing detail and a sender example.

## Platform event subscriptions

A webhook can also receive events from Lua itself. `lua webhooks subscribe --webhook-name <name> --event message.delivered` subscribes it to a WhatsApp delivery receipt; the subscribable events are `message.received`, `message.sent`, `message.delivered`, `message.read`, `message.failed`, `message.played`, `user.merged`, and `user.unmerged` (`lua webhooks list-events` prints them). `user.merged` means Lua merged two users who are the same person into one, and `user.unmerged` means that merge was undone; see [When two users merge](/build/identify-users#when-two-users-merge). The CLI accepts the two `user.*` events from lua-cli 3.46.0. Lua calls `execute` with `query` and `headers` empty and the event in `body`, with its `eventType` as the first field.

Lua delivers a subscribed event from a durable queue, at least once: after a failed attempt the platform tries again until it has made 3 attempts in total, 60 seconds apart, and `event.execution.eventId` stays the same across them. Write the handler to be idempotent: key side effects on that event ID, or on the message ID in the payload, rather than on "this call happened". For `user.merged` the event ID is the merge id, and for `user.unmerged` it is `<mergeId>-undo`. An event whose envelope exceeds 50,000 characters runs once and is never retried.

## Webhooks, triggers, and integration webhooks

| | Webhook | [Trigger](/concepts/triggers) | [Integration webhook](/concepts/integrations) |
| - | - | - | - |
| What calls it | Any system, at a URL you give it | Any system, at a URL Lua prints | A connected SaaS (Linear, HubSpot) on an event you subscribe to |
| What runs | Your `execute` function | No code of yours; Lua shapes the event | Whatever the subscription points at |
| What it ends in | Your response body | A model turn, one tool call, or a workflow run | A background agent turn on channel `integration:<type>`, or a webhook or trigger URL you pass with `--hook-url` |
| Manage with | `lua webhooks` | `lua triggers` | `lua integrations webhooks` |

Pick a webhook when you need to control the response or do work without involving the model. Pick a trigger when the event should wake the agent and nothing more. Pick an integration webhook when the source is one of Lua's connected integrations, so you don't have to register a URL with the vendor yourself.

## When to use a webhook

* An external system must notify the agent, and you need to respond to it, transform its payload, or update state: webhook.
* The event should become a message to the agent, a single tool call, or a workflow start, with no custom code: [trigger](/concepts/triggers).
* The work recurs on a schedule rather than on an event: [job](/concepts/jobs).
* The work is multi-step, long-running, or needs approval: let the webhook call `Workflows.start` and return.

## Limits

* There is no server-side time limit on a handler and no per-webhook timeout setting: the caller's connection is dropped after about 90 seconds while the handler keeps running, and the return value is lost. Hand long work to a job or a workflow and return.
* Lua does not retry direct requests. A handler that throws answers 500.
* A deactivated webhook (`lua webhooks deactivate --webhook-name <name>`) answers 404 `Webhook not found`, the same as one that doesn't exist.
* `secret` must be a compile-time literal. Setting it to `''` and pushing removes signing.
* Only the six `message.*` events, `user.merged`, and `user.unmerged` are subscribable; `delivered`, `read`, `failed`, and `played` are WhatsApp status receipts.

## Next steps

<Columns cols={2}>
  <Card title="Handle a webhook" href="/build/handle-a-webhook">Define, sign, test, and release one.</Card>
  <Card title="LuaWebhook reference" href="/reference/sdk/luawebhook">Schemas, the event object, signing, and errors.</Card>
  <Card title="lua webhooks" href="/reference/cli/webhooks">View, activate, subscribe, and deploy.</Card>
  <Card title="Triggers" href="/concepts/triggers">The no-code alternative that wakes the agent.</Card>
</Columns>


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