Webhooks Guide¶
This guide covers everything @makegov/tango-node provides for building, testing, and operating webhook integrations against the Tango API: signing helpers, signature verification, and management commands for endpoints and alerts.
If you only need the SDK method signatures, see API_REFERENCE.md § Webhooks. For the API-level contract (signing scheme, event taxonomy, retry behavior), see the Tango Webhooks Partner Guide.
Breaking change in v0.4.0: subject-based webhook subscriptions have been removed. Use the Alerts API for filter-based delivery. Mirrors makegov/tango#2267.
Contents¶
- Install
- Concepts in 60 seconds
- Quickstart: zero to receiving
- Programmatic use
- Webhook write API
- Common workflows
- Troubleshooting
Install¶
npm install @makegov/tango-node
# or
yarn add @makegov/tango-node
# or
pnpm add @makegov/tango-node
The signing helpers and full webhook write API are included in the default install — no extras needed.
import { TangoClient, generateSignature, verifySignature, parseSignatureHeader, SIGNATURE_HEADER, SIGNATURE_PREFIX } from "@makegov/tango-node";
Concepts in 60 seconds¶
Tango webhooks have three pieces of state:
| Concept | What it is | Tango term |
|---|---|---|
| Endpoint | The URL Tango POSTs to, plus a generated signing secret | WebhookEndpoint |
| Alert | A saved query-filter that fires deliveries when matching records appear | WebhookAlert |
| Delivery | A single signed POST Tango makes when a matching event fires | (the request itself) |
A typical setup:
- Create an endpoint with the public URL of your handler. Tango returns a
secret— save it; it's used to sign every delivery. - Create one or more alerts describing the records your handler cares about (e.g. new IT-services contracts).
- Tango POSTs to your endpoint when matching records appear. 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. - Your handler verifies the signature, parses the body, and acts on it.
Quickstart: zero to receiving¶
Assumes you have a TANGO_API_KEY and want to receive webhooks for new IT-services contracts.
1. See what you can subscribe to¶
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const info = await client.listWebhookEventTypes();
console.log(info.event_types);
// [{ event_type: "alerts.contract.match", description: "...", schema_version: 1 }, ...]
2. See what a payload looks like¶
const sample = await client.getWebhookSamplePayload({ eventType: "alerts.contract.match" });
console.log(JSON.stringify(sample, null, 2));
Fetches the canonical JSON shape Tango will deliver for that event type. No alert needed.
3. Register your endpoint and alert¶
When you're ready for end-to-end testing, expose your local handler via a tunnel (ngrok http 3000, cloudflared tunnel, etc.) and register that public URL with Tango:
// Endpoint names are unique per user. Tango returns a secret — save it.
const endpoint = await client.createWebhookEndpoint({
callbackUrl: "https://<your-tunnel>.ngrok.io/tango/webhooks",
name: "dev",
});
console.log("Secret:", endpoint.secret); // save this!
// Create an alert — fires when matching records appear
const alert = await client.createWebhookAlert({
name: "New IT cloud contracts",
query_type: "contract", // singular — required
filters: { naics: "541511" }, // any /api/contracts/ filter
});
4. Force a test delivery¶
const result = await client.testWebhookEndpoint(endpoint.id);
console.log(result.success, result.status_code);
You should see a signed delivery hit your handler with the X-Tango-Signature header generated by Tango.
Programmatic use¶
Signature verification in your handler¶
verifySignature is the only function you need in production. It takes (body, header, secret) — note the arg order.
Call it on the raw request body bytes — not on a re-serialized parsed body. The HMAC is computed over the exact bytes Tango sent; reformatting or reordering keys breaks it.
import { verifySignature } from "@makegov/tango-node";
// Express example
app.post("/tango/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body; // Buffer — express.raw() gives you bytes
const signatureHeader = req.headers["x-tango-signature"];
if (!verifySignature(rawBody, signatureHeader, process.env.TANGO_WEBHOOK_SECRET)) {
return res.status(401).json({ error: "invalid_signature" });
}
const payload = JSON.parse(rawBody.toString("utf8"));
// ... act on payload.events ...
res.json({ ok: true });
});
verifySignature signature:
function verifySignature(body: string | Buffer, header: string | null | undefined, secret: string): boolean;
- Returns
falsefor missing, empty, malformed, or mismatched headers — never throws on mismatch. - Uses
timingSafeEqualfromnode:cryptointernally. - Accepts both the canonical
sha256=<hex>header form and a bare hex string (legacy compatibility).
Fastify example:
import { verifySignature } from "@makegov/tango-node";
fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, (req, body, done) => {
done(null, body);
});
fastify.post("/tango/webhooks", async (request, reply) => {
const rawBody = request.body as Buffer;
const signatureHeader = request.headers["x-tango-signature"] as string | undefined;
if (!verifySignature(rawBody, signatureHeader ?? null, process.env.TANGO_WEBHOOK_SECRET!)) {
reply.code(401).send({ error: "invalid_signature" });
return;
}
const payload = JSON.parse(rawBody.toString("utf8"));
// ... handle payload ...
reply.send({ ok: true });
});
Generating signatures (for testing)¶
generateSignature produces the exact header value Tango sends. Use it in tests to sign synthetic payloads before POSTing to your handler.
import { generateSignature, SIGNATURE_HEADER } from "@makegov/tango-node";
const body = Buffer.from(
JSON.stringify({
event_type: "alerts.entity.match",
alert_id: "alert_123",
query_type: "entity",
filters: { uei: "ABC123" },
matches: { new: [], modified: [], new_count: 0, modified_count: 0 },
checked_at: "2024-01-01T00:00:00Z",
}),
);
const signatureHeader = generateSignature(body, "test_secret");
// → "sha256=<lowercase hex>"
// Drive your handler directly (e.g. in a test):
const response = await fetch("http://localhost:3000/tango/webhooks", {
method: "POST",
headers: {
"Content-Type": "application/json",
[SIGNATURE_HEADER]: signatureHeader,
},
body,
});
generateSignature signature:
function generateSignature(body: string | Buffer, secret: string): string;
// Returns: "sha256=<lowercase hex HMAC-SHA256>"
The return value is always the prefixed form sha256=<hex> — pass it directly as the X-Tango-Signature header value.
Parsing the signature header¶
parseSignatureHeader breaks a raw X-Tango-Signature header value into its component parts. Mainly useful for debugging or building custom verification logic.
import { parseSignatureHeader } from "@makegov/tango-node";
const parsed = parseSignatureHeader("sha256=abc123def456");
// → { algorithm: "sha256", signature: "abc123def456" }
const bad = parseSignatureHeader("sha256=");
// → null (empty digest)
const missing = parseSignatureHeader(null);
// → null
Returns null for absent, empty, or malformed values (non-hex digest, empty digest). Never throws.
Webhook write API¶
All methods are on TangoClient. They're async and return Promises. Webhook APIs require Large / Enterprise tier access.
Endpoints¶
An endpoint is the URL Tango POSTs to, paired with a signing secret.
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
// List
const list = await client.listWebhookEndpoints({ page: 1, limit: 25 });
// Get one
const endpoint = await client.getWebhookEndpoint("ENDPOINT_UUID");
// Create — `name` is required and must be unique per user; save the returned `secret`
const created = await client.createWebhookEndpoint({
callbackUrl: "https://example.com/tango/webhooks",
name: "Production handler",
isActive: true, // default true
});
console.log("Secret:", created.secret); // only returned on create — save it
// Update
await client.updateWebhookEndpoint(created.id, { isActive: false });
// Delete
await client.deleteWebhookEndpoint(created.id);
createWebhookEndpoint also accepts the snake_case canonical API form directly (callback_url, is_active). CamelCase is preferred for new code.
WebhookEndpoint shape:
interface WebhookEndpoint {
id: string;
name: string;
callback_url: string;
secret?: string; // present on create response only
is_active: boolean;
created_at: string;
updated_at: string;
}
Alerts (filter-subscription API)¶
Alerts are the SDK's interface for telling Tango "deliver me records matching this filter." Subject-based subscriptions (match by event type + specific subject IDs) were removed in v0.4.0 — alerts are now the only way to subscribe.
// Create
const alert = await client.createWebhookAlert({
name: "New IT cloud contracts",
query_type: "contract", // singular — required
filters: { naics: "541511" }, // required, non-empty
frequency: "daily", // optional
});
// List
const alerts = await client.listWebhookAlerts({ page: 1, pageSize: 25 });
// Get
const alert = await client.getWebhookAlert("ALERT_UUID");
// Update
await client.updateWebhookAlert("ALERT_UUID", { name: "Updated name" });
// Delete
await client.deleteWebhookAlert("ALERT_UUID");
WebhookAlert shape:
interface WebhookAlert {
alert_id: string;
name: string;
query_type: string;
filters: Record<string, unknown>;
frequency: string;
cron_expression: string | null;
status: "active" | "paused";
created_at: string;
last_checked_at: string | null;
match_count: number;
}
Event types and sample payloads¶
// List all supported event types
const info = await client.listWebhookEventTypes();
// info.event_types → [{ event_type, description, schema_version }]
// Fetch a canonical sample payload for one event type
const sample = await client.getWebhookSamplePayload({ eventType: "alerts.contract.match" });
// Or fetch sample payloads for all event types at once
const all = await client.getWebhookSamplePayload();
getWebhookSamplePayload wraps GET /api/webhooks/endpoints/sample-payload/. When eventType is omitted, returns all event types. The response includes a signature_header field showing what the X-Tango-Signature header will look like — useful for understanding the wire format.
Test delivery¶
Force Tango to POST a real test delivery to a registered endpoint:
const result = await client.testWebhookEndpoint("ENDPOINT_UUID");
console.log(result.success); // boolean
console.log(result.status_code); // HTTP code Tango got from your endpoint
console.log(result.response_time_ms);
console.log(result.message);
console.log(result.error); // set on failure
WebhookTestDeliveryResult shape:
interface WebhookTestDeliveryResult {
success: boolean;
status_code?: number;
response_time_ms?: number;
endpoint_url?: string;
message?: string;
error?: string;
response_body?: string;
test_payload?: Record<string, unknown>;
}
testWebhookDelivery(options?) is a legacy alias that accepts { endpointId?: string }. If endpointId is omitted, the API auto-resolves the caller's only endpoint (404 if none, 400 if multiple). Prefer testWebhookEndpoint for new code.
Common workflows¶
"Set me up to receive contract-match alerts from scratch"¶
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
// 1. Confirm available event types
const { event_types } = await client.listWebhookEventTypes();
console.log(event_types.map((e) => e.event_type));
// 2. Create endpoint — expose your handler via ngrok/cloudflared first
const endpoint = await client.createWebhookEndpoint({
callbackUrl: "https://<id>.ngrok.io/tango/webhooks",
});
// Save endpoint.secret — you need it to verify incoming deliveries
process.env.TANGO_WEBHOOK_SECRET = endpoint.secret!;
// 3. Create an alert — fires when matching records appear
await client.createWebhookAlert({
name: "New IT cloud contracts",
query_type: "contract",
filters: { naics: "541511" },
});
// 4. Force a test delivery to verify your handler is reachable
const result = await client.testWebhookEndpoint(endpoint.id);
console.log("Delivery success:", result.success, "Status:", result.status_code);
"Verify a Tango delivery in any HTTP framework"¶
import { verifySignature } from "@makegov/tango-node";
// The pattern is the same in Express, Fastify, Hono, Next.js API routes, etc.:
// 1. Get raw body bytes BEFORE any JSON parsing middleware
// 2. Get the X-Tango-Signature header
// 3. Call verifySignature(rawBody, header, secret)
function handleTangoWebhook(rawBody: Buffer, signatureHeader: string | undefined) {
if (!verifySignature(rawBody, signatureHeader ?? null, process.env.TANGO_WEBHOOK_SECRET!)) {
throw new Error("invalid_signature");
}
return JSON.parse(rawBody.toString("utf8"));
}
"Test my handler in a unit test (no network)"¶
Use generateSignature to sign synthetic payloads and POST them directly to your handler — no Tango account or live endpoint needed:
import { generateSignature, SIGNATURE_HEADER } from "@makegov/tango-node";
const secret = "test_secret";
const payload = {
timestamp: "2024-01-01T00:00:00Z",
events: [{ event_type: "alerts.contract.match", record: { piid: "ABC123" } }],
};
const rawBody = Buffer.from(JSON.stringify(payload));
const sig = generateSignature(rawBody, secret);
// Drive your handler directly — e.g. with supertest or a test fetch:
const res = await request(app).post("/tango/webhooks").set("Content-Type", "application/json").set(SIGNATURE_HEADER, sig).send(rawBody);
expect(res.status).toBe(200);
"Inspect what bytes Tango actually sends"¶
const sample = await client.getWebhookSamplePayload({ eventType: "alerts.contract.match" });
// sample.signature_header shows the X-Tango-Signature format
// sample.sample_delivery shows the exact JSON body shape
console.log(JSON.stringify(sample.sample_delivery, null, 2));
Troubleshooting¶
Signature always fails. Verify on raw bytes, not on a re-serialized parsed body. The HMAC is over exact bytes; reformatting whitespace or reordering keys breaks it. Most frameworks expose the raw body separately from a parsed JSON shortcut — use the raw one. In Express, use express.raw({ type: "application/json" }) before your route handler.
verifySignature returns false even with the right secret. Check argument order: it's (body, header, secret) — the header is second, secret is third. This differs from some other webhook libraries.
createWebhookEndpoint returns 400 or "endpoint already exists". Endpoint names are unique per user — if you've already created one with that name, either pick a different name or use listWebhookEndpoints() to find the existing one and reuse its ID.
createWebhookAlert throws TangoValidationError: query_type is required. The query_type field is singular — "contract", not "contracts".
testWebhookEndpoint returns success: false. Tango reached your endpoint but got a non-2xx response. Check result.status_code and result.response_body in the result, then look at your handler's logs.
getWebhookSamplePayload throws with 401. Set TANGO_API_KEY (or pass apiKey to TangoClient). This endpoint requires authentication.
listWebhookAlerts returns an empty array unexpectedly. Check your API key — alerts are scoped to the authenticated user. Also confirm the endpoint UUID associated with your alerts matches your current endpoint (alerts pointing at a deleted endpoint aren't automatically cleaned up).