Skip to content

Tango – Webhooks Partner Guide

Welcome! This guide walks you through enabling and consuming outbound webhooks from Tango so your application can react to fresh federal-spending data without polling our API.

Jump to:


1. What you get

Tango pushes a JSON payload to your server whenever tracked resources change. Today this includes:

  • Awards (new awards, new transactions)
  • Opportunities (new opportunities, new notices, updates)
  • Entities (new entities, updates)
  • Grants (new grants, updates)
  • Forecasts (new forecasts, updates)

  • Near–real-time: you'll be notified minutes after Tango's ETL jobs finish.

  • Lightweight: one payload lists just the IDs that changed – you then pull full details via the existing REST API if needed.
  • Flexible filtering: Control exactly what notifications you receive using subjects (entity, award, agency, etc.) and event types.
  • Reliability features: Automatic circuit breaker pattern for endpoint health management and intelligent retry strategies.

Note (Awards): For correctness, awards webhooks are published after award bundling/materialization so a webhook will not arrive before the corresponding award is queryable via the API.

1.1 When events are published (“data is ready”)

We intentionally publish events only once the underlying data is queryable via the API.

  • awards.*: published after award bundling/materialization
  • opportunities.*: published at the end of load_opportunities
  • entities.*: published at the end of load_entities / load_dsbs
  • grants.*: published at the end of load_grants
  • forecasts.*: published at the end of load_forecasts

1.2 Subjects you can subscribe to (current)

An event can match your subscription via any of its subjects.

  • Awards (awards.new_award, awards.new_transaction)
  • entity:<UEI> (recipient)
  • award:<award_key>
  • awarding_agency:<org_fh_key> (best-effort; prefers Organization.l2_fh_key when available)
  • funding_agency:<org_fh_key> (best-effort; prefers Organization.l2_fh_key when available)
  • Opportunities (opportunities.updated, opportunities.new_opportunity, opportunities.new_notice)
  • opportunity:<opportunity_id>
  • notice:<notice_id>
  • notice_type:<code> (1-letter notice type code)
  • agency:<...>
  • Entities (entities.new_entity, entities.updated)
  • entity:<UEI>
  • Grants (grants.updated, grants.new_grant)
  • grant:<grant_id>
  • agency:<...>
  • Forecasts (forecasts.updated, forecasts.new_forecast)
  • forecast:<source_system:external_id>
  • agency:<...> (may be an acronym)

2. Access requirements

Important: Webhook subscriptions are only available to Large and Enterprise tier users.

If you're on a Free or Small tier, you'll need to upgrade your subscription to access webhook functionality. Contact [email protected] for tier upgrade information.


3. On-boarding checklist

  1. Verify your tier – Ensure you have Large or Enterprise tier access.
  2. Provide a callback URL – a publicly reachable https:// endpoint that accepts HTTP POSTs. ‑ Recommended path: /tango/webhooks.
  3. Receive your shared secret – Tango generates a 32-byte hex secret (64 hex chars) and shares it out-of-band. You'll use this to verify signatures.
  4. Test your endpoint – Use the test delivery endpoint to verify connectivity.
  5. Configure your filters (via subscriptions) – • Catch-all (Enterprise only) – use subject_ids: [] to match all subjects for an event type. • Entity filtering – specify which UEIs you want to track. • Change-type filtering – choose which event_type values you want (e.g. new awards vs new transactions)

Note We create the initial Webhook Endpoint record for you. All subsequent management is done via the Subscription API below.


4. Security & authenticity

Every POST from Tango includes an HMAC-SHA-256 signature:

X-Tango-Signature: sha256=<hex digest>

The digest is computed over the raw request body using your secret. Verify it like so (Python snippet):

import hmac, hashlib, os, flask, json

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

@app.post("/tango/webhooks")
def recv():
    body = flask.request.get_data()
    sig  = flask.request.headers.get("X-Tango-Signature", "")[7:]  # strip "sha256="

    if not hmac.compare_digest(
        hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest(), sig):
        return "Invalid signature", 401

    payload = json.loads(body)
    # ... handle events ...
    return "ok", 200

If you respond with any 2xx status, Tango marks the delivery as successful. Non-2xx responses and time-outs are retried (at-least-once delivery). Current retry limits are error-aware:

  • 4xx: up to 2 attempts total
  • 5xx: up to 5 attempts total
  • network errors/timeouts: up to 7 attempts total

5. Subscription API

Base URL: https://tango.makegov.com/api/webhooks/
Auth: API Key – send Authorization: Api-Key <your-key>

