BonusFlow

Introduction

# BonusFlow Data API — Reference (v1)

The B2B data feed for financial sign-up bonuses, account perks, and earning rates across credit cards, banking, and brokerage products.

  • Base URL: https:///api/v1
  • Machine-readable spec: GET /api/v1/openapi.json (OpenAPI 3.1, unauthenticated)
  • Health check: GET /api/v1/status (unauthenticated)
  • Quickstart: docs/api/quickstart.md
  • Terms draft: docs/api/terms-draft.md
  • Incident workflow: docs/api/incident-response.md
  • Format: JSON. All timestamps are ISO 8601 (UTC).

Authentication

Every request requires a Bearer API key:

text
Authorization: Bearer bf_live_xxxxxxxxxxxxxxxxxxxxxxxx
  • Live keys are prefixed bf_live_ and read from the production catalog.
  • Sandbox keys are prefixed bf_test_ and should be used for integration testing against seeded or test data when sandbox access is enabled.
  • Keys are issued per corporate account and scoped to a subscription tier.
  • 401 — missing/invalid/revoked key. 403 — the key is valid but its tier is below the minimum for the endpoint. Today the only tier-gated endpoints are /changes and all /webhooks routes, which require growth or higher.
  • Rotate keys with zero downtime: issue a new key, migrate, then revoke the old one.

Example:

bash
"text">-emerald-400">curl https://<host>/api/v1/promotions?asset_class=bank_account \
  "text-sky-400">-H "Authorization: Bearer bf_live_xxxxxxxxxxxxxxxxxxxxxxxx"

Admins can seed a minimal integration dataset and a bf_test_ key with:

bash
"text">-emerald-400">ALLOW_SANDBOX_SEED=true npm run seed:api"text-sky-400">-sandbox

The script is guarded and refuses to write unless ALLOW_SANDBOX_SEED=true is present. The client it creates is on the starter tier, so the seeded key cannot reach /changes or /webhooks until an admin raises its tier.

Rate limits

Two windows are enforced per client and surfaced on every response:

HeaderMeaning
X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-ResetPer-minute window
X-RateLimit-Limit-Month / X-RateLimit-Remaining-Month / X-RateLimit-Reset-MonthMonthly quota

Exceeding either returns 429 Too Many Requests with a Retry-After header (seconds) reflecting the binding window.

Default tiers:

| Tier | Per-minute default | Monthly default | Notes | | --- | ---: | ---: | --- | | Starter | 60 requests | 10,000 requests | Default for new clients unless changed by an admin | | Growth | 300 requests | 100,000 requests | Higher-volume catalog sync and webhook users | | Enterprise | 1,200 requests | 1,000,000 requests | Custom commercial agreements may override these defaults |

Commercial behavior:

  • Per-minute and monthly quotas are hard limits by default.
  • Overages are not served automatically. A client that exceeds either window receives 429 Too Many Requests.
  • The Retry-After header points to the binding reset window: the next minute for minute-limit overages, or the first day of the next UTC month for monthly-quota overages.
  • Admins can raise a client's rateLimitPerMinute or rateLimitPerMonth for custom contracts without changing the public API contract.
  • Premium endpoint access is controlled by the client tier; unauthorized endpoints return 403 Forbidden.

Example response headers:

http
400">X-RateLimit-Limit: 60
400">X-RateLimit-Remaining: 59
400">X-RateLimit-Reset: 1811788860
400">X-RateLimit-Limit-Month: 10000
400">X-RateLimit-Remaining-Month: 9999
400">X-RateLimit-Reset-Month: 1811894400

Pagination & field projection

All list endpoints share one envelope:

json
{
  "object": "list",
  "count": 50,
  "total": 327,
  "limit": 50,
  "offset": 0,
  "has_more": true,
  "data": [ /* … */ ]
}
  • limit — items per page (default 50, max 200)
  • offset — items to skip
  • fields — comma-separated allowlist, e.g. ?fields=title,bonus (id and object always included)

Example page request:

http
400 font-semibold">GET /api/v1/promotions?asset_class=credit_card&limit=2&offset=0&fields=title,bonus,verification

Example response:

