Opportunity updates in Slack¶
Get a Slack message every time a new SAM.gov opportunity matches a saved filter — e.g., "new HHS solicitations in NAICS 541512." Tango POSTs an alerts.opportunity.match event; your receiver maps it to a Slack Block Kit message and forwards to a Slack Incoming Webhook.
The 1-line answer¶
Stand up a tiny HTTP receiver that:
- verifies the Tango signature on the inbound POST,
- iterates
events[].matches.new[]— each entry is a summary object with the fields you need (title, solicitation number, NAICS, response deadline, opportunity ID), - converts each match to Slack Block Kit blocks and POSTs to your Slack Incoming Webhook URL.
Then register your receiver URL as a Tango webhook endpoint and attach an opportunity alert.
The payload already has what you need
Tango's webhook payload includes a matches.new[] array of summary objects — opportunity_id, title, solicitation_number, naics_code, response_deadline. No fan-out fetch required for a Slack notification. See the Webhooks payload format for the full shape.
Prerequisites¶
- Tango API key (
TANGO_API_KEY). - A Slack workspace where you can install an app — you need permission to create an Incoming Webhook.
- Somewhere to host the receiver. Anywhere that can serve a public HTTPS endpoint works: a Cloudflare Worker, an AWS Lambda + API Gateway, a Fly.io / Render / Railway service, a Vercel / Netlify Function, or your own server.
Step 1 — Create the Slack Incoming Webhook¶
Follow Slack's setup guide — it's three clicks: create a Slack app, enable "Incoming Webhooks," and "Add New Webhook to Workspace." Pick the channel you want messages to land in.
Slack hands you a URL that looks like:
https://hooks.slack.com/services/T0XXXX/B0XXXX/abcDEFghi123…
Treat this URL as a secret — anyone with it can post to your channel.
Smoke-test it before wiring up Tango:
curl -X POST -H "Content-Type: application/json" \
--data '{"text": "Hello from Tango setup test."}' \
"$SLACK_WEBHOOK_URL"
You should see a plain message land in the channel within a second.
Step 2 — Stand up the receiver¶
The receiver is one HTTP handler. Four things it must do:
- Verify the
X-Tango-Signatureheader. Reject mismatches with 401. - Dedupe on
delivery_idif you might receive the same delivery twice (Tango retries on 5xx). Thedelivery_idis a UUID on the top-level payload, stable across retries. - Iterate
event.matches.new[]. Each entry is a summary object —opportunity_id,title,solicitation_number,naics_code,response_deadline. No additional fetch required. - Convert each match to a Slack message and POST it.
Pick whichever runtime fits your stack. Below are minimal examples in Python and Node; both use the helpers shipped in the official SDKs.
# pip install tango-python[webhooks] fastapi uvicorn httpx
import os
import httpx
from fastapi import FastAPI, Request, HTTPException
from tango.webhooks.signing import verify_signature, SIGNATURE_HEADER
SLACK_URL = os.environ["SLACK_WEBHOOK_URL"]
SECRET = os.environ["TANGO_WEBHOOK_SECRET"]
app = FastAPI()
seen_deliveries: set[str] = set() # use Redis/DB in production
@app.post("/tango/webhooks")
async def receive(req: Request):
body = await req.body()
if not verify_signature(body, SECRET, req.headers.get(SIGNATURE_HEADER)):
raise HTTPException(401, "invalid signature")
payload = await req.json()
# Dedupe on the top-level delivery_id (stable across retries).
delivery_id = payload.get("delivery_id")
if delivery_id and delivery_id in seen_deliveries:
return {"ok": True}
if delivery_id:
seen_deliveries.add(delivery_id)
async with httpx.AsyncClient(timeout=5.0) as http:
for event in payload["events"]:
if event["event_type"] != "alerts.opportunity.match":
continue
alert_id = event.get("alert_id", "")
# The payload carries summary objects — no fetch needed.
for match in event.get("matches", {}).get("new", []):
blocks = opportunity_to_blocks(match, alert_id)
await http.post(SLACK_URL, json={"blocks": blocks})
return {"ok": True}
def opportunity_to_blocks(match: dict, alert_id: str) -> list[dict]:
title = match.get("title") or "(untitled opportunity)"
sol = match.get("solicitation_number") or "—"
deadline = match.get("response_deadline") or "—"
naics = match.get("naics_code") or "—"
url = f"https://sam.gov/opp/{match['opportunity_id']}/view"
return [
{"type": "header", "text": {"type": "plain_text", "text": f"New: {title[:140]}"}},
{"type": "section", "fields": [
{"type": "mrkdwn", "text": f"*Solicitation*\n{sol}"},
{"type": "mrkdwn", "text": f"*Response by*\n{deadline}"},
{"type": "mrkdwn", "text": f"*NAICS*\n{naics}"},
{"type": "mrkdwn", "text": f"*Alert*\n{alert_id[:8]}…"},
]},
{"type": "actions", "elements": [
{"type": "button", "text": {"type": "plain_text", "text": "View on SAM.gov"}, "url": url},
]},
]
// npm i @makegov/tango-node hono
import { Hono } from "hono";
import { verifySignature, SIGNATURE_HEADER } from "@makegov/tango-node";
const SLACK_URL = process.env.SLACK_WEBHOOK_URL!;
const SECRET = process.env.TANGO_WEBHOOK_SECRET!;
const seenDeliveries = new Set<string>(); // use a real store in production
const app = new Hono();
app.post("/tango/webhooks", async (c) => {
const body = await c.req.text();
if (!verifySignature(body, SECRET, c.req.header(SIGNATURE_HEADER))) {
return c.json({ error: "invalid signature" }, 401);
}
const payload = JSON.parse(body);
// Dedupe on the top-level delivery_id (stable across retries).
const deliveryId: string | undefined = payload.delivery_id;
if (deliveryId && seenDeliveries.has(deliveryId)) {
return c.json({ ok: true });
}
if (deliveryId) seenDeliveries.add(deliveryId);
for (const event of payload.events) {
if (event.event_type !== "alerts.opportunity.match") continue;
const alertId: string = event.alert_id ?? "";
// The payload carries summary objects — no fetch needed.
for (const match of (event.matches?.new ?? []) as any[]) {
await fetch(SLACK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blocks: opportunityToBlocks(match, alertId) }),
});
}
}
return c.json({ ok: true });
});
function opportunityToBlocks(match: any, alertId: string) {
const title = (match.title ?? "(untitled opportunity)").slice(0, 140);
const sol = match.solicitation_number ?? "—";
const deadline = match.response_deadline ?? "—";
const naics = match.naics_code ?? "—";
const url = `https://sam.gov/opp/${match.opportunity_id}/view`;
return [
{ type: "header", text: { type: "plain_text", text: `New: ${title}` } },
{ type: "section", fields: [
{ type: "mrkdwn", text: `*Solicitation*\n${sol}` },
{ type: "mrkdwn", text: `*Response by*\n${deadline}` },
{ type: "mrkdwn", text: `*NAICS*\n${naics}` },
{ type: "mrkdwn", text: `*Alert*\n${alertId.slice(0, 8)}…` },
]},
{ type: "actions", elements: [
{ type: "button", text: { type: "plain_text", text: "View on SAM.gov" }, url },
]},
];
}
export default app;
Both examples assume TANGO_WEBHOOK_SECRET matches the secret you set when registering the endpoint with Tango (next step). For full receiver patterns — async deduplication, retries, structured logging — see the Webhooks user guide.
Step 3 — Register the receiver with Tango and create the alert¶
Two API calls: one to register the endpoint, one to create the alert.
# 1. Register the receiver URL as a webhook endpoint.
ENDPOINT_ID=$(curl -sS -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.makegov.com/api/webhooks/endpoints/" \
-d '{
"name": "Slack — HHS NAICS 541512",
"callback_url": "https://your-receiver.example.com/tango/webhooks",
"is_active": true
}' | jq -r '.endpoint_id')
# The response also includes a `secret`. Save it as TANGO_WEBHOOK_SECRET
# on the receiver — that's the shared secret Tango signs with.
# 2. Create the opportunity alert targeting this endpoint.
curl -X POST -H "X-API-KEY: $TANGO_API_KEY" \
-H "Content-Type: application/json" \
"https://tango.makegov.com/api/webhooks/alerts/" \
-d "{
\"name\": \"HHS NAICS 541512 opportunities\",
\"query_type\": \"opportunity\",
\"filters\": {\"agency\": \"7500\", \"naics\": \"541512\", \"active\": true},
\"frequency\": \"realtime\",
\"endpoint\": \"$ENDPOINT_ID\"
}"
import os
from tango import TangoClient
client = TangoClient(api_key=os.environ["TANGO_API_KEY"])
endpoint = client.create_webhook_endpoint(
name="Slack — HHS NAICS 541512",
callback_url="https://your-receiver.example.com/tango/webhooks",
is_active=True,
)
print("Save this as TANGO_WEBHOOK_SECRET:", endpoint["secret"])
alert = client.create_webhook_alert(
name="HHS NAICS 541512 opportunities",
query_type="opportunity",
filters={"agency": "7500", "naics": "541512", "active": True},
frequency="realtime",
endpoint=endpoint["endpoint_id"],
)
import { TangoClient } from "@makegov/tango-node";
const client = new TangoClient({ apiKey: process.env.TANGO_API_KEY });
const endpoint = await client.createWebhookEndpoint({
name: "Slack — HHS NAICS 541512",
callback_url: "https://your-receiver.example.com/tango/webhooks",
is_active: true,
});
console.log("Save this as TANGO_WEBHOOK_SECRET:", (endpoint as any).secret);
const alert = await client.createWebhookAlert({
name: "HHS NAICS 541512 opportunities",
query_type: "opportunity",
filters: { agency: "7500", naics: "541512", active: true },
frequency: "realtime",
endpoint: (endpoint as any).endpoint_id,
});
Save the endpoint secret
The endpoint-create response includes a one-time secret. Tango signs every delivery with this secret; your receiver verifies with the same value. Store it as TANGO_WEBHOOK_SECRET in your receiver's environment. Lose it and you'll have to rotate by recreating the endpoint.
Step 4 — Test before you wait for a real match¶
You don't want to discover the receiver is broken when the first real match shows up. Use client.test_webhook_delivery() (or client.testWebhookEndpoint() in Node) to fire a synthetic delivery at your endpoint immediately:
client.test_webhook_delivery(endpoint_id=endpoint["endpoint_id"])
await client.testWebhookEndpoint(endpoint.endpoint_id);
You should see a Slack message arrive within a few seconds. If you don't:
- Check the endpoint logs — does the request reach the receiver?
- Did
verifySignaturereturnfalse? Double-checkTANGO_WEBHOOK_SECRETmatches the endpoint secret. - Did
payload["events"]exist? The sample-payload shape is documented in the Webhooks user guide §6.
For deeper local debugging, the SDKs ship a WebhookReceiver that records deliveries to an in-process queue and a simulate.deliver() helper that signs a payload without involving the live Tango API at all. See the Python / Node reference.
Tuning the filter¶
Too many alerts is worse than too few — Slack channels die from noise. Two levers:
- Narrow the filter. Add
set_aside,agency, orplace_of_performanceto cut volume. The same filter parameters available onGET /api/opportunities/work in the alertfiltersobject. - Switch frequency.
realtimefires on every ingestion cycle (multiple times per day). For lower-priority feeds,dailyrolls all new matches from the past 24h into a single delivery — fewer Slack messages, batched.
To see how many matches your current filter is producing before going live:
curl -H "X-API-KEY: $TANGO_API_KEY" \
"https://tango.makegov.com/api/opportunities/?agency=7500&naics=541512&active=true&first_notice_date_after=2026-04-01" | jq '.count'
If count is in the thousands, narrow the filter — that's roughly how many alerts your channel will see when matches start landing.
Updating or pausing the alert¶
# 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}'
# Resume
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": true}'
filters and query_type are immutable post-create — to change either, delete and recreate.
What about contracts, grants, forecasts?¶
Same pattern. Swap query_type for contract / grant / forecast, point filters at the same fields you'd pass to the equivalent list endpoint, and adapt the Block Kit mapping to the relevant entity (PIID + recipient + obligation for contracts, opportunity number + funding categories for grants, etc.). The signature verification, dedup, and Slack-post pieces are identical.