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 event
  • event_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 separate prices[] 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

FieldTypeRequiredFormat / ValuesNotes
event_idstringany stable unique IDMust stay the same across odds updates and from pre-match into live
event_namestring
sportstringe.g. "Football", "AFL"A sport name, not an internal ID
competitionstringe.g. "Premier League"
start_timestringISO-8601See Timestamps section
statusenumactive, live, suspended, finishedEmpty defaults to live if is_live=true, otherwise active
is_livebooleantrue / false
is_visiblebooleantrue / falseUse only if you have no finished status — see Best Practices
last_updatedstringISO-8601
participantsarraysee ParticipantTeams or players
marketsarraysee Market

Participant

FieldTypeRequiredNotes
participant_idstring
namestringTeam or player name
rolestringe.g. "home" / "away"
logo_urlstring

Market

FieldTypeRequiredFormat / ValuesNotes
market_idstring✅¹stable unique IDStable across odds updates
market_namestring✅¹e.g. "Match Winner"
market_typestringe.g. "match_winner"Machine-readable type, if available
scopestring
line_valuenumbere.g. -1.5Handicap or total line as a separate number — do not bury it only in the name
statusenumactive, suspended, closedEmpty defaults to active
display_orderintegerControls display ordering
last_updatedstringISO-8601
selectionsarraysee Selection

Selection

FieldTypeRequiredFormat / ValuesNotes
selection_idstringstable unique IDMust stay the same while odds change — do not mint a new ID on a price update
selection_namestring
odds_decimalnumber✅²e.g. 1.85 (2 dp)Canonical odds. Required unless you send odds_fractional
odds_fractionalstring✅²"a/b", e.g. "2/5"Decimal is derived as 1 + a/b. Required only if odds_decimal is absent
statusenumactive, suspended, closedEmpty defaults to active
handicap_valuenumbere.g. -1.5
previous_odds_decimalnumberIf sent, drives "Was → Now". Otherwise computed by diffing pulls
is_boostedboolean
deep_linkstringURLLink directly to the bet slip or selection
last_updatedstringISO-8601

📘

Notes

  • Strictly, the parser only hard-rejects on missing event_id, event_name, selection_id, selection_name, and odds. However market_id and market_name are 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.901.85)
  • The handicap market shows the line as a real number (line_value and handicap_value, not only in the name)
  • A fractional-only selection ("2/5" → decimal 1.40 is 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.

LevelAllowed Values
Eventactive, live, suspended, finished
Marketactive, suspended, closed
Selectionactive, 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.

  1. Keep IDs stable. event_id, market_id, and selection_id must 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.

  2. 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.

  3. 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.

  4. 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.

  5. Use ISO-8601 with a timezone offset and include last_updated at the event, market, and selection level. This is Storyly's freshness and diff signal.

  6. Map your statuses to Storyly's enums. Do not use custom status strings.

  7. Send previous_odds_decimal if you have it, for an instant "Was → Now" display. Otherwise Storyly derives it across pulls.

  8. No explicit finished status? If you signal a concluded event by flipping to suspended combined with a "no longer displayed" flag, map that flag to is_visible (false means Storyly marks the event as finished).

  9. One stable URL, consistent field names and paths. Do not rename fields or move arrays between pulls. The mapping is bound to those paths.

  10. 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-level prices array
  • price_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.