Skip to content

Get notified when new awards match your filter

You want a heads-up when a new contract award matches a saved filter — say, "DOD awards over $1M in NAICS 541512." There are two ways to do it. Polling is the default for most teams. Webhooks are the upgrade if you already run that kind of infrastructure.

The 1-line answer

  • Polling: GET /api/contracts/?<your filters>&modified_after=<last_run> on a 15-30 min cron. Dedupe on key. Done.
  • Webhooks: create a filter alert (POST /api/webhooks/alerts/) — and if your account has more than one webhook endpoint, you must pass endpoint=<uuid> (see Step 1: Create the filter alert under Option B).

How fast is "new"?

Whichever option you pick, latency is bounded by upstream data sources, not by your transport:

Source Cadence
FPDS publishes new contract rows Irregular; weekday business hours, large catch-ups overnight
Tango ingests from FPDS Twice daily
Tango's realtime alert evaluator */5 * * * * (every 5 min)

So the gap between a contract being signed and you finding out is typically hours, sometimes a day — not seconds. The 5-min webhook cron is downstream of the twice-daily FPDS pull; it does not make the upstream feed any fresher. If you need true second-level streaming, neither option will give it to you, because FPDS itself doesn't.

When to pick which

Polling Webhooks
Latency from FPDS ingest 0-30 min (your poll interval) ≤ 5 min
Latency from FPDS publish Hours (FPDS cadence) Hours (same)
Needs public HTTPS endpoint No Yes
Needs HMAC verifier No Yes
Needs a job queue No Strongly recommended
Retry / dedupe logic Free (idempotent by construction) Required (at-least-once delivery)
Backpressure if your end is slow None Tango opens the circuit breaker on you

Webhook only wins when you're already running a webhook stack (Stripe, GitHub, Slack, etc.). For a single BD / analytics team, a polling job has half the moving parts and the same effective freshness.


Option A — Polling (the default)

Run a cron that hits /api/contracts/ with your filters plus modified_after=<last successful run>. Track the last-run timestamp in your DB or a state file; dedupe on each result's key.

Sketch

every 15-30 min:
  since   = read last_successful_run_ts()      # ISO 8601
  results = GET /api/contracts/?<filters>&modified_after=<since>&ordering=-award_date&limit=100
  for row in results.paginate():
      if row.key not in already_seen_table:
          handle(row)
          already_seen_table.insert(row.key)
  write last_successful_run_ts(now_before_request)

A few rules to keep this boring:

  • Record now_before_request before you send the call, not after. Otherwise you'll lose records that landed during your fetch window.
  • Always paginate (next cursor). The default page size is small; a busy filter will exceed it.
  • Dedupe on key (the contract's stable primary id), not on (piid, agency). PIIDs are not globally unique.
  • modified_after covers both new and modified rows — that's normally what you want. If you only want net-new awards, also filter on award_date_gte=<some recent floor>.
  • Allowed ordering values are award_date, obligated, total_contract_value (and the same with a - prefix). key is not an allowed ordering.

Three-language smoke test

curl -H "X-API-KEY: $TANGO_API_KEY" \
  "https://tango.makegov.com/api/contracts/?\
funding_agency=DOD&naics=541512&obligated_gte=1000000\
&modified_after=2026-05-12T00:00:00Z&ordering=-award_date&limit=20"
import os
from tango import TangoClient

client = TangoClient(api_key=os.environ["TANGO_API_KEY"])

# The Python SDK takes `naics_code=` and maps it to the API's `naics=`.
# Same story for `psc_code` → `psc`.
#
# `modified_after` isn't exposed as a top-level kwarg on list_contracts() yet,
# so pass it via `filters={...}` — values there get forwarded verbatim.
contracts = client.list_contracts(
    funding_agency="DOD",
    naics_code="541512",
    obligated_gte="1000000",
    ordering="-award_date",
    limit=20,
    filters={"modified_after": "2026-05-12T00:00:00Z"},
)
for c in contracts.results:
    handle(c)
import { TangoClient } from "@makegov/tango-node";

const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY! });

