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.
Per-task callbacks
Section titled “Per-task callbacks”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.
Organization endpoints
Section titled “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
Section titled “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.
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.
Account events
Section titled “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.
{ "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.
Headers
Section titled “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 |
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
Section titled “Verifying the signature”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:
- Take the raw request body, byte for byte. Do not parse and re-serialize it.
- The signed content is
<webhook-id>.<webhook-timestamp>.<raw body>. - The key is the secret without the
whsec_prefix, base64-decoded (32 bytes). - The signature is
base64(HMAC-SHA256(key, signed content)). webhook-signatureholds one or more space-separatedv1,<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.- Reject messages whose
webhook-timestampis more than a few minutes away from your clock (the examples use 5 minutes). Every retry is signed afresh with a new timestamp.
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 Falseimport { 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); });}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
Section titled “Delivery, retries and duplicates”- A
2xxresponse 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.testis 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.idis the natural key.
Callback URL rules
Section titled “Callback URL rules”httporhttps, 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
Section titled “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), with the usual fixed answer, so you can build and test your receiver without spending
money. Differences from live tasks:
- the
webhook-idstarts withmsg_sinstead ofmsg_t; - a compatibility-API callback carries
"sandbox": true; in v1 the task object indatacarries"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
Section titled “Not covered”- The 2captcha
pingbackparameter is not supported (ERROR_BAD_PARAMETERS); usecallbackUrlorcallback_url.