json
{
  "object": "list",
  "count": 2,
  "total": 19,
  "limit": 2,
  "offset": 0,
  "has_more": true,
  "data": [
    {
      "id": "5a6d4c8b-20bf-4b16-b036-8f43c30b8a4d",
      "object": "promotion",
      "title": "Chase Sapphire Preferred 60,000 point offer",
      "bonus": {
        "value": 60000,
        "type": "points",
        "loyalty_program": {
          "program_key": "chase_ultimate_rewards",
          "program_name": "Chase Ultimate Rewards",
          "point_valuation_cents": 2
        },
        "cash_equivalent": 1200
      },
      "verification": {
        "status": "verified",
        "last_verified_at": "2026-06-04T12: 00: 00.000Z"
      }
    }
  ]
}

Endpoints

Status

MethodPathNotes
GET/statusPublic health check. No API key required.

Example:

json
{
  "status": "healthy",
  "version": "v1",
  "started_at": "2026-06-04T12: 00: 00.000Z",
  "checks": {
    "database": { "status": "pass", "latency_ms": 18 },
    "data": { "status": "pass", "message": "327 promotions indexed" },
    "stale_data": {
      "status": "pass",
      "message": "4 stale published promotions",
      "data": { "bank_account": 2, "credit_card": 2 }
    },
    "source_failures": {
      "status": "pass",
      "message": "1 source failure in the last 24 hours",
      "data": { "blocked": 1 }
    }
  }
}

Promotions

MethodPathNotes
GET/promotionsFilters: asset_class, institution, status (active or all), min_bonus, reward_currency, since
GET/promotions/{id}Single promotion
POST/promotions/{id}/reportFlag a data discrepancy (Active B2B Loop). Body: description required; field and reported_value optional.

A promotion's bonus block normalizes points/miles to a cash_equivalent (USD) when the reward is linked to a valued loyalty program:

json
"bonus": {
  "value": 60000,
  "type": "points",
  "loyalty_program": { "program_key": "chase_ultimate_rewards", "program_name": "Chase Ultimate Rewards", "point_valuation_cents": 2.0 },
  "cash_equivalent": 1200.00
}

Example bank-account promotion:

json
{
  "id": "f7d99ef0-7a10-4886-b2ff-36df55f17c11",
  "object": "promotion",
  "title": "Chase Total Checking $300 Bonus",
  "institution": "Chase",
  "product_name": "Total Checking",
  "asset_class": "bank_account",
  "account_type": "checking",
  "bonus": {
    "value": 300,
    "type": "cash",
    "components": null,
    "reward_packages": null,
    "loyalty_program": null,
    "cash_equivalent": 300
  },
  "requirements": [
    { "type": "direct_deposit", "amount": 500, "window_days": 90 }
  ],
  "fees": {
    "monthly": 12,
    "annual": null,
    "annual_fee_waived_first_year": false,
    "monthly_fee_waiver_condition": "Waived with qualifying direct deposits",
    "early_termination_fee": null
  },
  "bank_account": {
    "credit_card_funding": false,
    "credit_card_funding_limit": null,
    "apy": null
  },
  "eligibility": {
    "availability": "nationwide",
    "is_targeted": false,
    "in_branch_only": false
  },
  "offer_expiration_date": "2026-12-31",
  "official_source_url": "https://www.chase.com/personal/checking",
  "verification": {
    "status": "verified",
    "last_verified_at": "2026-06-04T12: 00: 00.000Z"
  },
  "created_at": "2026-06-01T16: 00: 00.000Z",
  "updated_at": "2026-06-04T12: 00: 00.000Z"
}

Example discrepancy report:

http
400 font-semibold">POST /api/v1/promotions/f7d99ef0-7a10-4886-b2ff-36df55f17c11/report

Only description is required. A non-empty string is expected; an empty or missing description returns 400 with Missing required field: description. field and reported_value are optional context that help ops triage faster, and reported_value is stored as a string.

Minimal request:

json
{
  "description": "The official landing page now shows a $200 bonus."
}

Request with optional context:

json
{
  "description": "The official landing page now shows a $200 bonus.",
  "field": "bonus.value",
  "reported_value": "200"
}

Response (201):

