Skip to content

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:

  1. verifies the Tango signature on the inbound POST,
  2. iterates events[].matches.new[] — each entry is a summary object with the fields you need (title, solicitation number, NAICS, response deadline, opportunity ID),
  3. 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:

  1. Verify the X-Tango-Signature header. Reject mismatches with 401.
  2. Dedupe on delivery_id if you might receive the same delivery twice (Tango retries on 5xx). The delivery_id is a UUID on the top-level payload, stable across retries.
  3. Iterate event.matches.new[]. Each entry is a summary object — opportunity_id, title, solicitation_number, naics_code, response_deadline. No additional fetch required.
  4. 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 verifySignature return false? Double-check TANGO_WEBHOOK_SECRET matches 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, or place_of_performance to cut volume. The same filter parameters available on GET /api/opportunities/ work in the alert filters object.
  • Switch frequency. realtime fires on every ingestion cycle (multiple times per day). For lower-priority feeds, daily rolls 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.