// The Node SDK accepts BOTH `naics` (raw API name) and `naics_code` (alias).
const page = await client.listContracts({
  funding_agency: "DOD",
  naics: "541512",
  obligated_gte: "1000000",
  modified_after: "2026-05-12T00:00:00Z",
  ordering: "-award_date",
  limit: 20,
});

for (const c of page.results) {
  handle(c);
}

That's the whole pattern. Half of all "stream awards" use cases stop here. If yours is one of them, you're done.


Option B — Webhooks (advanced)

Pick webhooks if you already run a public HTTPS endpoint with HMAC verification and a job queue, and want push delivery instead of polling. Otherwise stick with Option A.

Step 1: Create the filter alert

POST /api/webhooks/alerts/ is the entrypoint. Returns 201 Created with the new alert, or 200 OK with the existing one if a subscription with the same query_type + filters already exists (dedup by SHA-256 hash of the canonical filter).

Multi-endpoint accounts must specify endpoint

If your account has more than one webhook endpoint, the API rejects POST /api/webhooks/alerts/ without an explicit endpoint=<uuid> field — it will not guess which one to use. Single-endpoint accounts get auto-resolve for free. List your endpoints with GET /api/webhooks/endpoints/ and copy the id of the one you want.

curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
  -H "Content-Type: application/json" \
  "https://tango.makegov.com/api/webhooks/alerts/" \
  -d '{
    "name": "DOD IT services > $1M",
    "query_type": "contract",
    "filters": {
      "funding_agency": "DOD",
      "naics": "541512",
      "obligated_gte": "1000000"
    },
    "frequency": "realtime",
    "endpoint": "<endpoint-uuid-from-/api/webhooks/endpoints/>"
  }'
import os
from tango import TangoClient

client = TangoClient(api_key=os.environ["TANGO_API_KEY"])

# Filter keys here match the RAW API param names (`naics`, `psc`), not the
# `list_contracts()` kwarg aliases (`naics_code`, `psc_code`).
alert = client.create_webhook_alert(
    name="DOD IT services > $1M",
    query_type="contract",
    filters={
        "funding_agency": "DOD",
        "naics": "541512",
        "obligated_gte": "1000000",
    },
    frequency="realtime",
    endpoint="<endpoint-uuid>",  # required if you have >1 endpoint
)
print(alert.alert_id, alert.status)
import { TangoClient } from "@makegov/tango-node";

const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY! });

const alert = await client.createWebhookAlert({
  name: "DOD IT services > $1M",
  query_type: "contract",
  filters: {
    funding_agency: "DOD",
    naics: "541512",
    obligated_gte: "1000000",
  },
  frequency: "realtime",
  endpoint: "<endpoint-uuid>",  // required if you have >1 endpoint
});
console.log(alert.alert_id, alert.status);

Frequency choices

Frequency When it runs Tier
realtime Every 5 minutes (*/5 * * * *); independent of FPDS ingest All tiers
daily Once per day All tiers
weekly Once per week All tiers
custom 5-field cron in cron_expression Micro and above

"Realtime" is the most-frequent option, not a true streaming pipe. The evaluator polls every 5 min regardless of whether anything new landed upstream.

Step 2: Build the receiver

Tango POSTs a signed JSON batch to your endpoint. The receiver has three jobs: verify the signature, dedupe on delivery_id, return 2xx fast.

The payload shape (real production deliveries)

{
  "timestamp": "2026-05-12T18:20:14Z",
  "delivery_id": "8c5e3f6a-1234-4abc-9def-9b21abcde000",
  "events": [
    {
      "event_type": "alerts.contract.match",
      "alert_id": "e4c4aaaa-bbbb-cccc-dddd-eeeeffff0000",
      "query_type": "contract",
      "filters": {"funding_agency": "DOD", "naics": "541512", "obligated_gte": "1000000"},
      "matches": {
        "new_count": 2,
        "modified_count": 0,
        "new": [
          {"id": "CONT_AWD_47QFWA26F0009_4732_47QTCK18D0004_4732", "piid": "47QFWA26F0009", "obligated": 5364614.65, "recipient_uei": "ABC123DEF456"},
          {"id": "CONT_AWD_FA8773_24_C_0099_5700_GS35F0119Y_4732", "piid": "FA877324C0099",   "obligated": 1450000.00, "recipient_uei": "DEF456GHI789"}
        ],
        "modified": []
      },
      "checked_at": "2026-05-12T18:20:12Z"
    }
  ]
}

