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

# Webhooks

> Verify webhook signatures from Stripe, Shopify and Meta in one line: ctx.verifyStripe, verifyShopify, verifyMeta and verifyHmac.

When your function receives webhooks from an external service, anyone who knows the URL can send it fake data. Signature verification confirms the event really came from the service.

`ctx.verify*` does that check in one line: it reads the signature header, resolves the secret from your [secrets](/en/guides/functions/secrets), and throws if it does not match.

## Stripe

```typescript index.ts theme={null}
import { define, z } from "@jelou/functions";

export default define({
  name: "stripe-webhook",
  description: "Receives Stripe events",
  input: z.object({}).passthrough(),
  config: {
    public: true,
    path: "/webhooks/stripe",
    methods: ["POST"],
    mcp: false,
  },
  async handler(event, ctx, request) {
    await ctx.verifyStripe(request);

    ctx.log("Event verified", { type: event.type });

    if (event.type === "payment_intent.succeeded") {
      await ctx.jelou.send({
        type: "text",
        to: event.data.object.metadata.phone,
        text: "We received your payment!",
      });
    }

    return { received: true };
  },
});
```

Set the secret once:

```bash theme={null}
jelou functions secrets set stripe-webhook STRIPE_WEBHOOK_SECRET=whsec_...
```

<Note>
  The `event` your handler receives is already the Zod-validated body. The platform consumed the request body to validate it, so calling `request.json()` inside the handler throws — use the first parameter.
</Note>

## Supported providers

| Verifier                        | Header                  | Secret                                     |
| ------------------------------- | ----------------------- | ------------------------------------------ |
| `ctx.verifyStripe(request)`     | `stripe-signature`      | `STRIPE_WEBHOOK_SECRET`                    |
| `ctx.verifyShopify(request)`    | `x-shopify-hmac-sha256` | `SHOPIFY_WEBHOOK_SECRET`                   |
| `ctx.verifyMeta(request)`       | `x-hub-signature-256`   | `META_WEBHOOK_SECRET` or `META_APP_SECRET` |
| `ctx.verifyHmac(request, opts)` | The one you name        | `opts.secretEnv` or `opts.secret`          |

Stripe additionally rejects events older than 5 minutes, which stops someone from replaying a captured event.

## Other services

For Twilio, GitHub, Slack or any service with an HMAC-SHA256 signature, use `ctx.verifyHmac`. The header is required because there is no default:

```typescript theme={null}
await ctx.verifyHmac(request, {
  secretEnv: "WEBHOOK_SECRET",
  header: "x-signature",
});
```

## Rotate secrets

During a rotation, accept both the old and the new secret at once by comma-separating them:

```bash theme={null}
jelou functions secrets set my-webhook STRIPE_WEBHOOK_SECRET=whsec_new,whsec_old
```

Once you confirm the provider is using the new one, go back to a single value.

## Handle the failure

Verifiers throw `WebhookVerificationError` with a code telling you what happened:

```typescript theme={null}
import { define, WebhookVerificationError, z } from "@jelou/functions";

async handler(event, ctx, request) {
  try {
    await ctx.verifyStripe(request);
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      ctx.log("Signature rejected", { code: err.code, provider: err.provider });
      return new Response(null, { status: 401 });
    }
    throw err;
  }

  return { received: true };
}
```

| Code                | Meaning                                      |
| ------------------- | -------------------------------------------- |
| `missing_signature` | The request arrived with no signature header |
| `invalid_signature` | The signature does not match the body        |
| `expired_timestamp` | The event is too old (Stripe)                |
| `missing_secret`    | You did not configure that provider's secret |

<Tip>
  If you do not catch the error, the function responds with an error and the provider will retry the webhook. For Stripe and Shopify that is usually right only when the failure is transient; on an invalid signature it is better to return `401` and not retry.
</Tip>

## Public functions

Webhooks need `config.public: true` so the external service can call them without Jelou credentials. That is exactly why signature verification is mandatory: it is the only access control left.

```typescript theme={null}
config: {
  public: true,      // the provider has no Jelou API key
  methods: ["POST"], // webhooks are always POST
  mcp: false,        // pointless as an AI tool
}
```

See [public functions](/en/guides/functions/public).

## Testing

In tests, `createMockContext()` leaves every verifier throwing `missing_secret`. To exercise the rest of the handler, bypass verification:

```typescript theme={null}
import { createMockContext, createMockWebhookVerifiers } from "@jelou/functions/testing";

const verifiers = createMockWebhookVerifiers({ stripe: true });

const ctx = createMockContext({
  verifyStripe: verifiers.stripe,
});

// After running the handler you can inspect the recorded calls
verifiers.calls; // [{ method: "stripe", ... }]
```

See the [testing guide](/en/guides/functions/testing).

<CardGroup cols={2}>
  <Card title="Public functions" icon="globe" href="/en/guides/functions/public">
    Receive requests without Jelou credentials.
  </Card>

  <Card title="Secrets" icon="key" href="/en/guides/functions/secrets">
    Store each provider's secret.
  </Card>

  <Card title="Deferred runs" icon="clock" href="/en/guides/functions/diferidas">
    Book a follow-up when the webhook arrives.
  </Card>

  <Card title="Webhook receiver" icon="webhook" href="/en/guides/functions/ejemplo-webhook">
    Full copy-paste example.
  </Card>
</CardGroup>