json
{
  "id": "0f0b1f3a-1d2a-4a5e-9c1a-7d1a3f9c2b44",
  "object": "discrepancy_report",
  "promotion_id": "f7d99ef0-7a10-4886-b2ff-36df55f17c11",
  "field": "bonus.value",
  "reported_value": "200",
  "description": "The official landing page now shows a $200 bonus.",
  "status": "open",
  "created_at": "2026-06-04T12: 00: 00.000Z"
}

Products

MethodPathNotes
GET/productsMerged card and deposit/brokerage catalog. Filters below.
GET/products/{id}Single product
GET/products/{id}/promotionsPromotions for a product
GET/products/{id}/earning-ratesEarning rates for a card product

/products filters:

FilterApplies toMeaning
asset_classbothcredit_card selects card products only; bank_account or investment selects catalog products only. Omit to return both.
institution_idbothRestrict to one institution
networkcredit_cardCard network (visa, mastercard, amex, discover)
card_tiercredit_cardMaps to the card's card_type (personal, business, student)
credit_score_bandcredit_cardexcellent, good, fair, any
issuercredit_cardCase-insensitive substring match
min_annual_fee / max_annual_feecredit_cardAnnual fee bounds
product_typebank_account, investmente.g. checking, savings, brokerage
min_apybank_account, investmentMinimum APY
no_monthly_feebank_account, investmentPass true to exclude products with a monthly fee

/products returns two different shapes under the same "object": "product", depending on asset class. Card-side filters are ignored by catalog products and vice versa, so a mixed result set can contain both shapes in one page.

Example credit-card product (asset_class: "credit_card"):

json
{
  "id": "6a9f4b83-d8fc-4a49-9aa6-b86ab3e4d882",
  "object": "product",
  "asset_class": "credit_card",
  "issuer": "Chase",
  "card_name": "Sapphire Preferred",
  "product_family": "Sapphire",
  "card_network": "visa",
  "card_type": "personal",
  "credit_score_band": "excellent",
  "annual_fee": 95,
  "annual_fee_waived_first_year": false,
  "anniversary_bonus": null,
  "application_url": "https://creditcards.chase.com/rewards-credit-cards/sapphire/preferred",
  "official_source_url": "https://creditcards.chase.com/rewards-credit-cards/sapphire/preferred",
  "image_url": null,
  "notes": null,
  "data_source": "manual",
  "verification": {
    "status": "verified",
    "last_verified_at": "2026-06-04T12: 00: 00.000Z"
  },
  "created_at": "2026-06-01T16: 00: 00.000Z",
  "updated_at": "2026-06-04T12: 00: 00.000Z"
}

Example deposit product (asset_class: "bank_account"). Investment products use the same shape but carry a brokerage block instead of bank_account. Note that this shape's verification object has only last_verified_at — there is no status field on catalog products.

json
{
  "id": "c4e1a0d2-6b3f-4a77-9f10-2b83d5c9a115",
  "object": "product",
  "asset_class": "bank_account",
  "institution_id": "a1b2c3d4-1111-2222-3333-444455556666",
  "institution": "Chase",
  "product_name": "Total Checking",
  "product_type": "checking",
  "fees": {
    "monthly": 12,
    "fee_waiver_condition": "Waived with qualifying direct deposits",
    "min_opening_deposit": 0
  },
  "bank_account": {
    "apy": null,
    "apy_tiers": null,
    "interest_compounding": null,
    "atm_fee_reimbursement": null,
    "mobile_check_deposit": true
  },
  "application_url": "https://www.chase.com/personal/checking",
  "is_active": true,
  "data_source": "manual",
  "verification": {
    "last_verified_at": "2026-06-04T12: 00: 00.000Z"
  },
  "created_at": "2026-06-01T16: 00: 00.000Z",
  "updated_at": "2026-06-04T12: 00: 00.000Z"
}

Earning rates

MethodPathNotes
GET/earning-ratesCross-card comparison sorted by multiplier desc. Filters: category, reward_type, network. Powers "best card for dining".

The two earning-rate endpoints return different shapes, so pick the one that matches your use case.

GET /earning-rates (cross-card) joins the card and loyalty program and embeds them as nested objects, so a comparison table can be rendered without follow-up requests. loyalty_program is null for cash-back rates.