5.0 Discover supported event types

Tango exposes a discovery endpoint so clients can validate configurations without hard-coding event types:

GET /webhooks/event-types/

Response:

{
  "event_types": [
    {
      "event_type": "awards.new_award",
      "default_subject_type": "entity",
      "description": "A new award became available (subject defaults to entity UEI).",
      "schema_version": 1
    }
  ],
  "subject_types": ["entity", "award", "opportunity", "notice", "notice_type", "grant", "forecast", "agency", "awarding_agency", "funding_agency", "naics", "psc"],
  "subject_type_definitions": [
    {
      "subject_type": "entity",
      "description": "Entity UEI (vendor/recipient).",
      "id_format": "UEI string (e.g. 'ABCDEF123456').",
      "status": "active"
    },
    {
      "subject_type": "agency",
      "description": "Agency dimension for non-award domains (opportunities, grants, forecasts).",
      "id_format": "Preferred: Organization l2_fh_key/fh_key (string integer). Fallback: legacy Agency.code or (forecasts) source acronym when no org mapping exists.",
      "status": "active"
    },
    {
      "subject_type": "awarding_agency",
      "description": "Awarding agency (award-side) keyed by a stable Organization fh_key.",
      "id_format": "Organization fh_key (string integer; prefers L2 via l2_fh_key when available).",
      "status": "active"
    },
    {
      "subject_type": "funding_agency",
      "description": "Funding agency (award-side) keyed by a stable Organization fh_key.",
      "id_format": "Organization fh_key (string integer; prefers L2 via l2_fh_key when available).",
      "status": "active"
    },
    {
      "subject_type": "naics",
      "description": "NAICS classification dimension.",
      "id_format": "NAICS code string (e.g. '541330').",
      "status": "reserved"
    }
    // ... full list returned by the API
  ]
}

Note: Only some event types may be actively emitted at any given time. If you subscribe to an event type that is not currently emitted, you will simply not receive those events.

Note: status: "reserved" means the subject type is accepted/validated for subscription configs, but may not be emitted by Tango yet (so matching events may be zero until producers/publishers start tagging those subjects).

5.1 List current subscriptions

GET /webhooks/subscriptions/

Response

{
  "count": 1,
  "next": null,
  "previous": null,
  "results": [
    {
      "id": "e4c4…",           // UUID
      "subscription_name": "Track key vendors",
      "payload": {
        "records": [
          {
            "event_type": "awards.new_award",
            "subject_type": "entity",
            "subject_ids": ["UEI123ABC", "UEI987XYZ"]
          },
          {
            "event_type": "awards.new_transaction",
            "subject_type": "entity",
            "subject_ids": ["UEI123ABC", "UEI987XYZ"]
          }
        ]
      },
      "created_at": "2024-06-01T12:00:00Z"
    }
  ]
}

5.2 Create / replace a subscription

POST /webhooks/subscriptions/
Content-Type: application/json

Payload schema (subjects)

Each record now supports an explicit subject model:

  • subject_type: what you are subscribing to (e.g. entity)
  • subject_ids: list of IDs for that subject type

For backward compatibility, resource_ids is accepted as an alias for subject_ids (do not send both).

Note (Legacy): resource_ids is legacy/v1 terminology. Prefer subject_ids for all new integrations. We intend to deprecate v1-only fields over time.

Example 1: Filter by specific entities only

{
  "subscription_name": "Track specific vendors",
  "payload": {
    "records": [
      {
        "event_type": "awards.new_award",
        "subject_type": "entity",
        "subject_ids": ["UEI123ABC", "UEI987XYZ"]
      },
      {
        "event_type": "awards.new_transaction",
        "subject_type": "entity",
        "subject_ids": ["UEI123ABC", "UEI987XYZ"]
      }
    ]
  }
}

Example 2: Filter by change types only (all entities)

Enterprise tiers only: Large tier users must list specific subject_ids (no catch-all).

{
  "subscription_name": "All new awards",
  "payload": {
    "records": [
      {
        "event_type": "awards.new_award",
        "subject_type": "entity",
        "subject_ids": []  // Empty array means all entities (Enterprise only)
      }
    ]
  }
}

Example 3: Combined entity and change-type filtering

{
  "subscription_name": "Key vendor new awards",
  "payload": {
    "records": [
      {
        "event_type": "awards.new_award",
        "subject_type": "entity",
        "subject_ids": ["UEI123ABC", "UEI987XYZ"]
      }
    ]
  }
}

Example 4: Receive everything (default)

Enterprise tiers only: Large tier users must list specific subject_ids (no catch-all).

