---
title: "Register a webhook endpoint"
method: POST
path: "/v1/webhooks"
tags: ["Webhooks"]
---

# Register a webhook endpoint

`POST /v1/webhooks`

## Request

- Base URL: `https://api.kawaa.com`
- URL: `https://api.kawaa.com/v1/webhooks`
- Auth: one of:
  - API key in header `X-Api-Key`
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://api.kawaa.com/oauth/authorize`, token URL `https://api.kawaa.com/oauth/token`, scopes: `webhooks:write`)

Kawaa POSTs to `url` when one of `events` happens, signing each delivery. Past the plan's endpoint cap this answers `403 PLAN_LIMIT_EXCEEDED`. With a dashboard session, the session must have signed in within the last 15 minutes; an older one answers `403 REAUTHENTICATION_REQUIRED` (sign in again, then retry). An API key or a connected app is not held to this. The write is conditioned on the creating API key (or the key a dashboard session was signed in with, or a connected app's access token) still being active, so a request made just before that credential was revoked answers `401 API_KEY_REVOKED` and creates nothing. A password change, reset, account lock or white-label sub-account key reset that lands while the request is in progress answers `409 CONFLICT`; nothing is created.

**A scoped key needs more than `webhooks:write` here.** A subscription is a way to receive what its events carry, so each requested event also requires the scope that owns that data: `verification.completed` needs `verification:read`, `verification.failed` needs `verification:read`, `job.completed` needs `verification:read`, `job.failed` needs `verification:read`, `credits.low` needs `account:read`, `credits.exhausted` needs `account:read`, `blacklist.detected` needs `deliverability:read`, `dmarc.failure` needs `deliverability:read`. On an account that can have sub-accounts, `credits.low` and `credits.exhausted` additionally require `white_label:read`, because those deliveries name the sub-account and its label. Missing any of them is a `403` naming each event and the scope it needed, and no webhook is created. An unrestricted key needs none of this. `x-kawaa-event-scopes` carries the same table in machine-readable form.

## Request body

- object: Closed: only `url`, `description` and `events` are allowed. `is_active` is NOT one of them: an endpoint is created active, and it is the update operation that can deactivate it.
  - `url` string, required: Where Kawaa POSTs the callback. HTTPS only: anything else is 400 WEBHOOK_URL_INVALID. No `format: uri` or anchored scheme `pattern` is declared on this REQUEST field: the URL is parsed first, which tolerates surrounding whitespace and normalizes the protocol, so `" https://x"` and `"HTTPS://x"` are both accepted while either constraint refuses them. HTTPS is required, checked on the PARSED protocol, not on the string you sent. Before storing it, Kawaa replaces the request string with that parsed URL's canonical string, so every response URL is a well-formed URI.
  - `events` string[], required
  - `description` string, nullable: A note for your own reference. Send `null` to clear the stored one.

## Response `201`

Registered. This endpoint's initial signing secret is returned once; no read operation returns it, but the rotation operation can replace a lost or exposed value.

- object
  - `success` unknown, required
  - `data` object, required
    - `webhook` object, required
      - `webhook_id` string, required
      - `url` string, uri, required
      - `description` string, nullable
      - `events` string[], required
      - `is_active` boolean, required
      - `created_at` string, date-time, required
      - `secret` string, required: THE SIGNING SECRET. Every delivery to this endpoint carries `X-Kawaa-Signature-V2` (and the legacy `X-Kawaa-Signature`) computed from it; without it there is no way to tell a genuine callback from anything else that can reach the URL. Save it now: no read operation returns it. If it is lost or exposed, `POST /v1/webhooks/{id}/secret` replaces it and returns the replacement once, without deleting the endpoint.
    - `message` string, required

## Other responses

- `400`: The request is malformed, or names a field this endpoint does not accept. Retrying it unchanged will fail identically.
- `401`: No credential, or one that is not accepted. `code` distinguishes `API_KEY_SUSPENDED` (subscription ended; restartable) from `API_KEY_REVOKED`, `SESSION_EXPIRED` and `OAUTH_TOKEN_EXPIRED` (a connected app's one-hour access token lapsed: refresh it and retry, or sign in again if no refresh token was issued or the refresh is refused).
- `403`: Refused, and **the code says which of three problems it is**, and they need different remedies: - `INSUFFICIENT_SCOPE`: the credential is valid but not scoped for this route. With an API key, issue one that carries the scope named in the message. With an OAuth access token, connect the app again asking for that scope as well as the ones it has, unless the message says a connected app can never be given it; then use an API key with every scope the message names, or have the account owner do it in the dashboard. - `PLAN_UPGRADE_REQUIRED`: the account's plan does not include the feature. **No key of any scope reaches it**, so issuing another is wasted work; the remedy is a plan change. Returned by the verification routes for `options.enrich`, and by the finder, the monitors, the audit log, auto-clean and the white-label routes. - `PLAN_LIMIT_EXCEEDED`: the plan includes the feature and the account is at its ceiling (webhook endpoints, team seats, integrations, auto-clean lists). Delete one or upgrade. - `FORBIDDEN`: refused for a reason none of the above covers. `ErrorCode` is an open set, so branch on the code with a default rather than exhaustively.
- `409`: `CONFLICT`: a password change, reset, account lock or white-label sub-account key reset landed while this request was in progress, so the account no longer matches what the request was authenticated against. Nothing was created. Repeat the request if your credential still works.
- `429`: Too many requests. When the server knows how long to wait, `Retry-After` says so and `error.retry_after_seconds` carries the same number; the `X-RateLimit-*` headers say where you stand.
- `500`: The endpoint could not be created and the outcome is **indeterminate**. This answers a failure reading the endpoint quota or saving the endpoint, and a connection failure while saving can leave the endpoint created with no response to show for it. This POST is **not idempotent**: it takes no `Idempotency-Key`, and a blind retry can register a SECOND endpoint that then receives duplicate callbacks for every event. Read `GET /v1/webhooks` first and retry only if the endpoint is not there.
- `503`: The webhook URL's hostname could not be resolved right now (`WEBHOOK_DNS_TEMPORARY_FAILURE`). This is a TRANSIENT DNS failure, not a rejection of the URL; a permanent one is `400 WEBHOOK_URL_INVALID`. Nothing was created or changed. Retry.
- `default`: Any status this operation does not list. Kawaa's own errors carry the error envelope. The gateway in front of the API can answer any operation with its own bare body (`GatewayError`): a `503` after about 29 seconds, when the request may still have run and been charged, a `413` for a body over about 6 MB, or a `400` for a path with malformed percent-encoding.

---

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