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:
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/changesand all/webhooksroutes, which requiregrowthor higher.- Rotate keys with zero downtime: issue a new key, migrate, then revoke the old one.
Example:
"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:
"text">-emerald-400">ALLOW_SANDBOX_SEED=true npm run seed:api"text-sky-400">-sandboxThe 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:
| Header | Meaning |
|---|---|
X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset | Per-minute window |
X-RateLimit-Limit-Month / X-RateLimit-Remaining-Month / X-RateLimit-Reset-Month | Monthly 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-Afterheader 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
rateLimitPerMinuteorrateLimitPerMonthfor 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:
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: 1811894400Pagination & field projection
All list endpoints share one envelope:
{
"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 skipfields— comma-separated allowlist, e.g.?fields=title,bonus(idandobjectalways included)
Example page request:
400 font-semibold">GET /api/v1/promotions?asset_class=credit_card&limit=2&offset=0&fields=title,bonus,verificationExample response:
{
"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
| Method | Path | Notes |
|---|---|---|
| GET | /status | Public health check. No API key required. |
Example:
{
"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
| Method | Path | Notes |
|---|---|---|
| GET | /promotions | Filters: asset_class, institution, status (active or all), min_bonus, reward_currency, since |
| GET | /promotions/{id} | Single promotion |
| POST | /promotions/{id}/report | Flag 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:
"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:
{
"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:
400 font-semibold">POST /api/v1/promotions/f7d99ef0-7a10-4886-b2ff-36df55f17c11/reportOnly 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:
{
"description": "The official landing page now shows a $200 bonus."
}Request with optional context:
{
"description": "The official landing page now shows a $200 bonus.",
"field": "bonus.value",
"reported_value": "200"
}Response (201):
{
"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
| Method | Path | Notes |
|---|---|---|
| GET | /products | Merged card and deposit/brokerage catalog. Filters below. |
| GET | /products/{id} | Single product |
| GET | /products/{id}/promotions | Promotions for a product |
| GET | /products/{id}/earning-rates | Earning rates for a card product |
/products filters:
| Filter | Applies to | Meaning |
|---|---|---|
asset_class | both | credit_card selects card products only; bank_account or investment selects catalog products only. Omit to return both. |
institution_id | both | Restrict to one institution |
network | credit_card | Card network (visa, mastercard, amex, discover) |
card_tier | credit_card | Maps to the card's card_type (personal, business, student) |
credit_score_band | credit_card | excellent, good, fair, any |
issuer | credit_card | Case-insensitive substring match |
min_annual_fee / max_annual_fee | credit_card | Annual fee bounds |
product_type | bank_account, investment | e.g. checking, savings, brokerage |
min_apy | bank_account, investment | Minimum APY |
no_monthly_fee | bank_account, investment | Pass 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"):
{
"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.
{
"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
| Method | Path | Notes |
|---|---|---|
| GET | /earning-rates | Cross-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.
{
"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.
{
"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
| Method | Path |
|---|---|
| GET | /institutions |
| GET | /institutions/{id}/products |
Loyalty programs
| Method | Path | Notes |
|---|---|---|
| GET | /loyalty-programs | Named currencies with cents-per-point valuations |
Example loyalty program:
{
"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
| Method | Path | Notes |
|---|---|---|
| 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:
400 font-semibold">GET /api/v1/changes?since=2026-06-04T00:00:00.000Z&limit=50{
"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
| Method | Path | Notes |
|---|---|---|
| POST | /webhooks | Create a subscription. Signing secret returned once. |
| GET | /webhooks | List 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:
{
"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:
{
"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 subscriptionsecretX-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:
{
"event_type": "promotion.updated",
"event_id": "…",
"occurred_at": "2026-06-04T12: 00: 00.000Z",
"data": { /* full Promotion object */ }
}Retirement webhook payload:
{
"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:
"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:
{ "error": "Missing or invalid Authorization header" }Common statuses:
| Status | Meaning | Example |
|---|---|---|
400 | Invalid request body or query parameter | Missing since on /changes |
401 | Missing, malformed, revoked, or unknown API key | No Authorization header |
403 | API 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 |
404 | Resource not found | Unknown promotion ID |
429 | Per-minute or monthly quota exceeded | Retry-After header included |
500 | Internal service error | Unexpected server failure |
Rate-limited response:
400">HTTP/1.1 429 Too Many Requests
400">Retry-After: 42
400">X-RateLimit-Limit: 60
400">X-RateLimit-Remaining: 0{ "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
Deprecationresponse 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 Goneafter sunset. There is no410handling in the current codebase.
Until those land, treat direct communication and the change log as the deprecation channel.