Webhooks and events
Get every event that matters delivered to your server, signed, with retries.
Everything that happens on the platform is an event: an operation finished, a person answered, a connection broke, a spend cap was reached. A webhook is an endpoint on your server that NoClick sends the events you choose to, each one signed so you know it came from NoClick. Use them instead of polling: your server hears about an agent's reply or a failed run within a second or two of it happening.
Add an endpoint
import noclick
hook = noclick.Webhook.create(
"https://api.yourco.test/noclick/events",
events=["operation.succeeded", "operation.failed", "human.answered"],
)
print(hook.secret) # nk_whsec_...: shown this onceThe URL must be https and reachable from the internet. events names the event types it receives, and * matches the rest of a name: operation.* is both operation events, and * is everything.
hook.secret signs every delivery to this endpoint. It's on the object create returns, so store it where your receiver reads it, like NOCLICK_WEBHOOK_SECRET. You can also add endpoints in the console, under Settings > Webhooks.
To own the endpoint in code, so every deploy converges it, use from_name with the URL. It creates the endpoint on first use and, after that, brings its events and description in step with your code. A receiver that starts from code can then read the secret itself with reveal_secret(), which needs a key with the webhooks:reveal scope:
hook = noclick.Webhook.from_name(
"https://api.yourco.test/noclick/events",
create_if_missing=True,
events=["operation.succeeded", "operation.failed", "human.answered"],
)
secret = hook.secret or hook.reveal_secret() # set when this call created itEvery reveal is recorded with the key that made it, in your API request log (the console's Logs > API requests). A scope has one endpoint per URL: creating a second raises noclick.Conflict with the first one's id.
Your account's endpoints receive every event, your users' included; each event's user_id says whose it was. An endpoint created for a user receives only that user's events:
with noclick.as_user("acme"):
hook = noclick.Webhook.create("https://api.yourco.test/acme/events", events=["connection.*"])Such an endpoint is read acting for its user too (Webhook.get(hook_id, user="acme"), or inside as_user): from your account's own scope its id raises noclick.NotFound.
Verify each delivery
Every delivery is a POST with the event as its JSON body and a NoClick-Signature header. Check it with noclick.Webhook.verify, which needs the raw body exactly as it arrived:
import os
from flask import Flask, request
app = Flask(__name__)
@app.post("/noclick/events")
def noclick_events():
try:
event = noclick.Webhook.verify(request.get_data(), request.headers, os.environ["NOCLICK_WEBHOOK_SECRET"])
except noclick.WebhookSignatureError:
return "", 400
if event.type == "human.answered":
print(event.data["answer"])
return "", 204verify returns the noclick.Event the body carries and sends nothing. It raises noclick.WebhookSignatureError when the header is missing or malformed, when no signature matches the body, or when the delivery was signed more than 5 minutes from now, so a captured delivery can't be replayed later. Pass tolerance_s= to change that window.
Parse the body only after verifying it: a framework that parses JSON and serializes it again changes the bytes, and the signature no longer matches.
If you verify without the SDK, the header is t=<unix seconds>,v1=<signature>, and the signature is the hex HMAC-SHA256 of <t>.<body> keyed with the secret. While you rotate the secret the header carries two v1= values; accept the delivery if either matches.
What a delivery holds
{
"object": "event",
"id": "7c1e9a4b-2d3f-4a8e-9b6c-0f5d2e8a1b37",
"type": "operation.failed",
"user_id": "user_42",
"created_at": "2026-09-26T16:02:11Z",
"data": {
"object": "operation",
"id": "40000000-0000-4000-8000-000000000009",
"kind": "agent.message",
"status": "failed",
"error": { "type": "spend_cap_exceeded", "message": "Acme Corp reached its $50 monthly cap." }
}
}data is the object the event is about, as its own read returns it: an operation's event holds the operation (with its summary), a person's answer the request, a connection's the connection, a wake-up's the wake-up. Two carry a record of their own: agent.turn.* holds an agent_turn (the turn's operation id, the agent_id and its name as agent, the conversation, what started_by it: message, wakeup, trigger or channel, and the reply with its tool_calls and cost_usd, or the error), and spend.cap_reached a spend_cap (its scope, key, user or agent, the id it's about, cap_usd, spent_usd and when it resets_at). usage.recorded holds a usage record (see Usage, as it happens), whose id is the event's, and user.spending.* the user's spending, as user.spending reads it. A very large object is cut to its plain fields with truncated: true; read the whole of it by its id.
The same event can arrive more than once (a retry after your server timed out, a redelivery), so use event.id to skip one you've already handled. Events can arrive out of order; compare created_at if order matters.
Answer quickly, work after
Answer with any 2xx within 10 seconds and the delivery succeeds. Anything else, including a redirect or a timeout, counts as a failure. If handling an event takes longer, put it on a queue and answer first.
Retries
A failed delivery is retried with growing gaps (a minute, five minutes, half an hour, two hours, five hours, then every 12 hours) for 3 days, then marked failed. The endpoint's status turns failing while its latest attempt failed, and back to healthy once one succeeds; success_rate_24h is the share of its attempts in the last day your server answered 2xx, and last_failure its latest failed attempt. NoClick never turns an endpoint off on its own.
See what went wrong, and send what your server missed once it's fixed:
hook = noclick.Webhook.from_id("5e2d8c1a-7b4f-4e93-a6d0-3c9f1b2e8a54")
print(hook.status, hook.success_rate_24h, hook.last_failure)
for delivery in hook.deliveries(status=["retrying", "failed"]):
print(delivery.event_type, delivery.attempts, delivery.response_status, delivery.next_retry_at)
hook.redeliver(delivery)A delivery's status is pending, retrying, succeeded or failed; response_status and response_body (its first 4 KB) are your server's latest answer, and error says why the latest attempt failed. redeliver sends the same event again now, the same bytes with a fresh signature, whatever its status: one still retrying keeps its retries, and one that had settled stays settled. Deliveries are kept for 30 days.
Send a test event
sent = hook.test("human.answered")
print(sent.status, sent.response_status, sent.response_body)test signs and sends a sample event of that type right away and waits up to 10 seconds for your answer. Its data is a sample with test: true. Test events aren't retried, don't appear in the event log, and don't count toward the endpoint's health.
Try it on your machine
Your laptop isn't reachable from the internet, so let the CLI forward events to it. It reads the event log every two seconds and prints a secret for the session to verify with:
noclick webhooks listen --forward-to http://localhost:3000/noclick/eventsRotate the secret
new_secret = hook.rotate_secret()For a day after a rotation, each delivery is signed with both the new secret and the old one, so your receiver keeps verifying while you deploy the new secret. Pass both to verify in that window: noclick.Webhook.verify(body, headers, [new_secret, old_secret]). If the old secret leaked, pass grace_s=0 to stop using it at once.
Change, pause or delete an endpoint
hook.update(events=["operation.*", "human.*"])
hook.update(enabled=False) # pause
hook.update(enabled=True)
hook.delete()While an endpoint is paused, events aren't queued for it: what happens meanwhile isn't delivered later. Read it from the event log instead. Deliveries it was already retrying wait while it's paused and carry on once it's enabled, unless their 3 days ran out.
Read past events
Every event is kept for 30 days, whether or not an endpoint received it:
from datetime import datetime, timedelta, timezone
week = datetime.now(timezone.utc) - timedelta(days=7)
for event in noclick.Event.list(type="domain.*", since=week):
print(event.created_at, event.type, event.data["name"])
event = noclick.Event.get("7c1e9a4b-2d3f-4a8e-9b6c-0f5d2e8a1b37")
for delivery in event.deliveries:
print(delivery.webhook_id, delivery.status)Like other reads, Event.list reads your account's own events outside as_user, and user_filter=noclick.ALL_USERS reads your users' too. since takes the events at that moment and after, until those before it. Event.get reads any of your account's events by id, as Operation.from_id does; acting for a user, it reads only that user's, with the deliveries to that user's own endpoints. The console's Logs > Events shows the same log, with each event's deliveries.
Your agents and channels hear them too
The events your webhooks receive can also wake your agents and post in your channels, with no server of yours in between:
- A wake-up waits for one:
agent.wake(conversation, when="connection.connected", filters={"name": "gmail"}, note=...). See Wake it up later. - A trigger gives an agent a turn on each:
noclick.OnPlatformEvent("operation.failed", filters={"kind": "agent.task"}). See Triggers. - A channel posts each one in its room:
room.subscribe("connection.disconnected"). See Channels.
They hear the way endpoints do: one of your account's hears every event, your users' included, and a user's hears only that user's. filters are dotted paths into the event's data and the values they must have. An agent never hears what its own turns caused, and a channel never hears its own halting or its agents' turns, so nothing feeds on itself.
Event types
noclick.Event.types() returns this catalog:
| Type | When |
|---|---|
operation.succeeded | An operation finished: an agent turn, a connect link, a call. |
operation.failed | An operation failed, was canceled or expired; data.error says why. |
human.answered | A person answered a request, an approval or a form. |
human.expired | A request to a person expired unanswered. |
connection.connected | A connection's account was connected, or reconnected. |
connection.disconnected | A connection stopped working: its account was revoked or removed at the provider. |
connection.deleted | A connection was deleted, and its account disconnected with it. |
agent.turn.completed | An agent finished a turn; data holds the reply. |
agent.turn.failed | An agent's turn failed. |
agent.conversation.handed_off | An agent handed a conversation to a person with its hand_off tool; data names the agent, the conversation, its user, the person (to), the agent's summary and the person's request (request_id). On a call it's the transfer: to is the phone, with the call's call_sid. |
agent.wakeup.delivered | A wake-up started its turn; data is the wake-up, with the turn's operation_id. |
agent.wakeup.missed | A routine (a recurring wake-up) came due and its turn couldn't be paid for, so it missed that time and comes round at its next; data.reason is credits, spend_cap (your account's monthly cap) or budget (the agent's, its key's or its user's), and data.error says why. |
agent.wakeup.failed | A wake-up's turn couldn't run and it won't come again: a one-off your account couldn't pay for, or the agent expired; data.error says why. |
agent.job.update | A job sent its parent an update; data is the update, with its job_id and the agent_id of the agent doing the work. |
agent.asked_owner | An agent built in the app messaged you with its message_coordinator tool; data names the agent (agent_id), its workflow_id, the conversation and its message. |
workflow.run.failed | A workflow's run failed; data names the workflow, the run_id, the failing step, the error and failed_runs_last_24h. Failures of one workflow within six hours fold into the first, so a workflow failing every minute sends one. |
payment.completed | A person paid one of a refusal's offers; data says what (kind, what, price_usd) and names its operation_id. |
inbox.message.received | Mail arrived at an inbox. |
phone.call.completed | A call an agent placed from one of your numbers ended; data holds the transcript. |
channel.halted | A channel stopped: its budget ran out, it hit its turn limit, or it was closed. |
sandbox.terminated | A sandbox stopped: its time ran out, it was terminated, or it failed. |
machine.restarted | A machine is running again after a restart you asked for, or one NoClick did when it stopped answering (data.reason); turns it interrupted failed as interrupted. |
machine.failed | A machine failed: it didn't come up, or kept failing after a restart and a rebuild on a new host; data.status_reason says which. |
machine.restored | A machine's host failed and NoClick rebuilt it on a new one from its last backup (data.restored_from, null when it had none yet). |
machine.unpaid | Your balance couldn't pay a machine's daily charge: it keeps running for data.stops_after_hours, then stops until the balance covers what it owes; unpaid for data.deleted_after_days, it's deleted. |
repo.pushed | A repo's branch moved: a git push, or a commit through the API; data names the branch and its commits before and after. |
deployment.ready | An app's deployment is live. |
deployment.failed | An app's deployment failed to build or start. |
schedule.paused | An agent's trigger (a schedule, a webhook, an app event) kept failing the same way and stopped until the agent is resumed, or an app's schedule did until it's resumed; data is the trigger or the schedule. |
domain.active | A domain's records are in place and it's serving. |
domain.expiring | A domain expires within 30 days and won't renew on its own. |
database.quota_warning | A database reached 80% of its size limit. |
card.authorization.requested | A card purchase waits for a person's approval: announced first (data.kind purchase), or held at the merchant (charge). |
card.transaction.declined | A card charge was declined; data.decline_reason says why. |
card.transaction.refunded | A merchant refunded a card charge. |
card.dispute.updated | A card dispute moved on: submitted, won, lost or expired. |
card.balance.negative | Your company's card balance went below zero, so every card is frozen until it's funded; data.top_up_url is where to fund it. |
card.balance.funded | The card balance is funded again after going below zero: the cards NoClick froze pay again. |
spend.cap_reached | A key, user or agent reached its monthly spend cap, and work it would have paid for is refused; data.scope says which. Once a month per cap. |
usage.recorded | Spend for one of your users settled: a turn, an AI request, a tool run, a call, a stretch of a sandbox, an email, a text, a purchase or a renewal, or a day of storage; data is the usage record, to bill them on. |
user.spending.paused | You paused a user's spending; data is their spending: the message and action they're told, and since. |
user.spending.resumed | You resumed a user's spending; data.replayed is how many of their held messages are running now. |
A job ends as an operation does: a spawned child's task sends operation.succeeded or operation.failed with data.kind agent.task, so there's no separate type for it.
When a spend cap is reached, NoClick also emails your account's owner, at most once a day while it refuses work. It never writes to your users about money: the event is how your own code decides whether and how to tell them.
Some types are in the catalog before anything sends them, so your code can subscribe ahead of the primitives behind them: repo.pushed, deployment.ready, deployment.failed, domain.active, domain.expiring, database.quota_warning, the card.* types, the machine.* types, and spend.cap_reached for an app's budget (data.scope project). EventType.emitted says which types NoClick sends today. A call to a user's own number that an agent answered isn't an event yet; calls an agent placed are.
New types are added over time; ignore the ones you don't handle.
Limits and billing
An account has up to 16 endpoints, your users' included; more raises noclick.QuotaExceeded. A URL that isn't https, or that points at a private network address, is refused with noclick.InvalidRequest, and so is a delivery to an address that later turns private. Events, deliveries and retries are free.
Errors
| Error | When |
|---|---|
noclick.WebhookSignatureError | verify couldn't prove a delivery came from NoClick. Answer it with a 400. |
noclick.InvalidRequest | The URL isn't https, points at a private address, or an event type isn't in the catalog. |
noclick.Conflict | The scope already has an endpoint for that URL (existing_id names it). |
noclick.PermissionDenied | reveal_secret() without the webhooks:reveal scope. |
noclick.QuotaExceeded | The account already has 16 endpoints. |
noclick.NotFound | No webhook, delivery or event has that id (events and deliveries go after 30 days). |
See the reference for Webhook, Webhook.verify and Event.list.