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 Webhook

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

Attributes
idstr
urlstr
eventslist[str]

The event types it receives; * matches the rest of a name.

enabledbool
statusstr

healthy, failing (its latest deliveries failed; retries continue) or disabled.

descriptionstr | None
success_rate_24hfloat | None

The share of attempts in the last 24 hours your server answered 2xx (None if none).

last_failuredict[str, Any] | None

The latest failed attempt: at, response_status and error (None if none).

user_idstr | None

The user whose events it receives (None: every event).

created_atAny
secretstr | None

The signing secret (nk_whsec_...), present on the object that created the webhook (create, or from_name when it created one), rotate_secret or reveal_secret; any other read leaves it out.

createclassmethodasync

noclick.Webhook.create(
    url, *, events, description=None, user=..., idempotency_key=None, client=None
) -> Webhook

Start delivering events (type names; operation.* or * for more at once) to url, an https address your server answers.

Parameters
urlstrrequired
eventsSequence[str]required
descriptionstr | Nonedefault None
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
idempotency_keystr | None

Makes a retried create safe: the same key returns the first result. Generated when omitted.

(default None)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Webhook

The new webhook. Its secret is shown this once: store it where your receiver reads it.

Usage

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 once

Only 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
) -> Webhook

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

Parameters
urlstrrequired
create_if_missingbooldefault False
eventsSequence[str] | Nonedefault None
descriptionstr | Nonedefault None
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Webhook

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().

Usage

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=...) -> Webhook

A lazy reference to a webhook by id.

Parameters
webhook_idstrrequired
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
Returns
Webhook

A webhook that loads the first time you read a field. Nothing is sent yet.

Usage
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) -> Webhook

Load a webhook by id now; an unknown id raises noclick.NotFound.

Parameters
webhook_idstrrequired
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Webhook

The webhook, loaded (without its secret).

Usage
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).

Parameters
limitint | None

Return at most this many.

(default None)
user_filterAny

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.

(default None)
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
list[Webhook]

Every matching webhook, newest first, fetched page by page.

Usage
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
) -> Page

One page of webhooks; pass next_cursor back as cursor.

Parameters
cursorstr | None

The next_cursor of the page before; omit it for the first page.

(default None)
limitint | None

Return at most this many.

(default None)
user_filterAny

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.

(default None)
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Page

A page whose data holds the webhooks and whose next_cursor is None on the last page.

Usage
page = noclick.Webhook.list_page(limit=20)
for hook in page.data:
    print(hook.url, hook.status)

updateasync

hook.update(*, url=..., events=..., enabled=..., description=...) -> Webhook

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

Parameters
urlAnyomit to leave it unchanged
eventsAnyomit to leave it unchanged
enabledAnyomit to leave it unchanged
descriptionAnyomit to leave it unchanged
Returns
Webhook

This webhook, updated.

Usage
hook = noclick.Webhook.from_id("5e2d8c1a-7b4f-4e93-a6d0-3c9f1b2e8a54")
hook.update(events=["operation.*", "human.*"])
hook.update(enabled=False)   # pause it

deleteasync

hook.delete() -> None

Delete the webhook; deliveries still retrying stop.

Usage
noclick.Webhook.from_id("5e2d8c1a-7b4f-4e93-a6d0-3c9f1b2e8a54").delete()

rotate_secretasync

hook.rotate_secret(*, grace_s=None) -> str

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

Parameters
grace_sint | Nonedefault None
Returns
str

The new secret, shown this once (also on hook.secret).

Usage
hook = noclick.Webhook.from_id("5e2d8c1a-7b4f-4e93-a6d0-3c9f1b2e8a54")
secret = hook.rotate_secret()
print(secret)   # store it with your receiver, then deploy

reveal_secretasync

hook.reveal_secret() -> str

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

Returns
str

The secret (nk_whsec_...), also on hook.secret.

Usage
hook = noclick.Webhook.from_name("https://api.yourco.test/noclick/events")
secret = hook.reveal_secret()

testasync

hook.test(event_type='operation.succeeded') -> WebhookDelivery

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

Parameters
event_typestrdefault 'operation.succeeded'
Returns
WebhookDelivery

The delivery: its status (succeeded or failed), response_status and response_body.

Usage
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).

Parameters
statusstr | Sequence[str] | Nonedefault None
eventAnydefault None
limitint | None

Return at most this many.

(default None)
Returns
list[WebhookDelivery]

Every matching delivery, fetched page by page (or the first limit).

Usage
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) -> WebhookDelivery

Send a delivery's event again now (the same event id, so a receiver that ignores repeats stays correct), whatever its status.

Parameters
deliverystr | WebhookDeliveryrequired
Returns
WebhookDelivery

The delivery, with this attempt counted in attempts and its outcome in status.

Usage

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

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

Parameters
payloadbytes | strrequired
headersMapping[str, Any]required
secretstr | Sequence[str]required
tolerance_sintdefault 300
Returns
Event

The event the body carries, ready to act on.

Usage

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 "", 204

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

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

Attributes
idstr
webhook_idstr | None
event_idstr | None
event_typestr | None
statusstr
attemptsint
response_statusint | None
response_bodystr | None
request_bodystr | None
duration_msint | None
errorstr | None

Why the latest attempt failed (Answered 502, No answer within 10 seconds.); None once it succeeded.

next_retry_atAny
created_atAny
rawdict[str, Any]

The response JSON exactly as the API sent it.

okbool

Whether your server accepted it (a 2xx).

Event

class Event

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

Attributes
idstr
typestr

What happened, like operation.succeeded.

datadict[str, Any]

The object the event is about, as its own read returns it.

user_idstr | None

The user it happened for (None for your account's own).

created_atAny
deliverieslist[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=...) -> Event

A lazy reference to an event by id.

Parameters
event_idstrrequired
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
Returns
Event

An event that loads the first time you read a field. Nothing is sent yet.

Usage
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) -> Event

Load an event by id now, with its deliveries; an unknown id (or one older than 30 days) raises noclick.NotFound.

Parameters
event_idstrrequired
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Event

The event, loaded.

Usage
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'.

Parameters
typestr | Sequence[str] | Nonedefault None
sinceAnydefault None
untilAnydefault None
limitint | None

Return at most this many.

(default None)
user_filterAny

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.

(default None)
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
list[Event]

Every matching event, fetched page by page (or the first limit).

Usage

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

One page of events; pass next_cursor back as cursor.

Parameters
cursorstr | None

The next_cursor of the page before; omit it for the first page.

(default None)
limitint | None

Return at most this many.

(default None)
typestr | Sequence[str] | Nonedefault None
sinceAnydefault None
untilAnydefault None
user_filterAny

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.

(default None)
userAny

Who the call acts for. Defaults to the user set by noclick.as_user (none: the account itself).

(defaults to the current user)
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
Page

A page whose data holds the events and whose next_cursor is None on the last page.

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

Parameters
clientClient | None

The client to send with; defaults to the module client built from NOCLICK_API_KEY.

(default None)
Returns
list[EventType]

Every event type with what it means, in catalog order (grouped by area).

Usage
for kind in noclick.Event.types():
    print(kind.name, kind.description)

EventType

class EventType

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

Attributes
namestr
descriptionstr
emittedbool
rawdict[str, Any]

The response JSON exactly as the API sent it.

areastr

What the event is about: operation, human, domain, ...