# Webhooks

> Get a task's final result and account events POSTed to your server. Callback URLs and console endpoints, event payloads, signature verification in Python, Node.js and Go, retries and limits.

Source: https://noptcha.com/docs/webhooks/

Instead of polling, Noptcha can POST a task’s final result to your server. Organization endpoints can also receive [account events](#account-events): a low balance, top-ups, refunds, disputes and API keys about to expire. There are two ways to receive webhooks, and they can be combined.

|  | Per-task callback | Organization endpoint |
| --- | --- | --- |
| Set up | `callback_url` (v1) or `callbackUrl` ([compatibility API](https://noptcha.com/docs/compatibility-api.md)) in the create request | In the console: add an endpoint, choose the events |
| Events | That task’s final result (v1: a `task.solved` or `task.failed` event; compatibility API: the `getTaskResult` body) | `task.solved`, `task.failed`, and the [account events](#account-events) |
| Keys | Live and test keys | Task events: live-key tasks only; account events do not depend on the key. Send a test event from the console to try an endpoint |
| Signed with | Your organization’s default webhook secret (console) | The endpoint’s own secret |

Webhooks are an addition to, not a replacement for, reading the result: results are kept for 10 minutes, and a message can be late or lost, so keep [`GET /v1/tasks/{id}`](https://noptcha.com/docs/native-api/tasks.md) as a fallback.

## Per-task callbacks

Add the URL when you create the task:

```json
{"type": "recaptcha.v2", "params": {"…": "…"}, "callback_url": "https://example.com/hooks/noptcha"}
```

The compatibility API takes the same URL as `callbackUrl` next to `clientKey`. `null` and an empty string mean no callback. A URL that breaks the [rules below](#callback-url-rules) is rejected when you create the task (v1: `422 invalid_params` with pointer `/callback_url`; compatibility API: `ERROR_INCORRECT_SESSION_DATA`), and no task is created or charged. A `callback_url` that is not a string is `400 invalid_request`.

## Organization endpoints

In the console you can add endpoints, pick the events each one receives, see and rotate its secret, disable it, and look at its recent deliveries. A new endpoint in the console starts with only the task events ticked: tick the account events you want, so a receiver that only handles task bodies is not sent events it does not know. Each endpoint has its own secret (`whsec_…`), and an organization can have up to 10 endpoints. Adding an endpoint, changing its address, and showing or rotating any signing secret (including the default one) ask you to confirm again (step-up).

An endpoint that fails continuously for 24 hours is disabled; re-enable it in the console once your server is back. From the console you can send a test event to an endpoint: it delivers one `webhook.test` event (also to a disabled endpoint), with a new `msg_test_…` `webhook-id` each time. The endpoint’s delivery history in the console (kept for 7 days) shows the result of every attempt: attempt number, HTTP status, time taken, and your host name (not the path or query string). Deliveries of per-task callbacks are listed there too. Test events and resends together are limited to 30 per organization per 10 minutes.

For about 15 minutes after a task message was first sent to an endpoint (about 25 hours for an account event), you can resend it from the delivery history (same `webhook-id`). After that the console answers `410 payload_expired`: the message body is no longer kept. For a task, read it with `GET /v1/tasks/{id}` while its result is still kept (10 minutes), or use the result you stored.

## Events

| Event | Sent when | `data` |
| --- | --- | --- |
| `task.solved` | A task finished with a solution | The task object, exactly as `GET /v1/tasks/{id}` returns it |
| `task.failed` | A task ended without a solution (`data.status` is `failed`, `data.error` says why) | The same |
| `balance.low` | The balance fell below your alert threshold | `balance`, `threshold`, `currency` |
| `payment.succeeded` | A top-up was credited to the balance | The payment: `id`, `amount`, `currency`, `created_at`, `credited_at` |
| `payment.refunded` | A refund of a top-up succeeded | `id`, `payment_id`, `amount`, `currency`, `refunded_at` |
| `payment.disputed` | A dispute (chargeback) was opened on a top-up | `id`, `payment_id`, `amount`, `currency`, `opened_at` |
| `key.expiring` | An API key with an expiry date expires within 7 days | `id`, `name`, `prefix`, `last4`, `mode`, `expires_at` |
| `webhook.test` | You request a test delivery for an endpoint in the console | `endpoint_id` and `message` |

The account events are only sent to organization endpoints that subscribe to them, never to a task’s callback URL. New event types can be added at any time: ignore events you do not know and answer them with `2xx`.

### Body

The body of a v1 callback and of every endpoint message is the event envelope:

```json
{
  "type": "task.solved",
  "timestamp": "2026-11-05T08:00:07.456Z",
  "data": {
    "id": "7259140378796032",
    "type": "recaptcha.v2",
    "status": "solved",
    "solution": {"token": "03AFcWeA…"},
    "error": null,
    "cost": {"amount": "0.001000", "currency": "USD"},
    "created_at": "2026-11-05T08:00:01.120Z",
    "completed_at": "2026-11-05T08:00:07.456Z"
  }
}
```

(Shortened; `data` has all the fields of the [task object](https://noptcha.com/docs/native-api/tasks.md).) `timestamp` is when the task became final. The time of each delivery attempt is in the `webhook-timestamp` header.

The compatibility API’s `callbackUrl` receives the same JSON as `getTaskResult` for the final result, plus the integer `taskId`:

```json
{"errorId": 0, "status": "ready", "solution": {"gRecaptchaResponse": "03AFcWeA…"}, "taskId": 7259140378796032, "…": "…"}
```

They are signed the same way; verify them if your client can. If it cannot, treat the callback only as a signal and confirm the result with `getTaskResult`.

### Account events

All five share the envelope above (`type`, `timestamp`, `data`). Amounts are strings in US dollars, as in the task object’s `cost`. Each event has a fixed `webhook-id` (below), so a retry or a repeated scan never produces a second message. An endpoint only receives events that happen after it subscribed.

**`balance.low`**: after a charge, your balance (cash plus promotional credit) is below the alert threshold that owners, admins and billing members set on the console’s Billing page (0 turns the alert off). The balance is read again just before the message is created: if it is back at or above the threshold (for example you just topped up), nothing is sent. At most one message per organization every 24 hours: while the balance stays low you get one a day, and a top-up does not restart the 24 hours. Raising the threshold when it was already set does. `webhook-id` is `msg_bl_<organization id>_<unix seconds>`. Test-key tasks cost nothing and never trigger it.

```json
{
  "type": "balance.low",
  "timestamp": "2026-11-05T08:00:07.456Z",
  "data": {"balance": "4.500000", "threshold": "5.000000", "currency": "USD"}
}
```

**`payment.succeeded`**, **`payment.refunded`**, **`payment.disputed`**: one message per top-up credited (`webhook-id` `msg_ps_<payment id>`), per successful refund (`msg_pr_<refund id>`; a top-up refunded in two parts sends two) and per dispute opened (`msg_pd_<dispute id>`; you are not sent another when it is resolved). `payment_id` is the `tp_…` ID shown for the top-up in the console. The bodies do not say how the payment was made.

```json
{
  "type": "payment.succeeded",
  "timestamp": "2026-11-05T08:00:07.456Z",
  "data": {
    "id": "tp_3k9x0q2m8w1v7c5b4n6z",
    "amount": "20.000000",
    "currency": "USD",
    "created_at": "2026-11-05T07:58:40.120Z",
    "credited_at": "2026-11-05T08:00:07.456Z"
  }
}
```

```json
{
  "type": "payment.refunded",
  "timestamp": "2026-11-06T10:12:00.000Z",
  "data": {
    "id": "41",
    "payment_id": "tp_3k9x0q2m8w1v7c5b4n6z",
    "amount": "5.000000",
    "currency": "USD",
    "refunded_at": "2026-11-06T10:12:00.000Z"
  }
}
```

`payment.disputed` has the same shape as `payment.refunded`, with `opened_at` instead of `refunded_at` and the amount in dispute.

**`key.expiring`**: an active API key that has an expiry date will expire within 7 days. Keys that are still working only for the grace period after a rotation are not included. One message per key and expiry date: changing the key’s expiry date sends a new one. `webhook-id` is `msg_ke_<key id>_<expiry as unix seconds>`; `timestamp` is when the upcoming expiry was noticed. Test keys are included (`data.mode` is `live` or `test`).

```json
{
  "type": "key.expiring",
  "timestamp": "2026-11-05T08:05:00.000Z",
  "data": {
    "id": "12",
    "name": "ci",
    "prefix": "nop_live_ab12",
    "last4": "wxyz",
    "mode": "live",
    "expires_at": "2026-11-10T00:00:00.000Z"
  }
}
```

**Email as well.** Whether or not you have an endpoint, `balance.low` is also emailed to the organization’s owners, admins and billing members, and `key.expiring` to its owners and admins (members with a verified email address). Payment events are not emailed. Owners and admins are also emailed when an endpoint is disabled for failing.

**Trying them out.** Test keys cannot trigger account events, except `key.expiring` for a test key that has an expiry date. To check your receiver, send a `webhook.test` event from the console, which also exercises the signature. You can also resend an account event from the delivery history.

### Headers

| Header | Meaning |
| --- | --- |
| `webhook-id` | Message ID. The same for every retry and manual resend of the message. Examples: `msg_t7259140378796032` (endpoint), `msg_t7259140378796032_cb` (live task callback), `msg_s7259140378796032_cb` (sandbox task callback), `msg_test_…` (test delivery; new for each test); account events are listed [above](#account-events) |
| `webhook-timestamp` | Unix seconds when this attempt was sent |
| `webhook-signature` | One or more space-separated `v1,<signature>` entries (there can be several at any time, for example during a secret rotation) |
| `Content-Type` | `application/json` |

## Verifying the signature

Always verify before you trust a message: anyone who knows your URL could POST to it. The scheme is [Standard Webhooks](https://www.standardwebhooks.com/), so the libraries listed there work; or verify it yourself, it takes a few lines:

1.  Take the **raw** request body, byte for byte. Do not parse and re-serialize it.
2.  The signed content is `<webhook-id>.<webhook-timestamp>.<raw body>`.
3.  The key is the secret without the `whsec_` prefix, base64-decoded (32 bytes).
4.  The signature is `base64(HMAC-SHA256(key, signed content))`.
5.  `webhook-signature` holds one or more space-separated `v1,<signature>` entries. Accept the message if **any** entry matches, comparing in constant time. There can be several entries at any time: the default secret used for per-task callbacks usually has a next secret signing alongside it, and during a rotation both the new and the previous secret sign.
6.  Reject messages whose `webhook-timestamp` is more than a few minutes away from your clock (the examples use 5 minutes). Every retry is signed afresh with a new timestamp.

> **Caution:** Keep the secret out of source code and logs. After a rotation the previous secret keeps signing for 24 hours, so you can deploy the new secret without dropping messages. Rotating again within those 24 hours stops the oldest secret at once.

verify.py

```python
import base64, hashlib, hmac, time

def verify(secret, headers, body, tolerance=300):
    """secret: "whsec_..."; headers: dict with lower-case names; body: raw bytes."""
    msg_id = headers.get("webhook-id", "")
    timestamp = headers.get("webhook-timestamp", "")
    try:
        if abs(time.time() - int(timestamp)) > tolerance:
            return False
    except ValueError:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for entry in headers.get("webhook-signature", "").split():
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode(), expected.encode()):
            return True
    return False
```

verify.mjs

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

// secret: "whsec_..."; headers: object with lower-case names; body: the raw body as a Buffer or string.
export function verify(secret, headers, body, toleranceSeconds = 300) {
  const id = headers['webhook-id'] ?? '';
  const timestamp = headers['webhook-timestamp'] ?? '';
  if (!/^[0-9]+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(body).digest();
  return (headers['webhook-signature'] ?? '').split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    const got = Buffer.from(signature ?? '', 'base64');
    return version === 'v1' && got.length === expected.length && timingSafeEqual(got, expected);
  });
}
```

verify.go

```go
package main

import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/base64"
  "math"
  "strconv"
  "strings"
  "time"
)

// Verify checks a webhook. secret is "whsec_..."; id, timestamp and signature are the webhook-id,
// webhook-timestamp and webhook-signature headers; body is the raw request body.
func Verify(secret, id, timestamp, signature string, body []byte, tolerance time.Duration) bool {
  sec, err := strconv.ParseInt(timestamp, 10, 64)
  if err != nil || math.Abs(time.Since(time.Unix(sec, 0)).Seconds()) > tolerance.Seconds() {
    return false
  }
  key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
  if err != nil {
    return false
  }
  mac := hmac.New(sha256.New, key)
  mac.Write([]byte(id + "." + timestamp + "."))
  mac.Write(body)
  expected := mac.Sum(nil)
  for _, entry := range strings.Fields(signature) {
    version, sig, ok := strings.Cut(entry, ",")
    if !ok || version != "v1" {
      continue
    }
    if got, err := base64.StdEncoding.DecodeString(sig); err == nil && hmac.Equal(got, expected) {
      return true
    }
  }
  return false
}
```

In your handler: read the raw body first, call the function with the three headers, answer `401` (or any non-2xx) when it returns false, and only then parse the JSON.

## Delivery, retries and duplicates

-   A `2xx` response acknowledges the message; the response body is ignored. Anything else, a redirect (not followed), or no complete response within 5 seconds is a failure. Answer quickly and do the real work afterwards.
-   A failed task event is retried 5, 25, 60, 150 and 300 seconds after the failure (each ±10%), and not after 10 minutes: results are only kept for 10 minutes. So a short outage is covered, a long one is not; read the task with `GET /v1/tasks/{id}` for anything you missed.
-   A failed account event is retried up to 8 times over 24 hours, 5 seconds, 5 minutes, 30 minutes, 1, 2, 4, 8 and 8 hours after the previous failure (each ±10%), and not after 24 hours. There is nothing to read them back from, so keep your endpoint up, or resend a missed one from the delivery history within about 25 hours.
-   Test-key callbacks are tried at most twice. `webhook.test` is never retried.
-   A message can arrive **more than once and out of order**. Deduplicate on `webhook-id` (it stays the same across retries and resends) and make your handler idempotent: for a task, `data.id` is the natural key.

## Callback URL rules

-   `http` or `https`, port 80, 443, 8080 or 8443, at most 2048 bytes, no user name or password, no `#fragment`.
-   The host must resolve to public addresses only: private, loopback, link-local and similar ranges are refused (an IP address in the URL is checked when you create the task; a name is checked again at delivery time, and a host that resolves to a non-public address is not contacted). Use a public URL; for local development expose your machine through a tunnel.

## Testing with a test key

A test key (`nop_test_…`) task with a callback URL gets its callback when the sandbox result is ready (see [Sandbox](https://noptcha.com/docs/sandbox.md)), with the usual fixed answer, so you can build and test your receiver without spending money. Differences from live tasks:

-   the `webhook-id` starts with `msg_s` instead of `msg_t`;
-   a compatibility-API callback carries `"sandbox": true`; in v1 the task object in `data` carries `"sandbox": true`, as everywhere in v1. Treat such a message as a test, never as a real result;
-   at most two attempts, with a 2-second timeout per attempt (live: 5 seconds);
-   organization endpoints do not receive sandbox tasks; send a test event from the console for them.

The callback is signed with your default webhook secret, so your verification code is tested too.

## Not covered

-   The 2captcha `pingback` parameter is not supported (`ERROR_BAD_PARAMETERS`); use `callbackUrl` or `callback_url`.
