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

# Handle a webhook

> Define a LuaWebhook with a signing secret, make its handler idempotent, test it locally, release it, and send it a signed request

After this guide, an external system POSTs to a URL on your agent and your code verifies the caller, ignores duplicates, updates data, and answers. Use a [webhook](/concepts/webhooks) to control the response or do work without the model; when the event should only wake the agent, [create a trigger](/build/create-a-trigger) instead.

*Verified against lua-cli 3.33.0.*

**Before you begin**

* A project created with `lua init` and signed in with `lua auth configure` ([Install and sign in](/get-started/install)).
* The sender's payload shape and the id it puts on each event.

<Steps>
  <Step title="Define the webhook">
    `LuaWebhook` takes a kebab-case `name`, an optional `secret`, optional Zod schemas for `body`, `headers`, and `query`, and `execute`. The schemas are not enforced and the event's `body` is typed `any`: a body that fails `bodySchema` still reaches `execute`, so keep the schema in a `const` and `safeParse` it first. The handler receives one event with `body`, `headers` (lowercase keys), `query`, and `timestamp`; whatever it returns is the response body with status 200, and a throw answers 500.

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

    // Read at compile time: a literal or a const, never env() or process.env.
    const SIGNING_SECRET = 'whsec_9f2e6c1a2b7d4c039e88';

    const settlement = z.object({
      eventId: z.string(),
      orderNumber: z.string(),
      amount: z.number(),
    });

    export default new LuaWebhook({
      name: 'payment-settled',
      description: 'Marks an order as paid when the billing system confirms settlement',
      secret: SIGNING_SECRET,
      bodySchema: settlement,
      async execute({ body }) {
        const parsed = settlement.safeParse(body);
        if (!parsed.success) return { received: false, error: 'Body does not match the schema' };
        const event = parsed.data;

        const seen = await Data.get('webhook-events', { eventId: { $eq: event.eventId } }, 1, 1);
        if (seen.data.length > 0) return { received: true, duplicate: true };
        await Data.create('webhook-events', { eventId: event.eventId, receivedAt: new Date().toISOString() });

        const page = await Data.get('orders', { orderNumber: { $eq: event.orderNumber } }, 1, 1);
        const order = page.data[0];
        if (!order) return { received: true, orderFound: false };
        await Data.update('orders', order.id, { status: 'paid', paidAmount: event.amount });
        return { received: true, orderFound: true };
      },
    });
    ```

    Generate your own secret (`openssl rand -hex 16`); the one printed here is public. It is the one secret that lives in source: committed with your code, stored in every source backup, and frozen into any agent template you publish, where the `SECRET_DETECTED` lint may refuse it. Rotate it by changing the constant and deploying. With `secret` set, the platform runs `execute` only for requests whose `x-lua-signature` header is `sha256=` plus the hex HMAC-SHA256 of the raw body keyed with that secret; a request without the header, or with a wrong one, gets 401 `Invalid webhook signature` before your code runs. So `secret` is for callers you control: a third-party sender that signs with its own scheme (Zendesk, Stripe, GitHub) can't reach a webhook with `secret` set. Leave it unset for those and check a token inside `execute`, for example `headers.authorization` against `` `Bearer ${env('WEBHOOK_TOKEN')}` ``, as the [customer support example](/examples/customer-support) does.
  </Step>

  <Step title="Make the handler idempotent">
    Senders retry until they get a 2xx, so the same event can arrive twice. Key every side effect on the sender's event id: the handler records it in a [`Data`](/build/store-and-search-data) collection before doing the work, and a repeat answers 200 with `duplicate: true`. Lua never retries a direct request but delivers subscribed platform events at least once, 3 attempts 60 seconds apart, with the same `event.execution.eventId` each time.

    <Info>
      Deployed runs only. `event.execution` is `undefined` under `lua test webhook` and absent from the type; see the [LuaWebhook reference](/reference/sdk/luawebhook).
    </Info>

    A webhook has no end user: `User.get()` needs a stored id ([Identify users](/build/identify-users)).
  </Step>

  <Step title="Register it and run it locally">
    Add the webhook to `LuaAgent.webhooks` in `src/index.ts`, then run it with a JSON object holding any of `query`, `headers`, and `body`. The local run skips the signature check.

    ```bash theme={null}
    lua test --ci webhook --name payment-settled --input '{"body":{"eventId":"evt_abc123","orderNumber":"A-1001","amount":42}}'
    ```

    ```text Output theme={null}
    …
    🚀 Executing webhook...
    Query: {}
    Headers: {}
    Body: {
      "eventId": "evt_abc123",
      "orderNumber": "A-1001",
      "amount": 42
    }

    ✅ Webhook execution successful!

    Webhook returned: Object — fields: received, orderFound
    Output:
    { received: true, orderFound: false }
    ```

    Run it again and the output is `{ received: true, duplicate: true }`: the local run wrote to the real `webhook-events` collection, shared by the sandbox and production. A body without `orderNumber` returns `{ received: false, error: 'Body does not match the schema' }`.
  </Step>

  <Step title="Release">
    Push uploads a webhook version and changes nothing for callers. `lua deploy webhook` then creates and promotes an agent version scoped to this webhook, so it is live at once and appears in `lua version list`.

    ```bash theme={null}
    lua push webhook --ci --force --name payment-settled
    lua deploy webhook --ci --name payment-settled --set-version latest --force
    ```

    To release it with other changes instead, skip the deploy: run `lua push all --ci --force`, then `lua version create --ci -m "Add payment-settled webhook"`, which prints ``✓ Created v<n> (staged). Run `lua version promote v<n>` to deploy.``, then `lua version promote <n>` with that number (`<n>` or `v<n>`, no confirmation; in a script `n=$(lua version list --limit 1 --json --ci | jq -r '.[0].version')`) ([Release an agent to production](/ship/releasing)). Either way, `lua webhooks view` confirms the deployment and prints the webhook id.

    ```bash theme={null}
    lua webhooks view --ci
    ```

    ```text Output theme={null}
    ============================================================
    ⚙️  Production Webhooks
    ============================================================

    🪝 payment-settled
       Webhook ID: 1801aee9-5b6d-41e8-bbb8-dca0d11c3e5f
       Deployed ⭐
       Deployed: 12/09/2026, 13:49:45
    …
    ```

    No command prints the URL: it is `https://webhook.heylua.ai/<agentId>/payment-settled` (the webhook id works in place of the name; the agent id is `agent.agentId` in `lua.skill.yaml`).
  </Step>

  <Step title="Verify">
    Sign the exact bytes you send: re-serializing the JSON changes whitespace or key order and invalidates the signature.

    ```bash theme={null}
    BODY='{"eventId":"evt_abc124","orderNumber":"A-1001","amount":42}'
    SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'whsec_9f2e6c1a2b7d4c039e88' | sed 's/^.* //')
    curl -X POST "https://webhook.heylua.ai/<<YOUR_AGENT_ID>>/payment-settled" \
      -H 'Content-Type: application/json' \
      -H "x-lua-signature: sha256=$SIG" \
      -d "$BODY"
    ```

    The response is the handler's return value, `{"received":true,"orderFound":false}` for an unknown order, and `lua logs --type webhook --name payment-settled --limit 5 --ci` shows the run. No CLI command sends a request to a deployed webhook.
  </Step>