See Webhooks user guide §6 — Payload format for the field-by-field reference. The summary objects in matches.new[] / matches.modified[] carry just enough to route on (id, piid, obligated, recipient_uei); pull the full record from GET /api/contracts/{id}/ if you need more.

Sample-payload preview vs production

GET /api/webhooks/endpoints/sample-payload/?event_type=alerts.contract.match returns a simplified preview (just new_ids / modified_ids, no delivery_id, no matches.new[]). Real deliveries from the dispatcher carry the richer shape above. Build against this guide and the webhooks user guide §6 — not against the sample-payload response.

Python receiver (Flask)

import hashlib
import hmac
import json
import os

import flask

SECRET = os.environ["TANGO_WEBHOOK_SECRET"].encode()
app = flask.Flask(__name__)

# Replace with Redis / your DB. Must persist across restarts and be fast.
_seen_deliveries: set[str] = set()


@app.post("/tango/webhooks")
def recv():
    body = flask.request.get_data()  # raw bytes, NOT request.json
    sig_header = flask.request.headers.get("X-Tango-Signature", "")
    expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    received = sig_header.removeprefix("sha256=")

    if not hmac.compare_digest(expected, received):
        return "Invalid signature", 401

    payload = json.loads(body)

    # 1. Idempotency: Tango guarantees at-least-once delivery.
    delivery_id = payload.get("delivery_id")
    if delivery_id and delivery_id in _seen_deliveries:
        return "ok", 200
    if delivery_id:
        _seen_deliveries.add(delivery_id)

    # 2. Enqueue and return 2xx fast. Don't process inline — slow handlers
    #    trigger retries and eventually trip the circuit breaker.
    for event in payload.get("events", []):
        if event.get("event_type") != "alerts.contract.match":
            continue  # ignore other event types in the same batch
        matches = event.get("matches", {})
        for match in matches.get("new", []) + matches.get("modified", []):
            enqueue_match(event["alert_id"], match)  # match has id, piid, obligated, recipient_uei

    return "ok", 200


def enqueue_match(alert_id: str, match: dict):
    # Push to Celery / RQ / SQS / whatever. Your worker fetches the full
    # award details from /api/contracts/{id}/ as needed.
    ...

Node receiver (Express)

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.TANGO_WEBHOOK_SECRET!;
const app = express();

// Replace with Redis / your DB. Must persist across restarts.
const seenDeliveries = new Set<string>();

// IMPORTANT: capture the raw body for HMAC. JSON-parse from the buffer, NOT via express.json().
app.post(
  "/tango/webhooks",
  express.raw({ type: "*/*" }),
  (req, res) => {
    const body = req.body as Buffer; // raw bytes

    const sigHeader = (req.header("x-tango-signature") || "").replace(/^sha256=/, "");
    const expected = crypto.createHmac("sha256", SECRET).update(body).digest("hex");

    const a = Buffer.from(expected, "utf8");
    const b = Buffer.from(sigHeader, "utf8");
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send("Invalid signature");
    }

    const payload = JSON.parse(body.toString("utf8"));

    // 1. Idempotency
    const deliveryId: string | undefined = payload.delivery_id;
    if (deliveryId && seenDeliveries.has(deliveryId)) {
      return res.status(200).send("ok");
    }
    if (deliveryId) seenDeliveries.add(deliveryId);

    // 2. Enqueue + ack
    for (const event of payload.events ?? []) {
      if (event.event_type !== "alerts.contract.match") continue;
      const matches = event.matches ?? {};
      for (const m of [...(matches.new ?? []), ...(matches.modified ?? [])]) {
        enqueueMatch(event.alert_id, m);
      }
    }

    res.status(200).send("ok");
  },
);

