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 onkey. Done. - Webhooks: create a filter alert (
POST /api/webhooks/alerts/) — and if your account has more than one webhook endpoint, you must passendpoint=<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_requestbefore you send the call, not after. Otherwise you'll lose records that landed during your fetch window. - Always paginate (
nextcursor). 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_aftercovers both new and modified rows — that's normally what you want. If you only want net-new awards, also filter onaward_date_gte=<some recent floor>.- Allowed
orderingvalues areaward_date,obligated,total_contract_value(and the same with a-prefix).keyis 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— samedelivery_idmay 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. AnyPOST /api/webhooks/alerts/(and anytest-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 isnaics_code(the SDK maps it to the API'snaics). Same forpsc_code→psc. The raw HTTP filter, and thefilters={}dict you pass tocreate_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>overbodyusing 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_idcan land more than once on retry. If your downstream isn't idempotent, you'll double-write. Dedupe ondelivery_id. - Polling without
modified_after. A rawaward_date_gtepoll will miss modifications to existing records.modified_aftercovers 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.
Related¶
- Contracts API reference
- Webhooks user guide — full subscription / payload / retry / circuit-breaker reference (see §6 Payload format)
- Vendor watchlist recipe — N-alerts pattern for tracking specific UEIs
- Awards by NAICS recipe — single alert across multiple NAICS codes