Skip to content

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

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:

  1. Create an endpoint with the public URL of your handler. Tango returns a secret — save it; it's used to sign every delivery.
  2. Create one or more alerts describing the records your handler cares about (e.g. new IT-services contracts).
  3. 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.
  4. 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 false for missing, empty, malformed, or mismatched headers — never throws on mismatch.
  • Uses timingSafeEqual from node:crypto internally.
  • 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).