Skip to content

Commit 0b1792e

Browse files
Merge pull request #3 from makegov/issue-1275/webhooks
[SDK: Node] Add Webhook endpoints/interface
2 parents d0801b6 + ec35c27 commit 0b1792e

9 files changed

Lines changed: 618 additions & 4 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dist/
1313
coverage/
1414
.build/
1515
.tmp/
16+
.npm-cache/
1617

1718
# Editor / OS
1819
.DS_Store
@@ -26,3 +27,4 @@ coverage/
2627

2728
# Other
2829
ROADMAP.md
30+
yoni/

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to `@makegov/tango-node` will be documented in this file.
44

55
This project follows [Semantic Versioning](https://semver.org/).
66

7+
## [Unreleased]
8+
9+
### Added
10+
11+
- Webhooks v2 client support: event type discovery, subscription CRUD, endpoint management, test delivery, and sample payload helpers. (refs `makegov/tango#1275`)
12+
13+
### Changed
14+
15+
- HTTP client now supports PATCH/PUT/DELETE for non-GET endpoints.
16+
717
## [0.1.0] - 2025-11-21
818

919
- Initial Node.js port of the Tango Python SDK.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ A modern Node.js SDK for the [Tango API](https://tango.makegov.com), featuring d
88

99
- **Dynamic Response Shaping** – Ask Tango for exactly the fields you want using a simple shape syntax.
1010
- **Type-Safe by Design** – Shape strings are validated against Tango schemas and mapped to generated TypeScript types.
11-
- **Comprehensive API Coverage** – Agencies, business types, entities, contracts, forecasts, opportunities, notices, and grants.
11+
- **Comprehensive API Coverage** – Agencies, business types, entities, contracts, forecasts, opportunities, notices, grants, and webhooks.
1212
- **Flexible Data Access** – Plain JavaScript objects backed by runtime validation and parsing, materialized via the dynamic model pipeline.
1313
- **Modern Node** – Built for Node 18+ with native `fetch` and ESM-first design.
1414
- **Tested Against the Real API** – Integration tests (mirroring the Python SDK) keep behavior aligned.

docs/API_REFERENCE.md

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const resp = await client.listAgencies({ page: 1, limit: 25 });
3434
| `page` | `number` | Page number (default 1). |
3535
| `limit` | `number` | Max results per page (default 25, max 100). |
3636

37-
#### Returns
37+
#### Returns (Agencies)
3838

3939
`PaginatedResponse<AgencyLike>`
4040

@@ -109,7 +109,7 @@ page: number,
109109
limit: number
110110
```
111111

112-
#### Returns
112+
#### Returns (Contracts)
113113

114114
`PaginatedResponse<Contract>` materialized according to the requested shape. Date/datetime fields are parsed, decimals normalized to strings, nested recipients, agencies, and locations are objects.
115115

@@ -167,6 +167,132 @@ Search SAM.gov opportunities with shaping.
167167

168168
---
169169

170+
## Webhooks (v2)
171+
172+
Webhook APIs let **Large / Enterprise** users manage subscription filters for outbound Tango webhooks.
173+
174+
### `listWebhookEventTypes()`
175+
176+
Discover supported `event_type` values and subject types.
177+
178+
```ts
179+
const info = await client.listWebhookEventTypes();
180+
```
181+
182+
### `listWebhookSubscriptions(options?)`
183+
184+
```ts
185+
const subs = await client.listWebhookSubscriptions({ page: 1, pageSize: 25 });
186+
```
187+
188+
Notes:
189+
190+
- Uses `page` + `page_size` (not `limit`) for pagination on this endpoint.
191+
192+
### `getWebhookSubscription(id)`
193+
194+
```ts
195+
const sub = await client.getWebhookSubscription("SUBSCRIPTION_UUID");
196+
```
197+
198+
### `createWebhookSubscription({ subscriptionName, payload })`
199+
200+
```ts
201+
await client.createWebhookSubscription({
202+
subscriptionName: "Track specific vendors",
203+
payload: {
204+
records: [
205+
{ event_type: "awards.new_award", subject_type: "entity", subject_ids: ["UEI123ABC"] },
206+
{ event_type: "awards.new_transaction", subject_type: "entity", subject_ids: ["UEI123ABC"] },
207+
],
208+
},
209+
});
210+
```
211+
212+
Notes:
213+
214+
- Prefer v2 fields: `subject_type` + `subject_ids`.
215+
- Legacy compatibility: `resource_ids` is accepted as an alias for `subject_ids` (don’t send both).
216+
- Catch-all: `subject_ids: []` means “all subjects” for that record and is **Enterprise-only**. Large tier users must list specific IDs.
217+
218+
### `updateWebhookSubscription(id, patch)`
219+
220+
```ts
221+
await client.updateWebhookSubscription("SUBSCRIPTION_UUID", {
222+
subscriptionName: "Updated name",
223+
});
224+
```
225+
226+
### `deleteWebhookSubscription(id)`
227+
228+
```ts
229+
await client.deleteWebhookSubscription("SUBSCRIPTION_UUID");
230+
```
231+
232+
### Webhook endpoints
233+
234+
In production, MakeGov provisions the initial endpoint for you. These methods are most useful for dev/self-service.
235+
236+
```ts
237+
const endpoints = await client.listWebhookEndpoints({ page: 1, limit: 25 });
238+
const endpoint = await client.getWebhookEndpoint("ENDPOINT_UUID");
239+
```
240+
241+
```ts
242+
// Create (one endpoint per user)
243+
const created = await client.createWebhookEndpoint({ callbackUrl: "https://example.com/tango/webhooks" });
244+
245+
// Update
246+
await client.updateWebhookEndpoint(created.id, { isActive: false });
247+
248+
// Delete
249+
await client.deleteWebhookEndpoint(created.id);
250+
```
251+
252+
### `testWebhookDelivery(options?)`
253+
254+
Send an immediate test webhook to your configured endpoint.
255+
256+
```ts
257+
const result = await client.testWebhookDelivery();
258+
```
259+
260+
### `getWebhookSamplePayload(options?)`
261+
262+
Fetch Tango-shaped sample deliveries (and sample subscription request bodies).
263+
264+
```ts
265+
const sample = await client.getWebhookSamplePayload({ eventType: "awards.new_award" });
266+
```
267+
268+
### Deliveries / redelivery
269+
270+
The API does not currently expose a public `/api/webhooks/deliveries/` or redelivery endpoint. Use:
271+
272+
- `testWebhookDelivery()` for connectivity checks
273+
- `getWebhookSamplePayload()` for building handlers + subscription payloads
274+
275+
### Receiving webhooks (signature verification)
276+
277+
Every delivery includes an HMAC signature header:
278+
279+
- `X-Tango-Signature: sha256=<hex digest>`
280+
281+
Compute the digest over the **raw request body bytes** using your shared secret.
282+
283+
```ts
284+
import crypto from "node:crypto";
285+
286+
export function verifyTangoWebhookSignature(secret: string, rawBody: Buffer, signatureHeader: string | null): boolean {
287+
if (!signatureHeader) return false;
288+
const sig = signatureHeader.startsWith("sha256=") ? signatureHeader.slice("sha256=".length) : signatureHeader;
289+
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
290+
return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(sig, "hex"));
291+
}
292+
```
293+
294+
---
295+
170296
## Error Types
171297

172298
All thrown by async methods:

src/client.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import type { ShapeSpec } from "./shapes/types.js";
66
import { HttpClient } from "./utils/http.js";
77
import { unflattenResponse } from "./utils/unflatten.js";
88
import { PaginatedResponse, TangoClientOptions } from "./types.js";
9+
import type {
10+
WebhookEndpoint,
11+
WebhookEventTypesResponse,
12+
WebhookSamplePayloadResponse,
13+
WebhookSubscription,
14+
WebhookSubscriptionPayload,
15+
WebhookTestDeliveryResult,
16+
} from "./models/Webhooks.js";
917

1018
type AnyRecord = Record<string, unknown>;
1119

@@ -99,6 +107,11 @@ export interface ListEntitiesOptions extends ListOptionsBase {
99107
[key: string]: unknown;
100108
}
101109

110+
export interface ListWebhookSubscriptionsOptions {
111+
page?: number;
112+
pageSize?: number;
113+
}
114+
102115
export class TangoClient {
103116
private readonly http: HttpClient;
104117
private readonly shapeParser: ShapeParser;
@@ -403,6 +416,101 @@ export class TangoClient {
403416
return paginated;
404417
}
405418

419+
// ---------------------------------------------------------------------------
420+
// Webhooks (v2)
421+
// ---------------------------------------------------------------------------
422+
423+
async listWebhookEventTypes(): Promise<WebhookEventTypesResponse> {
424+
return await this.http.get<WebhookEventTypesResponse>("/api/webhooks/event-types/");
425+
}
426+
427+
async listWebhookSubscriptions(options: ListWebhookSubscriptionsOptions = {}): Promise<PaginatedResponse<WebhookSubscription>> {
428+
const { page = 1, pageSize } = options;
429+
const params: AnyRecord = { page };
430+
if (pageSize !== undefined) params.page_size = pageSize;
431+
432+
const data = await this.http.get<AnyRecord>("/api/webhooks/subscriptions/", params);
433+
return buildPaginatedResponse<WebhookSubscription>(data);
434+
}
435+
436+
async getWebhookSubscription(id: string): Promise<WebhookSubscription> {
437+
if (!id) throw new TangoValidationError("Webhook subscription id is required");
438+
return await this.http.get<WebhookSubscription>(`/api/webhooks/subscriptions/${encodeURIComponent(id)}/`);
439+
}
440+
441+
async createWebhookSubscription(options: { subscriptionName: string; payload: WebhookSubscriptionPayload }): Promise<WebhookSubscription> {
442+
const { subscriptionName, payload } = options;
443+
if (!subscriptionName) throw new TangoValidationError("Webhook subscriptionName is required");
444+
return await this.http.post<WebhookSubscription>("/api/webhooks/subscriptions/", {
445+
subscription_name: subscriptionName,
446+
payload,
447+
});
448+
}
449+
450+
async updateWebhookSubscription(
451+
id: string,
452+
options: { subscriptionName?: string; payload?: WebhookSubscriptionPayload },
453+
): Promise<WebhookSubscription> {
454+
if (!id) throw new TangoValidationError("Webhook subscription id is required");
455+
const body: AnyRecord = {};
456+
if (options.subscriptionName !== undefined) body.subscription_name = options.subscriptionName;
457+
if (options.payload !== undefined) body.payload = options.payload;
458+
return await this.http.patch<WebhookSubscription>(`/api/webhooks/subscriptions/${encodeURIComponent(id)}/`, body);
459+
}
460+
461+
async deleteWebhookSubscription(id: string): Promise<void> {
462+
if (!id) throw new TangoValidationError("Webhook subscription id is required");
463+
await this.http.delete(`/api/webhooks/subscriptions/${encodeURIComponent(id)}/`);
464+
}
465+
466+
async listWebhookEndpoints(options: { page?: number; limit?: number } = {}): Promise<PaginatedResponse<WebhookEndpoint>> {
467+
const { page = 1, limit = 25 } = options;
468+
const params: AnyRecord = { page, limit: Math.min(limit, 100) };
469+
const data = await this.http.get<AnyRecord>("/api/webhooks/endpoints/", params);
470+
471+
// Endpoints are commonly paginated like other Tango resources, but keep this resilient.
472+
if (Array.isArray(data)) {
473+
return { count: data.length, next: null, previous: null, pageMetadata: null, results: data as WebhookEndpoint[] };
474+
}
475+
return buildPaginatedResponse<WebhookEndpoint>(data);
476+
}
477+
478+
async getWebhookEndpoint(id: string): Promise<WebhookEndpoint> {
479+
if (!id) throw new TangoValidationError("Webhook endpoint id is required");
480+
return await this.http.get<WebhookEndpoint>(`/api/webhooks/endpoints/${encodeURIComponent(id)}/`);
481+
}
482+
483+
async createWebhookEndpoint(options: { callbackUrl: string; isActive?: boolean }): Promise<WebhookEndpoint> {
484+
const { callbackUrl, isActive = true } = options;
485+
if (!callbackUrl) throw new TangoValidationError("Webhook callbackUrl is required");
486+
return await this.http.post<WebhookEndpoint>("/api/webhooks/endpoints/", { callback_url: callbackUrl, is_active: isActive });
487+
}
488+
489+
async updateWebhookEndpoint(id: string, options: { callbackUrl?: string; isActive?: boolean }): Promise<WebhookEndpoint> {
490+
if (!id) throw new TangoValidationError("Webhook endpoint id is required");
491+
const body: AnyRecord = {};
492+
if (options.callbackUrl !== undefined) body.callback_url = options.callbackUrl;
493+
if (options.isActive !== undefined) body.is_active = options.isActive;
494+
return await this.http.patch<WebhookEndpoint>(`/api/webhooks/endpoints/${encodeURIComponent(id)}/`, body);
495+
}
496+
497+
async deleteWebhookEndpoint(id: string): Promise<void> {
498+
if (!id) throw new TangoValidationError("Webhook endpoint id is required");
499+
await this.http.delete(`/api/webhooks/endpoints/${encodeURIComponent(id)}/`);
500+
}
501+
502+
async testWebhookDelivery(options: { endpointId?: string } = {}): Promise<WebhookTestDeliveryResult> {
503+
const body: AnyRecord = {};
504+
if (options.endpointId) body.endpoint_id = options.endpointId;
505+
return await this.http.post<WebhookTestDeliveryResult>("/api/webhooks/endpoints/test-delivery/", body);
506+
}
507+
508+
async getWebhookSamplePayload(options: { eventType?: string } = {}): Promise<WebhookSamplePayloadResponse> {
509+
const params: AnyRecord = {};
510+
if (options.eventType) params.event_type = options.eventType;
511+
return await this.http.get<WebhookSamplePayloadResponse>("/api/webhooks/endpoints/sample-payload/", params);
512+
}
513+
406514
private parseShape(shape: string | null | undefined, flat: boolean, flatLists: boolean): ShapeSpec | null {
407515
if (!shape) return null;
408516
return this.shapeParser.parseWithFlags(shape, flat, flatLists);

0 commit comments

Comments
 (0)