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

# Webhooks

> Run workflows when systems POST webhooks.

A webhook trigger runs a [workflow](https://docs.os.meetluno.ai/learn/workflows/overview.md) or [agent](https://docs.os.meetluno.ai/learn/agents/overview.md) when an external system `POST`s a request to its endpoint. It's the primary way to react to events from other services: signups, payments, CI events, and the like.

## Example requests

Ask your coding agent which external event should kick off a run. It can create the endpoint and validate the payload.

> "When Stripe sends a payment succeeded webhook, update the customer record and start onboarding."

> "When we get a new GitHub issue, triage it and add the right labels."

> "When our app POSTs a new signup, enrich the profile and notify the sales channel."

## Define a webhook

Use `defineWebhookSource` with a `slug`, `name`, `description`, an `endpoint`, and a `payload` schema, then attach a target.

```ts src/triggers/signup.ts theme={null}
import { defineWebhookSource } from "@meetluno/luno/trigger";
import { z } from "zod";
import workflow from "../workflows/signup-pipeline";

export default defineWebhookSource({
  slug: "signup",
  name: "Signup",
  description: "Fires when a new signup is posted.",
  endpoint: "signup",
  payload: z.object({
    name: z.string().trim().min(1),
    email: z.string().email(),
  }),
}).attach({ workflow });
```

| Option | Required | What it does |
| - | - | - |
| `slug` | Yes | Stable trigger slug (used in the [attachment id](https://docs.os.meetluno.ai/learn/triggers/overview.md#attachment-id) and run history) |
| `name` | Yes | Human-readable label shown in the platform |
| `description` | Yes | What the webhook listens for, shown in the platform |
| `endpoint` | Yes | The route suffix; the webhook URL is `POST /triggers/{endpoint}` |
| `payload` | Yes | Zod schema the incoming body must match for the trigger to fire |

## The webhook URL

The endpoint becomes the route `POST /triggers/{endpoint}`. Print the full URL for a deployed trigger (requires deploy access to the project, since the URL embeds the webhook API key):

```bash theme={null}
luno triggers url signup
```

Send a test request with the URL from `luno triggers url`, or fire the webhook path from the CLI or the trigger detail **Invoke** button. The body is validated against the same attachment `payload` schema:

```bash theme={null}
luno triggers invoke signup --input '{"name":"Ada","email":"ada@example.com"}'
luno triggers invoke signup --input '{"name":"Ada","email":"ada@example.com"}' --workflow signup-pipeline
```

When the body matches `payload`, the trigger fires and starts a run. When it doesn't, no run starts: the `POST` still answers `200` with `{ ok: true, skipped: true }` (so senders don't retry a payload that can never match) and a skipped fire is recorded on the trigger with the validation detail. Malformed JSON is the exception and gets `400`.

Webhooks ack asynchronously: the `POST` returns immediately — `202` with the `runId` when a trigger matches, or `{ ok: true, skipped: true }` when none does — and the run executes in the background. The workflow's output is **not** returned in the webhook response (there's no "respond to webhook"). To return data to the caller, make an outbound call from the workflow, or have the caller poll the run via [run history](https://docs.os.meetluno.ai/learn/logs/overview.md) or the runs API.

## Authenticating webhooks

Each webhook route requires a webhook API key. `luno triggers url` returns the full URL with that key already included as a `?token=` query parameter, so you can hand it straight to the sending system:

```bash theme={null}
luno triggers url signup
```

If the sender prefers a header to the query parameter, pass the same key one of these ways:

| Form | Example |
| - | - |
| Query | `?token=<key>` or `?api_key=<key>` |
| Header | `Authorization: Bearer <key>` |
| Header | `x-api-key: <key>` |

A request with a missing or invalid key is rejected with `401`.

### Signed webhooks

Providers that sign each delivery (Kapso, Meta, GitHub, and similar) can authenticate with the signature instead of the key. Add `verify` to the source: ingress computes an HMAC of the **raw request body** with the signing secret and compares it in constant time to the signature header before the payload is matched or a run is queued.

```ts src/triggers/kapso.ts theme={null}
import { defineCredential } from "@meetluno/luno/credentials";
import { defineWebhookSource } from "@meetluno/luno/trigger";
import { z } from "zod";
import workflow from "../workflows/whatsapp-reply";

const kapso = defineCredential({
  key: "kapso",
  fields: { apiKey: z.string(), webhookSecret: z.string() },
});

export default defineWebhookSource({
  slug: "kapso",
  name: "Kapso",
  description: "WhatsApp messages delivered by Kapso.",
  endpoint: "kapso",
  payload: z.object({ type: z.string() }),
  verify: {
    header: "x-webhook-signature",
    secret: { credential: kapso, field: "webhookSecret" },
    // algorithm: "sha256" (default) | "sha1" | "sha512"; encoding: "hex" (default) | "base64"
    // prefix: "sha256=" when the header value carries one (Meta, GitHub)
  },
}).attach({ workflow });
```

The secret is read from the named static credential (project default, then org default), so create it once with `luno credentials create`. With `verify` set:

* A delivery with a valid signature is accepted **without** the webhook API key.
* A delivery with a valid API key is accepted even without a signature (handy for manual tests).
* Anything else (missing header, wrong secret, edited body, credential not configured) gets `401`, no run is queued, and a skipped fire is recorded on the trigger with the failing header in its detail.

Deliveries that send an `X-Idempotency-Key` (or `Idempotency-Key`) header are deduplicated while the first delivery is still queued or running, so a provider retry during a slow run does not fire twice. Batched deliveries (for example Kapso's `{ type, batch: true, data: [...] }`) are verified and passed through as one payload.

## Validation

The `payload` schema is both the contract and the gate: only requests that parse against it fire the trigger. Model just the fields you care about; extra fields are allowed at every object level when the schema is persisted for ingress matching, so you don't have to describe an entire third-party payload.

To narrow which events fire (for example, only `invoice.paid`), put those constraints in the `payload` schema itself — literals, enums, and string refinements all work:

```ts theme={null}
payload: z.object({
  type: z.literal("invoice.paid"),
  data: z.object({ id: z.string() }),
}),
```

### Exportable schemas

Webhook `payload` schemas are exported to JSON Schema at build time for platform matching, the canvas, and run forms. They must be **plain structural Zod** — objects, strings, literals, unions, and built-in validators like `.email()` or `.min()`. Do not use code-based methods like `.transform()`, `.preprocess()`, `.refine()`, or `.superRefine()`; `luno build` will fail with an error explaining the fix.

When you need to remap or normalize fields (for example, coalescing two payload keys into one), keep the exported schema as the wire shape and remap in `.attach({ transform })` or with a plain function at the top of `run()`. See [advanced triggers — transform a workflow input](https://docs.os.meetluno.ai/learn/triggers/advanced-triggers.md#transform-a-workflow-input).

```ts theme={null}
const WebhookPayload = z.object({
  landing_page_url: z.string().nullable().optional(),
  bootcamp_landing_page: z.string().nullable().optional(),
  email: z.string().email(),
});

export default defineWebhookSource({
  slug: "bootcamp-ae",
  name: "Bootcamp AE",
  description: "Fires when a bootcamp signup webhook arrives.",
  endpoint: "bootcamp-ae",
  payload: WebhookPayload,
}).attach({
  workflow,
  transform: (payload) => ({
    email: payload.email,
    landing_page_url: payload.landing_page_url ?? payload.bootcamp_landing_page ?? null,
  }),
});
```

The same rule applies to workflow `input` and `output` schemas when they are exported at build time. The matched `payload` type flows into `transform` and agent `prompt` callbacks.

## Shared endpoints

Multiple trigger files can share the same `endpoint`: one URL, many triggers, each with its own `slug`, `payload`, and `transform`. This is the pattern for a provider like Stripe that sends every event type to a single webhook URL.

```ts theme={null}
// src/triggers/stripe-invoice-paid.ts  (endpoint: "stripe")
// src/triggers/stripe-subscription-deleted.ts  (endpoint: "stripe", different slug/payload)
```

Each incoming request is matched against every trigger on the endpoint; matching ones fire, and a payload that matches none returns `{ ok: true, skipped: true }` and records a skipped run per trigger (with validation detail) so you can debug with `luno triggers runs list <slug> --outcome skipped`. List the triggers on a shared endpoint:

```bash theme={null}
luno triggers list --endpoint stripe
luno triggers url stripe          # one URL for the shared route
```

Use each trigger's own `slug` for run history (`luno triggers runs list stripe-invoice-paid --workflow <workflow-slug>`).

## Next steps

> **[Advanced triggers](https://docs.os.meetluno.ai/learn/triggers/advanced-triggers.md)**
>
> Transform payloads, attach agents, and interpolate prompts.

> **[App events](https://docs.os.meetluno.ai/learn/triggers/app-events.md)**
>
> Point a webhook at a connected third-party app.

> **[Schedules](https://docs.os.meetluno.ai/learn/triggers/schedules.md)**
>
> Run on a cron schedule instead of an inbound request.

> **[Triggers overview](https://docs.os.meetluno.ai/learn/triggers/overview.md)**
>
> Sources, attach, and attachment ids.


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