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

# Create Webhook

> Subscribe an HTTPS endpoint to account events. The HMAC signing secret is returned once — store it immediately.

Subscribes a URL to platform events — push delivery of the same events [`GET /updates`](/docs/developers/api-reference/updates/poll) serves. Each delivery is signed with HMAC-SHA256 via the `X-OpenTrain-Signature` header; verify it with the [signature guide](/docs/developers/guides/verify-webhook-signatures).

**The `secret` is returned only in this response.** It cannot be retrieved later — if lost, [delete](/docs/developers/api-reference/webhooks/delete) the subscription and create a new one. **There is no backfill:** a new subscription starts at the current event high-water mark and only receives events created after it; older events remain reachable via [`GET /updates`](/docs/developers/api-reference/updates/poll).

Accounts can hold at most **10** subscriptions. For the polling-vs-webhooks decision framework and the full delivery lifecycle, see [stay in sync](/docs/developers/guides/stay-in-sync).

**Requirements:** `webhooks:manage` scope + the `public_api_webhooks` feature + the matching read scope for every subscribed event type (e.g. `proposal.received` needs `proposals:read`).

## Request

<ParamField body="url" type="string" required>
  Absolute `https` URL that will receive deliveries (max 2048 chars). Plain `http` is allowed only for `localhost` during development.
</ParamField>

<ParamField body="eventTypes" type="string[]" required>
  Non-empty array of event types from the [event catalog](/docs/developers/api-reference/updates/poll): `proposal.received`, `proposal.status_changed`, `message.received`, `contract.created`, `milestone.status_changed`, `payment.pending`, `approval.confirmed`, `contract.budget_state_changed`. Duplicates are de-duplicated.
</ParamField>

## Response

<ResponseField name="webhook" type="object">
  The subscription: `{id, url, eventTypes, status: "ACTIVE", createdAt, disabledAt, disabledReason}` — same shape as [`GET /webhooks/{id}`](/docs/developers/api-reference/webhooks/get).
</ResponseField>

<ResponseField name="secret" type="string">
  The `whsec_…` signing secret. **Shown once — store it now.**
</ResponseField>

<ResponseField name="message" type="string">
  Reminds you to store the secret.
</ResponseField>

## Deliveries

Each event is a `POST` to your URL with `Content-Type: application/json`, `User-Agent: OpenTrain-Webhooks/1.0`, and headers `X-OpenTrain-Event` (the event type), `X-OpenTrain-Delivery` (unique delivery ID — dedupe on it), and `X-OpenTrain-Signature` (`t=<unix seconds>,v1=<hex HMAC>`). The body is exactly one [`/updates` event record](/docs/developers/api-reference/updates/poll) — IDs only, never content.

Respond with any `2xx` within 10 seconds. Failures retry up to 5 attempts with backoff (1m, 5m, 30m, 120m); after 10 consecutive exhausted deliveries the subscription is auto-disabled — recover by deleting and re-creating it.

## Errors

| Status | `code`         | Meaning                                                                                                                                                           |
| ------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `BAD_REQUEST`  | Body not valid JSON; `url` missing/invalid/not https (`details.field: "url"`); `eventTypes` empty or contains an unsupported type (`details.supportedEventTypes`) |
| `401`  | `UNAUTHORIZED` | Missing or invalid token                                                                                                                                          |
| `403`  | `FORBIDDEN`    | Missing `webhooks:manage`, missing a subscribed event type's read scope, or `public_api_webhooks` disabled                                                        |
| `409`  | `CONFLICT`     | Subscription limit reached (`details: {limit: 10}`) — delete an existing webhook first                                                                            |

<RequestExample>
  ```bash curl theme={null}
  curl -sS -X POST https://app.opentrain.ai/api/public/v1/webhooks \
    -H "Authorization: Bearer $OT_API_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://example.com/hooks/opentrain",
      "eventTypes": ["proposal.received", "message.received", "approval.confirmed"]
    }'
  ```

  ```bash CLI theme={null}
  opentrain webhooks create \
    --url https://example.com/hooks/opentrain \
    --events proposal.received,message.received,approval.confirmed --json
  ```

  ```json MCP: opentrain_create_webhook theme={null}
  {
    "url": "https://example.com/hooks/opentrain",
    "eventTypes": ["proposal.received", "message.received", "approval.confirmed"]
  }
  ```
</RequestExample>

<ResponseExample>
  ```json 201 theme={null}
  {
    "webhook": {
      "id": "<WEBHOOK_ID>",
      "url": "https://example.com/hooks/opentrain",
      "eventTypes": ["proposal.received", "message.received", "approval.confirmed"],
      "status": "ACTIVE",
      "createdAt": "2026-06-12T10:00:00.000Z",
      "disabledAt": null,
      "disabledReason": null
    },
    "secret": "whsec_<SECRET>",
    "message": "Store the secret now — it is only returned once. Use it to verify the X-OpenTrain-Signature header on deliveries."
  }
  ```

  ```json 409 (limit reached) theme={null}
  {
    "error": "Webhook subscription limit reached (10). Delete an existing webhook first.",
    "code": "CONFLICT",
    "requestId": "<REQUEST_ID>",
    "details": {
      "limit": 10
    }
  }
  ```
</ResponseExample>
