Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dist/
coverage/
.build/
.tmp/
.npm-cache/

# Editor / OS
.DS_Store
Expand All @@ -26,3 +27,4 @@ coverage/

# Other
ROADMAP.md
yoni/
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to `@makegov/tango-node` will be documented in this file.

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

## [Unreleased]

### Added

- Webhooks v2 client support: event type discovery, subscription CRUD, endpoint management, test delivery, and sample payload helpers. (refs `makegov/tango#1275`)

### Changed

- HTTP client now supports PATCH/PUT/DELETE for non-GET endpoints.

## [0.1.0] - 2025-11-21

- Initial Node.js port of the Tango Python SDK.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ A modern Node.js SDK for the [Tango API](https://tango.makegov.com), featuring d

- **Dynamic Response Shaping** – Ask Tango for exactly the fields you want using a simple shape syntax.
- **Type-Safe by Design** – Shape strings are validated against Tango schemas and mapped to generated TypeScript types.
- **Comprehensive API Coverage** – Agencies, business types, entities, contracts, forecasts, opportunities, notices, and grants.
- **Comprehensive API Coverage** – Agencies, business types, entities, contracts, forecasts, opportunities, notices, grants, and webhooks.
- **Flexible Data Access** – Plain JavaScript objects backed by runtime validation and parsing, materialized via the dynamic model pipeline.
- **Modern Node** – Built for Node 18+ with native `fetch` and ESM-first design.
- **Tested Against the Real API** – Integration tests (mirroring the Python SDK) keep behavior aligned.
Expand Down
130 changes: 128 additions & 2 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const resp = await client.listAgencies({ page: 1, limit: 25 });
| `page` | `number` | Page number (default 1). |
| `limit` | `number` | Max results per page (default 25, max 100). |

#### Returns
#### Returns (Agencies)

`PaginatedResponse<AgencyLike>`

Expand Down Expand Up @@ -109,7 +109,7 @@ page: number,
limit: number
```

#### Returns
#### Returns (Contracts)

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

Expand Down Expand Up @@ -167,6 +167,132 @@ Search SAM.gov opportunities with shaping.

---

## Webhooks (v2)

Webhook APIs let **Large / Enterprise** users manage subscription filters for outbound Tango webhooks.

### `listWebhookEventTypes()`

Discover supported `event_type` values and subject types.

```ts
const info = await client.listWebhookEventTypes();
```

### `listWebhookSubscriptions(options?)`

```ts
const subs = await client.listWebhookSubscriptions({ page: 1, pageSize: 25 });
```

Notes:

- Uses `page` + `page_size` (not `limit`) for pagination on this endpoint.

### `getWebhookSubscription(id)`

```ts
const sub = await client.getWebhookSubscription("SUBSCRIPTION_UUID");
```

### `createWebhookSubscription({ subscriptionName, payload })`

```ts
await client.createWebhookSubscription({
subscriptionName: "Track specific vendors",
payload: {
records: [
{ event_type: "awards.new_award", subject_type: "entity", subject_ids: ["UEI123ABC"] },
{ event_type: "awards.new_transaction", subject_type: "entity", subject_ids: ["UEI123ABC"] },
],
},
});
```

Notes:

- Prefer v2 fields: `subject_type` + `subject_ids`.
- Legacy compatibility: `resource_ids` is accepted as an alias for `subject_ids` (don’t send both).
- Catch-all: `subject_ids: []` means “all subjects” for that record and is **Enterprise-only**. Large tier users must list specific IDs.

### `updateWebhookSubscription(id, patch)`

```ts
await client.updateWebhookSubscription("SUBSCRIPTION_UUID", {
subscriptionName: "Updated name",
});
```

### `deleteWebhookSubscription(id)`

```ts
await client.deleteWebhookSubscription("SUBSCRIPTION_UUID");
```

### Webhook endpoints

In production, MakeGov provisions the initial endpoint for you. These methods are most useful for dev/self-service.

```ts
const endpoints = await client.listWebhookEndpoints({ page: 1, limit: 25 });
const endpoint = await client.getWebhookEndpoint("ENDPOINT_UUID");
```

```ts
// Create (one endpoint per user)
const created = await client.createWebhookEndpoint({ callbackUrl: "https://example.com/tango/webhooks" });

// Update
await client.updateWebhookEndpoint(created.id, { isActive: false });

// Delete
await client.deleteWebhookEndpoint(created.id);
```

### `testWebhookDelivery(options?)`

Send an immediate test webhook to your configured endpoint.

```ts
const result = await client.testWebhookDelivery();
```

### `getWebhookSamplePayload(options?)`

Fetch Tango-shaped sample deliveries (and sample subscription request bodies).

```ts
const sample = await client.getWebhookSamplePayload({ eventType: "awards.new_award" });
```

### Deliveries / redelivery

The API does not currently expose a public `/api/webhooks/deliveries/` or redelivery endpoint. Use:

- `testWebhookDelivery()` for connectivity checks
- `getWebhookSamplePayload()` for building handlers + subscription payloads

### Receiving webhooks (signature verification)

Every delivery includes an HMAC signature header:

- `X-Tango-Signature: sha256=<hex digest>`

Compute the digest over the **raw request body bytes** using your shared secret.

```ts
import crypto from "node:crypto";

export function verifyTangoWebhookSignature(secret: string, rawBody: Buffer, signatureHeader: string | null): boolean {
if (!signatureHeader) return false;
const sig = signatureHeader.startsWith("sha256=") ? signatureHeader.slice("sha256=".length) : signatureHeader;
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(sig, "hex"));
}
```

---

## Error Types

All thrown by async methods:
Expand Down
108 changes: 108 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import type { ShapeSpec } from "./shapes/types.js";
import { HttpClient } from "./utils/http.js";
import { unflattenResponse } from "./utils/unflatten.js";
import { PaginatedResponse, TangoClientOptions } from "./types.js";
import type {
WebhookEndpoint,
WebhookEventTypesResponse,
WebhookSamplePayloadResponse,
WebhookSubscription,
WebhookSubscriptionPayload,
WebhookTestDeliveryResult,
} from "./models/Webhooks.js";

type AnyRecord = Record<string, unknown>;

Expand Down Expand Up @@ -99,6 +107,11 @@ export interface ListEntitiesOptions extends ListOptionsBase {
[key: string]: unknown;
}

export interface ListWebhookSubscriptionsOptions {
page?: number;
pageSize?: number;
}

export class TangoClient {
private readonly http: HttpClient;
private readonly shapeParser: ShapeParser;
Expand Down Expand Up @@ -403,6 +416,101 @@ export class TangoClient {
return paginated;
}

// ---------------------------------------------------------------------------
// Webhooks (v2)
// ---------------------------------------------------------------------------

async listWebhookEventTypes(): Promise<WebhookEventTypesResponse> {
return await this.http.get<WebhookEventTypesResponse>("/api/webhooks/event-types/");
}

async listWebhookSubscriptions(options: ListWebhookSubscriptionsOptions = {}): Promise<PaginatedResponse<WebhookSubscription>> {
const { page = 1, pageSize } = options;
const params: AnyRecord = { page };
if (pageSize !== undefined) params.page_size = pageSize;

const data = await this.http.get<AnyRecord>("/api/webhooks/subscriptions/", params);
return buildPaginatedResponse<WebhookSubscription>(data);
}

async getWebhookSubscription(id: string): Promise<WebhookSubscription> {
if (!id) throw new TangoValidationError("Webhook subscription id is required");
return await this.http.get<WebhookSubscription>(`/api/webhooks/subscriptions/${encodeURIComponent(id)}/`);
}

async createWebhookSubscription(options: { subscriptionName: string; payload: WebhookSubscriptionPayload }): Promise<WebhookSubscription> {
const { subscriptionName, payload } = options;
if (!subscriptionName) throw new TangoValidationError("Webhook subscriptionName is required");
return await this.http.post<WebhookSubscription>("/api/webhooks/subscriptions/", {
subscription_name: subscriptionName,
payload,
});
}

async updateWebhookSubscription(
id: string,
options: { subscriptionName?: string; payload?: WebhookSubscriptionPayload },
): Promise<WebhookSubscription> {
if (!id) throw new TangoValidationError("Webhook subscription id is required");
const body: AnyRecord = {};
if (options.subscriptionName !== undefined) body.subscription_name = options.subscriptionName;
if (options.payload !== undefined) body.payload = options.payload;
return await this.http.patch<WebhookSubscription>(`/api/webhooks/subscriptions/${encodeURIComponent(id)}/`, body);
}

async deleteWebhookSubscription(id: string): Promise<void> {
if (!id) throw new TangoValidationError("Webhook subscription id is required");
await this.http.delete(`/api/webhooks/subscriptions/${encodeURIComponent(id)}/`);
}

async listWebhookEndpoints(options: { page?: number; limit?: number } = {}): Promise<PaginatedResponse<WebhookEndpoint>> {
const { page = 1, limit = 25 } = options;
const params: AnyRecord = { page, limit: Math.min(limit, 100) };
const data = await this.http.get<AnyRecord>("/api/webhooks/endpoints/", params);

// Endpoints are commonly paginated like other Tango resources, but keep this resilient.
if (Array.isArray(data)) {
return { count: data.length, next: null, previous: null, pageMetadata: null, results: data as WebhookEndpoint[] };
}
return buildPaginatedResponse<WebhookEndpoint>(data);
}

async getWebhookEndpoint(id: string): Promise<WebhookEndpoint> {
if (!id) throw new TangoValidationError("Webhook endpoint id is required");
return await this.http.get<WebhookEndpoint>(`/api/webhooks/endpoints/${encodeURIComponent(id)}/`);
}

async createWebhookEndpoint(options: { callbackUrl: string; isActive?: boolean }): Promise<WebhookEndpoint> {
const { callbackUrl, isActive = true } = options;
if (!callbackUrl) throw new TangoValidationError("Webhook callbackUrl is required");
return await this.http.post<WebhookEndpoint>("/api/webhooks/endpoints/", { callback_url: callbackUrl, is_active: isActive });
}

async updateWebhookEndpoint(id: string, options: { callbackUrl?: string; isActive?: boolean }): Promise<WebhookEndpoint> {
if (!id) throw new TangoValidationError("Webhook endpoint id is required");
const body: AnyRecord = {};
if (options.callbackUrl !== undefined) body.callback_url = options.callbackUrl;
if (options.isActive !== undefined) body.is_active = options.isActive;
return await this.http.patch<WebhookEndpoint>(`/api/webhooks/endpoints/${encodeURIComponent(id)}/`, body);
}

async deleteWebhookEndpoint(id: string): Promise<void> {
if (!id) throw new TangoValidationError("Webhook endpoint id is required");
await this.http.delete(`/api/webhooks/endpoints/${encodeURIComponent(id)}/`);
}

async testWebhookDelivery(options: { endpointId?: string } = {}): Promise<WebhookTestDeliveryResult> {
const body: AnyRecord = {};
if (options.endpointId) body.endpoint_id = options.endpointId;
return await this.http.post<WebhookTestDeliveryResult>("/api/webhooks/endpoints/test-delivery/", body);
}

async getWebhookSamplePayload(options: { eventType?: string } = {}): Promise<WebhookSamplePayloadResponse> {
const params: AnyRecord = {};
if (options.eventType) params.event_type = options.eventType;
return await this.http.get<WebhookSamplePayloadResponse>("/api/webhooks/endpoints/sample-payload/", params);
}

private parseShape(shape: string | null | undefined, flat: boolean, flatLists: boolean): ShapeSpec | null {
if (!shape) return null;
return this.shapeParser.parseWithFlags(shape, flat, flatLists);
Expand Down
Loading