{
  "subscription_name": "All events",
  "payload": {
    "records": [
      {
        "event_type": "awards.new_award",
        "subject_type": "entity",
        "subject_ids": []
      },
      {
        "event_type": "awards.new_transaction",
        "subject_type": "entity",
        "subject_ids": []
      }
    ]
  }
}

Returns 201 Created + body of the new subscription.

Example 5: Subscribe by award (instead of entity)

{
  "subscription_name": "Award-specific updates",
  "payload": {
    "records": [
      {
        "event_type": "awards.new_transaction",
        "subject_type": "award",
        "subject_ids": ["W15QKN24C1234"]
      }
    ]
  }
}

Example 6: Subscribe by agency

For awards, agency subscriptions must specify whether you mean the funding or awarding agency using subject_type: "funding_agency" or subject_type: "awarding_agency". For non-award domains, subject_type: "agency" is still used.

{
  "subscription_name": "All awards for a specific agency",
  "payload": {
    "records": [
      {
        "event_type": "awards.new_award",
        "subject_type": "funding_agency",
        "subject_ids": ["123456789"]
      }
    ]
  }
}

Example 7: Subscribe to opportunities by notice type

{
  "subscription_name": "SAM notices: Pre-solicitation",
  "payload": {
    "records": [
      {
        "event_type": "opportunities.updated",
        "subject_type": "notice_type",
        "subject_ids": ["p"]
      }
    ]
  }
}

Available event types:

  • Use GET /webhooks/event-types/ for the authoritative list.
  • Today, the primary emitted sources are:
  • Awards:
    • awards.new_award
    • awards.new_transaction
  • Opportunities:
    • opportunities.new_opportunity
    • opportunities.new_notice
    • opportunities.updated
  • Entities:
    • entities.new_entity
    • entities.updated
  • Grants:
    • grants.new_grant
    • grants.updated
  • Forecasts:
    • forecasts.new_forecast
    • forecasts.updated (subject id is "<source_system>:<external_id>", e.g. "HHS:abc-123")

5.3 Update an existing subscription

PATCH /webhooks/subscriptions/{id}/

Body identical to POST – it replaces the stored subscription configuration.

5.4 Delete a subscription

DELETE /webhooks/subscriptions/{id}/

If you delete every subscription row, you will stop receiving webhooks until you create at least one new subscription.

Note: You must maintain at least one subscription row to receive webhooks.

5.5 Test webhook delivery

POST /webhooks/endpoints/test-delivery/

Sends a test webhook to your endpoint to verify connectivity. Returns detailed information about the delivery attempt.

Response (success):

{
  "success": true,
  "status_code": 200,
  "response_time_ms": 145,
  "message": "Test delivery successful! Response time: 145ms. Your webhook endpoint is configured correctly."
}

Response (failure):

{
  "success": false,
  "status_code": 500,
  "message": "Test delivery failed with status 500. Please check your endpoint implementation and ensure it returns 2xx status codes.",
  "response_body": "<first 1000 chars of response>"
}

5.6 Get sample payload

GET /webhooks/endpoints/sample-payload/?event_type=awards.new_award

Returns a sample webhook payload for testing your handler implementation.

Notes:

  • GET /webhooks/endpoints/sample-payload/ (no params) returns samples for all supported event types.
  • GET /webhooks/endpoints/sample-payload/?event_type=<event_type> returns a single sample.

Response:

{
  "event_type": "awards.new_award",
  "sample_delivery": {
    "timestamp": "2024-01-15T10:30:00Z",
    "events": [
      {
        "event_type": "awards.new_award",
        "created_at": "2024-01-15T10:29:12.123Z",
        "change": "new_award",
        "award_type": "contract",
        "award_key": "CONT_AWD_12345_9700_SPE2DX22D0001_9700",
        "recipient_id": "UEI123ABC"
      }
    ]
  },
  "sample_subjects": [
    {"subject_type": "entity", "subject_id": "UEI123ABC"},
    {"subject_type": "award", "subject_id": "CONT_AWD_12345_9700_SPE2DX22D0001_9700"}
  ],
  "sample_subscription_requests": {
    "by_subject_type": {
      "entity": {
        "subscription_name": "Sample: awards.new_award by entity",
        "payload": {
          "records": [
            {"event_type": "awards.new_award", "subject_type": "entity", "subject_ids": ["UEI123ABC"]}
          ]
        }
      }
    },
    "catch_all": {
      "subscription_name": "Sample: awards.new_award (catch-all)",
      "payload": {
        "records": [
          {"event_type": "awards.new_award", "subject_type": "entity", "subject_ids": []}
        ]
      }
    }
  }
}

