> ## 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={"system"}
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={"system"}
// 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.


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