Set Up Storyly Betting Feed
This guide describes the data Storyly's betting feed ingestion expects, so your sports and odds feed can power automated story and banner content. It is the betting counterpart of the product feed guide.
Before You Start
The feed must be a single JSON document reachable at a stable HTTPS URL. Basic auth or a custom header such as a bearer token or API key is supported.
Every betting event must carry at minimum:
event_id— a stable, unique identifier for the eventevent_name- At least one market, and each selection in it must have
selection_id,selection_name, and an odds value (decimal or fractional)
You do not have to rename your fields to match Storyly's. Storyly's mapping wizard maps your native field names onto Storyly's schema. Keep your field names and JSON paths consistent and your IDs stable, and the wizard handles the rest.
Feed Structure
Storyly's canonical model is nested: an event contains markets, each market contains selections, and the odds live on each selection.
event
├─ participants[] (teams / players — optional)
└─ markets[]
└─ selections[] (odds embedded here)
Root Document
{
"feed_version": "1.0",
"generated_at": "2026-06-25T09:30:00Z",
"events": [ { /* BettingEvent */ } ]
}
Minimal Valid Event
{
"event_id": "EVT-12345",
"event_name": "Brisbane Lions v Sydney Swans",
"sport": "AFL",
"competition": "AFL Premiership",
"start_time": "2026-06-25T09:30:00Z",
"status": "active",
"is_live": false,
"last_updated": "2026-06-25T08:00:00Z",
"markets": [
{
"market_id": "MKT-1",
"market_name": "Match Winner",
"status": "active",
"selections": [
{ "selection_id": "SEL-1", "selection_name": "Brisbane Lions", "status": "active", "odds_decimal": 1.85 },
{ "selection_id": "SEL-2", "selection_name": "Sydney Swans", "status": "active", "odds_decimal": 1.95 }
]
}
]
}
Note
If your feed is normalized instead (flat
markets[],selections[]and a separateprices[]join table linked by ID, with no odds embedded on the selection), that is also supported. See the Normalized Feeds section at the end of this guide.
Field Reference
- ✅ = required (feed is rejected without it). Required IDs must be non-empty strings.
- ◻ = optional
Event
| Field | Type | Required | Format / Values | Notes |
|---|---|---|---|---|
event_id | string | ✅ | any stable unique ID | Must stay the same across odds updates and from pre-match into live |
event_name | string | ✅ | ||
sport | string | ◻ | e.g. "Football", "AFL" | A sport name, not an internal ID |
competition | string | ◻ | e.g. "Premier League" | |
start_time | string | ◻ | ISO-8601 | See Timestamps section |
status | enum | ◻ | active, live, suspended, finished | Empty defaults to live if is_live=true, otherwise active |
is_live | boolean | ◻ | true / false | |
is_visible | boolean | ◻ | true / false | Use only if you have no finished status — see Best Practices |
last_updated | string | ◻ | ISO-8601 | |
participants | array | ◻ | see Participant | Teams or players |
markets | array | ◻ | see Market |
Participant
| Field | Type | Required | Notes |
|---|---|---|---|
participant_id | string | ◻ | |
name | string | ◻ | Team or player name |
role | string | ◻ | e.g. "home" / "away" |
logo_url | string | ◻ |
Market
| Field | Type | Required | Format / Values | Notes |
|---|---|---|---|---|
market_id | string | ✅¹ | stable unique ID | Stable across odds updates |
market_name | string | ✅¹ | e.g. "Match Winner" | |
market_type | string | ◻ | e.g. "match_winner" | Machine-readable type, if available |
scope | string | ◻ | ||
line_value | number | ◻ | e.g. -1.5 | Handicap or total line as a separate number — do not bury it only in the name |
status | enum | ◻ | active, suspended, closed | Empty defaults to active |
display_order | integer | ◻ | Controls display ordering | |
last_updated | string | ◻ | ISO-8601 | |
selections | array | ◻ | see Selection |
Selection
| Field | Type | Required | Format / Values | Notes |
|---|---|---|---|---|
selection_id | string | ✅ | stable unique ID | Must stay the same while odds change — do not mint a new ID on a price update |
selection_name | string | ✅ | ||
odds_decimal | number | ✅² | e.g. 1.85 (2 dp) | Canonical odds. Required unless you send odds_fractional |
odds_fractional | string | ✅² | "a/b", e.g. "2/5" | Decimal is derived as 1 + a/b. Required only if odds_decimal is absent |
status | enum | ◻ | active, suspended, closed | Empty defaults to active |
handicap_value | number | ◻ | e.g. -1.5 | |
previous_odds_decimal | number | ◻ | If sent, drives "Was → Now". Otherwise computed by diffing pulls | |
is_boosted | boolean | ◻ | ||
deep_link | string | ◻ | URL | Link directly to the bet slip or selection |
last_updated | string | ◻ | ISO-8601 |
Notes
- Strictly, the parser only hard-rejects on missing
event_id,event_name,selection_id,selection_name, and odds. Howevermarket_idandmarket_nameare needed for stable identity and display — always send them.
Odds rule
Sends odds_decimal (preferred). If you send only odds_fractional ("a/b"), decimal is derived as 1 + a/b ("2/5" → 1.40). If you send both, they must agree within 0.01, otherwise the feed is rejected.
Complete Example
A full event with participants and two markets: one with plain decimal odds, one handicap market showing line_value and handicap_value, a fractional-only selection, a suspended selection, previous_odds_decimal for a "Was → Now", a boost, and a deep link. This is a valid feed Storyly accepts as-is.
{
"feed_version": "1.0",
"generated_at": "2026-06-25T09:30:00Z",
"events": [
{
"event_id": "EVT-2026-AFL-0625-BRISYD",
"event_name": "Brisbane Lions v Sydney Swans",
"sport": "AFL",
"competition": "AFL Premiership",
"start_time": "2026-06-25T09:30:00Z",
"status": "active",
"is_live": false,
"last_updated": "2026-06-25T08:55:12Z",
"participants": [
{ "participant_id": "TEAM-BRIS", "name": "Brisbane Lions", "role": "home", "logo_url": "https://cdn.example.com/teams/brisbane.png" },
{ "participant_id": "TEAM-SYD", "name": "Sydney Swans", "role": "away", "logo_url": "https://cdn.example.com/teams/sydney.png" }
],
"markets": [
{
"market_id": "MKT-MATCHWINNER",
"market_name": "Match Winner",
"market_type": "match_winner",
"status": "active",
"display_order": 1,
"last_updated": "2026-06-25T08:55:12Z",
"selections": [
{
"selection_id": "SEL-BRIS-WIN",
"selection_name": "Brisbane Lions",
"status": "active",
"odds_decimal": 1.85,
"previous_odds_decimal": 1.90,
"deep_link": "https://book.example.com/bet?selection=SEL-BRIS-WIN"
},
{
"selection_id": "SEL-SYD-WIN",
"selection_name": "Sydney Swans",
"status": "active",
"odds_decimal": 1.95,
"is_boosted": true
}
]
},
{
"market_id": "MKT-HANDICAP-1.5",
"market_name": "Handicap (-1.5)",
"market_type": "match_handicap",
"status": "active",
"line_value": -1.5,
"display_order": 2,
"last_updated": "2026-06-25T08:55:12Z",
"selections": [
{
"selection_id": "SEL-BRIS-HCAP",
"selection_name": "Brisbane Lions -1.5",
"status": "active",
"handicap_value": -1.5,
"odds_fractional": "2/5"
},
{
"selection_id": "SEL-SYD-HCAP",
"selection_name": "Sydney Swans +1.5",
"status": "suspended",
"handicap_value": 1.5,
"odds_decimal": 2.10
}
]
}
]
}
]
}
Notes on the example:
- The first market uses decimal odds and shows a price drift (
1.90→1.85) - The handicap market shows the line as a real number (
line_valueandhandicap_value, not only in the name) - A fractional-only selection (
"2/5"→ decimal1.40is derived) is included - A suspended selection stays in the feed with its ID intact rather than being dropped
Formats and Rules
Status Values
Use exactly these values (case-sensitive). If your feed uses different codes such as "A" or "OPEN", map them to Storyly's values in the wizard's status maps. Unknown values are rejected.
| Level | Allowed Values |
|---|---|
| Event | active, live, suspended, finished |
| Market | active, suspended, closed |
| Selection | active, suspended, closed |
Timestamps
ISO-8601 format. Preferred: RFC3339 with a timezone offset (e.g. 2026-06-25T09:30:00Z). Also accepted: no offset (assumed UTC), space separator (2026-06-25 09:30:00), or date-only (2026-06-25). An empty or absent timestamp is allowed.
Odds
Decimal numbers, not strings. Two decimal places is sufficient.
Best Practices
These are the things that most affect how cleanly your feed maps and refreshes, based on real integrations.
-
Keep IDs stable.
event_id,market_id, andselection_idmust not change across odds updates or from pre-match into live. Re-minting IDs on a price change breaks selection tracking and "Was → Now" odds. This is the single most important rule. -
Provide a top-level, unique
event_id. A display name like "Team A v Team B" is not unique — rematches collide. If your event ID only lives deeper inside a market or fixture object, surface it at the event level. -
Prefer the nested shape. Selections inside markets, odds on the selection. It maps one-to-one to Storyly's model. Normalized feeds are supported but require an extra join step.
-
Prefer decimal odds, 2 decimal places. If you send fractional only, use a clean
"a/b"format. Do not send a decimal and a fractional that disagree. -
Use ISO-8601 with a timezone offset and include
last_updatedat the event, market, and selection level. This is Storyly's freshness and diff signal. -
Map your statuses to Storyly's enums. Do not use custom status strings.
-
Send
previous_odds_decimalif you have it, for an instant "Was → Now" display. Otherwise Storyly derives it across pulls. -
No explicit
finishedstatus? If you signal a concluded event by flipping tosuspendedcombined with a "no longer displayed" flag, map that flag tois_visible(falsemeans Storyly marks the event as finished). -
One stable URL, consistent field names and paths. Do not rename fields or move arrays between pulls. The mapping is bound to those paths.
-
Set the feed's update interval to match how fast your odds move. For live odds, a sub-hourly interval (minutes) is supported.
Normalized Feeds
If your feed delivers flat markets[], selections[], and a separate prices[] join table at the event level (where selections carry no odds and prices[] links selectionId, marketId, and price), Storyly denormalizes it automatically. In the mapping wizard, additionally map:
prices_path→ the event-levelpricesarrayprice_selection_id,price_market_id,price_odds_decimal,price_last_updated→ the fields on a price row
Point selection_id and selection_name at the event-level selections. If your events have no top-level ID, you can derive it from a nested array, for example markets.0.fixtureId. Selection status can be derived from booleans — isScratched maps to closed, isDisplayed=false maps to suspended.