json
{
  "id": "820bc83e-97ef-4c94-8029-fc492332c8c1",
  "object": "earning_rate",
  "category": "dining",
  "multiplier": 3,
  "reward_type": "points",
  "card": {
    "id": "6a9f4b83-d8fc-4a49-9aa6-b86ab3e4d882",
    "issuer": "Chase",
    "card_name": "Sapphire Preferred",
    "card_network": "visa",
    "card_type": "personal",
    "annual_fee": 95
  },
  "loyalty_program": {
    "program_key": "chase_ultimate_rewards",
    "program_name": "Chase Ultimate Rewards",
    "point_valuation_cents": 2
  },
  "notes": null
}

GET /products/{id}/earning-rates (single card) returns the flat record instead. The card is already known from the path, so it exposes card_template_id and loyalty_program_id as raw IDs rather than nested objects, plus timestamps. Rows come back sorted by category.

json
{
  "id": "820bc83e-97ef-4c94-8029-fc492332c8c1",
  "object": "earning_rate",
  "card_template_id": "6a9f4b83-d8fc-4a49-9aa6-b86ab3e4d882",
  "category": "dining",
  "multiplier": 3,
  "reward_type": "points",
  "loyalty_program_id": "2e079424-b770-4c89-9b93-a20b1af8d29f",
  "notes": null,
  "created_at": "2026-06-01T16: 00: 00.000Z",
  "updated_at": "2026-06-04T12: 00: 00.000Z"
}

Resolve loyalty_program_id against /loyalty-programs to get the program name and cents-per-point valuation.

Institutions

MethodPath
GET/institutions
GET/institutions/{id}/products

Loyalty programs

MethodPathNotes
GET/loyalty-programsNamed currencies with cents-per-point valuations

Example loyalty program:

json
{
  "id": "2e079424-b770-4c89-9b93-a20b1af8d29f",
  "object": "loyalty_program",
  "program_key": "chase_ultimate_rewards",
  "program_name": "Chase Ultimate Rewards",
  "issuer": "Chase",
  "point_valuation_cents": 2,
  "valuation_updated_at": "2026-06-01T00: 00: 00.000Z"
}

Change feed

MethodPathNotes
GET/changes?since={timestamp}New/updated/retired promotions since a timestamp (polling alternative to webhooks)

Requires the growth tier or higher. A starter key gets 403 with { "error": "This endpoint requires the growth tier or higher" }. Ask an admin to move your client to growth before integrating incremental sync.

Use since for incremental sync. The value must be an ISO 8601 timestamp. Returned events use the same public event names as webhooks: promotion.created, promotion.updated, and promotion.retired.

Retired promotions include retirement_reason when the system has enough context. Current reason codes are past_expiration_date, not_seen, source_404, source_410, redirected_to_generic_page, offer_unavailable_text, and manual_unpublish.

Example:

http
400 font-semibold">GET /api/v1/changes?since=2026-06-04T00:00:00.000Z&limit=50
json
{
  "object": "list",
  "count": 1,
  "total": 1,
  "limit": 50,
  "offset": 0,
  "has_more": false,
  "data": [
    {
      "event_type": "promotion.retired",
      "occurred_at": "2026-06-04T12: 00: 00.000Z",
      "retirement_reason": {
        "code": "offer_unavailable_text",
        "detail": "Official source changed: requirements"
      },
      "promotion": {
        "id": "f7d99ef0-7a10-4886-b2ff-36df55f17c11",
        "object": "promotion",
        "title": "Chase Total Checking $300 Bonus",
        "verification": {
          "status": "review_needed",
          "last_verified_at": "2026-06-04T12: 00: 00.000Z"
        }
      }
    }
  ]
}

Webhooks

MethodPathNotes
POST/webhooksCreate a subscription. Signing secret returned once.
GET/webhooksList your subscriptions
PATCH/webhooks/{id}Update url / event_types / asset_classes / is_active
DELETE/webhooks/{id}Delete a subscription

Requires the growth tier or higher. Every /webhooks route — create, list, update, and delete — is gated. A starter key gets 403 with { "error": "This endpoint requires the growth tier or higher" }.

Event types: promotion.created, promotion.updated, promotion.retired.

Example create request:

json
{
  "url": "https://client.example.com/webhooks/bonusflow",
  "event_types": ["promotion.created", "promotion.updated", "promotion.retired"],
  "asset_classes": ["bank_account", "credit_card", "investment"]
}

