Skip to content

Webhooks

Instead of polling, Noptcha can POST a task’s final result to your server. Organization endpoints can also receive 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) 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
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} as a fallback.

Add the URL when you create the task:

{"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 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.

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.

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.

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

{
"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.) 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:

{"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.

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.

{
"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.

{
"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"
}
}
{
"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).

{
"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.

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
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

Always verify before you trust a message: anyone who knows your URL could POST to it. The scheme is Standard Webhooks, 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.
verify.py
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
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
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.

  • 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.
  • 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.

A test key (nop_test_…) task with a callback URL gets its callback when the sandbox result is ready (see Sandbox), 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.

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