function enqueueMatch(alertId: string, match: unknown) {
  // Push to BullMQ / SQS / whatever. Pull full record from
  // GET /api/contracts/{id}/ in the worker.
}

Key rules:

  • Sign the raw body bytes, not a re-serialized JSON. Any whitespace change breaks the HMAC.
  • Constant-time compare (hmac.compare_digest / crypto.timingSafeEqual) — never ==.
  • Return 2xx within a few seconds. Synchronous work trips retries and eventually opens the circuit breaker.
  • Dedupe on delivery_id — same delivery_id may arrive more than once on retry. It is the canonical idempotency key.
  • Ignore unknown event types in the batch (don't 4xx) — a single delivery may contain multiple event types for the same endpoint.

Step 3: Test end-to-end with the webhook lab

The Tango repo ships a standalone webhook lab that receives, verifies, and displays signed deliveries. Use it during integration before you have a production endpoint.

# In your tango checkout:
just webhook-lab                                       # starts on localhost:8011
cloudflared tunnel --url http://localhost:8011        # public URL
just webhook-lab-subscribe --url "https://<tunnel>/tango/webhooks" --clear

Then trigger a test delivery:

curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
  -H "Content-Type: application/json" \
  "https://tango.makegov.com/api/webhooks/endpoints/test-delivery/" \
  -d '{"endpoint": "<endpoint-uuid>"}'   # required for multi-endpoint accounts

Managing the alert

# List your filter alerts
curl -H "X-API-KEY: $TANGO_API_KEY" \
  "https://tango.makegov.com/api/webhooks/alerts/"

# Pause without deleting
curl -X PATCH -H "X-API-KEY: $TANGO_API_KEY" \
  -H "Content-Type: application/json" \
  "https://tango.makegov.com/api/webhooks/alerts/<alert_id>/" \
  -d '{"is_active": false}'

# Delete
curl -X DELETE -H "X-API-KEY: $TANGO_API_KEY" \
  "https://tango.makegov.com/api/webhooks/alerts/<alert_id>/"

query_type and filters are immutable after creation — delete and recreate to change them. You can update name, frequency, cron_expression, and is_active.


Common pitfalls

  • Expecting sub-minute latency. Don't. FPDS ingests twice daily; effective end-to-end latency from "vendor signs contract" to "your code sees it" is measured in hours, not seconds — regardless of whether you poll or use webhooks.
  • Forgetting endpoint=<uuid> on multi-endpoint accounts. Any POST /api/webhooks/alerts/ (and any test-delivery) returns 400 with "You have multiple webhook endpoints. Please specify which endpoint to use via the 'endpoint' field." Pass the UUID explicitly.
  • Python SDK kwarg surprise. client.list_contracts(naics=...) raises — the kwarg is naics_code (the SDK maps it to the API's naics). Same for psc_codepsc. The raw HTTP filter, and the filters={} dict you pass to create_webhook_alert(), both use the API name (naics, psc).
  • Filter doesn't match /api/contracts/. Test the filter against the API first. If /api/contracts/?<filters> returns nothing, the alert will never fire and your poll loop will never see anything.
  • HMAC mismatch. You're hashing a re-serialized body, or you stripped a trailing newline. Hash the raw request bytes as received. Signature is sha256=<64 hex chars> over body using your endpoint's secret.
  • Slow receiver. If your handler takes more than a few seconds, Tango retries with exponential backoff (up to 10 min between attempts) and eventually opens the circuit breaker. Always enqueue + return 2xx.
  • At-least-once duplicates. The same delivery_id can land more than once on retry. If your downstream isn't idempotent, you'll double-write. Dedupe on delivery_id.
  • Polling without modified_after. A raw award_date_gte poll will miss modifications to existing records. modified_after covers both new and updated rows. Use both if you want strict "new awards only".
  • Tier caps. Free tier gets 1 filter alert; Micro 3; Small 5; Medium 10; Large 25. Hitting the cap returns 400.