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

# React to inbound webhooks

> Run a workflow whenever an external service (Stripe, GitHub, Airtable…) POSTs to it — add a webhook trigger and handle the payload.

Add a `webhook` trigger to a [workflow](/framework/workflows) — it's an *additional* trigger, so the workflow stays request-callable too. Zite mints a stable ingress URL per webhook workflow:

```text theme={null}
POST https://<host>/public/{flowId}/webhooks/{endpointId}/{token}
```

**Possession of the URL is the auth** — the secret token is baked into the path (the Zapier / Make / Airtable model). Paste it into the upstream's dashboard; no separate HMAC or signing secret.

## The workflow

The upstream's JSON body arrives directly as `input`. Constrain `inputSchema` to the exact event so off-type deliveries fail validation (visible in run history) instead of running with the wrong shape:

```typescript theme={null}
// apps/<app>/src/api/onChargeSucceeded.ts
import { z } from 'zod';
import { createEndpoint } from 'zitejs/backend';
import { zite } from 'zitejs/db';
import { ZiteNotifications } from 'zitejs/notifications';

export default createEndpoint({
  description: 'Notify the team when Stripe reports a successful charge',
  webhook: {},
  inputSchema: z.object({
    id: z.string(),
    type: z.literal('charge.succeeded'), // ← reject every other Stripe event type
    data: z.object({ object: z.object({ amount: z.number(), currency: z.string() }) }),
  }),
  outputSchema: z.object({ ok: z.boolean() }),
  execute: async ({ input }) => {
    // Idempotency is your responsibility — providers retry, so the same delivery
    // can arrive more than once. Dedup on the stable per-delivery id.
    const seen = await zite.WebhookEvents.findOne({ filters: { eventId: input.id } });
    if (seen) return { ok: true };
    await zite.WebhookEvents.create({ record: { eventId: input.id } });

    const { amount, currency } = input.data.object;
    await ZiteNotifications.create({
      recipients: ['user_finance'],
      title: `Payment received: ${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`,
      idempotencyKey: input.id,
    });
    return { ok: true };
  },
});
```

After you add the workflow, the URL surfaces in the chat's webhook panel and the app's Webhooks settings tab — fire one test event to capture a real payload and refine `inputSchema`.

## Rules

* **`context.user` is `null` on webhook fires** — the event body is your only input.
* **Respond within \~30s** — upstreams retry on timeout, re-running your code.
* **Dedup on a stable delivery id** — usually in the body (`input.id` for Stripe, `input.event_id` for Slack). GitHub's is header-only at `input.__webhook.headers['x-github-delivery']`. Inbound headers are lowercased (`authorization` / `cookie` stripped).
* **One webhook per workflow.** For two upstreams, create two workflows sharing helper code.
* **Don't try/catch the `inputSchema` parse** — let Zod errors bubble so failed deliveries show in run history.
* **Scope the upstream** to send only the event types you handle, or Zod rejects most of the fan-out.

See the [Webhooks reference](/framework/webhooks) for the URL format and per-workflow token env var.