6. Payload format

A single delivery looks like:

{
    "timestamp": "2024-01-15T10:30:00Z",
    "events": [
        {
            "event_type": "awards.new_transaction",
            "created_at": "2024-01-15T10:29:12.123Z",
            "change": "new_transaction",
            "award_key": "OT_AWD_FA24012490094_9700_-NONE-_-NONE-",
            "award_type": "ota",
            "recipient_id": "UEI123ABC",
            "transaction_ids": [
                "OT_9700_-NONE-_FA24012490094_0_-NONE-_-NONE-"
            ]
        },
        {
            "event_type": "awards.new_award",
            "created_at": "2024-01-15T10:29:12.123Z",
            "change": "new_award",
            "award_key": "OT_AWD_FA24012490094_9700_-NONE-_-NONE-",
            "award_type": "ota"
        }
    ]
}

6.1 Event types and payloads

New Award Events

{
  "event_type": "awards.new_award",
  "created_at": "2024-01-15T10:29:12.123Z",
  "change": "new_award",
  "award_type": "contract",    // contract | idv | ota | otidv
  "award_key": "W15QKN24C1234",
  "recipient_id": "UEI123ABC"
}

New Transaction Events

{
  "event_type": "awards.new_transaction",
  "created_at": "2024-01-15T10:29:12.123Z",
  "change": "new_transaction",
  "award_type": "contract",
  "award_key": "W15QKN24C1234",
  "recipient_id": "UEI123ABC",
  "transaction_ids": ["TXN_KEY_A", "TXN_KEY_B", "TXN_KEY_C"]
}

Entity Events

{
  "event_type": "entities.new_entity",
  "created_at": "2024-01-15T10:29:12.123Z",
  "change": "new_entity",
  "uei": "UEI123ABC"
}
{
  "event_type": "entities.updated",
  "created_at": "2024-01-15T10:29:12.123Z",
  "change": "updated",
  "uei": "UEI123ABC"
}

Key facts:

  • Batched – events are grouped per endpoint per dispatch run.
  • At-least-once – retries can cause duplicates; your handler must be idempotent. If you need a de-dupe key, derive it from stable identifiers in each event (e.g. event_type + award_key + recipient_id + change).
  • Server-side filtered – only events matching your subscription filters are sent.

Examples

These are copy/paste subscription payloads for common use cases.

Notes:

  • Replace placeholder IDs like <UEI> / <ORG_FH_KEY> / <OPPORTUNITY_UUID> with real values.
  • For agency-based filters, we recommend using the Organization fh_key/l2_fh_key (stable). You can look this up via /api/organizations/ (search by agency name/acronym).

Subscribe to new awards funded by the VA

{
  "subscription_name": "New awards funded by the VA",
  "payload": {
    "records": [
      {
        "event_type": "awards.new_award",
        "subject_type": "funding_agency",
        "subject_ids": ["<VA_ORG_FH_KEY>"]
      }
    ]
  }
}

Subscribe to new awards for Oshkosh

{
  "subscription_name": "New awards for Oshkosh (by UEI)",
  "payload": {
    "records": [
      {
        "event_type": "awards.new_award",
        "subject_type": "entity",
        "subject_ids": ["<OSHKOSH_UEI>"]
      }
    ]
  }
}

Subscribe to new opportunities out of CMS

{
  "subscription_name": "New opportunities from CMS",
  "payload": {
    "records": [
      {
        "event_type": "opportunities.new_opportunity",
        "subject_type": "agency",
        "subject_ids": ["<CMS_ORG_FH_KEY>"]
      }
    ]
  }
}

Subscribe to updates for opportunity <UUID>

If you want both updates and new notices for the same opportunity, include both records:

{
  "subscription_name": "Updates + new notices for one opportunity",
  "payload": {
    "records": [
      {
        "event_type": "opportunities.updated",
        "subject_type": "opportunity",
        "subject_ids": ["<OPPORTUNITY_UUID>"]
      },
      {
        "event_type": "opportunities.new_notice",
        "subject_type": "opportunity",
        "subject_ids": ["<OPPORTUNITY_UUID>"]
      }
    ]
  }
}

Subscribe to updates on forecasts out of GSA

{
  "subscription_name": "Forecast updates from GSA",
  "payload": {
    "records": [
      {
        "event_type": "forecasts.updated",
        "subject_type": "agency",
        "subject_ids": ["<GSA_ORG_FH_KEY>"]
      }
    ]
  }
}

