Skip to content

Commit 7ede733

Browse files
feat(webhooks): remove subject-based subscription surface (v0.7.0)
Folds in the work from PR #27 (issue-2275-drop-subjects) plus three cleanup-batch carry-overs from tango's `dogpile/api-cleanup-batch`: - #2256: add `endpoint` kwarg to create_webhook_alert (multi-endpoint accounts can now use the convenience wrapper) - #2254: remove `ordering` kwarg from list_notices and list_protests (server rejects every value) - #2252: switch test_webhook_delivery to canonical `endpoint` body key Closes makegov/tango#2275 — part of makegov/tango#2267. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 05cd92b commit 7ede733

16 files changed

Lines changed: 171 additions & 722 deletions

CHANGELOG.md

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

88
## [Unreleased]
99

10+
> Combined release: API parity (formerly tracked as PR #25) + subject-based
11+
> webhook removal (formerly tracked as PR #27 / issue #2275). Bumped to
12+
> `0.7.0` because removing `create_webhook_subscription` /
13+
> `update_webhook_subscription` / `delete_webhook_subscription` /
14+
> `list_webhook_subscriptions` / `get_webhook_subscription` and the
15+
> `WebhookSubscription` + `WebhookSubjectTypeDefinition` dataclasses is a
16+
> breaking change.
17+
1018
### Added
11-
- `ordering` parameter on `list_forecasts`, `list_grants`, `list_subawards`, `list_gsa_elibrary_contracts`, `list_opportunities`, `list_notices`, and `list_protests`. Prefix with `-` for descending. Closes a parity gap with the API surface (these endpoints all accept `?ordering=` server-side).
19+
- `ordering` parameter on `list_forecasts`, `list_grants`, `list_subawards`, `list_gsa_elibrary_contracts`, and `list_opportunities`. Prefix with `-` for descending. Closes a parity gap with the API surface (these endpoints all accept `?ordering=` server-side).
1220
- `create_webhook_endpoint` accepts `name=` (keyword-only). Required by the Tango API since multi-endpoint support landed; omitting it now emits a `DeprecationWarning` and will become an error in a future major version.
13-
- `create_webhook_subscription` accepts `endpoint=`, `subscription_type=`, `query_type=`, `filter_definition=`, `frequency=`, `cron_expression=`, `is_active=` (keyword-only). Lets callers target a specific endpoint and create filter subscriptions through the canonical API.
14-
- `update_webhook_subscription` accepts `frequency=`, `cron_expression=`, `is_active=` for filter-subscription updates.
1521
- `update_webhook_endpoint` accepts `name=` for renaming an endpoint.
16-
- Webhook alerts (filter subscriptions): `list_webhook_alerts`, `get_webhook_alert`, `create_webhook_alert`, `update_webhook_alert`, `delete_webhook_alert` — the convenience layer over `/api/webhooks/alerts/`. New `WebhookAlert` dataclass exported from the top-level package.
22+
- Webhook alerts (filter subscriptions): `list_webhook_alerts`, `get_webhook_alert`, `create_webhook_alert`, `update_webhook_alert`, `delete_webhook_alert` — the canonical write surface over `/api/webhooks/alerts/`. New `WebhookAlert` dataclass exported from the top-level package.
1723
- `resolve(name, target_type, ...)` — POST `/api/resolve/` to rank entity / organization candidates from a free-text name. Returns `ResolveResult` with `ResolveCandidate` entries (both exported).
1824
- `validate(identifier_type, value)` — POST `/api/validate/` to validate the format of a PIID, solicitation number, or UEI. Returns `ValidateResult` (exported).
1925
- Reference data: `list_departments`, `get_department`, `list_psc`, `get_psc`, `get_psc_metrics`, `get_naics`, `get_naics_metrics`, `get_business_type`, `list_assistance_listings`, `get_assistance_listing`, `list_mas_sins`, `get_mas_sin`.
@@ -22,6 +28,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2228
- Agency sub-resources: `list_agency_awarding_contracts`, `list_agency_funding_contracts`.
2329
- Misc: `search_opportunity_attachments(q, top_k, include_extracted_text)` for `/api/opportunities/attachment-search/`; `get_version()` for `/api/version/`; `list_api_keys()` for `/api/api-keys/`.
2430

31+
### Changed
32+
- `create_webhook_alert` accepts `endpoint=` (keyword-only). Required for accounts with multiple webhook endpoints; auto-resolves for single-endpoint accounts. Closes the multi-endpoint smoke-test gap (tango#2256).
33+
- `test_webhook_delivery` now sends the canonical `endpoint` body key instead of the deprecated `endpoint_id` alias (tango#2252). The Python kwarg name stays `endpoint_id=` for backwards compatibility; the wire payload is what changed.
34+
35+
### Removed
36+
- **Subject-based webhook subscription surface** (tango#2275). Migrate to `create_webhook_alert(...)` and the alerts API.
37+
- Methods: `list_webhook_subscriptions`, `get_webhook_subscription`, `create_webhook_subscription`, `update_webhook_subscription`, `delete_webhook_subscription`.
38+
- Dataclasses: `WebhookSubscription`, `WebhookSubjectTypeDefinition`. Both are no longer exported from the top-level `tango` package — importing them raises `ImportError`.
39+
- Fields: `default_subject_type` removed from `WebhookEventType`; `subject_types` and `subject_type_definitions` removed from `WebhookEventTypesResponse`. The server's `/api/webhooks/event-types/` response no longer carries these.
40+
- CLI: the entire `tango webhooks subscriptions` Click subgroup (`list` / `get` / `create` / `delete`). Use the SDK's `client.create_webhook_alert(...)` etc. directly — there is no CLI subgroup for alerts.
41+
- `ordering` kwarg from `list_notices` and `list_protests`. The notices and protests viewsets reject every `?ordering=` value at runtime (tango#2254); the kwarg silently sent unsupported values. Other five list methods retain `ordering`.
42+
2543
### Fixed
2644
- `TangoClient._post()` and `_patch()` now accept both `json_data=` (positional) and `json=` (keyword) for backward compatibility. Internal callers and docs examples that use `json=` no longer fail with `TypeError`.
2745

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -384,9 +384,8 @@ tango webhooks simulate --secret $SECRET --event-type entities.updated # sign +
384384
tango webhooks simulate --secret $SECRET --event-type entities.updated \
385385
--to http://127.0.0.1:8011/tango/webhooks # also POST
386386

387-
# Manage real subscriptions and endpoints
388-
tango webhooks endpoints create|list|get|delete
389-
tango webhooks subscriptions create|list|get|delete
387+
# Manage delivery endpoints
388+
tango webhooks endpoints create|list|get|delete
390389

391390
# Force a real test delivery from Tango
392391
tango webhooks trigger

docs/API_REFERENCE.md

Lines changed: 18 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1554,7 +1554,7 @@ results = client.search_opportunity_attachments(
15541554

15551555
## Webhook Alerts
15561556

1557-
The Alerts API is a filter-subscription convenience layer on top of subscriptions.
1557+
The Alerts API is the canonical (and only) write surface for webhook subscriptions. Every alert maps to one of the five `alerts.*.match` event types and delivers when its saved-search filters match new or modified records.
15581558

15591559
### list_webhook_alerts()
15601560

@@ -1578,9 +1578,20 @@ alert = client.create_webhook_alert(
15781578
)
15791579
```
15801580

1581+
For multi-endpoint accounts, pin the delivery target with `endpoint=`:
1582+
1583+
```python
1584+
alert = client.create_webhook_alert(
1585+
name="New cloud IT contracts",
1586+
query_type="contract",
1587+
filters={"naics": "541511"},
1588+
endpoint="ENDPOINT_UUID",
1589+
)
1590+
```
1591+
15811592
**Notes:**
15821593
- `name` and `query_type` are required. `query_type` is **singular** (e.g. `"contract"`, not `"contracts"`).
1583-
- Field naming differs from `create_webhook_subscription`: `name` / `filters` here vs `subscription_name` / `filter_definition`.
1594+
- `endpoint=` is optional and only required when the account has multiple webhook endpoints; for single-endpoint accounts the server auto-resolves.
15841595

15851596
### update_webhook_alert()
15861597

@@ -1614,67 +1625,19 @@ keys = client.list_api_keys()
16141625

16151626
## Webhooks
16161627

1617-
Webhook APIs let **Large / Enterprise** users manage subscription filters for outbound Tango webhooks.
1628+
Webhook APIs let **Large / Enterprise** users manage delivery endpoints and discover the supported event-type catalog. Filter subscriptions (alerts) live in the [Webhook Alerts](#webhook-alerts) section above.
16181629

16191630
> **For testing, signing, and a CLI tool**, see [`docs/WEBHOOKS.md`](WEBHOOKS.md). This section covers SDK method signatures only.
16201631
16211632
### list_webhook_event_types()
16221633

1623-
Discover supported `event_type` values and subject types.
1634+
Discover supported `event_type` values.
16241635

16251636
```python
16261637
info = client.list_webhook_event_types()
16271638
print(info.event_types[0].event_type)
16281639
```
16291640

1630-
### list_webhook_subscriptions()
1631-
1632-
```python
1633-
subs = client.list_webhook_subscriptions(page=1, page_size=25)
1634-
```
1635-
1636-
Notes:
1637-
1638-
- This endpoint uses `page` + `page_size` (tier-capped) rather than `limit`.
1639-
1640-
### get_webhook_subscription()
1641-
1642-
```python
1643-
sub = client.get_webhook_subscription("SUBSCRIPTION_UUID")
1644-
```
1645-
1646-
### create_webhook_subscription()
1647-
1648-
```python
1649-
sub = client.create_webhook_subscription(
1650-
"Track specific vendors",
1651-
{
1652-
"records": [
1653-
{"event_type": "awards.new_award", "subject_type": "entity", "subject_ids": ["UEI123ABC"]},
1654-
{"event_type": "awards.new_transaction", "subject_type": "entity", "subject_ids": ["UEI123ABC"]},
1655-
]
1656-
},
1657-
)
1658-
```
1659-
1660-
Notes:
1661-
1662-
- Prefer v2 fields: `subject_type` + `subject_ids`.
1663-
- Legacy compatibility: `resource_ids` is accepted as an alias for `subject_ids` (don’t send both).
1664-
- Catch-all: `subject_ids: []` means “all subjects” for that record and is **Enterprise-only**. Large tier users must list specific IDs.
1665-
1666-
### update_webhook_subscription()
1667-
1668-
```python
1669-
sub = client.update_webhook_subscription("SUBSCRIPTION_UUID", subscription_name="Updated name")
1670-
```
1671-
1672-
### delete_webhook_subscription()
1673-
1674-
```python
1675-
client.delete_webhook_subscription("SUBSCRIPTION_UUID")
1676-
```
1677-
16781641
### list_webhook_endpoints()
16791642

16801643
List your webhook endpoint(s).
@@ -1710,10 +1673,10 @@ print(result.success, result.status_code)
17101673

17111674
### get_webhook_sample_payload()
17121675

1713-
Fetch Tango-shaped sample deliveries (and sample subscription request bodies).
1676+
Fetch Tango-shaped sample deliveries.
17141677

17151678
```python
1716-
sample = client.get_webhook_sample_payload(event_type="awards.new_award")
1679+
sample = client.get_webhook_sample_payload(event_type="alerts.contract.match")
17171680
print(sample["event_type"])
17181681
```
17191682

@@ -1722,7 +1685,7 @@ print(sample["event_type"])
17221685
The API does not currently expose a public `/api/webhooks/deliveries/` or redelivery endpoint. Use:
17231686

17241687
- `test_webhook_delivery()` for connectivity checks
1725-
- `get_webhook_sample_payload()` for building handlers + subscription payloads
1688+
- `get_webhook_sample_payload()` for building handlers
17261689

17271690
### Receiving webhooks (signature verification)
17281691

docs/WEBHOOKS.md

Lines changed: 54 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Webhooks Guide
22

3-
This guide covers everything `tango-python` provides for **building, testing, and operating webhook integrations against the Tango API**: signing helpers, a local receiver, a command-line tool, and management commands for the underlying endpoints and subscriptions.
3+
This guide covers everything `tango-python` provides for **building, testing, and operating webhook integrations against the Tango API**: signing helpers, a local receiver, a command-line tool, and management commands for the underlying endpoints and alerts.
44

55
If you only need the SDK method signatures, see [`API_REFERENCE.md` § Webhooks](API_REFERENCE.md#webhooks). For the API-level contract (signing scheme, event taxonomy, retry behavior), see the [Tango Webhooks Partner Guide](https://docs.makegov.com/webhooks-user-guide/).
66

@@ -18,7 +18,6 @@ If you only need the SDK method signatures, see [`API_REFERENCE.md` § Webhooks]
1818
- [`tango webhooks fetch-sample`](#tango-webhooks-fetch-sample)
1919
- [`tango webhooks list-event-types`](#tango-webhooks-list-event-types)
2020
- [`tango webhooks endpoints`](#tango-webhooks-endpoints)
21-
- [`tango webhooks subscriptions`](#tango-webhooks-subscriptions)
2221
- [Programmatic use](#programmatic-use)
2322
- [Signature verification in your handler](#signature-verification-in-your-handler)
2423
- [`WebhookReceiver` in pytest fixtures](#webhookreceiver-in-pytest-fixtures)
@@ -54,18 +53,18 @@ tango webhooks --help
5453

5554
## Concepts in 60 seconds
5655

57-
Tango webhooks have three pieces of state:
56+
Tango webhooks have two pieces of state:
5857

5958
| Concept | What it is | Tango term |
6059
|---|---|---|
6160
| **Endpoint** | The URL Tango POSTs to, plus a generated signing secret | `WebhookEndpoint` |
62-
| **Subscription** | A filter saying *which events* you want delivered to that endpoint | `WebhookSubscription` |
61+
| **Alert** | A saved-search filter saying *which matches* to deliver | `WebhookAlert` (filter subscription) |
6362
| **Delivery** | A single signed POST Tango makes when a matching event fires | (the request itself) |
6463

6564
A typical setup:
6665

6766
1. **Create an endpoint** (`POST /api/webhooks/endpoints/`) with the public URL of your handler. Tango returns a `secret` — save it; it's used to sign every delivery.
68-
2. **Create one or more subscriptions** (`POST /api/webhooks/subscriptions/`) describing the events your handler cares about (e.g. `entities.updated` for specific UEIs).
67+
2. **Create one or more alerts** (`POST /api/webhooks/alerts/`) describing the saved-search matches you want delivered (e.g. opportunities matching `naics=541511`). Each alert maps to one of five `alerts.*.match` event types.
6968
3. **Tango POSTs** to your endpoint when matching events fire. The body is JSON; the header `X-Tango-Signature: sha256=<hex>` is the HMAC-SHA256 of the raw body bytes keyed by your endpoint's secret.
7069
4. **Your handler verifies the signature**, parses the body, and acts on it.
7170

@@ -80,15 +79,17 @@ Assumes you have a `TANGO_API_KEY` and want to receive entity-update webhooks fo
8079
```bash
8180
export TANGO_API_KEY=...
8281
tango webhooks list-event-types
83-
# entities.updated An entity record was updated
84-
# awards.created A new award was published
85-
# ...
82+
# alerts.opportunity.match New/updated opportunity matched a saved alert
83+
# alerts.contract.match New/updated contract matched a saved alert
84+
# alerts.entity.match Entity matched a saved alert
85+
# alerts.grant.match Grant matched a saved alert
86+
# alerts.forecast.match Forecast matched a saved alert
8687
```
8788

8889
### 2. See what a payload looks like
8990

9091
```bash
91-
tango webhooks fetch-sample --event-type entities.updated
92+
tango webhooks fetch-sample --event-type alerts.entity.match
9293
```
9394

9495
Prints the canonical JSON shape Tango will deliver. No POST, no signature — just the body.
@@ -107,26 +108,32 @@ In another shell, drive it with the canonical sample, signed locally:
107108
```bash
108109
tango webhooks simulate \
109110
--secret $TANGO_WEBHOOK_SECRET \
110-
--event-type entities.updated \
111+
--event-type alerts.entity.match \
111112
--to http://127.0.0.1:8011/tango/webhooks
112113
```
113114

114-
The listener should print a `verified` delivery with the entities-updated body. You now have a feedback loop: edit your handler, re-run `simulate`, see the result.
115+
The listener should print a `verified` delivery with the alerts.entity.match body. You now have a feedback loop: edit your handler, re-run `simulate`, see the result.
115116

116117
### 4. Wire up the real Tango → your handler path
117118

118-
When you're ready for end-to-end testing against Tango itself, expose your local listener via a tunnel (`ngrok http 8011`, `cloudflared tunnel`, etc.) and register that public URL with Tango:
119+
When you're ready for end-to-end testing against Tango itself, expose your local listener via a tunnel (`ngrok http 8011`, `cloudflared tunnel`, etc.) and register that public URL with Tango, then create an alert via the SDK:
119120

120121
```bash
121122
# Use the public URL the tunnel gave you.
122123
tango webhooks endpoints create --url https://<your-tunnel>.ngrok.io/tango/webhooks
123124
# Save the `secret` from the response — that's what your handler uses to verify.
125+
```
124126

125-
tango webhooks subscriptions create \
126-
--name "watch UEI ABC123" \
127-
--event-type entities.updated \
128-
--subject-type entity \
129-
--subject-id ABC123
127+
```python
128+
# Create an alert (filter subscription) via the SDK
129+
from tango import TangoClient
130+
131+
client = TangoClient()
132+
client.create_webhook_alert(
133+
name="watch UEI ABC123",
134+
query_type="entity",
135+
filters={"uei": "ABC123"},
136+
)
130137
```
131138

132139
To force a real test delivery from Tango (without waiting for an actual event):
@@ -194,7 +201,7 @@ Three sources for the payload (mutually exclusive):
194201

195202
| Flag | Source | When to use |
196203
|---|---|---|
197-
| `--event-type X` | Fetches the canonical sample for `X` from Tango | You want a realistic body without setting up a subscription |
204+
| `--event-type X` | Fetches the canonical sample for `X` from Tango | You want a realistic body without setting up an alert |
198205
| `--payload-file PATH` | Reads a JSON file | You're testing a specific shape (regression, edge case) |
199206
| *(neither)* | A built-in placeholder envelope | Smoke-testing the wiring |
200207

@@ -239,22 +246,27 @@ tango webhooks endpoints delete ENDPOINT_ID [--yes]
239246

240247
`create` returns the generated `secret` once — save it. `delete` prompts for confirmation; `--yes` skips. `--inactive` registers the endpoint disabled (no deliveries until you re-enable it).
241248

242-
### `tango webhooks subscriptions`
249+
### Managing alerts
243250

244-
Manage **what Tango delivers**.
251+
Alerts (filter subscriptions) are the canonical way to control what Tango delivers. There is no CLI subgroup for them — use the SDK directly:
245252

246-
```bash
247-
tango webhooks subscriptions list [--page N] [--page-size N]
248-
tango webhooks subscriptions get SUBSCRIPTION_ID
249-
tango webhooks subscriptions create \
250-
--name "watch UEI ABC123" \
251-
--event-type entities.updated \
252-
--subject-type entity \
253-
--subject-id ABC123
254-
tango webhooks subscriptions delete SUBSCRIPTION_ID [--yes]
253+
```python
254+
from tango import TangoClient
255+
256+
client = TangoClient()
257+
258+
client.list_webhook_alerts()
259+
client.get_webhook_alert("ALERT_UUID")
260+
client.create_webhook_alert(
261+
name="watch UEI ABC123",
262+
query_type="entity",
263+
filters={"uei": "ABC123"},
264+
)
265+
client.update_webhook_alert("ALERT_UUID", name="Renamed")
266+
client.delete_webhook_alert("ALERT_UUID")
255267
```
256268

257-
`create` builds a single-record subscription (one event type, one subject type, one or more subject IDs). For multi-record subscriptions, call `client.create_webhook_subscription(...)` directly with a hand-crafted `payload` dict.
269+
For multi-endpoint accounts, pass `endpoint=<uuid>` to `create_webhook_alert` to pin which endpoint the alert delivers to.
258270

259271
---
260272

@@ -355,16 +367,19 @@ export TANGO_API_KEY=...
355367
tango webhooks list-event-types
356368
# 2. Stand up a tunnel so Tango can reach you
357369
ngrok http 8011 &
358-
# 3. Register your endpoint and subscription
370+
# 3. Register your endpoint
359371
tango webhooks endpoints create --url https://<id>.ngrok.io/tango/webhooks
360372
# (save the `secret` from the response into TANGO_WEBHOOK_SECRET)
361-
tango webhooks subscriptions create \
362-
--name "entities" --event-type entities.updated \
363-
--subject-type entity --subject-id <UEI>
364-
# 4. Run the listener pointed at your downstream handler
373+
# 4. Create an alert via the SDK
374+
python -c '
375+
from tango import TangoClient
376+
TangoClient().create_webhook_alert(
377+
name="entities", query_type="entity", filters={"uei": "<UEI>"}
378+
)'
379+
# 5. Run the listener pointed at your downstream handler
365380
tango webhooks listen --port 8011 --secret $TANGO_WEBHOOK_SECRET \
366381
--forward-to http://localhost:4242/wh
367-
# 5. Force a test delivery
382+
# 6. Force a test delivery
368383
tango webhooks trigger
369384
```
370385

@@ -377,7 +392,7 @@ You don't need a Tango account or any tunnel:
377392
tango webhooks listen --port 8011 --secret dev --forward-to http://127.0.0.1:4242/wh
378393

379394
# In another shell, drive it. Use Tango-shaped bodies if you have an API key:
380-
tango webhooks simulate --secret dev --event-type entities.updated \
395+
tango webhooks simulate --secret dev --event-type alerts.entity.match \
381396
--to http://127.0.0.1:8011/tango/webhooks
382397

383398
# Or use a custom shape from a file (no API key required):
@@ -397,7 +412,7 @@ def test_handler_round_trip():
397412
with WebhookReceiver(secret="s").run() as rx:
398413
result = simulate.deliver(
399414
target_url=rx.url,
400-
payload={"events": [{"event_type": "entities.updated", "uei": "X"}]},
415+
payload={"events": [{"event_type": "alerts.entity.match", "alert_id": "X"}]},
401416
secret="s",
402417
)
403418
assert result.status_code == 200
@@ -407,7 +422,7 @@ def test_handler_round_trip():
407422
### "I need to inspect what bytes Tango actually sends"
408423

409424
```bash
410-
tango webhooks simulate --secret $TANGO_WEBHOOK_SECRET --event-type entities.updated
425+
tango webhooks simulate --secret $TANGO_WEBHOOK_SECRET --event-type alerts.entity.match
411426
# Prints { "delivered": false, "headers": {...}, "sent_payload": {...} }
412427
```
413428

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "tango-python"
7-
version = "0.6.0"
7+
version = "0.7.0"
88
description = "Python SDK for the Tango API"
99
readme = "README.md"
1010
requires-python = ">=3.12"

0 commit comments

Comments
 (0)