beaconbox 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
beaconbox/__init__.py ADDED
@@ -0,0 +1,125 @@
1
+ """Official Python SDK for BeaconBox.
2
+
3
+ BeaconBox is a private inbox where only businesses a customer has bought from can reach them, so
4
+ shipping, tracking, payment and firmware updates never get lost to spam.
5
+
6
+ .. code-block:: python
7
+
8
+ from beaconbox import BeaconBox
9
+
10
+ client = BeaconBox() # reads BEACONBOX_API_KEY
11
+
12
+ result = client.messages.push(
13
+ recipient_email="buyer@example.com",
14
+ subject="Your order has shipped",
15
+ body="Tracking XY123456789EE. Estimated delivery Thursday.",
16
+ )
17
+ print(result.id)
18
+
19
+ Everything is also available awaited, from :class:`AsyncBeaconBox`, with identical signatures.
20
+
21
+ The two conventions this SDK exists to keep you from having to remember are on
22
+ :class:`BeaconBox`: it supplies the idempotency key every write requires, and it never raises on
23
+ a channel that was skipped.
24
+ """
25
+
26
+ from ._logging import install_null_handler
27
+ from ._version import __version__
28
+ from .client import AsyncBeaconBox, BeaconBox
29
+ from .enums import (
30
+ Channel,
31
+ ChannelSendStatus,
32
+ MessageKind,
33
+ MessageStatus,
34
+ RecipientStatus,
35
+ SkipReason,
36
+ WebhookEventType,
37
+ WhatsAppReplyForward,
38
+ )
39
+ from .errors import (
40
+ APIConnectionError,
41
+ APIError,
42
+ AuthenticationError,
43
+ BeaconBoxError,
44
+ ConflictError,
45
+ InvalidRequestError,
46
+ PermissionDeniedError,
47
+ RateLimitError,
48
+ ResourceMissingError,
49
+ ServerError,
50
+ WebhookVerificationError,
51
+ )
52
+ from .models import (
53
+ ApiKey,
54
+ BatchItemResult,
55
+ BatchResult,
56
+ CreditBalance,
57
+ DeliveryStatus,
58
+ Message,
59
+ MessagePage,
60
+ MessagePush,
61
+ MessagePushResult,
62
+ NewApiKey,
63
+ NewWebhookEndpoint,
64
+ RecipientSms,
65
+ RetractResult,
66
+ SmsDelivery,
67
+ SmsOutcome,
68
+ WebhookEndpoint,
69
+ WebhookEvent,
70
+ WhatsAppErasureReceipt,
71
+ WhatsAppOptInOutcome,
72
+ WhatsAppOutcome,
73
+ )
74
+ from .retry import RetryPolicy
75
+
76
+ # A library must be silent until the application asks otherwise. See beaconbox._logging: this
77
+ # attaches a NullHandler to the `beaconbox` logger and configures nothing else, so importing this
78
+ # SDK never prints anything and never touches the root logger.
79
+ install_null_handler()
80
+
81
+ __all__ = [
82
+ "APIConnectionError",
83
+ "APIError",
84
+ "ApiKey",
85
+ "AsyncBeaconBox",
86
+ "AuthenticationError",
87
+ "BatchItemResult",
88
+ "BatchResult",
89
+ "BeaconBox",
90
+ "BeaconBoxError",
91
+ "Channel",
92
+ "ChannelSendStatus",
93
+ "ConflictError",
94
+ "CreditBalance",
95
+ "DeliveryStatus",
96
+ "InvalidRequestError",
97
+ "Message",
98
+ "MessageKind",
99
+ "MessagePage",
100
+ "MessagePush",
101
+ "MessagePushResult",
102
+ "MessageStatus",
103
+ "NewApiKey",
104
+ "NewWebhookEndpoint",
105
+ "PermissionDeniedError",
106
+ "RateLimitError",
107
+ "RecipientSms",
108
+ "RecipientStatus",
109
+ "ResourceMissingError",
110
+ "RetractResult",
111
+ "RetryPolicy",
112
+ "ServerError",
113
+ "SkipReason",
114
+ "SmsDelivery",
115
+ "SmsOutcome",
116
+ "WebhookEndpoint",
117
+ "WebhookEvent",
118
+ "WebhookEventType",
119
+ "WebhookVerificationError",
120
+ "WhatsAppErasureReceipt",
121
+ "WhatsAppOptInOutcome",
122
+ "WhatsAppOutcome",
123
+ "WhatsAppReplyForward",
124
+ "__version__",
125
+ ]
beaconbox/_core.py ADDED
@@ -0,0 +1,248 @@
1
+ """The part of this SDK that has no idea HTTP exists.
2
+
3
+ Everything here is a pure function of its inputs: turn a :class:`Call` into the bytes and headers
4
+ that should go on the wire, and turn a status code plus a body back into a model or an exception.
5
+ No sockets, no sleeping, no httpx import anywhere in this module.
6
+
7
+ That line is what lets the sync client and the async client be the same client. Authentication,
8
+ the idempotency key, URL construction, JSON encoding, error mapping and response parsing are
9
+ written once here; :mod:`beaconbox._transport` adds only the two things that genuinely differ
10
+ between them, which are how you send a request and how you wait.
11
+
12
+ It is also what makes the SDK testable without a network: a test can build a ``Call``, prepare it
13
+ and assert on the exact headers, or hand :func:`Core.interpret` a canned 429 and assert on the
14
+ exception, with no server and no mocking library involved.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import uuid
21
+ from collections.abc import Callable, Mapping
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Generic, TypeVar
24
+ from urllib.parse import quote, urlencode, urlsplit
25
+
26
+ from ._version import __version__
27
+ from .errors import APIError, error_class_for
28
+
29
+ T = TypeVar("T")
30
+
31
+ API_PREFIX = "/api/v1"
32
+ DEFAULT_BASE_URL = "https://api.beaconbox.eu"
33
+
34
+ _LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
35
+
36
+
37
+ def new_idempotency_key() -> str:
38
+ """A fresh v4 UUID for the ``Idempotency-Key`` header.
39
+
40
+ Minted once per logical call and reused for every retry of it. See
41
+ :meth:`Core.prepare`, which is where that pairing is enforced.
42
+ """
43
+ return str(uuid.uuid4())
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Call(Generic[T]):
48
+ """One API operation, described without reference to how it will be sent.
49
+
50
+ ``parse`` is carried alongside the request rather than looked up afterwards, so a resource
51
+ method's return type is fixed at the point the call is described and a transport cannot get
52
+ it wrong.
53
+ """
54
+
55
+ method: str
56
+ path: str
57
+ parse: Callable[[Any], T]
58
+ body: dict[str, Any] | None = None
59
+ params: Mapping[str, Any] = field(default_factory=dict)
60
+
61
+ route: str | None = None
62
+ """A templated, **log-safe** name for this operation, for example
63
+ ``/recipients/{email}/sms``.
64
+
65
+ Set it on every call whose path interpolates a value. ``path`` cannot be logged: a recipient's
66
+ email address is substituted straight into it, so logging the path would write personal data
67
+ into a merchant's log aggregator on every request. Query strings are never logged for the same
68
+ reason (``?recipient_email=``), and neither are bodies or headers.
69
+ """
70
+
71
+ @property
72
+ def log_name(self) -> str:
73
+ """What :mod:`beaconbox._transport` logs. Never the interpolated path."""
74
+ return f"{self.method} {self.route or self.path}"
75
+
76
+ @property
77
+ def needs_idempotency(self) -> bool:
78
+ """Every ``POST`` in this API requires an ``Idempotency-Key``, and nothing else does.
79
+
80
+ ``PUT`` and ``DELETE`` on a recipient's phone number are idempotent by construction:
81
+ setting a number twice leaves one number. Sending a message twice sends two messages and
82
+ charges two credits, which is the whole reason the header is mandatory on ``POST``.
83
+ """
84
+ return self.method == "POST"
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class PreparedRequest:
89
+ """Exactly what should go on the wire. Built once, sent as many times as it takes."""
90
+
91
+ method: str
92
+ url: str
93
+ headers: dict[str, str]
94
+ content: bytes | None
95
+
96
+
97
+ class Core:
98
+ """Request building and response interpretation, shared by both clients."""
99
+
100
+ def __init__(
101
+ self,
102
+ api_key: str,
103
+ *,
104
+ base_url: str = DEFAULT_BASE_URL,
105
+ user_agent_suffix: str | None = None,
106
+ ) -> None:
107
+ if not api_key or not api_key.strip():
108
+ raise ValueError(
109
+ "BeaconBox: an API key is required. Pass api_key= or set BEACONBOX_API_KEY."
110
+ )
111
+ self._api_key = api_key.strip()
112
+ self.base_url = _validated_base_url(base_url)
113
+ suffix = f" {user_agent_suffix}" if user_agent_suffix else ""
114
+ self.user_agent = f"beaconbox-python/{__version__}{suffix}"
115
+
116
+ def __repr__(self) -> str:
117
+ """No key, ever. This object ends up inside client reprs, tracebacks and log lines."""
118
+ return f"Core(base_url={self.base_url!r})"
119
+
120
+ def prepare(self, call: Call[Any], idempotency_key: str | None = None) -> PreparedRequest:
121
+ """Build the request for ``call``.
122
+
123
+ **Call this once per logical operation, outside any retry loop.** The idempotency key is
124
+ minted here, so preparing again per attempt would mint a second key, and a second key is
125
+ what turns a retry into a duplicate message and a duplicate charge.
126
+ """
127
+ url = self.base_url + API_PREFIX + call.path
128
+ query = {k: _query_value(v) for k, v in call.params.items() if v is not None}
129
+ if query:
130
+ url += "?" + urlencode(query)
131
+
132
+ headers = {
133
+ "Authorization": f"Bearer {self._api_key}",
134
+ "Accept": "application/json",
135
+ "User-Agent": self.user_agent,
136
+ }
137
+ content: bytes | None = None
138
+ if call.body is not None:
139
+ headers["Content-Type"] = "application/json"
140
+ content = json.dumps(call.body, separators=(",", ":")).encode()
141
+ if call.needs_idempotency:
142
+ headers["Idempotency-Key"] = idempotency_key or new_idempotency_key()
143
+
144
+ return PreparedRequest(method=call.method, url=url, headers=headers, content=content)
145
+
146
+ def interpret(
147
+ self,
148
+ call: Call[T],
149
+ status_code: int,
150
+ headers: Mapping[str, str],
151
+ content: bytes,
152
+ ) -> T:
153
+ """Turn a response into a model, or raise the matching :class:`~beaconbox.errors.APIError`.
154
+
155
+ Only called for a response the transport has decided not to retry, so anything reaching
156
+ here with a 4xx or 5xx is final.
157
+ """
158
+ if status_code >= 400:
159
+ raise self._to_error(status_code, headers, content)
160
+ return call.parse(_decode(content))
161
+
162
+ def _to_error(self, status_code: int, headers: Mapping[str, str], content: bytes) -> APIError:
163
+ body = _decode(content)
164
+ body = body if isinstance(body, dict) else {"raw": body}
165
+ error_code = body.get("error_code") if isinstance(body.get("error_code"), str) else None
166
+ detail = body.get("detail")
167
+ summary = detail if isinstance(detail, str) else error_code or "request failed"
168
+ request_id = _header(headers, "x-request-id")
169
+ return error_class_for(status_code)(
170
+ f"BeaconBox: {summary} (HTTP {status_code})",
171
+ status_code=status_code,
172
+ error_code=error_code,
173
+ body=body,
174
+ request_id=request_id,
175
+ retry_after=retry_after_seconds(headers),
176
+ )
177
+
178
+
179
+ def retry_after_seconds(headers: Mapping[str, str]) -> float | None:
180
+ """``Retry-After`` in seconds, when the server sent a sane one.
181
+
182
+ Only the delta-seconds form is honoured. The HTTP-date form is legal and essentially never
183
+ used by an API, and parsing it would mean trusting the caller's clock to agree with the
184
+ server's, which is the assumption that makes it worse than the SDK's own backoff.
185
+ """
186
+ value = _header(headers, "retry-after")
187
+ if value is None:
188
+ return None
189
+ try:
190
+ seconds = float(value.strip())
191
+ except ValueError:
192
+ return None
193
+ return seconds if seconds >= 0 else None
194
+
195
+
196
+ def _header(headers: Mapping[str, str], name: str) -> str | None:
197
+ """Case-insensitive lookup, because a raw mapping in a test is not httpx's ``Headers``."""
198
+ for key, value in headers.items():
199
+ if key.lower() == name:
200
+ return value
201
+ return None
202
+
203
+
204
+ def _decode(content: bytes) -> Any:
205
+ """Body to Python. A 204 has none, and a non-JSON body is preserved rather than swallowed."""
206
+ if not content or not content.strip():
207
+ return {}
208
+ try:
209
+ return json.loads(content)
210
+ except ValueError:
211
+ return {"raw": content.decode("utf-8", "replace")}
212
+
213
+
214
+ def _query_value(value: Any) -> str:
215
+ if isinstance(value, bool):
216
+ return "true" if value else "false"
217
+ return str(value)
218
+
219
+
220
+ def _validated_base_url(base_url: str) -> str:
221
+ """Reject a base URL that would put an API key on the wire in clear.
222
+
223
+ ``http`` is allowed only for a loopback host, which is what the local development stack and
224
+ the SDK's own live tests run against. Anywhere else it means the bearer token, the recipient's
225
+ email address and the body of the message are readable by anything on the path, and an SDK
226
+ that shrugs at that is the reason it happens in production.
227
+ """
228
+ cleaned = base_url.strip().rstrip("/")
229
+ parts = urlsplit(cleaned)
230
+ if parts.scheme not in {"http", "https"}:
231
+ raise ValueError(f"BeaconBox: base_url must be http or https, got {base_url!r}")
232
+ if not parts.netloc:
233
+ raise ValueError(f"BeaconBox: base_url must include a host, got {base_url!r}")
234
+ if parts.scheme == "http" and (parts.hostname or "") not in _LOCAL_HOSTS:
235
+ raise ValueError(
236
+ f"BeaconBox: refusing to send an API key over plain http to {parts.hostname!r}. "
237
+ "Use https (http is permitted for localhost only)."
238
+ )
239
+ return cleaned
240
+
241
+
242
+ def path_segment(value: str) -> str:
243
+ """Percent-encode one path segment.
244
+
245
+ ``safe=""`` so that an email address's ``@``, and above all a ``/``, cannot walk out of the
246
+ segment they were substituted into and address a different endpoint.
247
+ """
248
+ return quote(value, safe="")
beaconbox/_logging.py ADDED
@@ -0,0 +1,81 @@
1
+ """How this library logs, and the rules it will not break.
2
+
3
+ A library's logging is not an application's logging. The rules below are the standard ones, and
4
+ each is here because breaking it steals a decision from the person integrating this SDK:
5
+
6
+ * **One logger per module, named after the module.** Everything lands under the ``beaconbox``
7
+ hierarchy, so an application silences or raises the whole SDK with one line:
8
+
9
+ .. code-block:: python
10
+
11
+ logging.getLogger("beaconbox").setLevel(logging.DEBUG)
12
+
13
+ * **A** :class:`~logging.NullHandler` **on the root of that hierarchy, and nothing else.** Added in
14
+ :mod:`beaconbox.__init__`. It means importing this SDK produces no output, ever, until the
15
+ application asks for some.
16
+ * **Never configure logging.** No ``basicConfig``, no handlers, no formatters, no level set on the
17
+ root logger. A library that calls ``basicConfig()`` at import silently hijacks the logging of
18
+ every application that imports it.
19
+ * **Never log anything sensitive.** See :func:`safe_extra`. This is the rule with teeth: BeaconBox
20
+ requests carry an API key, a recipient's email address and the text of a message to a real
21
+ person. A log line is copied to an aggregator, retained for months and read by people who were
22
+ never meant to see any of that.
23
+
24
+ What actually gets logged, all of it on the ``beaconbox._transport`` logger:
25
+
26
+ =========== ===========================================================================
27
+ ``DEBUG`` every request, and every response, with its status and elapsed time
28
+ ``WARNING`` a retry, with the reason and the backoff
29
+ =========== ===========================================================================
30
+
31
+ Nothing is logged at ``INFO`` or above in normal operation. An SDK that chatters at ``INFO`` is an
32
+ SDK whose users filter it out, which is how the ``WARNING`` that mattered gets filtered out too.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import logging
38
+ from typing import Any
39
+
40
+ LOGGER_NAME = "beaconbox"
41
+
42
+
43
+ def get_logger(name: str) -> logging.Logger:
44
+ """The logger for a module inside this package. Always use this rather than a bare
45
+ ``getLogger``, so nothing accidentally logs outside the ``beaconbox`` hierarchy."""
46
+ return logging.getLogger(name)
47
+
48
+
49
+ def install_null_handler() -> None:
50
+ """Attach a :class:`~logging.NullHandler` to ``beaconbox``, once.
51
+
52
+ Without it, a warning emitted before the application configures logging would either print
53
+ ``No handlers could be found`` or fall through to the root logger's ``lastResort`` handler and
54
+ appear on stderr, uninvited.
55
+ """
56
+ root = logging.getLogger(LOGGER_NAME)
57
+ if not any(isinstance(handler, logging.NullHandler) for handler in root.handlers):
58
+ root.addHandler(logging.NullHandler())
59
+
60
+
61
+ def safe_extra(**fields: Any) -> dict[str, Any]:
62
+ """The allow-list for structured log fields.
63
+
64
+ Deliberately an allow-list rather than a redaction pass. Redaction is a list of things somebody
65
+ remembered to hide, and the field that gets added next year is not on it. This way a value has
66
+ to be named here to ever reach a log line.
67
+
68
+ **What is never logged, and why:**
69
+
70
+ * the API key, or any header (``Authorization`` is a header);
71
+ * the request or response body (a message body is text written to a named customer);
72
+ * the query string (``?recipient_email=buyer@example.com``);
73
+ * the interpolated URL path (``/recipients/buyer@example.com/sms``). The templated
74
+ :attr:`~beaconbox._core.Call.route` is logged instead;
75
+ * webhook secrets, and the payload of a webhook.
76
+
77
+ The idempotency key is logged, because it is a random value of the caller's choosing that
78
+ identifies one attempt, and correlating retries without it is guesswork. If a caller passes
79
+ something identifying as their own key, that is their value in their logs.
80
+ """
81
+ return {"beaconbox": fields}
beaconbox/_ops.py ADDED
@@ -0,0 +1,196 @@
1
+ """Every API operation, described once.
2
+
3
+ A resource method on the sync client and its twin on the async client build the *same*
4
+ :class:`~beaconbox._core.Call` from this module, so the URL, the body shape and the response type
5
+ of an operation have exactly one definition. What differs between the two clients is the keyword
6
+ ``await``, and nothing else.
7
+
8
+ The prose that teaches an operation lives on the resource methods, where an editor will show it.
9
+ What lives here is the wire contract.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+ from typing import Any
16
+
17
+ from ._core import Call, path_segment
18
+ from .models import (
19
+ ApiKey,
20
+ BatchResult,
21
+ CreditBalance,
22
+ Message,
23
+ MessagePage,
24
+ MessagePushResult,
25
+ NewApiKey,
26
+ NewWebhookEndpoint,
27
+ RecipientSms,
28
+ RetractResult,
29
+ SmsOutcome,
30
+ WebhookEndpoint,
31
+ WhatsAppErasureReceipt,
32
+ WhatsAppOutcome,
33
+ )
34
+
35
+ Payload = dict[str, Any]
36
+
37
+
38
+ def _nothing(_: Any) -> None:
39
+ """For a 204. The call succeeded and the body is empty by design."""
40
+ return None
41
+
42
+
43
+ def _keys(body: Any) -> tuple[ApiKey, ...]:
44
+ items = body.get("items") if isinstance(body, dict) else None
45
+ return tuple(ApiKey.from_api(i) for i in (items or []) if isinstance(i, dict))
46
+
47
+
48
+ def _endpoints(body: Any) -> tuple[WebhookEndpoint, ...]:
49
+ items = body.get("items") if isinstance(body, dict) else None
50
+ return tuple(WebhookEndpoint.from_api(i) for i in (items or []) if isinstance(i, dict))
51
+
52
+
53
+ # --- Messages ---------------------------------------------------------------------------
54
+
55
+
56
+ def push(body: Payload) -> Call[MessagePushResult]:
57
+ return Call("POST", "/messages", MessagePushResult.from_api, body=body)
58
+
59
+
60
+ def push_batch(bodies: Sequence[Payload]) -> Call[BatchResult]:
61
+ # `items`, which is what the API's MessageBatchPush declares. The name matters: a batch sent
62
+ # under any other key is a 422 that only shows up against a real server.
63
+ return Call("POST", "/messages/batch", BatchResult.from_api, body={"items": list(bodies)})
64
+
65
+
66
+ def get_message(public_id: str) -> Call[Message]:
67
+ return Call(
68
+ "GET", f"/messages/{path_segment(public_id)}", Message.from_api, route="/messages/{id}"
69
+ )
70
+
71
+
72
+ def list_messages(
73
+ recipient_email: str | None, limit: int | None, cursor: str | None
74
+ ) -> Call[MessagePage]:
75
+ return Call(
76
+ "GET",
77
+ "/messages",
78
+ MessagePage.from_api,
79
+ params={"recipient_email": recipient_email, "limit": limit, "cursor": cursor},
80
+ )
81
+
82
+
83
+ def resend_sms(public_id: str) -> Call[SmsOutcome]:
84
+ return Call(
85
+ "POST",
86
+ f"/messages/{path_segment(public_id)}/sms",
87
+ SmsOutcome.from_api,
88
+ route="/messages/{id}/sms",
89
+ )
90
+
91
+
92
+ def resend_whatsapp(public_id: str) -> Call[WhatsAppOutcome]:
93
+ return Call(
94
+ "POST",
95
+ f"/messages/{path_segment(public_id)}/whatsapp",
96
+ WhatsAppOutcome.from_api,
97
+ route="/messages/{id}/whatsapp",
98
+ )
99
+
100
+
101
+ def retract(public_id: str) -> Call[RetractResult]:
102
+ return Call(
103
+ "POST",
104
+ f"/messages/{path_segment(public_id)}/retract",
105
+ RetractResult.from_api,
106
+ route="/messages/{id}/retract",
107
+ )
108
+
109
+
110
+ # --- Credits ----------------------------------------------------------------------------
111
+
112
+
113
+ def credit_balance() -> Call[CreditBalance]:
114
+ return Call("GET", "/credits", CreditBalance.from_api)
115
+
116
+
117
+ # --- Recipients -------------------------------------------------------------------------
118
+
119
+
120
+ def recipient_sms(email: str) -> Call[RecipientSms]:
121
+ return Call(
122
+ "GET",
123
+ f"/recipients/{path_segment(email)}/sms",
124
+ RecipientSms.from_api,
125
+ route="/recipients/{email}/sms",
126
+ )
127
+
128
+
129
+ def set_recipient_phone(email: str, phone: str) -> Call[RecipientSms]:
130
+ return Call(
131
+ "PUT",
132
+ f"/recipients/{path_segment(email)}/phone",
133
+ RecipientSms.from_api,
134
+ body={"phone": phone},
135
+ route="/recipients/{email}/phone",
136
+ )
137
+
138
+
139
+ def clear_recipient_phone(email: str) -> Call[RecipientSms]:
140
+ return Call(
141
+ "DELETE",
142
+ f"/recipients/{path_segment(email)}/phone",
143
+ RecipientSms.from_api,
144
+ route="/recipients/{email}/phone",
145
+ )
146
+
147
+
148
+ def erase_whatsapp(email: str) -> Call[WhatsAppErasureReceipt]:
149
+ return Call(
150
+ "POST",
151
+ f"/recipients/{path_segment(email)}/whatsapp/erase",
152
+ WhatsAppErasureReceipt.from_api,
153
+ route="/recipients/{email}/whatsapp/erase",
154
+ )
155
+
156
+
157
+ # --- Keys -------------------------------------------------------------------------------
158
+
159
+
160
+ def list_keys() -> Call[tuple[ApiKey, ...]]:
161
+ return Call("GET", "/keys", _keys)
162
+
163
+
164
+ def create_key(name: str | None) -> Call[NewApiKey]:
165
+ return Call("POST", "/keys", NewApiKey.from_api, body={"name": name})
166
+
167
+
168
+ def revoke_key(key_id: str) -> Call[None]:
169
+ return Call("DELETE", f"/keys/{path_segment(key_id)}", _nothing, route="/keys/{id}")
170
+
171
+
172
+ # --- Webhook endpoints ------------------------------------------------------------------
173
+
174
+
175
+ def list_webhook_endpoints() -> Call[tuple[WebhookEndpoint, ...]]:
176
+ return Call("GET", "/webhook-endpoints", _endpoints)
177
+
178
+
179
+ def create_webhook_endpoint(
180
+ url: str, event_types: Sequence[str], description: str
181
+ ) -> Call[NewWebhookEndpoint]:
182
+ return Call(
183
+ "POST",
184
+ "/webhook-endpoints",
185
+ NewWebhookEndpoint.from_api,
186
+ body={"url": url, "event_types": list(event_types), "description": description},
187
+ )
188
+
189
+
190
+ def delete_webhook_endpoint(public_id: str) -> Call[None]:
191
+ return Call(
192
+ "DELETE",
193
+ f"/webhook-endpoints/{path_segment(public_id)}",
194
+ _nothing,
195
+ route="/webhook-endpoints/{id}",
196
+ )