Webhook
Webhooks and events: everything that happens, delivered to your server, signed.
hook = noclick.Webhook.from_name("https://api.yourco.test/noclick/events", create_if_missing=True,
events=["operation.*", "human.answered"])
secret = hook.reveal_secret() # needs the webhooks:reveal scope
# in your receiver
event = noclick.Webhook.verify(request_body, request_headers, secret)
print(event.type, event.data)Every event is also kept for 30 days: noclick.Event.list(type="domain.*").
class WebhookAn endpoint on your server that receives events, signed with its secret.
One created for a user (inside as_user, or with user=) receives
only that user's events; your account's own receive every event, your
users' included.
- Like every live object, it also has hydrate(), refresh() and raw.
| id | str | |
| url | str | |
| events | list[str] | The event types it receives; |
| enabled | bool | |
| status | str |
|
| description | str | None | |
| success_rate_24h | float | None | The share of attempts in the last 24 hours your server answered 2xx (None if none). |
| last_failure | dict[str, Any] | None | The latest failed attempt: |
| user_id | str | None | The user whose events it receives (None: every event). |
| created_at | Any | |
| secret | str | None | The signing secret ( |
createclassmethodasync
noclick.Webhook.create(
url, *, events, description=None, user=..., idempotency_key=None, client=None
) -> WebhookStart delivering events (type names; operation.* or * for
more at once) to url, an https address your server answers.
NoneWho the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
Makes a retried create safe: the same key returns the first result. Generated when omitted.
None)The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)The new webhook. Its secret is shown this once: store it where your receiver reads it.
Receive what your code waits on:
hook = noclick.Webhook.create(
"https://api.yourco.test/noclick/events",
events=["operation.succeeded", "operation.failed", "human.answered"],
description="Production receiver",
)
print(hook.secret) # nk_whsec_...: shown onceOnly one user's events:
with noclick.as_user("acme"):
hook = noclick.Webhook.create("https://api.yourco.test/acme/events", events=["connection.*"])from_nameclassmethod
noclick.Webhook.from_name(
url, *, create_if_missing=False, events=None, description=None, user=...,
client=None
) -> WebhookA lazy reference to the webhook delivering to url in the current (or given)
user; url names it, since a scope has one webhook per address. With
create_if_missing=True your code is the definition: it's created on first use
with events (and description), and an existing one whose events or
description differ is updated to match, so a deploy can own its endpoint.
FalseNoneNoneWho the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)A webhook that loads (or is created, or brought in step with your definition) the
first time it's used. When this call created it, secret holds its signing secret;
otherwise read it with reveal_secret().
Own your receiver's endpoint in code, and read its secret on every start:
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()from_idclassmethod
noclick.Webhook.from_id(webhook_id, *, client=None, user=...) -> WebhookA lazy reference to a webhook by id.
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
A webhook that loads the first time you read a field. Nothing is sent yet.
hook = noclick.Webhook.from_id("5e2d8c1a-7b4f-4e93-a6d0-3c9f1b2e8a54")
print(hook.status, hook.success_rate_24h)getclassmethodasync
noclick.Webhook.get(webhook_id, *, user=..., client=None) -> WebhookLoad a webhook by id now; an unknown id raises noclick.NotFound.
Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)The webhook, loaded (without its secret).
hook = noclick.Webhook.get("5e2d8c1a-7b4f-4e93-a6d0-3c9f1b2e8a54")
print(hook.url, hook.events, hook.status)listclassmethodasync
noclick.Webhook.list(
*, limit=None, user_filter=None, user=..., client=None
) -> list[Webhook]Your account's webhooks (or the current user's, or those user_filter covers).
Return at most this many.
None)Which users a read covers: noclick.ALL_USERS, noclick.NO_USER, a User, or your id for one. It filters without acting as that user.
None)Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)Every matching webhook, newest first, fetched page by page.
for hook in noclick.Webhook.list():
print(hook.url, hook.status, hook.last_failure)
everyone = noclick.Webhook.list(user_filter=noclick.ALL_USERS)list_pageclassmethodasync
noclick.Webhook.list_page(
*, cursor=None, limit=None, user_filter=None, user=..., client=None
) -> PageOne page of webhooks; pass next_cursor back as cursor.
The next_cursor of the page before; omit it for the first page.
None)Return at most this many.
None)Which users a read covers: noclick.ALL_USERS, noclick.NO_USER, a User, or your id for one. It filters without acting as that user.
None)Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)A page whose data holds the webhooks and whose next_cursor is None on the last page.
page = noclick.Webhook.list_page(limit=20)
for hook in page.data:
print(hook.url, hook.status)updateasync
hook.update(*, url=..., events=..., enabled=..., description=...) -> WebhookChange where it delivers, what it receives, or pause it
(enabled=False: events that happen meanwhile are not delivered later).
A field you leave out is unchanged.
This webhook, updated.
hook = noclick.Webhook.from_id("5e2d8c1a-7b4f-4e93-a6d0-3c9f1b2e8a54")
hook.update(events=["operation.*", "human.*"])
hook.update(enabled=False) # pause itdeleteasync
hook.delete() -> NoneDelete the webhook; deliveries still retrying stop.
noclick.Webhook.from_id("5e2d8c1a-7b4f-4e93-a6d0-3c9f1b2e8a54").delete()rotate_secretasync
hook.rotate_secret(*, grace_s=None) -> strReplace the signing secret. For grace_s (default a day, at most 7
days) each delivery is signed with both, so deliveries keep verifying while
you deploy the new one; pass grace_s=0 if the old one leaked.
NoneThe new secret, shown this once (also on hook.secret).
hook = noclick.Webhook.from_id("5e2d8c1a-7b4f-4e93-a6d0-3c9f1b2e8a54")
secret = hook.rotate_secret()
print(secret) # store it with your receiver, then deployreveal_secretasync
hook.reveal_secret() -> strRead the current signing secret, so a receiver that starts from code (or a
webhook from_name found rather than created) can verify deliveries. Needs
a key with the webhooks:reveal scope, which full access leaves out; every
reveal is recorded with the key that made it.
The secret (nk_whsec_...), also on hook.secret.
hook = noclick.Webhook.from_name("https://api.yourco.test/noclick/events")
secret = hook.reveal_secret()testasync
hook.test(event_type='operation.succeeded') -> WebhookDeliverySend a sample event of event_type now, signed like a real one, and
wait up to 10 seconds for your server's answer. It isn't retried and isn't
recorded in Event.list.
'operation.succeeded'The delivery: its status (succeeded or failed), response_status and response_body.
hook = noclick.Webhook.from_id("5e2d8c1a-7b4f-4e93-a6d0-3c9f1b2e8a54")
sent = hook.test("human.answered")
print(sent.status, sent.response_status, sent.duration_ms)deliveriesasync
hook.deliveries(*, status=None, event=None, limit=None) -> list[WebhookDelivery]Deliveries newest first (the last 30 days), optionally only those with
status (pending, retrying, succeeded, failed) or of one
event (an Event or id).
NoneNoneReturn at most this many.
None)Every matching delivery, fetched page by page (or the first limit).
hook = noclick.Webhook.from_id("5e2d8c1a-7b4f-4e93-a6d0-3c9f1b2e8a54")
for delivery in hook.deliveries(status=["retrying", "failed"], limit=20):
print(delivery.event_type, delivery.attempts, delivery.response_status, delivery.next_retry_at)redeliverasync
hook.redeliver(delivery) -> WebhookDeliverySend a delivery's event again now (the same event id, so a receiver that ignores repeats stays correct), whatever its status.
The delivery, with this attempt counted in attempts and its outcome in status.
Once your receiver is fixed, send what it missed:
hook = noclick.Webhook.from_id("5e2d8c1a-7b4f-4e93-a6d0-3c9f1b2e8a54")
for missed in hook.deliveries(status="failed"):
hook.redeliver(missed)verifystatic
noclick.Webhook.verify(payload, headers, secret, *, tolerance_s=300) -> EventCheck a delivery in your receiver: payload is the raw body exactly
as it arrived (not parsed and re-serialized), headers its headers (any
mapping; the name is matched whatever its case) and secret the
webhook's, or a list of secrets while you rotate. The NoClick-Signature
header is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 of
"<t>.<body>" keyed with the secret. A timestamp more than
tolerance_s from now is refused, so a captured delivery can't be
replayed later. It sends nothing.
Raises noclick.WebhookSignatureError when the header is missing or
malformed, the timestamp is outside the tolerance, or no signature matches.
300The event the body carries, ready to act on.
In a Flask receiver, answer 400 to anything that doesn't verify, and 2xx quickly (do slow work after):
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 "", 204While you rotate, accept both secrets:
import os
secrets =[os.environ["NOCLICK_WEBHOOK_SECRET"], os.environ["NOCLICK_WEBHOOK_SECRET_OLD"]]
event = noclick.Webhook.verify(request.get_data(), request.headers, secrets)WebhookDelivery
class WebhookDeliveryOne event sent (or on its way) to one webhook. status is pending
(not tried yet), retrying (an attempt failed; next_retry_at says when
the next one is), succeeded (your server answered 2xx) or failed (three
days of retries ran out). response_status and response_body (its first
4 KB) are from the latest attempt; request_body is the JSON that was sent.
| id | str | |
| webhook_id | str | None | |
| event_id | str | None | |
| event_type | str | None | |
| status | str | |
| attempts | int | |
| response_status | int | None | |
| response_body | str | None | |
| request_body | str | None | |
| duration_ms | int | None | |
| error | str | None | Why the latest attempt failed ( |
| next_retry_at | Any | |
| created_at | Any | |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
| ok | bool | Whether your server accepted it (a 2xx). |
Event
class EventSomething that happened: an operation finished, a person answered, a domain
went live. type names it (see Event.types()) and data is the object
it's about, as its own read returns it (data["object"] names the kind,
data["id"] its id). Delivered to your webhooks, and kept for 30 days.
- Like every live object, it also has hydrate(), refresh() and raw.
| id | str | |
| type | str | What happened, like |
| data | dict[str, Any] | The object the event is about, as its own read returns it. |
| user_id | str | None | The user it happened for (None for your account's own). |
| created_at | Any | |
| deliveries | list[WebhookDelivery] | Where this event went: one delivery per webhook that subscribes to it, each with its status and attempts. An event from a listing loads its full view on first read. |
from_idclassmethod
noclick.Event.from_id(event_id, *, client=None, user=...) -> EventA lazy reference to an event by id.
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
An event that loads the first time you read a field. Nothing is sent yet.
event = noclick.Event.from_id("7c1e9a4b-2d3f-4a8e-9b6c-0f5d2e8a1b37")
print(event.type, event.created_at)getclassmethodasync
noclick.Event.get(event_id, *, user=..., client=None) -> EventLoad an event by id now, with its deliveries; an unknown id (or one older
than 30 days) raises noclick.NotFound.
Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)The event, loaded.
event = noclick.Event.get("7c1e9a4b-2d3f-4a8e-9b6c-0f5d2e8a1b37")
for delivery in event.deliveries:
print(delivery.webhook_id, delivery.status, delivery.attempts)listclassmethodasync
noclick.Event.list(
*, type=None, since=None, until=None, limit=None, user_filter=None, user=...,
client=None
) -> list[Event]Events newest first, from the last 30 days. type is one type or
several, and * matches the rest of a name (domain.*); since and
until are timezone-aware datetimes or ISO strings. Outside as_user
you read your account's own events; user_filter reads your users'.
NoneNoneNoneReturn at most this many.
None)Which users a read covers: noclick.ALL_USERS, noclick.NO_USER, a User, or your id for one. It filters without acting as that user.
None)Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)Every matching event, fetched page by page (or the first limit).
See what happened to your domains this week:
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"])Every failure, across your users too:
failed = noclick.Event.list(type=["operation.failed", "agent.turn.failed"], user_filter=noclick.ALL_USERS, limit=50)list_pageclassmethodasync
noclick.Event.list_page(
*, cursor=None, limit=None, type=None, since=None, until=None, user_filter=None,
user=..., client=None
) -> PageOne page of events; pass next_cursor back as cursor.
The next_cursor of the page before; omit it for the first page.
None)Return at most this many.
None)NoneNoneNoneWhich users a read covers: noclick.ALL_USERS, noclick.NO_USER, a User, or your id for one. It filters without acting as that user.
None)Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)A page whose data holds the events and whose next_cursor is None on the last page.
page = noclick.Event.list_page(type="operation.failed", limit=100)
while True:
for event in page.data:
print(event.id, event.data["kind"])
if page.next_cursor is None:
break
page = noclick.Event.list_page(type="operation.failed", limit=100, cursor=page.next_cursor)typesclassmethodasync
noclick.Event.types(*, client=None) -> list[EventType]The catalog of event types a webhook can subscribe to.
The client to send with; defaults to the module client built from NOCLICK_API_KEY.
None)Every event type with what it means, in catalog order (grouped by area).
for kind in noclick.Event.types():
print(kind.name, kind.description)EventType
class EventTypeOne kind of event: its name (operation.succeeded) and what it means.
area is the part before the first dot, the thing the event is about.
emitted is whether NoClick sends it today: the rest are listed so your
code can subscribe before the primitive behind them ships.
| name | str | |
| description | str | |
| emitted | bool | |
| raw | dict[str, Any] | The response JSON exactly as the API sent it. |
| area | str | What the event is about: |