Skip to main content

API reference

Webhooks API

Create and manage webhooks programmatically to receive real-time notifications.

List webhooks

GET/v1/webhooks
Response
{
  "success": true,
  "data": {
    "webhooks": [
      {
        "webhook_id": "wh_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
        "url": "https://YOUR-ENDPOINT.example.com/kawaa",
        "description": "Production endpoint",
        "events": ["job.completed", "job.failed"],
        "is_active": true,
        "created_at": "2026-01-15T10:00:00Z",
        "updated_at": "2026-01-15T10:00:00Z",
        "last_triggered_at": "2026-02-03T14:35:42Z",
        "failure_count": 0,
        "disabled_reason": null
      }
    ],
    "count": 1
  }
}

A webhook created with an API key or by a connected app is switched off when that key is revoked or that app is disconnected, and so is one that a key or an app pointed at its current URL (or, when none did, whose events it changed or that it turned on). A password change disconnects every app, and an account lock turns off every key and every app, so they switch those webhooks off too. A switched-off webhook is kept, with is_active: false and disabled_reason set to api_key_revoked or app_disconnected, so you can check where it sends and turn it back on or delete it. disabled_reason is null for a webhook that is on, or that you switched off yourself.

Create webhook

POST/v1/webhooks

Request body

ParameterTypeRequiredDescription
urlstringRequiredHTTPS URL to receive webhooks
eventsstring[]RequiredEvents to subscribe to. Only events with available: true in the catalog can be subscribed; the API rejects the rest
descriptionstringOptionalFree-form label for the webhook
cURL
curl -X POST https://api.kawaa.com/v1/webhooks \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://YOUR-ENDPOINT.example.com/kawaa",
    "events": ["job.completed", "job.failed"]
  }'

Replace https://YOUR-ENDPOINT.example.com/kawaa with your own public HTTPS endpoint. The API resolves the host when you create or update the webhook, and refuses a name that does not resolve, or resolves to a private address, with HTTP 400 WEBHOOK_URL_INVALID. The placeholder is a reserved name that never resolves, so it is refused as written.

The body accepts only url, events and description; any other key (for example event) returns HTTP 400 naming the supported fields rather than creating the webhook without it.

The response includes the webhook's signing secret: store it to verify the X-Kawaa-Signature-V2 header on deliveries (see Signature verification; the legacy X-Kawaa-Signature uses the same secret). Deliverable events: verification.completed, verification.failed, job.completed, job.failed, credits.low, credits.exhausted, blacklist.detected and dmarc.failure. verification.* events fire for single verifications (POST /v1/verify), fresh verifications only, since cached results return synchronously; bulk jobs emit job.* events instead. credits.low is delivered at most once per 7 days and credits.exhausted at most once per 24 hours. credits.exhausted fires when a charge leaves the balance below 1 credit, the price of a fresh check: a cached result costs 0.5, so its credits_remaining can be 0.5 rather than 0.

Update webhook

PUT/v1/webhooks/{id}

Update an existing webhook. The body accepts url, events, description and is_active (set it to false to pause deliveries without deleting the webhook). Only the fields you send change. The read-only fields a GET returns (webhook_id, created_at, updated_at, last_triggered_at, failure_count, disabled_reason) are accepted as no-ops so a GET payload can be sent straight back; any other key returns HTTP 400.

Sending is_active: true for a webhook Kawaa switched off clears its disabled_reason. From a dashboard session it needs a sign-in within the last 15 minutes, as adding a webhook does; an older session gets HTTP 403 REAUTHENTICATION_REQUIRED. An API key is not held to this.

Get webhook

GET/v1/webhooks/{id}

Return one webhook in the same shape as the list entries above. The signing secret is never included. It is shown once on creation and again only by the regenerate call below. An id that belongs to another account is a 404.

Delivery logs

GET/v1/webhooks/{id}/logs

List the deliveries attempted to this webhook, newest first. Logs are kept for 30 days. A verification.* or job.* delivery carries its job's results, so it is removed with them if your plan keeps results for less time, and can no longer be retried.

Query parameters

ParameterDescription
limitEntries per page (default: 20, max: 100)
offsetEntries to skip (default: 0, max: 1,000)
cursorOpaque cursor from the previous page's cursor, for paging past the offset bound. Pass it back unchanged: a cursor that does not decode, is not in the shape this endpoint issues, or belongs to another webhook is a 400 INVALID_CURSOR
Response
{
  "success": true,
  "data": {
    "logs": [
      {
        "log_id": "3f6c1a2e-8d4b-4b1f-9a70-5c2e7d1b9f11",
        "event": "job.completed",
        "status": "success",
        "status_code": 200,
        "response_time_ms": 184,
        "request_body": "{\"event\":\"job.completed\",\"data\":{...}}",
        "response_body": "OK",
        "error_message": null,
        "created_at": "2026-02-03T14:35:42.100Z",
        "delivery_type": "event"
      }
    ],
    "count": 1,
    "total": 1,
    "limit": 20,
    "offset": 0
  }
}

status is success or failed; status_code and response_body are null when no HTTP response was received (timeout, DNS or connection error; see error_message). delivery_type distinguishes real event deliveries from test sends and retry re-sends. A failed entry can be re-sent with POST /v1/webhooks/{id}/logs/{log_id}/retry, which records the re-send as a new entry.