7. Filtering strategies

Note (catch-all / wildcard): subject_ids: [] (or legacy resource_ids: []) means “all subjects” for that record. This is Enterprise only; Large tier users must list specific IDs.

7.1 High-volume integrations

For applications processing many entities, filter by event type to reduce noise:

{
  "subscription_name": "New awards only",
  "payload": {
    "records": [
      {
        "event_type": "awards.new_award",
        "resource_ids": []  // All entities, only new awards
      }
    ]
  }
}

7.2 Entity-focused integrations

For applications tracking specific vendors, filter by entity:

{
  "subscription_name": "Track key vendors",
  "payload": {
    "records": [
      {
        "event_type": "awards.new_award",
        "resource_ids": ["UEI123ABC", "UEI987XYZ"]
      },
      {
        "event_type": "awards.new_transaction",
        "resource_ids": ["UEI123ABC", "UEI987XYZ"]
      }
    ]
  }
}

7.3 Precision integrations

For maximum control, specify exactly what you need:

{
  "subscription_name": "Critical vendor new awards",
  "payload": {
    "records": [
      {
        "event_type": "awards.new_award",
        "resource_ids": ["UEI123ABC"]  // Only new awards for this specific vendor
      }
    ]
  }
}

7.4 Multiple event type combinations

You can create complex filters by combining multiple records:

{
  "subscription_name": "Mixed tracking",
  "payload": {
    "records": [
      {
        "event_type": "awards.new_award",
        "resource_ids": []  // All new awards
      },
      {
        "event_type": "awards.new_transaction",
        "resource_ids": ["UEI123ABC", "UEI456DEF"]  // Transactions for specific vendors only
      }
    ]
  }
}

7.5 Client-side filtering

You can still filter in your webhook handler if needed:

@app.post("/tango/webhooks")
def handle_webhook():
    payload = json.loads(request.get_data())

    for event in payload["events"]:
        if event.get("change") == "new_award":
            # Handle only new awards
            process_new_award(event)

But server-side filtering is more efficient and reduces bandwidth usage.


8. Best practices on your side

  1. Return 200 quickly — enqueue the work in your own job queue and respond; do not block processing.
  2. Test your endpoint first — use the test delivery endpoint to verify connectivity before going live.
  3. Harden the endpoint — HTTPS only, accept POST only, max-payload 256 KB.
  4. Store the last successful timestamp — helps spot missed deliveries.
  5. Use exponential back-off when pulling details — Tango's public API has rate limits; stagger follow-up fetches if you receive a large batch.
  6. Configure appropriate filters — reduce bandwidth and processing by filtering server-side.
  7. Monitor endpoint health — Tango uses circuit breaker patterns to protect both systems from cascading failures.

9. Reliability features

Circuit Breaker Pattern

Tango implements automatic circuit breaker protection for webhook endpoints:

  • Automatic failure detection — After repeated failures (currently: 5 consecutive failures), the circuit opens
  • Cool-down — While open, deliveries are skipped for a short period (currently: 5 minutes)
  • Recovery testing — After cool-down, Tango allows a single probe delivery (half-open)
  • Automatic recovery — Upon success, normal operation resumes; upon failure, the circuit re-opens (currently: 10 minute cool-down)
  • Resource protection — Prevents wasting resources on consistently failing endpoints

This ensures both Tango and your systems remain stable even during outages.

Retry Strategy

Failed webhooks are retried with:

  • Error-aware retry limits — Retry caps differ by error type:
  • Client errors (4xx): up to 2 attempts total
  • Server errors (5xx): up to 5 attempts total
  • Network errors/timeouts: up to 7 attempts total

10. Troubleshooting

Symptom Most likely cause Next steps
Receive 401 from Tango on Subscription API Missing/invalid API key Ensure Authorization: Api-Key … header
Receive 403 on Subscription API Insufficient tier access Upgrade to Large or Enterprise tier
Webhook payloads stop arriving Circuit breaker activated due to failures Fix endpoint issues; circuit will auto-recover after cool-down
Test delivery fails Endpoint connectivity issues Check HTTPS cert, firewall rules, response time
Signature mismatch Using wrong secret or modified body Re-sync secret; ensure you hash the raw bytes exactly
Not receiving expected events Incorrect subscription filters Review your payload.records configuration
Receiving too many events No filters or broad filters Add specific subject_ids (or legacy resource_ids) or limit event_type values

Need help? Email [email protected] with your endpoint URL & the approximate timestamp of the last delivery you saw.


Happy shipping! 🚀