Build With Tango: From polling to webhooks

Most Tango integrations start the same way: a script that hits /api/opportunities/ every fifteen minutes, diffs the response against last run's IDs, alerts on what's new. It's the most common shape we see — by a lot. And it's the right call until the staleness, the wasted calls, or the state management starts to hurt. Webhooks fix that. Here's the migration.

The pattern, as built

The polling workflow, distilled:

from tango import TangoClient

tango = TangoClient()

# On cron, every 15 minutes:
page = tango.list_opportunities(
    limit=100,
    naics="541512",
    set_aside="SBA",
    active=True,
)

seen = load_seen("state.json")
new = [r for r in page.results if r["opportunity_id"] not in seen]

for opp in new:
    post_to_slack(opp)

save_seen("state.json", seen | {r["opportunity_id"] for r in page.results})

This is the bones of saved-search-watcher in our cookbook — a YAML-driven, fork-and-ship version of the same shape. It works. But it has three structural problems:

  1. Up to one full poll interval of staleness. If you poll every fifteen minutes and the notice you care about posts at minute one, you find out fourteen minutes later.
  2. You pay for "no change." Most calls return the same top results. The Nth request retrieves nothing new.
  3. State is your problem. Lose state.json and the next run re-alerts on the entire top-100. Multiple replicas? Now you need locking.

The deeper issue: polling is you asking us "anything new?" on a clock. The information flow should run the other way.

The migration: subscribe, don't poll

You give Tango a callback URL and a filter; Tango POSTs a signed payload to your endpoint when a matching record posts, updates, or gets awarded. Two SDK calls:

endpoint = tango.create_webhook_endpoint(
    callback_url="https://your-app.example.com/webhooks/tango",
    name="small IT recompetes",
    is_active=True,
)
# Save endpoint.secret — you'll need it to verify deliveries.

alert = tango.create_webhook_alert(
    name="small IT recompetes",
    query_type="opportunities",
    filters={
        "naics": "541512",
        "set_aside": "SBA",
        "active": True,
    },
    frequency="realtime",
    endpoint=endpoint.id,
)

Those are the same filters you were passing to list_opportunities(). The migration shape is: take the kwargs, move them under filters=, drop the cron. One endpoint can host many alerts — different NAICS, different agencies, different notice types — all funneling into the same handler.

The receiver

~80 lines of FastAPI:

from fastapi import FastAPI, Request, HTTPException
from tango.webhooks import verify_signature
import os

app = FastAPI()
SECRET = os.environ["TANGO_WEBHOOK_SECRET"]

@app.post("/webhooks/tango")
async def receive(request: Request):
    body = await request.body()
    sig = request.headers.get("X-Tango-Signature", "")

    if not verify_signature(body, SECRET, sig):
        raise HTTPException(401, "bad signature")

    event = await request.json()
    # event_id, event_type, payload — route however you want.
    post_to_slack(event["payload"])
    return {"status": "ok"}

The constant-time HMAC compare is what stops anyone from spoofing deliveries. The full version — event_id dedupe so retries don't double-fire, a pluggable sink for Slack/stdout/your-thing-here — is in webhook-receiver. It ships with a register.py that creates the endpoint, wires the example alert, writes the secret to a 0600 file beside the script (never to stdout), and fires a test delivery so you can confirm the receiver is up before any real events arrive.

What a real delivery looks like

To make it concrete: NAICS 541512, SBA set-aside, currently active on SAM.gov. A live match right now is "NATO Improved Link Eleven (NILE) In-Service Support 6 (ISS 6)", solicitation N0003926RA002, Navy, response deadline 2026-07-06. With the polling watcher, you'd find this on whichever 15-minute tick happened after Navy posted it. With the webhook alert above, your receiver gets a POST within seconds of Tango ingesting it. Same opportunity_id, same fields — only the trigger changed.

What you'd build with it

  • Per-NAICS Slack channels. One alert per NAICS, all routed by query_type and filters.naics. The "should I be on this?" decision happens server-side; the channel routing happens in five lines of receiver code.
  • CRM enrichment on the event. Webhook arrives → enrich with the incumbent-radar recipe (contracts, IDVs, OTAs, subaward flow off a single UEI) → upsert into HubSpot or Twenty. The trigger is the procurement event, not the nightly batch.
  • LLM summarization at posting time. Push the event onto a queue, run a Claude tool-use loop (see opportunities-agent) over the matching opportunity plus related contracts, drop the summary into the same Slack message. The notice arrives already triaged.
  • More than opportunities. The same shape covers protests, contracts, forecasts, notices. Pick the endpoint, pick the filters, point at the same receiver.

When to stay on polling

Webhooks aren't always the answer. The watcher is the right call when you want to own the schedule (e.g. a daily digest, not realtime), when you're already running cron on a host you trust and don't want to expose a public endpoint, or when your filter logic is fundamentally different from what the alert filters express and you'd polling-filter the results anyway.

The cookbook ships both — saved-search-watcher pulls; webhook-receiver gets pushed to. The honest framing isn't "polling is bad" — it's "polling is right until it isn't, and that point arrives sooner than you think." If you're integrating Tango today, start with the watcher. When you outgrow it, the migration is a kwargs-to-filters rewrite and a thirty-minute FastAPI setup. The free tier is there for a reason.

Ready to get started with Tango?

If you're working with federal procurement data, Tango provides a unified API that combines federal procurement data sets and improves on them with a developer-friendly approach. Skip the complexity of scraping and joining multiple government APIs yourself.

Sign up for Tango