count is the number of entries on this page. total is an older name for the same number, kept so existing clients keep working: it is not the number of deliveries the webhook has, which is not counted. When cursor is present there are more entries; pass it back to read the next page.

A retry re-sends the logged body unchanged, including its original timestamp, signed at the time of the re-send: its X-Kawaa-Signature-V2 carries a fresh t, so it passes the 300-second window however old the original delivery is. It answers HTTP 200 with success saying whether your endpoint accepted the re-sent delivery. A log_id that does not exist, or belongs to another webhook, is HTTP 404 Log entry not found and nothing is sent. If the log entry cannot be read at that moment the answer is HTTP 503 and nothing is sent; try again shortly.

Delete webhook

DELETE/v1/webhooks/{id}

Remove a webhook subscription.

Test webhook

POST/v1/webhooks/{id}/test

Send a signed test delivery to check that your endpoint verifies the signature and handles the payload. The body may name the event to send, for example {"event": "job.completed"}; without one, verification.completed is sent. A name that is not an event is refused with HTTP 400.

A test delivery has the same headers and the same body fields as a real delivery of that event, and carries "test": true as well. The values are placeholders (a placeholder job id, example.com, a fixed date), so do not act on them. The payload_example for each event in GET /v1/webhooks/events is the event and data a test sends.

Test delivery (job.completed)
{
  "event": "job.completed",
  "data": {
    "job_id": "00000000-0000-4000-8000-000000000000",
    "status": "completed",
    "total_emails": 1000,
    "processed_emails": 1000,
    "requested_emails": 1000,
    "partial": false,
    "credits_refunded": 0,
    "summary": { "valid": 850, "invalid": 100, "risky": 30, "unknown": 20 },
    "created_at": "2026-01-01T12:00:00.000Z",
    "completed_at": "2026-01-01T12:05:00.000Z",
    "download_url": "https://api.kawaa.com/v1/jobs/00000000-0000-4000-8000-000000000000/download"
  },
  "webhook_id": "wh_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
  "timestamp": "2026-02-03T14:35:42.100Z",
  "test": true
}

A few fields appear on real deliveries only in some cases, and a test shows the common case: job.completed carries cache_discount_pending only while a cached-result discount is still being applied; verification.completed omits sub_status when the result has none, and omits status, sub_status, email and quality_score if the result could not be read (read the verdict from GET /v1/jobs/{id}); and on an account with white-label sub-accounts, a credits.* event about a sub-account also carries account_type, sub_account_id, sub_account_name and credits_allocated.

Regenerate secret

POST/v1/webhooks/{id}/secret

Generate a new signing secret for the webhook. The response is the only place the new secret is shown, so store it before you leave the response.

The old secret stops working at once. There is no overlap: every delivery from then on is signed with the new secret only (a delivery already in flight may still carry the old one). Until your receiver has the new secret it refuses those deliveries, and a 401 or any other 4xx answer is final, not retried, so events sent in that gap are not delivered on their own. To keep the gap short and lose nothing:

  • Keep the secret in configuration your receiver can pick up without a full deploy, and change it as soon as this call returns.
  • If you can, have your receiver accept a signature made with either the old or the new secret while the change rolls out, so a delivery signed just before the rotation still verifies. Remove the old secret once every instance has the new one.
  • Then re-send each delivery that failed in the gap from the delivery log (Logs in the dashboard, or POST /v1/webhooks/{id}/logs/{log_id}/retry). A re-send is signed with the current secret.

From a dashboard session it needs a sign-in within the last 15 minutes, as changing a webhook's URL or events does; an older session gets HTTP 403 REAUTHENTICATION_REQUIRED. An API key is not held to this. Each regeneration is recorded in your audit log as settings.webhook_secret_regenerated, without either secret.

Available events

GET/v1/webhooks/events

Get the webhook event catalog. Each event is an object with an available flag; only events with available: true can be subscribed to.

Response
{
  "success": true,
  "data": {
    "events": [
      {
        "name": "job.completed",
        "description": "Triggered when a bulk verification job is completed",
        "category": "jobs",
        "available": true,
        "payload_example": {
          "event": "job.completed",
          "data": {
            "job_id": "00000000-0000-4000-8000-000000000000",
            "status": "completed",
            "total_emails": 1000,
            "processed_emails": 1000,
            "requested_emails": 1000,
            "partial": false,
            "credits_refunded": 0,
            "summary": { "valid": 850, "invalid": 100, "risky": 30, "unknown": 20 },
            "created_at": "2026-01-01T12:00:00.000Z",
            "completed_at": "2026-01-01T12:05:00.000Z",
            "download_url": "https://api.kawaa.com/v1/jobs/00000000-0000-4000-8000-000000000000/download"
          }
        }
      },
      {
        "name": "verification.completed",
        "description": "Triggered when a single email verification completes",
        "category": "verification",
        "available": true,
        "payload_example": {
          "event": "verification.completed",
          "data": {
            "job_id": "00000000-0000-4000-8000-000000000000",
            "status": "valid",
            "verified_at": "2026-01-01T12:05:00.000Z",
            "credits_used": 1,
            "email": "user@example.com",
            "sub_status": "mailbox_exists",
            "quality_score": 95
          }
        }
      }
    ]
  }
}