---
title: "Create a webhook"
method: POST
path: "/v1/projects/{project_id}/webhooks"
tags: ["Webhooks"]
---

# Create a webhook

`POST /v1/projects/{project_id}/webhooks`

## Request

- Base URL: `https://api.naturali.ai`
- URL: `https://api.naturali.ai/v1/projects/{project_id}/webhooks`
- Auth: one of:
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://api.naturali.ai/authorize`, token URL `https://api.naturali.ai/token`, scopes: `mcp:access`)

Register an endpoint and subscribe it to one or more event types.
The response carries `secret` — the signing key, in plaintext. **This is the only time it is returned.** Store it where your receiver can read it; if you lose it, rotate rather than re-create, so the endpoint keeps its delivery history. Send `secret` yourself to sign with a key your receiver already holds; the response then returns that one.
A project holds at most 20 webhooks, inactive ones included; the 21st is refused with `409 webhook_limit_reached`.
Returns `501` on a deployment with no credential-sealing key configured, since the secret could not then be stored safely.

## Request body

- WebhookCreate
  - `url` string, uri, required: An `https://` endpoint (`http://` is accepted for localhost, so a tunnel works in development).
  - `events` string[], required: Which events this endpoint receives. Each entry is an exact type (`generation.completed`), a resource wildcard (`generation.*`), or `*` for everything. A bare resource name (`generation`) matches nothing and is rejected.
  - `description` string, nullable
  - `active` boolean
  - `secret` string: A signing key of your choosing, 32 to 256 characters with no whitespace. Omit it on create to have one generated.

## Response `201`

Webhook created. The signing secret is included, once.

- WebhookWithSecret
  - `id` string, required: Public webhook ID (whk_ prefix).
  - `project_id` string, required
  - `url` string, uri, required: Where deliveries are POSTed.
  - `events` string[], required: Which events this endpoint receives. Each entry is an exact type (`generation.completed`), a resource wildcard (`generation.*`), or `*` for everything. A bare resource name (`generation`) matches nothing and is rejected.
  - `description` string, nullable, required: Operator-facing label.
  - `active` boolean, required: Whether deliveries are attempted.
  - `created_at` string, date-time, required
  - `updated_at` string, date-time, required
  - `secret` string, required: The signing key, returned only by create and rotate. A generated one carries the `whsec_` prefix; a chosen one is returned as sent. Never readable afterwards.

## Other responses

- `400`: The request was malformed or failed validation.
- `401`: Missing or invalid credentials.
- `404`: The resource does not exist (existence is not leaked).
- `409`: The project already holds 20 webhooks (`webhook_limit_reached`); `details.limit` carries the ceiling.
- `501`: The requested path is not enabled on this deployment — a webhook secret cannot be stored where no credential-sealing key is configured.

---

[API](https://skmtc.dev/naturali/apis/naturali-ai-api.md) · [All operations](https://skmtc.dev/naturali/apis/naturali-ai-api/llms.txt) · [OpenAPI document](https://skmtc.dev/naturali/apis/naturali-ai-api/revisions/e4905b78f422?raw)