</Steps>

## Options you may need

### Subscribe to platform events

A pushed webhook can also subscribe to the platform's WhatsApp delivery events (`lua webhooks list-events` names them); `event.body` then carries `eventType` first, `query` and `headers` are empty, and nothing is signed.

```bash theme={null}
lua webhooks list-events
lua webhooks subscribe --webhook-name payment-settled --event message.delivered
```

## If it isn't working

<AccordionGroup>
  <Accordion title="401 Invalid webhook signature">
    The header is missing or lacks the `sha256=` prefix, the body was re-serialized after signing, or the secret differs from the deployed version's. Sign the string you send; deploy again after changing the secret.
  </Accordion>

  <Accordion title="404 Webhook not found">
    The webhook isn't deployed, was deactivated, or the URL is wrong. Check `lua webhooks view --ci` for `Deployed`; `lua webhooks activate --webhook-name payment-settled` turns it back on.
  </Accordion>

  <Accordion title="lua compile fails on the secret">
    The message reads ``Webhook `secret` must be a string literal or a compile-time-resolvable constant``. Replace `env('…')` or `process.env` with a literal or a `const` in the same file; rotate by changing the value and deploying.
  </Accordion>
</AccordionGroup>

## Next steps

<Columns cols={2}>
  <Card title="LuaWebhook reference" href="/reference/sdk/luawebhook">Schemas, the event object, signing, delivery, and errors.</Card>
  <Card title="lua webhooks reference" href="/reference/cli/webhooks">View, deploy, activate, deactivate, subscribe, and delete.</Card>
  <Card title="Create a trigger" href="/build/create-a-trigger">Wake the agent from a URL without a handler.</Card>
  <Card title="Send proactive messages" href="/build/send-proactive-messages">Message the end user from the handler.</Card>
</Columns>
