Skip to content

Commit 1e049bd

Browse files
vdavezclaude
andcommitted
Add webhooks signing helpers, local receiver, and tango CLI
Tier 1+2 of the Stripe-CLI-style webhook DX migration from tango/tools/ webhook_lab into the SDK. - tango.webhooks: HMAC-SHA256 signing helpers (verify_signature, generate_signature, parse_signature_header). Pure stdlib; importable from a default install. - WebhookReceiver: stdlib-based local listener with optional forwarding, delivery history, and on_delivery callback. Usable as a context manager inside integration tests. - simulate.deliver: offline sign+POST helper for driving a receiver without provisioning a real subscription. - New tango[webhooks] extra installs click and a tango console script with `webhooks listen|trigger|simulate` subcommands. - Top-level re-exports: WebhookReceiver, Delivery, verify_signature, generate_signature, parse_signature_header. The script name `tango` is flagged in CHANGELOG as revisitable before release if it conflicts with sibling tooling (e.g. tango-scripts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 35f8d77 commit 1e049bd

13 files changed

Lines changed: 998 additions & 1 deletion

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- `tango.webhooks` subpackage with HMAC-SHA256 signing helpers (`verify_signature`, `generate_signature`, `parse_signature_header`) that mirror the canonical Tango server scheme byte-for-byte. Importable from a default `pip install tango-python` (pure stdlib).
12+
- `WebhookReceiver`: a stdlib-based local HTTP listener for development and integration tests. Verifies signatures, optionally forwards each delivery to a downstream URL, and records deliveries in memory for inspection. Usable as a context manager (`with WebhookReceiver(secret=...).run() as rx: ...`).
13+
- `tango.webhooks.simulate.deliver(...)`: locally sign and POST a payload to any URL — no Tango involvement. Useful for offline iteration on receiver code.
14+
- New `tango[webhooks]` extra (adds `click`) ships a `tango` console script with `webhooks listen|trigger|simulate` subcommands. Stripe-CLI-style developer ergonomics for testing webhook integrations.
15+
16+
### Notes
17+
- Console script name `tango` may be revisited before the next release if it conflicts with sibling tooling (`tango-scripts` reuses the bare name).
18+
1019
## [0.5.0] - 2026-04-08
1120

1221
### Added

pyproject.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ notebooks = [
4141
"jupyter>=1.0.0",
4242
"ipykernel>=6.25.0",
4343
]
44+
webhooks = [
45+
"click>=8.1",
46+
]
47+
48+
[project.scripts]
49+
tango = "tango.webhooks.cli:main"
4450

4551
[project.urls]
4652
Homepage = "https://github.com/makegov/tango-python"
@@ -116,6 +122,10 @@ exclude_lines = [
116122
[tool.hatch.build.targets.wheel]
117123
packages = ["tango"]
118124

125+
[[tool.mypy.overrides]]
126+
module = "tango.webhooks.cli"
127+
disallow_untyped_decorators = false
128+
119129
[dependency-groups]
120130
dev = [
121131
"python-dotenv>=1.2.1",

tango/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
ShapeParser,
2929
TypeGenerator,
3030
)
31+
from .webhooks import (
32+
generate_signature,
33+
parse_signature_header,
34+
verify_signature,
35+
)
36+
from .webhooks.receiver import Delivery, WebhookReceiver
3137

3238
__version__ = "0.5.0"
3339
__all__ = [
@@ -53,4 +59,9 @@
5359
"ModelFactory",
5460
"TypeGenerator",
5561
"SchemaRegistry",
62+
"Delivery",
63+
"WebhookReceiver",
64+
"generate_signature",
65+
"parse_signature_header",
66+
"verify_signature",
5667
]

tango/webhooks/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Tango webhooks: signature helpers and developer tooling.
2+
3+
The signing helpers (:func:`verify_signature`, :func:`generate_signature`,
4+
:func:`parse_signature_header`) are pure stdlib and importable from a default
5+
``pip install tango``. The CLI (``tango webhooks ...``) and the in-process
6+
:class:`~tango.webhooks.receiver.WebhookReceiver` ship with the
7+
``tango[webhooks]`` extra.
8+
"""
9+
10+
from tango.webhooks.signing import (
11+
SIGNATURE_HEADER,
12+
SIGNATURE_PREFIX,
13+
generate_signature,
14+
parse_signature_header,
15+
verify_signature,
16+
)
17+
18+
__all__ = [
19+
"SIGNATURE_HEADER",
20+
"SIGNATURE_PREFIX",
21+
"generate_signature",
22+
"parse_signature_header",
23+
"verify_signature",
24+
]

tango/webhooks/cli.py

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
"""Command-line interface for Tango webhook tooling.
2+
3+
This module is the entry point for the ``tango`` console script. Click is an
4+
optional dependency installed via the ``tango[webhooks]`` extra; importing
5+
this module without it raises a friendly error that points users at the
6+
right install command.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import sys
13+
import threading
14+
from pathlib import Path
15+
from typing import Any
16+
17+
try:
18+
import click
19+
except ImportError as _import_error: # pragma: no cover - tested via subprocess
20+
sys.stderr.write(
21+
"tango CLI requires the 'webhooks' extra. Install it with:\n"
22+
" pip install 'tango-python[webhooks]'\n"
23+
)
24+
raise SystemExit(1) from _import_error
25+
26+
from tango.webhooks import simulate
27+
from tango.webhooks.receiver import Delivery, WebhookReceiver
28+
from tango.webhooks.signing import SIGNATURE_HEADER
29+
30+
31+
@click.group()
32+
@click.version_option(package_name="tango-python", prog_name="tango")
33+
def main() -> None:
34+
"""Tango developer tooling."""
35+
36+
37+
@main.group()
38+
def webhooks() -> None:
39+
"""Receive, trigger, and simulate Tango webhook deliveries."""
40+
41+
42+
@webhooks.command("listen")
43+
@click.option("--port", type=int, default=8011, show_default=True, help="TCP port to bind.")
44+
@click.option("--host", default="127.0.0.1", show_default=True, help="Bind address.")
45+
@click.option(
46+
"--path",
47+
default="/tango/webhooks",
48+
show_default=True,
49+
help="URL path to accept deliveries on.",
50+
)
51+
@click.option(
52+
"--secret",
53+
envvar="TANGO_WEBHOOK_SECRET",
54+
default="",
55+
help="Shared secret. Reads TANGO_WEBHOOK_SECRET if unset. "
56+
"If empty, deliveries are accepted without signature verification.",
57+
)
58+
@click.option(
59+
"--forward-to",
60+
default=None,
61+
help="Optional URL to mirror each delivery to (preserves body and signature).",
62+
)
63+
@click.option(
64+
"--require-signature/--allow-unsigned",
65+
default=None,
66+
help="Override default policy. Default: require when --secret is set.",
67+
)
68+
def listen_cmd(
69+
port: int,
70+
host: str,
71+
path: str,
72+
secret: str,
73+
forward_to: str | None,
74+
require_signature: bool | None,
75+
) -> None:
76+
"""Run a local receiver and stream deliveries to stdout."""
77+
receiver = WebhookReceiver(
78+
secret=secret,
79+
path=path,
80+
host=host,
81+
port=port,
82+
forward_to=forward_to,
83+
require_signature=require_signature,
84+
on_delivery=_print_delivery,
85+
)
86+
receiver.start()
87+
try:
88+
click.echo(f"Listening on {receiver.url}")
89+
if not secret:
90+
click.echo(
91+
" WARNING: no --secret provided; signatures will not be verified.",
92+
err=True,
93+
)
94+
if forward_to:
95+
click.echo(f" Forwarding to {forward_to}")
96+
click.echo(" Press Ctrl+C to stop.")
97+
threading.Event().wait() # block until interrupted
98+
except KeyboardInterrupt:
99+
click.echo("\nStopping...")
100+
finally:
101+
receiver.stop()
102+
103+
104+
@webhooks.command("trigger")
105+
@click.option(
106+
"--endpoint-id",
107+
default=None,
108+
help="Endpoint UUID. If omitted, the server's default endpoint is used.",
109+
)
110+
@click.option(
111+
"--api-key",
112+
envvar="TANGO_API_KEY",
113+
help="Tango API key (or set TANGO_API_KEY).",
114+
)
115+
@click.option(
116+
"--base-url",
117+
envvar="TANGO_BASE_URL",
118+
default="https://tango.makegov.com",
119+
show_default=True,
120+
help="Tango base URL (or set TANGO_BASE_URL).",
121+
)
122+
def trigger_cmd(endpoint_id: str | None, api_key: str | None, base_url: str) -> None:
123+
"""Ask Tango to send a real test delivery to your configured endpoint."""
124+
from tango import TangoClient
125+
126+
client = TangoClient(api_key=api_key, base_url=base_url)
127+
result = client.test_webhook_delivery(endpoint_id=endpoint_id)
128+
click.echo(
129+
json.dumps(
130+
{
131+
"success": result.success,
132+
"status_code": result.status_code,
133+
"response_time_ms": result.response_time_ms,
134+
"endpoint_url": result.endpoint_url,
135+
"message": result.message,
136+
"error": result.error,
137+
},
138+
indent=2,
139+
)
140+
)
141+
if not result.success:
142+
raise SystemExit(1)
143+
144+
145+
@webhooks.command("simulate")
146+
@click.option("--to", "target_url", required=True, help="Receiver URL to POST to.")
147+
@click.option(
148+
"--secret",
149+
envvar="TANGO_WEBHOOK_SECRET",
150+
required=True,
151+
help="Shared secret used to sign the payload (or TANGO_WEBHOOK_SECRET).",
152+
)
153+
@click.option(
154+
"--payload-file",
155+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
156+
default=None,
157+
help="Path to a JSON file with the body to send. Mutually exclusive with --event-type.",
158+
)
159+
@click.option(
160+
"--event-type",
161+
default=None,
162+
help="Fetch a canonical sample for this event type from Tango and send that.",
163+
)
164+
@click.option(
165+
"--api-key",
166+
envvar="TANGO_API_KEY",
167+
help="Tango API key, only needed with --event-type (or TANGO_API_KEY).",
168+
)
169+
@click.option(
170+
"--base-url",
171+
envvar="TANGO_BASE_URL",
172+
default="https://tango.makegov.com",
173+
show_default=True,
174+
help="Tango base URL, only needed with --event-type.",
175+
)
176+
def simulate_cmd(
177+
target_url: str,
178+
secret: str,
179+
payload_file: Path | None,
180+
event_type: str | None,
181+
api_key: str | None,
182+
base_url: str,
183+
) -> None:
184+
"""Sign a payload locally and POST it to a receiver. No Tango call unless --event-type."""
185+
if payload_file and event_type:
186+
raise click.UsageError("Use either --payload-file or --event-type, not both.")
187+
188+
payload: dict[str, Any] | list[Any]
189+
if payload_file:
190+
payload = json.loads(payload_file.read_text(encoding="utf-8"))
191+
elif event_type:
192+
from tango import TangoClient
193+
194+
client = TangoClient(api_key=api_key, base_url=base_url)
195+
payload = client.get_webhook_sample_payload(event_type=event_type)
196+
else:
197+
payload = {"events": [{"event_type": "tango.cli.simulated", "subject_ids": []}]}
198+
199+
result = simulate.deliver(target_url=target_url, payload=payload, secret=secret)
200+
click.echo(
201+
json.dumps(
202+
{
203+
"status_code": result.status_code,
204+
"signature": f"sha256={result.signature}",
205+
"sent_bytes": len(result.sent_bytes),
206+
"response_body": result.response_body[:500],
207+
},
208+
indent=2,
209+
)
210+
)
211+
if result.status_code >= 400:
212+
raise SystemExit(1)
213+
214+
215+
def _print_delivery(delivery: Delivery) -> None:
216+
"""Default ``listen`` callback: write a one-line summary plus body."""
217+
summary = _summarize(delivery.body_json)
218+
status = "verified" if delivery.verified else "UNVERIFIED"
219+
parts = [delivery.received_at, status, summary]
220+
if delivery.forward_status is not None:
221+
parts.append(f"forwarded={delivery.forward_status}")
222+
if delivery.forward_error:
223+
parts.append(f"forward_error={delivery.forward_error}")
224+
click.echo(" | ".join(parts))
225+
if delivery.body_json is not None:
226+
click.echo(json.dumps(delivery.body_json, indent=2, sort_keys=True))
227+
click.echo("")
228+
229+
230+
def _summarize(body: Any) -> str:
231+
if isinstance(body, dict):
232+
events = body.get("events")
233+
if isinstance(events, list) and events and isinstance(events[0], dict):
234+
event_type = events[0].get("event_type", "?")
235+
count = len(events)
236+
return f"{event_type} (n={count})"
237+
return "(no events)"
238+
239+
240+
# Make ``X-Tango-Signature`` accessible from this module for IDE completion.
241+
__all__ = ["main", "SIGNATURE_HEADER"]

0 commit comments

Comments
 (0)