webhook-co 0.1.0__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ dist/
6
+ build/
7
+ .coverage
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: webhook-co
3
+ Version: 0.1.0
4
+ Summary: The official Python SDK for the webhook.co API — a typed client with retries, cursor pagination, idempotency, and secret redaction.
5
+ Project-URL: Homepage, https://webhook.co
6
+ Project-URL: Repository, https://github.com/webhook-co/webhook
7
+ Project-URL: Issues, https://github.com/webhook-co/webhook/issues
8
+ Author: webhook.co
9
+ License-Expression: Apache-2.0
10
+ Keywords: api-client,sdk,standard-webhooks,webhook,webhook.co,webhooks
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: pydantic>=2.7
23
+ Provides-Extra: dev
24
+ Requires-Dist: black==26.5.1; extra == 'dev'
25
+ Requires-Dist: datamodel-code-generator==0.67.0; extra == 'dev'
26
+ Requires-Dist: isort==8.0.1; extra == 'dev'
27
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
28
+ Requires-Dist: pytest>=8; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # webhook.co Python SDK
32
+
33
+ The official Python SDK for the [webhook.co](https://webhook.co) API. A typed client with the hardening
34
+ you'd otherwise hand-roll: bearer auth, bounded retries with jitter, cursor pagination, idempotency, and
35
+ secret redaction — with pydantic v2 models generated from the same OpenAPI contract the API is built on.
36
+
37
+ Python 3.10+. Built on `httpx`.
38
+
39
+ ## Install
40
+
41
+ ```sh
42
+ pip install webhook-co
43
+ ```
44
+
45
+ ## Quickstart
46
+
47
+ ```python
48
+ import os
49
+ from webhook_co import WebhookClient
50
+
51
+ client = WebhookClient(api_key=os.environ["WEBHOOK_API_KEY"])
52
+
53
+ # Create an endpoint — the one-time ingest URL is returned once, so capture it now.
54
+ endpoint = client.endpoints.create(name="orders-prod")
55
+ print(endpoint.ingest_url)
56
+
57
+ # List events for that endpoint (auto-paginates).
58
+ for event in client.events.list(endpoint.id):
59
+ print(event.id, event.provider, event.verification_state)
60
+ ```
61
+
62
+ The API key is a `whk_`-prefixed token from your dashboard. Keep it server-side — this SDK never prints
63
+ it, but it's still a credential.
64
+
65
+ Responses come back as validated pydantic models, so attributes are typed and snake_cased
66
+ (`endpoint.org_id`, `endpoint.created_at`).
67
+
68
+ ## Pagination
69
+
70
+ List methods return a `Paginator` you can iterate directly — it follows the cursor for you:
71
+
72
+ ```python
73
+ for endpoint in client.endpoints.list(name="prod"):
74
+ print(endpoint.id)
75
+
76
+ # Collect everything (careful with large result sets):
77
+ failed = client.deliveries.list(status=["failed"]).collect()
78
+
79
+ # One page at a time (e.g. to build your own UI):
80
+ page = client.endpoints.list_page(limit=50)
81
+ print(page.items, page.next_cursor)
82
+ ```
83
+
84
+ ## Errors
85
+
86
+ Every failure is a `WebhookError` subclass, so you can narrow with `except` — no string matching:
87
+
88
+ ```python
89
+ from webhook_co import (
90
+ WebhookRateLimitError,
91
+ WebhookNotFoundError,
92
+ WebhookAuthenticationError,
93
+ )
94
+
95
+ try:
96
+ client.endpoints.get(endpoint_id)
97
+ except WebhookNotFoundError:
98
+ ... # 404 — no such endpoint
99
+ except WebhookRateLimitError as err:
100
+ print(f"retry after {err.retry_after_s}s")
101
+ except WebhookAuthenticationError:
102
+ ... # 401 — the key is invalid, expired, or revoked
103
+ ```
104
+
105
+ Each error carries `code` (a stable capability-error string), `status` (the HTTP status), and
106
+ `request_id` when the server sent one.
107
+
108
+ ## Retries & idempotency
109
+
110
+ The client retries idempotent requests on transient failures (429/502/503/504 and network errors) with
111
+ capped exponential backoff and jitter, honouring `Retry-After`. It **never** blind-retries a
112
+ non-idempotent write — creating an endpoint, rotating a secret, or an un-keyed replay won't be sent twice
113
+ by the SDK. Replays carry an idempotency key (auto-generated if you don't pass one), so those are safe:
114
+
115
+ ```python
116
+ import uuid
117
+
118
+ client.events.replay(
119
+ event.id,
120
+ target={"kind": "destination", "destinationId": destination_id},
121
+ idempotency_key=str(uuid.uuid4()),
122
+ )
123
+ ```
124
+
125
+ Tune the budget per client:
126
+
127
+ ```python
128
+ client = WebhookClient(api_key, max_retries=4, timeout_s=15) # defaults: 2 retries, 30s
129
+ ```
130
+
131
+ ## Payloads
132
+
133
+ `events.get_payload` decodes the wire envelope and hands you the exact bytes (length-checked, so a
134
+ truncated body raises rather than silently short-reading):
135
+
136
+ ```python
137
+ payload = client.events.get_payload(event.id)
138
+ print(payload.content_type, len(payload.body)) # payload.body is bytes
139
+ ```
140
+
141
+ ## Configuration
142
+
143
+ | Argument | Default | Notes |
144
+ | -------------- | ------------------------ | ------------------------------------------------------------ |
145
+ | `api_key` | — | Required. A `whk_` API key. |
146
+ | `base_url` | `https://api.webhook.co` | Must be https (loopback http allowed for self-host / dev). |
147
+ | `http_client` | a new `httpx.Client` | Pass your own for custom transports/proxies. |
148
+ | `max_retries` | `2` | Retries after the first attempt, idempotent requests only. |
149
+ | `timeout_s` | `30` | Per-request timeout (seconds), per httpx connect/read/write phase. |
150
+ | `refresh_auth` | — | Hook returning a rotated bearer on a 401 (OAuth flows). |
151
+ | `on_debug` | — | Redacted, single-line diagnostics — never the raw key. |
152
+
153
+ Use it as a context manager to close the underlying connection pool:
154
+
155
+ ```python
156
+ with WebhookClient(api_key) as client:
157
+ client.whoami()
158
+ ```
159
+
160
+ ## API surface
161
+
162
+ `endpoints` (list · list_page · get · create · delete · rotate · `provider_secrets` add/list/revoke) ·
163
+ `events` (list · list_page · get · get_payload · tail · replay) · `deliveries` (list · list_page · get) ·
164
+ `replay_destinations` (create · list · delete · enable · set_ordered · rotate_signing_secret ·
165
+ list_signing_secrets) · `subscriptions` (create · list · delete) · `audit.verify` · `whoami`.
166
+
167
+ ## License
168
+
169
+ Apache-2.0
@@ -0,0 +1,139 @@
1
+ # webhook.co Python SDK
2
+
3
+ The official Python SDK for the [webhook.co](https://webhook.co) API. A typed client with the hardening
4
+ you'd otherwise hand-roll: bearer auth, bounded retries with jitter, cursor pagination, idempotency, and
5
+ secret redaction — with pydantic v2 models generated from the same OpenAPI contract the API is built on.
6
+
7
+ Python 3.10+. Built on `httpx`.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pip install webhook-co
13
+ ```
14
+
15
+ ## Quickstart
16
+
17
+ ```python
18
+ import os
19
+ from webhook_co import WebhookClient
20
+
21
+ client = WebhookClient(api_key=os.environ["WEBHOOK_API_KEY"])
22
+
23
+ # Create an endpoint — the one-time ingest URL is returned once, so capture it now.
24
+ endpoint = client.endpoints.create(name="orders-prod")
25
+ print(endpoint.ingest_url)
26
+
27
+ # List events for that endpoint (auto-paginates).
28
+ for event in client.events.list(endpoint.id):
29
+ print(event.id, event.provider, event.verification_state)
30
+ ```
31
+
32
+ The API key is a `whk_`-prefixed token from your dashboard. Keep it server-side — this SDK never prints
33
+ it, but it's still a credential.
34
+
35
+ Responses come back as validated pydantic models, so attributes are typed and snake_cased
36
+ (`endpoint.org_id`, `endpoint.created_at`).
37
+
38
+ ## Pagination
39
+
40
+ List methods return a `Paginator` you can iterate directly — it follows the cursor for you:
41
+
42
+ ```python
43
+ for endpoint in client.endpoints.list(name="prod"):
44
+ print(endpoint.id)
45
+
46
+ # Collect everything (careful with large result sets):
47
+ failed = client.deliveries.list(status=["failed"]).collect()
48
+
49
+ # One page at a time (e.g. to build your own UI):
50
+ page = client.endpoints.list_page(limit=50)
51
+ print(page.items, page.next_cursor)
52
+ ```
53
+
54
+ ## Errors
55
+
56
+ Every failure is a `WebhookError` subclass, so you can narrow with `except` — no string matching:
57
+
58
+ ```python
59
+ from webhook_co import (
60
+ WebhookRateLimitError,
61
+ WebhookNotFoundError,
62
+ WebhookAuthenticationError,
63
+ )
64
+
65
+ try:
66
+ client.endpoints.get(endpoint_id)
67
+ except WebhookNotFoundError:
68
+ ... # 404 — no such endpoint
69
+ except WebhookRateLimitError as err:
70
+ print(f"retry after {err.retry_after_s}s")
71
+ except WebhookAuthenticationError:
72
+ ... # 401 — the key is invalid, expired, or revoked
73
+ ```
74
+
75
+ Each error carries `code` (a stable capability-error string), `status` (the HTTP status), and
76
+ `request_id` when the server sent one.
77
+
78
+ ## Retries & idempotency
79
+
80
+ The client retries idempotent requests on transient failures (429/502/503/504 and network errors) with
81
+ capped exponential backoff and jitter, honouring `Retry-After`. It **never** blind-retries a
82
+ non-idempotent write — creating an endpoint, rotating a secret, or an un-keyed replay won't be sent twice
83
+ by the SDK. Replays carry an idempotency key (auto-generated if you don't pass one), so those are safe:
84
+
85
+ ```python
86
+ import uuid
87
+
88
+ client.events.replay(
89
+ event.id,
90
+ target={"kind": "destination", "destinationId": destination_id},
91
+ idempotency_key=str(uuid.uuid4()),
92
+ )
93
+ ```
94
+
95
+ Tune the budget per client:
96
+
97
+ ```python
98
+ client = WebhookClient(api_key, max_retries=4, timeout_s=15) # defaults: 2 retries, 30s
99
+ ```
100
+
101
+ ## Payloads
102
+
103
+ `events.get_payload` decodes the wire envelope and hands you the exact bytes (length-checked, so a
104
+ truncated body raises rather than silently short-reading):
105
+
106
+ ```python
107
+ payload = client.events.get_payload(event.id)
108
+ print(payload.content_type, len(payload.body)) # payload.body is bytes
109
+ ```
110
+
111
+ ## Configuration
112
+
113
+ | Argument | Default | Notes |
114
+ | -------------- | ------------------------ | ------------------------------------------------------------ |
115
+ | `api_key` | — | Required. A `whk_` API key. |
116
+ | `base_url` | `https://api.webhook.co` | Must be https (loopback http allowed for self-host / dev). |
117
+ | `http_client` | a new `httpx.Client` | Pass your own for custom transports/proxies. |
118
+ | `max_retries` | `2` | Retries after the first attempt, idempotent requests only. |
119
+ | `timeout_s` | `30` | Per-request timeout (seconds), per httpx connect/read/write phase. |
120
+ | `refresh_auth` | — | Hook returning a rotated bearer on a 401 (OAuth flows). |
121
+ | `on_debug` | — | Redacted, single-line diagnostics — never the raw key. |
122
+
123
+ Use it as a context manager to close the underlying connection pool:
124
+
125
+ ```python
126
+ with WebhookClient(api_key) as client:
127
+ client.whoami()
128
+ ```
129
+
130
+ ## API surface
131
+
132
+ `endpoints` (list · list_page · get · create · delete · rotate · `provider_secrets` add/list/revoke) ·
133
+ `events` (list · list_page · get · get_payload · tail · replay) · `deliveries` (list · list_page · get) ·
134
+ `replay_destinations` (create · list · delete · enable · set_ordered · rotate_signing_secret ·
135
+ list_signing_secrets) · `subscriptions` (create · list · delete) · `audit.verify` · `whoami`.
136
+
137
+ ## License
138
+
139
+ Apache-2.0
@@ -0,0 +1,67 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "webhook-co"
7
+ version = "0.1.0"
8
+ description = "The official Python SDK for the webhook.co API — a typed client with retries, cursor pagination, idempotency, and secret redaction."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "webhook.co" }]
13
+ keywords = ["webhook", "webhooks", "sdk", "standard-webhooks", "webhook.co", "api-client"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = ["httpx>=0.27", "pydantic>=2.7"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://webhook.co"
29
+ Repository = "https://github.com/webhook-co/webhook"
30
+ Issues = "https://github.com/webhook-co/webhook/issues"
31
+
32
+ # Pinned exactly so the committed generated models (src/webhook_co/_generated/models.py) are byte-stable
33
+ # across environments and the drift guard (tests/test_generated_drift.py) is reproducible in CI.
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "pytest>=8",
37
+ "pytest-cov>=5",
38
+ "datamodel-code-generator==0.67.0",
39
+ "black==26.5.1",
40
+ "isort==8.0.1",
41
+ ]
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/webhook_co"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ # Make the top-level `scripts` package importable by the generated-types drift test.
49
+ pythonpath = ["."]
50
+
51
+ [tool.coverage.run]
52
+ source = ["webhook_co"]
53
+ # The generated models carry no hand-written logic; they are drift-guarded, not coverage-guarded.
54
+ omit = ["*/webhook_co/_generated/*"]
55
+
56
+ [tool.coverage.report]
57
+ exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError"]
58
+
59
+ # The generated models are formatted by datamodel-code-generator's own black/isort pass; standalone
60
+ # runs must not touch them or they'd diverge from a fresh generation (the drift test compares bytes).
61
+ [tool.black]
62
+ target-version = ["py310"]
63
+ extend-exclude = "/_generated/"
64
+
65
+ [tool.isort]
66
+ profile = "black"
67
+ extend_skip_glob = ["*/_generated/*"]
File without changes
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """Generate the SDK's pydantic v2 models from the golden OpenAPI document.
3
+
4
+ The models are DERIVED from packages/openapi/src/openapi.json (itself drift-guarded against the Zod
5
+ contract), so the SDK can never silently diverge from the wire shape. The output is committed and
6
+ drift-tested (tests/test_generated_drift.py) exactly like the golden spec, using PINNED tool versions
7
+ (see pyproject.toml [project.optional-dependencies].dev) so the bytes are reproducible in CI.
8
+
9
+ Pipeline: run datamodel-code-generator, then collapse the scalar RootModel wrappers it emits for
10
+ `anyOf: [constrained-scalar, null]` fields (so `delivery.status_code == 200` works instead of silently
11
+ being False), then re-format with black. `render()` is the single source of truth used by both `main()`
12
+ and the drift test, so the committed bytes are exactly reproducible.
13
+
14
+ Regenerate: python -m scripts.generate_models (from sdks/python, with the dev extras installed)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import pathlib
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+
25
+ import black
26
+
27
+ HERE = pathlib.Path(__file__).resolve().parent
28
+ PACKAGE_ROOT = HERE.parent
29
+ SPEC = PACKAGE_ROOT.parent.parent / "packages" / "openapi" / "src" / "openapi.json"
30
+ OUT = PACKAGE_ROOT / "src" / "webhook_co" / "_generated" / "models.py"
31
+
32
+ # Flags chosen for a modern, stable pydantic v2 surface: PEP 604 unions (`X | None`), builtin generics
33
+ # (`list`/`dict`), and `Annotated` field constraints, snake_case attributes with camelCase aliases,
34
+ # `format: uuid` as plain str, forward-compatible `extra=ignore`, and OPEN enums (so an older SDK doesn't
35
+ # crash on a new server-side enum value). Keep this list in lockstep with the drift test via `render()`.
36
+ ARGS = [
37
+ "--input-file-type",
38
+ "openapi",
39
+ "--output-model-type",
40
+ "pydantic_v2.BaseModel",
41
+ "--target-python-version",
42
+ "3.10",
43
+ "--use-annotated",
44
+ "--use-standard-collections",
45
+ "--use-union-operator",
46
+ "--use-schema-description",
47
+ "--snake-case-field",
48
+ "--type-mappings",
49
+ "uuid=string",
50
+ "--ignore-enum-constraints",
51
+ "--extra-fields",
52
+ "ignore",
53
+ "--formatters",
54
+ "black",
55
+ "isort",
56
+ "--disable-timestamp",
57
+ ]
58
+
59
+ # The pinned black target — matches the SDK's minimum Python and keeps the collapse re-format reproducible.
60
+ _BLACK_MODE = black.Mode(target_versions={black.TargetVersion.PY310})
61
+
62
+ _SIMPLE_BASES = {"int", "str", "float", "bool"}
63
+ _ROOT_HEADER = re.compile(r"^class (\w+)\(RootModel\[(\w+)\]\):$")
64
+
65
+
66
+ def build_argv(input_path: pathlib.Path, output_path: pathlib.Path) -> list[str]:
67
+ return [
68
+ sys.executable,
69
+ "-m",
70
+ "datamodel_code_generator",
71
+ "--input",
72
+ str(input_path),
73
+ "--output",
74
+ str(output_path),
75
+ *ARGS,
76
+ ]
77
+
78
+
79
+ def _collapse_scalar_root_models(text: str) -> str:
80
+ """Inline `class X(RootModel[<scalar>])` wrappers to their base scalar.
81
+
82
+ datamodel-code-generator wraps a constrained scalar inside an `anyOf: [scalar, null]` in a named
83
+ RootModel (e.g. `StatusCode(RootModel[int])`), typing the field as `StatusCode | None`. That reads
84
+ wrong (`delivery.status_code == 200` is always False; `str(destination_id)` is `"root=..."`). Only
85
+ the SCALAR RootModels are collapsed; a union RootModel (e.g. `AuditVerifyResponse`) is left intact.
86
+ """
87
+ lines = text.split("\n")
88
+ kept: list[str] = []
89
+ mapping: dict[str, str] = {}
90
+ i = 0
91
+ while i < len(lines):
92
+ header = _ROOT_HEADER.match(lines[i])
93
+ if header and header.group(2) in _SIMPLE_BASES:
94
+ mapping[header.group(1)] = header.group(2)
95
+ # Drop the class block: its header + all following blank/indented body lines.
96
+ i += 1
97
+ while i < len(lines) and (
98
+ lines[i] == "" or lines[i].startswith((" ", "\t"))
99
+ ):
100
+ i += 1
101
+ continue
102
+ kept.append(lines[i])
103
+ i += 1
104
+ result = "\n".join(kept)
105
+ for name, base in mapping.items():
106
+ result = re.sub(rf"\b{name}\b", base, result)
107
+ return result
108
+
109
+
110
+ def render(spec_path: pathlib.Path) -> str:
111
+ """The full generation pipeline (datamodel-codegen → collapse scalar RootModels → black). The single
112
+ source of truth for the committed file and the drift test.
113
+ """
114
+ with tempfile.TemporaryDirectory() as tmp:
115
+ raw_out = pathlib.Path(tmp) / "models.py"
116
+ subprocess.run(build_argv(spec_path, raw_out), check=True, capture_output=True)
117
+ raw = raw_out.read_text()
118
+ collapsed = _collapse_scalar_root_models(raw)
119
+ return black.format_str(collapsed, mode=_BLACK_MODE)
120
+
121
+
122
+ def main() -> None:
123
+ OUT.parent.mkdir(parents=True, exist_ok=True)
124
+ OUT.write_text(render(SPEC))
125
+ print(f"✓ generated {OUT}")
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
@@ -0,0 +1,90 @@
1
+ """The official Python SDK for the webhook.co API.
2
+
3
+ from webhook_co import WebhookClient
4
+
5
+ client = WebhookClient(api_key="whk_...")
6
+ for endpoint in client.endpoints.list():
7
+ print(endpoint.id, endpoint.name)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ._errors import (
13
+ WebhookAPIError,
14
+ WebhookAuthenticationError,
15
+ WebhookConfigError,
16
+ WebhookConflictError,
17
+ WebhookConnectionError,
18
+ WebhookError,
19
+ WebhookInvalidRequestError,
20
+ WebhookNotFoundError,
21
+ WebhookPermissionError,
22
+ WebhookRateLimitError,
23
+ WebhookTargetUnreachableError,
24
+ WebhookUnexpectedResponseError,
25
+ )
26
+ from ._generated.models import (
27
+ AddedProviderSecret,
28
+ AuditVerifyResponse,
29
+ AuthContext,
30
+ CreatedEndpoint,
31
+ CreatedReplayDestination,
32
+ DeletedEndpoint,
33
+ Delivery,
34
+ DeliveryAttempt,
35
+ Endpoint,
36
+ Event,
37
+ EventsTailResponse,
38
+ EventSummary,
39
+ ProviderSecretSummary,
40
+ ReplayDestination,
41
+ ReplayDestinationDeleted,
42
+ RevokedProviderSecret,
43
+ RotatedSigningSecret,
44
+ SigningSecretMetadata,
45
+ Subscription,
46
+ SubscriptionDeleted,
47
+ )
48
+ from ._pagination import Page, Paginator
49
+ from .client import EventPayload, WebhookClient
50
+
51
+ __all__ = [
52
+ "WebhookClient",
53
+ "EventPayload",
54
+ "Page",
55
+ "Paginator",
56
+ # errors
57
+ "WebhookError",
58
+ "WebhookConfigError",
59
+ "WebhookConnectionError",
60
+ "WebhookAPIError",
61
+ "WebhookAuthenticationError",
62
+ "WebhookPermissionError",
63
+ "WebhookNotFoundError",
64
+ "WebhookInvalidRequestError",
65
+ "WebhookConflictError",
66
+ "WebhookRateLimitError",
67
+ "WebhookTargetUnreachableError",
68
+ "WebhookUnexpectedResponseError",
69
+ # models
70
+ "Endpoint",
71
+ "CreatedEndpoint",
72
+ "DeletedEndpoint",
73
+ "Event",
74
+ "EventSummary",
75
+ "EventsTailResponse",
76
+ "Delivery",
77
+ "DeliveryAttempt",
78
+ "ReplayDestination",
79
+ "CreatedReplayDestination",
80
+ "ReplayDestinationDeleted",
81
+ "RotatedSigningSecret",
82
+ "SigningSecretMetadata",
83
+ "Subscription",
84
+ "SubscriptionDeleted",
85
+ "AddedProviderSecret",
86
+ "ProviderSecretSummary",
87
+ "RevokedProviderSecret",
88
+ "AuditVerifyResponse",
89
+ "AuthContext",
90
+ ]
@@ -0,0 +1,40 @@
1
+ """Client configuration resolution.
2
+
3
+ The base URL is the destination for the bearer credential, so it MUST be https — plaintext http is
4
+ rejected except for loopback (local dev / self-host) — otherwise a misconfiguration could downgrade the
5
+ key to plaintext or redirect it to a hostile host. The URL must be a clean origin+path (no query/
6
+ fragment); the normalised form is returned.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from urllib.parse import urlsplit
12
+
13
+ from ._errors import WebhookConfigError
14
+
15
+ #: The canonical hosted API (mirrors the OpenAPI ``servers`` entry).
16
+ DEFAULT_BASE_URL = "https://api.webhook.co"
17
+
18
+ _LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
19
+
20
+
21
+ def resolve_base_url(raw: str | None) -> str:
22
+ """Validate + normalise a base URL (defaulting to the hosted API).
23
+
24
+ https-only except loopback http; no query/fragment; a trailing slash is stripped so
25
+ ``f"{base}/v1/…"`` concatenation is always well-formed.
26
+ """
27
+ value = raw or DEFAULT_BASE_URL
28
+ parts = urlsplit(value)
29
+ if not parts.scheme or not parts.netloc:
30
+ raise WebhookConfigError(f"invalid base URL: {value}")
31
+ loopback_http_ok = parts.scheme == "http" and parts.hostname in _LOOPBACK_HOSTS
32
+ if parts.scheme != "https" and not loopback_http_ok:
33
+ raise WebhookConfigError(
34
+ f"base URL must be https (got {parts.scheme}://): {value}"
35
+ )
36
+ if parts.query or parts.fragment:
37
+ raise WebhookConfigError(
38
+ f"base URL must not carry a query or fragment: {value}"
39
+ )
40
+ return f"{parts.scheme}://{parts.netloc}{parts.path}".rstrip("/")