Example create response:

json
{
  "id": "6c68a99c-a969-4d1b-9f07-5f94135a71bb",
  "object": "webhook_subscription",
  "url": "https://client.example.com/webhooks/bonusflow",
  "event_types": ["promotion.created", "promotion.updated", "promotion.retired"],
  "asset_classes": ["bank_account", "credit_card", "investment"],
  "is_active": true,
  "created_at": "2026-06-04T12: 00: 00.000Z",
  "updated_at": "2026-06-04T12: 00: 00.000Z",
  "secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}

Delivery & verification. Each delivery is a POST with headers:

  • X-BonusFlow-Signature: sha256= — HMAC-SHA256 of the raw body, keyed with your subscription secret
  • X-BonusFlow-Event, X-BonusFlow-Delivery

The secret is prefixed whsec_ followed by 48 hex characters, and is shown only in the create response — store it immediately.

Verify by recomputing the HMAC over the raw request body and comparing. Delivery is attempted up to 3 times, with retries scheduled 30s, 2min, and 10min after the preceding failure; events that exhaust all attempts move to a dead_lettered status and can be replayed by ops.

Payload:

json
{
  "event_type": "promotion.updated",
  "event_id": "…",
  "occurred_at": "2026-06-04T12: 00: 00.000Z",
  "data": { /* full Promotion object */ }
}

Retirement webhook payload:

json
{
  "event_type": "promotion.retired",
  "event_id": "…",
  "occurred_at": "2026-06-04T12: 00: 00.000Z",
  "retirement_reason": {
    "code": "source_404",
    "detail": "Official source returned 404"
  },
  "data": { /* full Promotion object */ }
}

Webhook receiver sketch. crypto.timingSafeEqual throws ERR_CRYPTO_TIMING_SAFE_EQUAL_DATA_LENGTH when the two buffers differ in length, so a malformed or truncated header would crash the handler rather than fail verification. Check the prefix and compare lengths before calling it:

ts
"text-purple-400">import crypto "text-purple-400">from "node:crypto";

"text-purple-400">function verifyBonusFlowWebhook(rawBody: Buffer, header: string, secret: string) {
  if (!header.startsWith("sha256=")) "text-purple-400">return false;

  "text-purple-400">const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  "text-purple-400">const received = Buffer."text-purple-400">from(header);
  "text-purple-400">const computed = Buffer."text-purple-400">from(expected);
  if (received.length !== computed.length) "text-purple-400">return false;

  "text-purple-400">return crypto.timingSafeEqual(received, computed);
}

Sign over the raw bytes of the request body, not a re-serialized JSON object — most frameworks require disabling the default body parser or capturing the raw buffer first. A complete Node/Express-style receiver, including raw-body capture, is in docs/api/examples/webhook-receiver-node.ts.

Errors

Errors use a compact JSON envelope:

json
{ "error": "Missing or invalid Authorization header" }

Common statuses:

StatusMeaningExample
400Invalid request body or query parameterMissing since on /changes
401Missing, malformed, revoked, or unknown API keyNo Authorization header
403API key is valid but its tier is below the endpoint minimum/changes or /webhooks requested with a starter key: This endpoint requires the growth tier or higher
404Resource not foundUnknown promotion ID
429Per-minute or monthly quota exceededRetry-After header included
500Internal service errorUnexpected server failure

Rate-limited response:

http
400">HTTP/1.1 429 Too Many Requests
400">Retry-After: 42
400">X-RateLimit-Limit: 60
400">X-RateLimit-Remaining: 0
json
{ "error": "Rate limit exceeded" }

Versioning & deprecation

  • Current major version is v1 (path prefix /v1). It is the only version that has ever shipped, so no deprecation has been exercised yet.
  • Breaking changes carry a minimum 90-day notice, communicated to integrators directly.

The following is the intended policy for the first major-version transition, not currently implemented behavior. Do not write client logic against it yet:

  • Deprecated endpoints are expected to return a Deprecation response header carrying the sunset date. No route emits this header today.
  • Two major versions are expected to run in parallel during a transition, with the retired version returning 410 Gone after sunset. There is no 410 handling in the current codebase.

Until those land, treat direct communication and the change log as the deprecation channel.