--- url: https://docs.dataflair.ai/guide.md description: >- What this documentation covers, who it is for, and where the shared conventions live. --- # Introduction This is DataFlair's public documentation site. It covers the integration surfaces of three products: * **[DataFlair.ai](/dataflair/)**: the Toplist API, the WordPress plugin, and the Jira and HubSpot integration guides. Reference material for building an integration against DataFlair's APIs from outside this codebase, in any stack. * **[Stats](/stats/)**: affiliate tracking. Postbacks, the Operator Reporting API, tracking links and testing tools. * **[Marketplace](/marketplace/)**: selling ad inventory. Ad tags, ad server connections and the Custom Ad Server API. This Guide section covers the conventions of the Toplist API: how authentication works and how errors are reported. Stats postbacks and the Marketplace Ad Server API work differently, so each has its own authentication and error pages under its product. For the concrete endpoints, request/response shapes, and integration details of a specific API, go to the page for that product. ## Who this is for The motivating case for the API docs is a developer building an integration against DataFlair's Toplist API from outside this codebase. DataFlair does not ship a client library in any language. You build your own HTTP client against the plain-JSON contract documented here. Nothing on this site assumes any particular stack or language. Examples are `curl` (a raw HTTP request) or plain pseudocode (an algorithm you implement yourself), and a few pages also show PHP, Node or Python. --- --- url: https://docs.dataflair.ai/guide/getting-started.md description: The shortest path to a first successful call to the DataFlair Toplist API. --- # Getting started A minimal path to your first successful API call. ## 1. Get a credential DataFlair issues API credentials out of band. There's no self-service signup. Your DataFlair account contact will give you either a static bearer token, or a key + secret pair, along with the domain your integration should call. See [Authentication](/guide/authentication) for what each option means and which one to pick. For a periodic background-sync integration (the common case, see [Integration model](/dataflair/toplist-api/integration-model)), the static token is the simpler starting point: no exchange call, no expiry to manage. ## 2. Make your first request The example below calls the Toplist API, the only API module in DataFlair.ai today. Other integration surfaces, such as Stats postbacks and the Marketplace Ad Server API, have their own authentication and errors, described under their products. Every authenticated request carries your token the same way, and should always send `Accept: application/json`: ```bash curl "https://{your-tenant-domain}/api/v1/toplists?template_id=55" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` `template_id` scopes the request to one **geo family**: every toplist built from the same template, one row per market/country plus an optional global variant. See [Toplists endpoints](/dataflair/toplist-api/toplists-endpoint) for the full parameter reference and [Geo-targeting & compliance](/dataflair/toplist-api/geo-targeting) for what to do with the `geo` object on each row you get back. ## 3. Handle errors A wrong or missing credential, a missing scope, and rate limiting all return a consistent JSON error shape. See [Error handling](/guide/error-handling) for the full reference table before you write your error-handling code. ## Next steps * [Brands endpoint](/dataflair/toplist-api/brands-endpoint): look up the full brand catalog directly, for example by ID, independent of any specific toplist. * [Integration model](/dataflair/toplist-api/integration-model): why there's no live "resolve my visitor" endpoint, and what your integration needs to do instead. * [Geo-targeting & compliance](/dataflair/toplist-api/geo-targeting): the resolution algorithm you must implement before rendering anything sourced from this API. --- --- url: https://docs.dataflair.ai/guide/authentication.md description: >- The two authentication modes for the Toplist API, the bearer header, scopes and IP allowlisting. --- # Authentication The Toplist API supports two independent authentication modes. Both are fully supported, real, production auth paths. This isn't a "legacy vs. new" situation, it's two tools for two situations. Whichever mode you use, every authenticated request carries the resulting token the same way: ``` Authorization: Bearer ``` ## Which mode should I use? For a periodic background-sync integration, fetching data on a schedule rather than on the critical path of a real visitor request, which is the pattern this site's APIs are designed around (see [Integration model](/dataflair/toplist-api/integration-model)), a **static token** is the simpler fit: no exchange call, no expiry to manage. An **HMAC key/secret exchange** is documented too, for integrations whose operational constraints favor short-lived, auto-expiring credentials in memory over one long-lived static value. That's your call to make, not a case where one mode is deprecated. ## Static token DataFlair issues you a static bearer token directly, out of band. Your DataFlair account contact provides it; there's no self-service endpoint to mint one. Use it directly as the bearer token on every request, with no exchange call required. It stays valid until revoked or until its own `expires_at` (if any): treat it as a long-lived secret, stored the way you'd store any API key, never in a repo or client-side code. ## Key/secret exchange Exchange a key + secret pair for a short-lived signed bearer token via a dedicated exchange endpoint. See each API's own reference page for the exact exchange request/response shape, for example, the Toplist API's [Auth token endpoint](/dataflair/toplist-api/authentication). ## Scopes Every credential, on either auth mode, carries a list of scopes. Each endpoint you call requires a specific scope; a credential missing it gets a `403 insufficient_scope` response, not partial or filtered data. Unless your DataFlair contact has deliberately issued you a narrower credential, this isn't something you need to actively manage day to day. It's here so a `403` with that error code is recognizable if it ever comes up. ## IP allowlisting {#ip-allowlisting} A credential may optionally be restricted to a specific list of source IPs on DataFlair's side. If yours is, calling from an unlisted IP fails. See [Error handling](/guide/error-handling) for how this surfaces. --- --- url: https://docs.dataflair.ai/guide/error-handling.md description: The JSON error shapes and status codes used by the Toplist API. --- # Error handling These error conventions apply to the Toplist API. Stats postbacks and the Ad Server API report errors in their own formats, described on their own pages. **Always send `Accept: application/json` on every request**, including auth exchanges. Requests run on a middleware stack that needs this header to return the JSON error shapes below. Without it, a validation or throttling error can come back as an HTML redirect instead. | Status | `error` | When | Body example | |---|---|---|---| | `401` | `invalid_credentials` | Token exchange: key not found, or secret is wrong (identical response for both, deliberately, so a caller can't use the response to figure out whether a key exists at all) | `{"error":"invalid_credentials","message":"Invalid API key or secret."}` | | `403` | `ip_not_allowed` | Token exchange: caller's IP isn't on that credential's allowlist | `{"error":"ip_not_allowed","message":"Your IP address is not in the allowed list for this credential."}` | | `401` | `unauthenticated` | Any data endpoint: missing/invalid/expired bearer token, **or** an IP-allowlist mismatch on this path (surfaces identically to a bad token, not as a 403) | `{"error":"unauthenticated","message":"Invalid or missing API token."}` | | `403` | `insufficient_scope` | Any data endpoint: your credential doesn't carry the scope that endpoint requires | `{"error":"insufficient_scope","message":"This credential does not have the required scope: toplist:read"}` | | `404` | `not_found` | Single-resource lookups: no matching, currently-available resource | `{"error":"not_found","message":"Toplist not found or not available."}` | | `422` | N/A (standard validation) | Token exchange: a required field is missing from the request body | `{"message":"The key field is required. (and 1 more error)","errors":{"key":["The key field is required."],"secret":["The secret field is required."]}}` | | `429` | N/A (standard throttle) | Rate limit exceeded | Standard `Retry-After` / `X-RateLimit-*` headers, `{"message":"Too Many Attempts."}` | Rate limits are set per endpoint and per credential. Check the specific API's reference pages (for example, the Toplist API's [Auth token endpoint](/dataflair/toplist-api/authentication) for the token-exchange limit, and the [Toplist API overview](/dataflair/toplist-api/) for the default per-credential data-endpoint limit) for exact numbers. --- --- url: https://docs.dataflair.ai/guide/ai-and-mcp.md description: >- Connect the DataFlair docs to your AI assistant over MCP, copy a page as a prompt, or read llms.txt. Read only and public, with no account and no key. --- # Connect the docs to your AI assistant The MCP server is read only and public. It needs no account and no key. ```text https://docs.dataflair.ai/mcp ``` ## Connect ::: code-group ```bash [Claude Code] claude mcp add --transport http dataflair-docs https://docs.dataflair.ai/mcp ``` ```json [Cursor] { "mcpServers": { "dataflair-docs": { "url": "https://docs.dataflair.ai/mcp" } } } ``` ```bash [Codex] codex mcp add dataflair-docs --url https://docs.dataflair.ai/mcp ``` ```json [OpenCode] { "$schema": "https://opencode.ai/config.json", "mcp": { "dataflair-docs": { "type": "remote", "url": "https://docs.dataflair.ai/mcp" } } } ``` ```json [Claude Desktop] { "mcpServers": { "dataflair-docs": { "command": "npx", "args": ["-y", "mcp-remote@latest", "https://docs.dataflair.ai/mcp"] } } } ``` ```bash [Manual] curl -s https://docs.dataflair.ai/mcp \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` ::: In Cursor, you can also use the install link: Connect to Cursor. Claude Desktop has no remote-server support yet, so the config above runs [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) as a local bridge; `@latest` always resolves past the RCE fixed in v0.1.16. You can also skip the config file and add it from Settings → Connectors → Add custom connector with the same URL. The server speaks MCP over HTTP with no sessions. Send one JSON-RPC message in each POST request. A GET request answers `405`. ## Tools | Tool | What it does | | --- | --- | | `list_pages` | Lists every page with its product, title, path and direction. | | `search_docs` | Keyword search. Common words such as `how` and `the` are ignored, so a plain question works. Returns up to 10 pages, best match first, each with a snippet. | | `get_page` | Returns one page as markdown. The path comes from `list_pages` or `search_docs`. | Each page that describes an integration surface has a direction. Other pages, such as the guide, the product home pages and the setup pages, have none, and `list_pages` shows an empty direction for them. `you-call-us` means you write a client for DataFlair. `we-call-you` means you build the endpoint, and DataFlair calls it. An assistant that knows the direction does not write a client for an API you are meant to implement. ::: warning Documentation only It cannot read your account, your inventory, your reports or anyone's data. ::: ## Limits The server caps every request and every answer. A request body can hold at most 8 KB. A search query can hold at most 200 characters. A search returns at most 10 pages. A `get_page` path must be one of the listed pages. Two limits apply to how often you can call it. The network edge allows about 60 requests in 10 seconds from one IP address, and blocks that address for 10 seconds after that. The server also counts requests, and answers `429` with a `Retry-After` header when you go over. In both cases, wait a moment and try again. ## Other ways to give a page to an assistant * **Copy page.** Every page has a **Copy page** menu. **Copy page for LLM** puts the page and a short prompt on your clipboard. The prompt says which product the page belongs to, and who calls whom. * **View as Markdown.** Add `.md` to a page address to get the page as markdown. For an index page, drop the trailing slash first. For example, `/stats/tracking/` has the copy `/stats/tracking.md`. * **llms.txt.** [llms.txt](/llms.txt) lists every page. [llms-full.txt](/llms-full.txt) holds every page in one file. --- --- url: https://docs.dataflair.ai/dataflair/toplist-api.md description: >- Base URL, endpoints, scopes, rate limits and API versions for the DataFlair Toplist API. --- # Toplist API ## What this is, and isn't This is a **reference document only**: endpoints, authentication, response shapes, the geo resolution algorithm your integration must implement, and the compliance reasoning behind it. * **No SDK or package is provided.** DataFlair does not ship a client library in any language. You build your own HTTP client against the plain-JSON contract described in this section. * **No live "resolve my visitor's geo" endpoint exists.** DataFlair does not evaluate your visitor's location for you on a per-request basis. See [Integration model](/dataflair/toplist-api/integration-model). * Code samples in this section are **curl** (a raw HTTP request) or **plain pseudocode** (the resolution algorithm). The [playground](/dataflair/toplist-api/playground) also shows the same request in PHP, Node and Python. ## Base URL and scoping Base URL: `https://{your-tenant-domain}`. The domain of the request resolves your **tenant** only. The **site** your data is scoped to is a separate, finer-grained resolution: it comes from the `site_id` baked into the credential you authenticated with, not from the hostname. Changing which domain you call does not change which site's toplists you get back. That's determined entirely by which credential you used. Your DataFlair account contact will give you the exact domain and credentials for your integration. ## Endpoints All endpoints below (except the auth exchange) require the `Authorization: Bearer` header and are rate-limited **per-credential**, default **60 requests/minute** (your specific credential may be configured with a different limit: check with your DataFlair contact if you're unsure). | Method | Path | Requires scope | Purpose | |---|---|---|---| | `POST` | `/api/v1/auth/token` | none (key+secret) | Exchange credentials for a bearer token. See [Auth token endpoint](/dataflair/toplist-api/authentication) | | `GET` | `/api/v1/toplists` | `toplist:read` | List toplists, optionally filtered. See [Toplists endpoints](/dataflair/toplist-api/toplists-endpoint) | | `GET` | `/api/v1/toplists/by-slug/{slug}` | `toplist:read` | Fetch one toplist by its slug | | `GET` | `/api/v1/toplists/{id}` | `toplist:read` | Fetch one toplist by its numeric ID | | `GET` | `/api/v1/brands` | `brand:read` | List every active brand in the tenant. See [Brands endpoint](/dataflair/toplist-api/brands-endpoint) | ## API versions (v1 vs v2) Everything documented in this section is **v1** (`/api/v1/...`), the recommended surface for a new integration. A **v2** (`/api/v2/...`) also exists. Its **brand** shape is genuinely additive on top of v1: every v1 brand field name is unchanged, with ~15 new multi-vertical fields appended (sports betting fields like `sportsCovered` / `hasLiveBetting` / `hasBetBuilder`, poker fields like `pokerVariants` / `pokerNetwork`, and sweepstakes-casino fields like `hasSweepsCoins` / `scToUsdRate`). **Its offer shape is not additive. It's a rename.** The offer embedded inside a v2 toplist item uses different camelCase field names than either v1 offer shape, not just different casing: for example `wageringRequirement` (not `bonusWageringRequirement` as in v1's `/api/v1/brands`, and not `bonus_wagering_requirement` as in v1's toplist-embedded offer). A parser written against either v1 offer shape will silently get missing/`null` fields if pointed at a v2 response instead of an error. Don't assume v2's offer fields are a superset of v1's. The `geo` object and the outer toplist/item envelope (ids, position, template, site, etc.) are identical between the two versions. The two sub-objects that do differ are `brand` (additive) and `offer` (renamed). Don't assume only one of them changed. Use v2 instead of v1 only if you specifically need one of those multi-vertical fields. One practical heads-up if you do: v2's single-toplist lookups (`/api/v2/toplists/{id}` and `/api/v2/toplists/by-slug/{slug}`) are currently missing the same "must have a live edition" gate that v1's equivalents and both versions' `GET /toplists` listing apply. A toplist with no live edition, one that correctly never appears in `GET /api/v2/toplists` (or any v1 endpoint) and correctly 404s on v1's single-item lookups, can still come back as a 200 from v2's single-item lookup, just with an empty `items` array and no `currentPeriod`, instead of the 404 you'd get everywhere else. Don't treat a 200 from a v2 single-item lookup as proof a toplist is genuinely live; if you need that guarantee, check the `index()` listing (either version) or use v1's single-item endpoints instead. This is a known internal inconsistency, not intended behavior. Flag it to your DataFlair contact if you hit it. ## Non-goals * No SDK, package, or generated client is provided in any language. * No live per-request geo-resolution endpoint exists. You always fetch ahead and resolve locally (see [Integration model](/dataflair/toplist-api/integration-model)). * This section does not cover DataFlair's admin UI or internal editing workflows: only the read-only external API surface documented here. --- --- url: https://docs.dataflair.ai/dataflair/toplist-api/authentication.md description: >- Static token and HMAC key exchange for the Toplist API, with request and response examples. --- # Auth token endpoint This page documents the concrete request/response mechanics for both Toplist API auth modes. See [Authentication](/guide/authentication) first for which mode to pick and the shared bearer-header pattern. ## Option A: Static token (recommended for periodic sync) Use your issued static token directly as the bearer token on every request. No exchange call is required: ```bash curl "https://{your-tenant-domain}/api/v1/toplists?template_id=55" \ -H "Authorization: Bearer dfp_live_9f2c1a..." \ -H "Accept: application/json" ``` The token itself carries no built-in short expiry (unlike Option B). It stays valid until the credential's own `expires_at` (if any) or until it's revoked on DataFlair's side. ## Option B: HMAC token exchange Exchange a key + secret pair for a short-lived (1 hour) signed bearer token: ```bash curl -X POST https://{your-tenant-domain}/api/v1/auth/token \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{"key": "dfk_live_...", "secret": "dfs_..."}' ``` Success response: ```jsonc { "token": "eyJrZXkiOiJkZmtfbGl2ZV8uLi4iLCJ0cyI6MTc1MzE0ODAwMCwic2lnIjoiLi4uIn0=", "type": "bearer", "expires_in": 3600, "site": { "id": 7, "name": "Example Operator", "code": "EXOP", "domain": "example-operator.com" }, "scopes": ["toplist:read", "brand:read"] } ``` Use the returned `token` as the bearer token on subsequent requests. When it expires (1 hour), exchange again: there is no refresh-token step, just repeat the same call. * This exchange endpoint is throttled to **10 requests/minute per calling IP**, separately from the data-endpoint rate limit. Exchanging a token in a tight retry loop will get you throttled quickly, so cache the token for its full `expires_in` rather than re-exchanging on every call. * A wrong key or wrong secret both return the same `401 invalid_credentials` error (deliberately, see [Error handling](/guide/error-handling)). ## Scopes Every credential (either auth mode) carries a list of scopes, by default `["toplist:read", "brand:read"]`. Each endpoint requires one specific scope; a credential missing it gets a `403 insufficient_scope` response, not partial/filtered data. ## IP allowlisting See [Authentication](/guide/authentication#ip-allowlisting) for what IP allowlisting is and how a mismatch surfaces on this endpoint specifically: a `403 ip_not_allowed` on the exchange call itself, distinct from the `401` you'd get on a data endpoint (see [Error handling](/guide/error-handling)). --- --- url: https://docs.dataflair.ai/dataflair/toplist-api/toplists-endpoint.md description: >- List toplists by template or geo, fetch one toplist, and read the response shape. --- # Toplists endpoints ## `GET /api/v1/toplists` Returns every toplist visible to your authenticated site, paginated (default 15 per page, max 100, ordered newest-updated-first). Two optional, independent filters: * **`template_id`**: the filter you'll use most. Every toplist sharing one `template_id` is one **geo family**: one row per market, per country, and/or a global/rest-of-world variant, all built from the same template. This is how you fetch "everything I might need to show for this toplist concept" ahead of any specific visitor. If a family has more than 15 members, pass a higher `per_page` (up to 100). **`per_page` caps at 100 per request**, so a family larger than that is never returned in one call regardless of what you pass. Follow the `links`/`meta` pagination fields (see the response-wrapping note below) across successive requests, incrementing `page`, until `links.next` is `null` or `meta.current_page == meta.last_page`. Stopping after the first page silently drops the rest of the family, which can make a visitor whose country is only on a later page fall through to a global/no-match render. ```bash curl "https://{your-tenant-domain}/api/v1/toplists?template_id=55" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` * **`geo_type` + `geo_code`**: filter to toplists matching a specific market or country directly, if you already know exactly which geo variant you want and don't need the whole family. ```bash curl "https://{your-tenant-domain}/api/v1/toplists?geo_type=country&geo_code=IN" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` ## `GET /api/v1/toplists/by-slug/{slug}` and `GET /api/v1/toplists/{id}` Fetch exactly one toplist, by its slug or numeric ID respectively. Both return the identical per-toplist JSON shape documented below, or a `404 not_found` if it doesn't exist or isn't currently available to your site. ## Response shape Every toplist, from any of the three endpoints above, has this shape: ```jsonc { "type": "toplist", "id": 482, "name": "Best Casinos India", "status": "published", "locked": false, "version": "20260214100300", "owner": { "id": 12, "name": "Jane Doe" }, "createdAt": "2026-02-14T10:03:00+00:00", "updatedAt": "2026-07-01T09:12:44+00:00", "template": { "type": "listTemplate", "id": 55, "name": "Best Casinos", "productTypeId": 2, "productType": "Casino", "listClassificationTypeId": 1, "listClassificationType": "Best Of" }, "site": { "id": 7, "domain": "example-operator.com" }, "geo": { "geo_type": "country", "name": "India", "code": "IN", "coveredCountries": null }, "slug": "best-casinos-india", "currentPeriod": "July 2026", "publishedAt": "2026-07-01T09:12:44+00:00", "shortcode": null, "items": [ { "type": "topListItem", "id": 9931, "position": 1, "isLocked": false, "dealId": 204, "pros": ["Fast payouts", "24/7 live chat"], "cons": [], "brand": { "type": "brand", "id": 341, "externalId": "brand-341", "name": "Lucky Spin Casino", "slug": "lucky-spin-casino", "rating": 4.6, "logo": { "rectangular": "https://cdn.dataflair.ai/brands/341/logo-rect.png", "square": "https://cdn.dataflair.ai/brands/341/logo-square.png", "backgroundColor": "#101820" }, "licenses": ["Curacao"], "paymentMethods": ["UPI", "Visa", "Mastercard"], "restrictedCountries": [ { "name": "United States", "code": "US" }, { "name": "France", "code": "FR" } ], "allowedCountries": [ { "name": "India", "code": "IN" } ], "classificationTypes": ["Casino"], "languages": { "website": ["en", "hi"], "support": ["en"], "livechat": ["en"] } }, "offer": { "type": "offer", "id": 5820, "offerTypeId": 1, "offerTypeName": "Welcome Bonus", "offerText": "100% up to ₹20,000 + 100 Free Spins", "currencies": ["INR"], "has_free_spins": true, "bonus_wagering_requirement": 35, "bonus_expiry_date": "2026-12-31", "bonus_code": "LUCKY100", "minimum_deposit": 500, "max_payout": null, "max_bonus_amount": "20000.00", "is_sticky_bonus": false, "minimum_odds": null, "free_bet_value": null, "stake_returned": null, "bet_type": null, "tournament_ticket_value": null, "rakeback_percentage": null, "free_tickets": null, "geos": { "countries": ["India"], "markets": [] }, "trackers": [ { "id": 88, "campaignName": "India Launch", "trackerLink": "https://track.example.com/click?c=88", "tcLink": "https://example-operator.com/terms", "pageType": "LP", "geos": { "countries": ["India"], "markets": [] } } ] } } ] } ``` Notes worth calling out explicitly: * **`status` is always `"published"`.** The API only ever serves toplists it considers live. There's no draft/paused status value you need to branch on. * **`items` is `[]`, not omitted or null, when a toplist's live edition currently has zero items in it**, a valid, safe-to-iterate response. This is different from a toplist having **no** live edition at all: the single-item endpoints (`by-slug` / `{id}`) 404 in that case instead of returning `200` with `items: []`, and such a toplist never appears in `GET /api/v1/toplists` either. * **`locked`** (toplist-level) and **`isLocked`** (per-item) both reflect editorial state, not geo, unrelated to the geo-targeting resolution algorithm. * **`pros` and `cons`** (per item, taken from the item's brand) are always JSON arrays of plain-text strings: `[]` when nothing has been entered, never `null` and never an object. At most 8 lines per list and 160 characters per line. The app rejects `<` and `>` when the text is entered, so a line never contains them, but still treat the values as untrusted text and escape them when you render. They are an additive field on v1 and v2 (same values in both), not part of the nested `brand` object. The additive revision is announced by `/api/v1/meta` `contract_rev` 1.2.0 and `/api/v2/meta` 2.2.0. When a brand's pros or cons change, each site with an active webhook receives a `toplist.published` webhook for each of its own live toplists that lists the brand, so re-fetch the toplist on it as usual. These share the site's normal webhook delivery queue, so a brand on many toplists can take a few minutes to reach the site. * **`pageType`** (inside each tracker) is normally one of a small set of internal short codes (`LP`, `NDP`, `GP`, `RMP`, `RP`, `DEFAULT`), but isn't a closed/validated enum. Treat it as an opaque internal code you pass through or ignore, never show it to a user directly. * **`shortcode` is currently always `null`.** The field exists on the resource but isn't backed by any stored value or generation logic yet. Don't build against it until your DataFlair contact confirms it's populated. * **Money/precision offer fields are quoted strings, not JSON numbers.** `max_bonus_amount`, `max_payout`, `minimum_odds`, `free_bet_value`, `tournament_ticket_value`, and `rakeback_percentage` (when non-null) all render like `"20000.00"`: parse them with a decimal-safe type, not a native float. Plain counts such as `minimum_deposit` and `free_tickets` are real JSON integers and don't have this quirk. * **This endpoint wraps its payload in Laravel's `{"data": ...}` envelope**, with `links`/`meta` siblings around the `data` array: `{"data": [...], "links": {...}, "meta": {"current_page", "per_page", "total", "last_page", ...}}`. The two single-item endpoints wrap the same per-toplist shape in `{"data": { ... }}` too, just a single object, no `links`/`meta` alongside it. Always read from `response.data`, never the response root. **This wrapping is specific to these resource endpoints**: the auth exchange and every error response return their fields directly at the response root, with no `data` envelope. See [Geo-targeting & compliance](/dataflair/toplist-api/geo-targeting) for what the `geo` object means and how to use it before rendering anything. --- --- url: https://docs.dataflair.ai/dataflair/toplist-api/brands-endpoint.md description: >- List every active brand in the tenant and see how the shape differs from a brand inside a toplist. --- # Brands endpoint ## `GET /api/v1/brands` Returns every active brand in the tenant, paginated (default 15 per page, max 100, alphabetical by name). **Unlike the toplist endpoints, this one is not scoped to your specific site.** It returns every active brand across the whole tenant account, including brands that may never appear on your site's toplists. Use it if you need the full brand catalog independent of any specific toplist (for example, to look up a brand your own system already knows about by ID), but don't assume the result set matches "brands visible on my site." ```bash curl "https://{your-tenant-domain}/api/v1/brands" \ -H "Authorization: Bearer " \ -H "Accept: application/json" ``` ### Response shape ```jsonc { "data": [ { "type": "brand", "id": 341, "externalId": "brand-341", "name": "Lucky Spin Casino", "slug": "lucky-spin-casino", "rating": 4.6, "logo": { "rectangular": "https://cdn.dataflair.ai/brands/341/logo-rect.png", "square": "https://cdn.dataflair.ai/brands/341/logo-square.png", "backgroundColor": "#101820" }, "licenses": ["Curacao"], "paymentMethods": ["UPI", "Visa", "Mastercard"], "restrictedCountries": ["United States", "France"], "languages": { "website": ["en", "hi"], "support": ["en"], "livechat": ["en"] }, "offers": [ { "type": "offer", "id": 5820, "offerTypeId": 1, "offerTypeName": "Welcome Bonus", "offerText": "100% up to ₹20,000 + 100 Free Spins", "currencies": ["INR"], "hasFreeSpin": true, "bonusWageringRequirement": 35, "bonusExpiryDate": "2026-12-31", "bonusCode": "LUCKY100", "minimumDeposit": 500, "maxPayout": null, "maxBonusAmount": "20000.00", "isStickyBonus": false, "trackers": [ { "id": 88, "campaignName": "India Launch", "trackerLink": "https://track.example.com/click?c=88", "tcLink": "https://example-operator.com/terms", "pageType": "LP", "geos": { "countries": ["India"], "markets": [] } } ] } ] } ], "links": { "first": "...", "last": "...", "prev": null, "next": "..." }, "meta": { "current_page": 1, "per_page": 15, "total": 47, "last_page": 4 } } ``` Note what's absent compared to the embedded toplist brand: no `classificationTypes` field, per the inconsistency called out below. And note what's present here that isn't on the embedded brand: the full `offers` array, with each offer's `trackers` nested inside it. **This is not the same shape as the brand embedded in each toplist item (see [Toplists endpoints](/dataflair/toplist-api/toplists-endpoint)), and it's not a strict superset, so don't assume you can swap one for the other without checking field-by-field.** It adds fields the embedded brand doesn't have, most notably the brand's full `offers` array (with each offer's trackers nested inside it), but it also drops at least one field the embedded brand does have: `classificationTypes` isn't returned by this endpoint at all. If you're already consuming toplists and only need what's already embedded there, you likely don't need this endpoint at all. One inconsistency worth knowing about if you use both endpoints: the bonus/offer fields here use **camelCase** (`hasFreeSpin`, `bonusWageringRequirement`, `bonusCode`, `minimumDeposit`, and so on), while the equivalent fields on an offer embedded inside a toplist item use **snake\_case** (`has_free_spins`, `bonus_wagering_requirement`, `bonus_code`, `minimum_deposit`, ...). Same concepts, different casing depending on which endpoint you hit. Don't write one shared parser expecting identical field names from both. This endpoint wraps its payload in the same `{"data": [...], "links": {...}, "meta": {...}}` envelope as the toplist listing endpoint: see the response-wrapping note on [Toplists endpoints](/dataflair/toplist-api/toplists-endpoint). --- --- url: https://docs.dataflair.ai/dataflair/toplist-api/integration-model.md description: >- Fetch toplists ahead of time and resolve the visitor geo locally at render time. --- # Integration model: fetch ahead, resolve locally DataFlair does not offer a live, per-request "give me the right toplist for this visitor" endpoint. Instead: 1. You call `GET /api/v1/toplists` (optionally scoped to one template, see [Toplists endpoints](/dataflair/toplist-api/toplists-endpoint)) on your own schedule, e.g. a periodic background sync job, not on the critical path of a real visitor request. 2. You store what comes back in your own application (database, cache, wherever fits your stack). 3. At the moment you actually render a toplist to a real visitor, **your own code** detects that visitor's country and decides, against your already-fetched data, whether and what to show. See [Geo-targeting & compliance](/dataflair/toplist-api/geo-targeting) for the exact algorithm to run at this step. Nothing in step 3 calls back to DataFlair. This is a deliberate architectural choice, not a missing feature: it keeps DataFlair off your rendering hot path, and it's the same pattern DataFlair's own WordPress plugin uses (sync in the background, resolve geo locally at render time against already-synced data, see [Reference implementations](/dataflair/toplist-api/reference-implementations)). --- --- url: https://docs.dataflair.ai/dataflair/toplist-api/geo-targeting.md description: >- The geo object and the resolution algorithm your integration must implement before rendering. --- # Geo-targeting & compliance ## The geo family model One **template** (a toplist "concept," e.g. "Best Casinos") can back multiple **toplist** rows — different geos (country / market / global), and also multiple live rows for the **same** geo when those are separate page embeds (each with its own permanent toplist ID). All of them share the same `template.id`. `GET /api/v1/toplists?template_id=X` returns the whole family, fetched ahead of time per the [Integration model](/dataflair/toplist-api/integration-model). This endpoint is paginated too (default 15 per page, max 100): a family with more than 15 members needs a higher `per_page`, and a family larger than 100 needs multiple requests. Page through `links`/`meta` until exhausted (see the pagination note on [Toplists endpoints](/dataflair/toplist-api/toplists-endpoint)). Stop after the first page and you'll silently work from an incomplete family. ## The `geo` object | `geo_type` | `code` is... | `coveredCountries` is... | |---|---|---| | `"country"` | that country's ISO 3166-1 **alpha-2** code (e.g. `"IN"`, `"GB"`) | `null` | | `"market"` | the market's own short code (e.g. `"EU"`, `"NORDICS"`) | every member country's alpha-2 code, as an array (e.g. `["DE","FR","IT",...]`) | | `"global"` | `null` | `null` | `name` (e.g. `"India"`, `"Europe"`, `"Global"`) is a **display string only**. Never match visitor geo against it. Always match on `code` / `coveredCountries`. The alpha-2 format here is the same one both common visitor-geo sources already emit: Cloudflare's `CF-IPCountry` header and MaxMind's `country.iso_code` field both report uppercase ISO 3166-1 alpha-2 directly. No translation table is needed between either source and this `code` field. ## The resolution algorithm your integration must implement DataFlair does not run this for you. You fetch the data above ahead of time, and you implement this algorithm on your side, at the moment you actually render something to a real visitor. ### Layer 1: Render safety gate (mandatory, every render) Whichever toplist you're about to show, whether you picked it directly (e.g. you hard-coded "show toplist slug X on this page") or arrived at it via the optional Layer 2 cascade below, run this check first, every single time, before rendering anything: ``` function shouldRender(toplist, visitorCountryAlpha2): geo = toplist.geo if geo.geo_type == "global": return true // explicit "everyone" editorial choice if visitorCountryAlpha2 is null: return false // can't verify -> default-deny if geo.geo_type == "country": return geo.code == visitorCountryAlpha2 if geo.geo_type == "market": return visitorCountryAlpha2 in geo.coveredCountries return false ``` **No match means render nothing.** Not an error page, not a fallback to some other toplist: empty output. Concretely: if you have a page pinned to an India-geo toplist and a UK visitor lands on it, that visitor sees nothing from that toplist, never the India-market brands. To get `visitorCountryAlpha2`, try in order: a `CF-IPCountry` header (if you're behind Cloudflare) → an `X-Geoip-Country` header (common on other CDNs/reverse proxies that inject a geo header without being Cloudflare) → a GeoIP library/service of your choice → `null` if nothing resolves. Check both header names before falling back to a fresh GeoIP lookup: a visitor sitting behind a non-Cloudflare proxy that sets `X-Geoip-Country` already has a resolved geo available for free, and skipping straight to a GeoIP lookup wastes that free signal and can give a different, less accurate answer. Two details worth handling defensively regardless of source: * Treat known "unknown" sentinel values (Cloudflare emits `XX` for undetermined and `T1` for Tor exit traffic) as unresolved: fall through the same as a missing value, don't compare them as if they were real country codes. * Uppercase and trim whatever you get before comparing: don't assume the source always returns clean, consistently-cased input. ### Layer 2: Auto-select cascade (optional convenience) If you want **one embed to automatically adapt** across a whole template family, rather than manually placing one embed per country/market, fetch the family (`?template_id=X`) and pick a candidate: 1. **Exact match**: exactly one `geo_type="country"` row whose `code` equals the visitor's country → pick it. More than one exact country match → pick nothing, log it. Never guess. 2. **Covering market**: else, exactly one `geo_type="market"` row whose `coveredCountries` contains the visitor's country → pick it. If **more than one** market covers that visitor, treat it as ambiguous: pick nothing, log it. Never guess which one. 3. **Explicit global**: else, exactly one `geo_type="global"` row in the family → pick it. More than one global → pick nothing, log it. 4. **Otherwise**: no candidate. Whatever this cascade picks (if anything) still has to pass Layer 1 before you render it. Layer 2 only narrows down *which* row to check next. It never replaces the check itself. Prefer pinned embeds by toplist ID when a site has multiple live siblings for the same audience. ### Caching If you cache rendered output (a full-page cache, a CDN, anything that could serve one visitor's render to a different visitor later), any render whose outcome depended on the visitor's geo must be excluded from that cache, or scoped by country, and never served as one-size-fits-all. This is your responsibility; DataFlair has no visibility into your caching layer. ## Compliance rationale Geo-targeting here exists to enforce a **regulatory boundary**, not to personalize content. The underlying rule: if a visitor's location can't be shown brands compliantly for their jurisdiction, they see nothing. Never a best-effort guess at "the closest match." * **Default-deny.** An unresolved visitor country never matches a country or market toplist. Only an explicit `global` row renders unconditionally, and that's an editorial choice someone made when building that specific template family, not something the system does automatically to fill a gap. * **A missing global/rest-of-world variant is not a bug.** A template family with no `global` row is a valid, deliberate choice: it means "show this only in the specific markets/countries we've explicitly targeted, and nothing anywhere else." Don't build a fallback around this; treat "no candidate" as a legitimate outcome. This algorithm restates DataFlair's internal geo-targeting contract for an external audience. It's independently worded from that internal document, which remains DataFlair's own source of truth on its side. --- --- url: https://docs.dataflair.ai/dataflair/toplist-api/playground.md description: >- Send a real request to your own tenant from the browser, or copy the same request as curl, PHP, Node or Python. --- # Toplist API playground Build a request for [`GET /api/v1/toplists`](/dataflair/toplist-api/toplists-endpoint). Copy it in your language, or send it from this page. ## How the playground works * The request goes from your browser straight to your tenant. It does not pass through docs.dataflair.ai. * The tenant must end in `.dataflair.ai`. Any other host is rejected before a request is built. * Your token stays in memory in this page. It is not stored. It is not put in the address of this page, and it is not put in the code samples. * The playground only reads. `GET /api/v1/toplists` changes nothing on your tenant. * A failed request shows its status, its time and the body. It links to the matching row in the [error table](/guide/error-handling). * If your browser cannot reach your tenant, use the `curl` sample. * The playground does not follow redirects, so your token is not sent to any host but the one you typed. ## Test this This page is the test. The same request in `curl`: ```bash curl "https://{tenant}/api/v1/toplists?page=1" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` --- --- url: https://docs.dataflair.ai/dataflair/toplist-api/reference-implementations.md description: How the DataFlair WordPress plugin consumes the Toplist API. --- # Reference implementations DataFlair's own **WordPress plugin** (`DataFlair-Toplists`) is a real, production consumer of this exact Toplist API. It's a useful reference for how the [Integration model](/dataflair/toplist-api/integration-model) and [Geo-targeting & compliance](/dataflair/toplist-api/geo-targeting) algorithm come together in a working implementation: * It syncs toplist data in the background, on a schedule (the same fetch-ahead pattern documented in [Integration model](/dataflair/toplist-api/integration-model)) rather than calling DataFlair on the request path of a real site visitor. * At render time, it resolves geo locally against its already-synced data, implementing both Layer 1 (the mandatory render safety gate) and Layer 2 (the optional auto-select cascade), per DataFlair's internal geo-targeting contract. The plugin's source isn't published as part of this site. If you'd like to see it directly, ask your DataFlair account contact. --- --- url: https://docs.dataflair.ai/dataflair/wordpress-plugin.md description: >- Install the DataFlair Toplists plugin, connect it to your tenant with a bearer token, sync toplists and brands, and place them with a block or a shortcode. --- # WordPress plugin The DataFlair Toplists plugin is a working client of the [Toplist API](/dataflair/toplist-api/). It fetches your toplists and brands from DataFlair, stores them in your WordPress database, and renders them with a block or a shortcode. It makes no call to DataFlair when a visitor loads a page. This is the fetch-ahead pattern described in the [Integration model](/dataflair/toplist-api/integration-model). The plugin also applies the geo rules in [Geo-targeting and compliance](/dataflair/toplist-api/geo-targeting) when it renders. ## Requirements * WordPress 6.3 or later * PHP 8.1 or later * MySQL 5.7 or later, or MariaDB 10.3 or later, for JSON columns ## Install 1. Upload the `dataflair-toplists` folder to `/wp-content/plugins/`. 2. Activate the plugin in **Plugins**, then **Installed Plugins**. 3. Connect it to your tenant. See the next section. The plugin includes its dependencies, so you do not run `composer install` on the server. ## Connect Go to **DataFlair**, then **Settings**, then the **API Connection** tab. | Field | What to enter | | --- | --- | | **API Bearer Token** | Your DataFlair API bearer token. Use the static token. See [Authentication](/dataflair/toplist-api/authentication). | | **API Base URL** | Your tenant URL with the API path, for example `https://tenant.dataflair.ai/api/v1`. Leave it empty to let the plugin detect it from the token. | | **Brands API Version** | `v1` or `v2` of the brands endpoint. | Click **Test Connection**. It checks the toplists endpoint, which always uses `v1`. It does not exercise the Brands API Version you chose. A wrong token gives `401`. A token without the `toplist:read` scope gives `403`. See [Error handling](/guide/error-handling). ## Sync The plugin copies data from DataFlair into three tables in your database: `wp_dataflair_toplists`, `wp_dataflair_brands` and `wp_dataflair_alternative_toplists`. Start a sync in one of two ways: * **In WordPress.** On **DataFlair**, then **Dashboard**, click **Sync Brands** and **Sync Toplists**. * **With WP-CLI.** ```bash wp dataflair sync # everything wp dataflair sync --only=toplists # toplists only wp dataflair sync --only=brands # brands only ``` The command exits with a non-zero code when it fails, so a real cron job can react. It backs off on API rate limits. The plugin has **no automatic WP-Cron schedule**. To sync on a schedule, add the WP-CLI command to your server crontab. The **Sync Schedule** tab in Settings shows an example, and it holds a retry count and an alert email. ### Webhook sync On the **API Connection** tab, tick **Enable webhook sync**. DataFlair then pushes changes to your site as they happen, instead of waiting for the next sync. The plugin registers your site with DataFlair, and it reuses your API token. A delivery is signed with HMAC-SHA256 and verified before anything else runs. A repeated delivery does nothing. ## Place a toplist **Block.** Add the **DataFlair Toplist** block to a page or post. In the block settings, choose a toplist and set an item limit. **Shortcode.** ```text [dataflair_toplist id="123" limit="10"] ``` | Attribute | Required | Meaning | | --- | --- | --- | | `id` | yes, unless you use `slug` | The DataFlair toplist ID. | | `slug` | no | Look the toplist up by its slug instead of its ID. | | `title` | no | Replaces the display title of the toplist. | | `limit` | no | The most brands to show. The default, `0`, shows all. | The shortcode also accepts `layout`, `ctaMode`, `template` and `auto_geo`. ## Test this Add a toplist to a page and view it while logged out. The plugin reads from your database, so a missing toplist means the sync did not run or did not finish. Check **DataFlair**, then **Dashboard**, for the last sync time, and **Tools** for the **API Contract Check** diagnostic. --- --- url: https://docs.dataflair.ai/dataflair.md description: >- Toplists and brands for your site. The Toplist API, the WordPress plugin, and the Jira and HubSpot integration guides. --- # DataFlair.ai Toplists and brands for your site. Not covered here: CRM, Intelligence and KPI screens. You use those in the DataFlair.ai app. --- --- url: https://docs.dataflair.ai/dataflair/integrations/jira.md description: >- Connect Jira to DataFlair so approved deals create content tasks for your team. --- # Jira integration This guide explains how to connect Jira to DataFlair so approved deals can generate content tasks for your team. Audience: * **Non-technical users** (where to connect) * **Technical admin** (token/email/domain/project key setup) *** ## What this integration does When connected, DataFlair creates or updates Jira tasks for deal execution workflows. Jira task content is built from: * Deal data (brand, deal type, account manager, deal id) * Operator intake data (campaign/market/handoff details), merged in after the operator submits Typically the issue is created when the deal reaches **Approved** (once sales has the content-order details you require). After the operator intake is merged, the same issue is **updated** so the description includes the operator form payload. *** ## Before you start You need: * Jira Cloud account with API token access * Jira user email (the account used for API token) * Jira domain (example: `yourcompany.atlassian.net`) * Jira project key (example: `MKT`, `OPS`, `CONTENT`) * Access to DataFlair (`Settings -> Integrations`) *** ## Step 1 — Create a Jira API token 1. Sign in to your Atlassian account. 2. Open Atlassian API token management. 3. Create a new API token (name it clearly, e.g. `DataFlair Integration`). 4. Copy and securely store the token. *** ## Step 2 — Confirm Jira project details Collect: * **Email** of the Jira user that owns the token * **Domain** (without protocol preferred, e.g. `yourcompany.atlassian.net`) * **Project key** where tasks should be created Make sure this user can create/edit issues in that project. *** ## Step 3 — Connect Jira in DataFlair 1. In DataFlair, go to: * `Settings -> Integrations` 2. On the **Jira** card, click **Connect**. 3. Enter: * API token * Email * Domain * Project key 4. Save. ### Screenshots (DataFlair UI) **Jira integration card** — On the Integrations page, the card shows connection status, metrics, and actions **Test**, **Reconfigure**, **Resync**, and **Disconnect**. ![Jira integration card on the Integrations page](/images/integrations/jira/integration-card.png) **Configure / Reconfigure sheet** — A panel slides in from the right when you click **Connect** or **Reconfigure**. Enter API token, Atlassian email, domain, and project key here. ![Jira Connect or Reconfigure sheet](/images/integrations/jira/reconfigure-sheet.png) When a deal is **approved**, DataFlair creates the Jira task from sales-side deal data. When the **operator intake** is merged into CRM, the same Jira issue is **updated** so the description reflects operator form content. *** ## Step 4 — Run Jira test 1. Click **Test** on the Jira integration card. 2. Expected result: * Jira reachable * Credentials valid * Project configuration accepted If test fails: * Check token * Check email/token match * Check domain format * Check project key and permissions *** ## How Jira tasks are created (business view) ### Task naming format DataFlair generates Jira summaries in this format: `Content Task: [brand name] - [site], [due date]. [account manager], [deal type], [status]` ### What appears in the Jira task Typically includes: * Brand * Site * Due live date * Toplist position * Special requests * Market/context details from intake/deal * Account manager * Deal type * DataFlair Deal ID (traceability) ### Single-task behavior DataFlair uses the deal id as the anchor, so the workflow is designed around one Jira task per deal context, enriched as more intake/deal data becomes available. *** ## What non-technical users should do after setup 1. Create and approve deal in DataFlair. 2. Ensure operator intake is completed if your workflow uses intake-driven fields. 3. Confirm Jira issue appears in the configured project. 4. If anything is missing, use **Resync** on the Jira card after source data is corrected. *** ## Validation checklist (go-live) * \[ ] Jira API token created * \[ ] Correct email/domain/project key collected * \[ ] Jira connected in DataFlair * \[ ] Jira test passes * \[ ] One real deal flow tested end-to-end * \[ ] Team confirms issue content is complete and readable *** ## Troubleshooting ### Jira test fails immediately Bad token, wrong email, or wrong domain format are most common causes. ### Jira issue not created Confirm the deal reached **Approved** and required content-order fields are set if your process expects them before creation. Verify project permissions for the API user. ### Jira issue created but fields are incomplete Check source data in DataFlair deal + intake, then re-sync after correcting source values. --- --- url: https://docs.dataflair.ai/dataflair/integrations/hubspot.md description: >- Connect HubSpot to DataFlair to import Deals and Companies and keep linked records in sync. --- # HubSpot integration HubSpot is the source of truth for mapped Deal and Company fields. DataFlair imports them for review and refreshes linked records through manual pulls, webhook notifications, and scheduled reconciliation. Tenant edits do not push to HubSpot. Platform super admins retain separate, explicit outbound operations. ## 1. Connect the private app A HubSpot account administrator configures the app. In HubSpot, open **Development → Legacy apps**, then create a private app or edit the existing DataFlair app. On **Auth**, copy the access token securely. In DataFlair, open **Settings → Integrations → HubSpot → Connect / Reconfigure**, paste the token, and save. Use **Test** to verify read access. Required inbound scopes: | Purpose | Scope | |---|---| | Read Deal records and associations | `crm.objects.deals.read` | | Read Company records and associations | `crm.objects.companies.read` | | Read Deal property definitions | `crm.schemas.deals.read` | | Read Company property definitions | `crm.schemas.companies.read` | | Resolve a Deal's HubSpot owner to a DataFlair user (Commercial manager) | `crm.objects.owners.read` | No write scope is required for inbound sync. Existing write permissions can remain for platform super-admin operations. Only grant the object/schema write scopes needed for those operations; granting a token a write scope does not enable tenant outbound sync. Contact imports are not included in this inbound workflow. Official reference: [HubSpot private apps](https://developers.hubspot.com/docs/apps/legacy-apps/private-apps/overview). ## 2. Review smart field mappings Open **HubSpot → Import & mapping → Map fields**. Refresh the property schema, then select **Suggest mappings**. Suggestions consider the source object, property names, labels, synonyms, and compatible types. Review the confidence and explanation. Suggestions fill only empty, high-confidence rows; they do not replace saved or manually changed mappings. Select **Save mappings** to apply them. For dropdowns and multiple selections, expand **Translate dropdown values**. Map HubSpot options to DataFlair values such as licence names, country and state/province names, Product Type names, and `CPA`, `RevShare`, or `Hybrid`. Without an explicit conversion, the importer uses the HubSpot label and checks whether DataFlair recognises it. Unknown values require review. | Client field | HubSpot object | DataFlair destination / decision | |---|---|---| | Deal record name | Deal | Deal name, normally `dealname` | | IO or T\&C – SiGMA Play & Casinobee | Deal | Confirm an example before mapping to IO reference. A URL or IO type is not an invoice number. | | Licenses | Company | Brand licences | | License Numbers – SIGMA PLAY | Company | Brand licence numbers; ambiguous multi-licence number pairing requires review | | USA States + Canada Provinces | Company | Brand coverage, separate from deal-specific targeting | Use the actual internal property names from the portal. Do not create duplicate Company properties on Deal. Required Deal identity fields must be mapped or resolved during review. Unmapped destinations remain unchanged; explicit empty mapped values clear optional values. Clearing a required identity value requires review. ## 3. Pull and review the initial import 1. Save mappings. In **Verify portal & configure webhooks**, enter the HubSpot portal ID and save. Webhooks are the recommended path — see Section 4 below to configure them now. If skipping webhooks temporarily, leave notifications disabled and the signing secret empty; the scheduled reconciliation pull (every six hours) is the only fallback and is not real-time. Once the portal is verified, select **Pull now**. New HubSpot records are staged for review; already linked records are refreshed. 2. Select **Refresh status & properties** after the queued pull finishes. Large pulls are paginated; completion means every queued page has finished. 3. Review Company records first. Choose the existing DataFlair Brand and select **Import reviewed record**. Create any missing Brand through the normal Brand workflow first. 4. Review Deals. Inspect source properties and associations. A sole associated Company or a unique primary Company can be selected automatically. Otherwise choose the intended associated Company explicitly. 5. Resolve Brand, Product Type, and Deal Type. To link an existing DataFlair Deal, enter its ID; otherwise the import creates a new Deal after validation. 6. Inspect the imported Deal and Brand. Repeating a pull updates their recorded HubSpot links instead of duplicating them. Changing Company licences refreshes the Brand used by its Deals. It does not rewrite a Deal's separate targeting rules. Archived HubSpot records remain visible in import history; DataFlair does not delete local business history automatically. Association changes that conflict with an established Brand link require review. ### AI-assisted smart pre-selection When you open a Deal or Company record for review, DataFlair automatically pre-fills the Brand, Product Type(s), and Deal Type fields using a multi-tier resolution chain, so you rarely need to select them manually: 1. **Company graph** — if the HubSpot Deal has exactly one associated Company that is already linked to a DataFlair Brand, that Brand is selected immediately with no AI call needed. 2. **Token matcher** — the Deal name is tokenised and matched against all Brand names using word-boundary scoring. A high-confidence token match resolves without an external call. 3. **JEV (TypeSafe System One)** — if the above steps are inconclusive, the deal metadata and a list of candidate Brand names are sent to the JEV structured-data AI. JEV returns a strongly typed JSON response selecting the Brand, up to two Product Types (e.g. Casino and Sportsbook simultaneously), and the Deal Type. 4. **Claude Haiku (fallback)** — if JEV is unavailable or returns a low-confidence result, Claude Haiku receives the same prompt and returns a final suggestion. Product Type is a **multi-select** field — a single Deal can cover both Casino and Sportsbook verticals. The resolution chain infers all applicable verticals from the Deal name and description. All suggestions are editable before you confirm import; the resolver never auto-imports without your review. ## 4. Enable webhook-triggered pulls Webhooks deliver change notifications; DataFlair then reads the latest record and associations from HubSpot. They do not send DataFlair data to HubSpot. Private-app subscriptions must be configured in HubSpot's UI; DataFlair does not create or verify those subscriptions remotely. [HubSpot webhook guide](https://developers.hubspot.com/docs/api-reference/legacy/webhooks/guide) This entire setup is self-service on the tenant side. There is no callback URL to request from or hand to the DataFlair team in advance — DataFlair generates a URL unique to your account only after you complete step 2 below, and only your own account can see it. 1. In DataFlair, open **Import & mapping → Verify portal & configure webhooks**. 2. Enter the **HubSpot portal ID** and the app's **client secret for webhook signatures**. This is distinct from the API access token. Store it through this form rather than email or chat. Enable **Accept webhook notifications** and save. DataFlair checks that the portal ID matches the connected token. Saving here is also what creates your account's webhook record and its unique **Callback URL** for the first time — before this save the URL field is blank. 3. Copy the **Callback URL** now shown on this page. It must be publicly reachable over HTTPS. Use the exact URL; a local `.test` address cannot receive HubSpot deliveries. 4. In HubSpot, open the private app's **Webhooks** tab. Set **Target URL** to the copied callback URL. 5. Add subscriptions for **Deal** and **Company** creation, deletion, restoration, merges, and association changes where offered by the app. 6. Add **Property changed** subscriptions for every property listed in the DataFlair webhook panel. The list comes from saved mappings, so save mappings first. 7. Save/activate the subscriptions in HubSpot. Whenever mappings change, update these subscriptions too. A property-change subscription covers the selected property; it does not cover every field on the object. 8. Change one mapped field on a reviewed test Deal, then one Company field. Check **Last accepted notification**, refresh import status, and verify the local values. A received notification alone does not prove the queued import succeeded; inspect the record error/status too. ## Go-live journey ```text Settings → Integrations → HubSpot → Connect → Import & mapping → Refresh properties → Suggest → Review → Save → Verify portal → Pull now → Review Companies → Review Deals → Import → Configure receiver → Configure HubSpot subscriptions → Change HubSpot Company licence → Verify Brand update → Repeat pull → Verify no duplicate records → Edit DataFlair → Verify HubSpot is unchanged ``` Validate a cleared optional value, unknown dropdown option, ambiguous Company association, and archived record as well. Confirm both the queue and scheduler are running before go-live. ## Troubleshooting * **Schema or connection error:** check the access token and read scopes; refresh properties. Test does not create HubSpot properties. * **Unknown local value:** correct the option conversion or resolve the local reference, then retry the reviewed import. * **Webhook received but data unchanged:** check the queue, import record error, mapping, and whether the record has been reviewed and linked. * **No notifications:** check the exact deployed callback URL, receiver active state, portal ID, app signing secret, and HubSpot subscription activation/property coverage. * **Manual pull works but a Company-only change does not:** check Company property subscriptions independently of Deal subscriptions. * **AI pre-selection did not suggest a value:** the resolver falls back gracefully — select Brand, Product Type(s), and Deal Type manually. Check that the TYPESAFE\_API\_KEY environment variable is set on the server if JEV suggestions are missing entirely. ## Platform super-admin operations Platform administrators use **Admin → Tenants → tenant → HubSpot operations** to preview and execute permitted record or property writes. These operations require the relevant token scopes and a separate explicit confirmation. Tenant users and background import jobs cannot invoke them. The original outbound field definitions remain documented in the field matrix available from the HubSpot integration card for these operations. --- --- url: https://docs.dataflair.ai/stats/postbacks/contract.md description: >- Send one conversion to DataFlair Stats with a GET or POST. Endpoint, authentication, fields, responses, conversion types and rate limit. --- # Postback contract A postback tells DataFlair Stats that one player did something: registered, made a first deposit, or made another deposit. You send one request for each event, from your server. DataFlair records it against the affiliate and the program. ## Endpoint ```text GET or POST https://{stats-host}/api/postback/{program}/{token} ``` Copy the full URL from the Stats app: open the program, go to **Integrations**, and find the **Postback endpoint** card. The card also shows the masked token and has the **Generate token** and **Regenerate token** buttons. Both methods work. A POST sends a JSON body. A GET sends the same fields as query parameters, which suits operators that fire a URL pixel. DataFlair merges the query string and the body, so the field names are the same either way. Send `Accept: application/json` on every request so errors come back as JSON. ## Authentication The `{token}` in the URL authenticates the request. Each program has its own token. It is 64 hexadecimal characters and is separate from the API key DataFlair uses to pull your reports. * Treat the full URL as a secret. Send postbacks from your server. Do not put the URL in client-side code. * If the token is wrong or missing, DataFlair answers `401`. * **Regenerate token** in the app makes the old token stop working at once. Update your integration with the new URL. ### Signed requests (optional) A POST can carry a signature in place of the token in the URL. Use the URL without the token segment, `/api/postback/{program}`, and add two headers: | Header | Value | | --- | --- | | `X-Postback-Timestamp` | The current Unix time in seconds, as digits. | | `X-Postback-Signature` | The lowercase hexadecimal HMAC-SHA256 of `{timestamp}.{raw request body}`, with your postback token as the key. | DataFlair rejects a timestamp more than 300 seconds away from its own clock. Signing is for POST only. A GET postback uses the token in the URL. ## What you send ::: code-group ```bash [curl (POST)] curl -X POST "https://{stats-host}/api/postback/12/YOUR_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "affiliate_id": "AFF-00042", "conversion_type": "ftd", "postback_id": "pb_001", "amount": 25.00, "status": "approved" }' ``` ```bash [curl (GET)] curl -G "https://{stats-host}/api/postback/12/YOUR_TOKEN" \ -H "Accept: application/json" \ --data-urlencode 'affiliate_id=AFF-00042' \ --data-urlencode 'conversion_type=ftd' \ --data-urlencode 'postback_id=pb_001' \ --data-urlencode 'amount=25.00' \ --data-urlencode 'status=approved' ``` ::: The [request builder](/stats/postbacks/request-builder) fills in these commands for you. It does not send them. ## Fields | Field | Type | Required | Meaning | | --- | --- | --- | --- | | `affiliate_id` | string | yes | The DataFlair affiliate ID, for example `AFF-00042`. The affiliate must be approved for the program. `aff_id` is accepted in its place when the program has no field mapping. | | `conversion_type` | string | yes | One of `registration`, `ftd`, `deposit`. See [Conversion types](#conversion-types). | | `postback_id` | string | yes | Your unique id for this event. Up to 255 characters. See [Idempotency](/stats/postbacks/idempotency). | | `amount` | number | no | The amount, zero or more. Defaults to `0`. | | `currency` | string | no | A currency code, up to 8 characters. DataFlair stores it as you send it. | | `player_id` | string | no | Your internal player reference. Up to 255 characters. | | `occurred_at` | datetime | no | When the event happened in your system. DataFlair reports the conversion on this date when it is present. Without it, DataFlair uses the time it received the request. | | `sub_id` | string | no | The `sub_id` you received on the landing page. Up to 100 characters. Letters, digits, `_` and `-` only. See [Tracking links](/stats/tracking/). | | `tracking_id` | string | no | The `tracking_id` you received on the landing page, in the form `TL-` followed by letters or digits. | | `status` | string | no | One of `approved`, `pending`, `rejected`, `hold`, `adjusted`. Defaults to `approved`. See [Status values](/stats/postbacks/status-semantics). | | `kyc_status` | string | no | The player's KYC state: `pending`, `verified` or `rejected`. | | `kyc_verified` | boolean | no | A shorter form of `kyc_status`. `true` means verified and `false` means pending. `kyc_status` wins when you send both. | | `df_click_id` | string | no | A DataFlair click id in the form `dfc_` followed by 16 hexadecimal characters. Send it back as `sub_id` instead. | DataFlair ignores any other field. It records the source IP address of the request itself. ## What you get back A new conversion: ```json { "success": true, "conversion_id": 42, "commission_id": 17 } ``` `commission_id` is `null` when no commission record was created. A repeat of a `postback_id` DataFlair already has: ```json { "success": true, "duplicate": true, "conversion_id": 42, "commission_id": 17, "message": "Postback already processed (idempotent)." } ``` ## Errors Every error body has `"success": false` and an `error` code. A `message` describes it. | Status | `error` | What caused it | What to do | | --- | --- | --- | --- | | `401` | `unauthorized` | The token is wrong, or the URL has no token and no valid signature. The message says which: `Invalid postback token.`, `No postback token in URL. Expected: /api/postback/{program}/{token}`, or `Invalid or expired postback signature.` | Check the URL. Check that the token was not regenerated. | | `404` | `program_not_found` | The program does not exist or is not active. | Check the program id. Ask the operator to activate the program. | | `422` | `validation_error` | A field is missing or invalid. The `errors` object lists each field. | Fix the fields named in `errors`. | | `422` | `affiliate_not_found` | The affiliate is not approved for this program. | Send the `affiliate_id` you received on the landing page. | | `422` | `tracking_link_not_found` | The `tracking_id` is not valid for this program or affiliate. | Send the `tracking_id` you received, or leave it out. | | `429` | none | You sent more than 60 requests in one second with the same token. The response has a `Retry-After` header. | Wait, then retry with the same `postback_id`. | | `429` | none | The same `postback_id` arrived twice at the same moment. The message is `Concurrent delivery of the same postback_id; retry shortly.` | Retry with the same `postback_id`. | | `5xx` | none | A server error. | Retry with the same `postback_id`. | A `validation_error` looks like this: ```json { "success": false, "error": "validation_error", "message": "Request validation failed.", "errors": { "conversion_type": ["The selected conversion type is invalid."] } } ``` ## Conversion types {#conversion-types} | Type | Meaning | | --- | --- | | `registration` | A player account was created. | | `ftd` | The player's first deposit. | | `deposit` | A later deposit. | Postbacks carry these acquisition events only. DataFlair does not accept `cashout` in a postback. Withdrawals and revenue reach DataFlair through the [Operator Reporting API](/stats/operator-api/contract), which DataFlair pulls. ## Timeouts and retries The rate limit is 60 requests per second for each token. When the URL has no token, the limit applies to each source IP address. Retry a `5xx`, a `429` and a timeout with the same `postback_id`. Do not retry a `401`, a `404` or a `422` until you have fixed the cause. The [Idempotency](/stats/postbacks/idempotency) page explains why a retry is safe. ## Test this Check the endpoint, fire a test postback and replay a logged one from the Stats app. See [Testing postbacks](/stats/postbacks/testing). --- --- url: https://docs.dataflair.ai/stats/postbacks/idempotency.md description: >- How DataFlair treats a repeated postback_id, and how to retry a postback safely without double counting a conversion. --- # Postback idempotency Networks fail. A request can time out after DataFlair has already recorded it. The `postback_id` field lets you retry without counting the conversion twice. ## The rule Send a unique `postback_id` for every distinct event, and send the same `postback_id` when you retry the same event. DataFlair looks for an earlier conversion in the same program with the same `postback_id`. It only looks back **30 days**. | Situation | What DataFlair does | | --- | --- | | The `postback_id` is new for this program. | Creates the conversion. Answers `200` with `success: true` and a `conversion_id`. | | The same `postback_id` arrived within the last 30 days. | Creates nothing. Answers `200` with `success: true` and `duplicate: true`, plus the original `conversion_id`. | | The same `postback_id` arrived more than 30 days ago. | Treats it as a new event and creates a conversion. | | Two requests with the same `postback_id` arrive at the same moment. | Processes one. The other may get `429` with `Concurrent delivery of the same postback_id; retry shortly.` Retry it. | The `postback_id` is unique **per program**. The same value in two programs is two different events. ## A duplicate answer is a success ```json { "success": true, "duplicate": true, "conversion_id": 42, "commission_id": 17, "message": "Postback already processed (idempotent)." } ``` Treat `duplicate: true` the same as a normal `200`. Stop retrying. A repeat with the same `postback_id` and different values, for example another `amount`, does not change the original conversion. DataFlair answers with the duplicate response. ## Choosing a `postback_id` Use an id that your system already has for the event, so a retry produces the same value. A player id plus your own event id works: ```text ftd-PLAYER-1001-evt-8892 ``` Do not build the id from the current time or a random number. A retry would then carry a new id, and DataFlair would count the event again. ## When to retry | Response | What to do | | --- | --- | | `200`, with or without `duplicate: true` | Stop. The event is recorded. | | `401`, `404` or `422` | Stop. Fix the cause first. See the [error table](/stats/postbacks/contract#errors). | | `429` | Wait, then retry with the same `postback_id`. Use the `Retry-After` header when it is present. | | `5xx` or a timeout | Retry with the same `postback_id`. Wait longer between each attempt. | --- --- url: https://docs.dataflair.ai/stats/postbacks/status-semantics.md description: >- The five status values a postback can carry, what each one means in DataFlair reports, and the default. --- # Postback status values The optional `status` field tells DataFlair the state of the conversion in your system. Leave it out and DataFlair uses `approved`. ## Values | Status | Meaning | | --- | --- | | `approved` | Accepted and counted in the main report. This is the default. | | `pending` | Waiting for validation or settlement. | | `rejected` | Invalidated. It stays visible when you filter reports by status. | | `hold` | Held back for now. | | `adjusted` | Updated after the original event. | Send `approved` when the conversion is final. Send `pending` while your own checks are still running. A postback records the state at the time you send it. ## Things to know * **The default is `approved`.** A postback with no `status` counts in reports at once. Do not send a test event to a live program without a status that says what it is. See [Testing postbacks](/stats/postbacks/testing) for the test tool that marks events as tests. * **A repeat does not change the status.** Sending the same `postback_id` again returns the duplicate response. See [Idempotency](/stats/postbacks/idempotency). * **Any other value fails.** DataFlair answers `422` with `validation_error` for a status outside the five above. --- --- url: https://docs.dataflair.ai/stats/postbacks/request-builder.md description: >- Build the curl command for a postback from its fields. The builder does not send it. --- # Postback request builder Fill in the fields to build the `curl` command for one postback. The builder does not send anything. You copy the command and run it from your own server. ## Before you send it * **A real token records a real conversion.** A postback defaults to `approved` and counts in reports. The contract has no test flag on the request. To send a test, use the tools in the Stats app. See [Testing postbacks](/stats/postbacks/testing). * **Send it from your server.** Do not put the token in client-side code. See [Authentication](/stats/postbacks/contract#authentication). * **The host is a placeholder** until you enter one. Copy the real URL from the **Postback endpoint** card on the program's Integrations page. * **The example values** are the ones in the [Postback contract](/stats/postbacks/contract). Replace them with your own. --- --- url: https://docs.dataflair.ai/stats/postbacks/testing.md description: >- Three tools in the Stats app to test a postback integration, and what each one records. A test event does not earn commission. --- # Testing postbacks A postback that you send with a real token creates a real conversion. It defaults to `approved` and counts in reports. Test with the tools in the Stats app first. All three tools are on the program's **Integrations** page. ## 1. Check the endpoint Open the **Postback endpoint** card and click **Test endpoint**. DataFlair checks two things: the program is active, and it has a postback token. When both are true, it shows "Postback endpoint is ready to receive events." This check sends no postback and creates no conversion. ## 2. Fire a test postback Find the **Fire test postback** form in the **Field mapping** section. Choose an approved affiliate, a conversion type (`registration`, `ftd` or `deposit`) and an amount, then send it. DataFlair sends a synthetic postback through the real endpoint, so the token check, the validation and the duplicate check all run as they do for live traffic. If the program has a field mapping, the test postback uses your field names. * The event is marked as a **test**. It does not earn commission. * Its `postback_id` starts with `test-` and its `player_id` starts with `PLAYER-TEST-`. * It shows in the **Postback log** tab, and in the **Test fires** counter. The form appears once the program has a postback token and at least one approved affiliate. ## 3. Replay a logged postback Open the **Postback log** tab and click a row. The **Postback detail** panel opens. When a row can be replayed, the panel has a **Replay postback** button. A replay sends the logged request through the real endpoint again, with the program's current token. Use it after you fix a cause, such as an affiliate that was not approved yet. * A replay is **not** a test. It processes the original event as real traffic. If the original was rejected and now passes, it creates a real conversion. * Replaying a postback that was already accepted does nothing. DataFlair answers with the duplicate response. * You cannot replay a test event, or a request that failed authentication. ## What to check after a test 1. The Postback log shows the request with the status you expect. 2. The response codes match the [error table](/stats/postbacks/contract#errors). 3. Sending the same `postback_id` twice gives `duplicate: true` the second time. See [Idempotency](/stats/postbacks/idempotency). --- --- url: https://docs.dataflair.ai/stats/operator-api/contract.md description: >- The read-only daily aggregate endpoint you build so DataFlair Stats can pull registrations, first deposits and deposits for each affiliate. --- # Operator Reporting API You build one read-only endpoint on your server. It returns daily totals for each affiliate. DataFlair Stats calls it on a schedule and stores the numbers. Operators see every affiliate in the app. An affiliate sees only their own rows. ## What we send DataFlair sends a `GET` with a date range and one credential header. ::: code-group ```http [HTTP] GET /api/reports/affiliate-daily?from=2026-02-01&to=2026-02-07 HTTP/1.1 Host: operator.example.com X-API-Key: YOUR_KEY Accept: application/json ``` ```bash [curl] curl "https://operator.example.com/api/reports/affiliate-daily?from=2026-02-01&to=2026-02-07" \ -H "X-API-Key: YOUR_KEY" \ -H "Accept: application/json" ``` ::: | Query parameter | Type | Required | Meaning | | --- | --- | --- | --- | | `from` | `YYYY-MM-DD` | yes | The first day, inclusive. A UTC date. | | `to` | `YYYY-MM-DD` | yes | The last day, inclusive. A UTC date. | DataFlair calls the URL you save on the program's **Integrations** page, exactly as saved. It removes any query string from that URL and adds `from` and `to`. The path `/api/reports/affiliate-daily` is the convention. A different path works when you save it. If your API uses other names for `from` and `to`, or nests the rows in a different place, set a field mapping for the pull on the Integrations page. DataFlair reads the mapping for the parameter names and for the location of the `rows` array. ### Authentication Use a read-only credential. DataFlair sends it in one of these headers, according to the type you chose when you saved the connection: | Type | Header | | --- | --- | | API key (recommended, the default) | `X-API-Key: ` | | Bearer token | `Authorization: Bearer ` | ## What you return Status `200` with a JSON body: ::: code-group ```json \[exemplary/affiliate-daily/expected-response.json] { "from": "2026-02-01", "to": "2026-02-07", "rows": [ { "date": "2026-02-01", "affiliate_id": "AFF-00042", "registrations": 28, "ftds": 7, "deposit_amount": 840.00, "commission_amount": 100.80 } ] } ``` ::: `rows` may be empty. An empty array is a valid answer. ## Fields The response: | Field | Type | Required | Meaning | | --- | --- | --- | --- | | `rows` | array | yes | One object for each day and affiliate. | | `from`, `to` | string | no | The range you answered for. DataFlair reads them when they are present. | Each row: | Field | Type | Required | Meaning | | --- | --- | --- | --- | | `date` | `YYYY-MM-DD` | yes | The reporting day of the row. | | `affiliate_id` | string | yes | The affiliate ID from the tracking link, for example `AFF-00042`. Return it exactly as you captured it. | | `registrations` | integer | one metric is required | Player accounts created. | | `ftds` | integer | one metric is required | First-time depositors. | | `deposit_amount` | number | one metric is required | The sum of deposits. | | `commission_amount` | number | one metric is required | The commission you attribute to the affiliate for the day. Leave it out or send `null` when you do not calculate it. | | `clicks` | integer | no | Clicks you track yourself. DataFlair tracks clicks on its own. | At least one of `registrations`, `ftds`, `clicks`, `deposit_amount`, `commission_amount`, `reported_ngr` or `kyc_verified_ftds` must be on the first row of the response. Without one, DataFlair fails the whole pull as a schema mismatch. DataFlair also reads `ggr`, but it does not count toward that check. The [Metrics](/stats/operator-api/metrics) page explains each metric. DataFlair skips a row that has no `date`, no `affiliate_id`, or a `date` that is not in the form `YYYY-MM-DD`. It stores the other rows. The affiliate ID must be one DataFlair generated. You do not create affiliate IDs. You capture them from the landing URL, as described on [Tracking links](/stats/tracking/). ## Errors | Status | What caused it | What DataFlair does | | --- | --- | --- | | `200` | Success. | Reads the rows and stores them. | | `400`, and other `4xx` | A bad request, or a date it could not read. | Records the run as failed. Does not retry. | | `401`, `403` | The credential is wrong or missing. | Records the run as failed. Does not retry. | | `429` | Rate limited. | Waits for the `Retry-After` header, 10 seconds when the header is missing, at most 60 seconds. Then retries. | | `5xx` | A server error. | Waits 2 seconds, then 4 seconds, and retries. | | `200` with no `rows` array, no `date` or `affiliate_id` on the first row, or no metric on the first row | The body does not match the contract. | Records the run as failed with a schema mismatch. Does not retry. | ## Timeouts and retries DataFlair waits up to 30 seconds for each request. It makes up to 3 attempts in one run. The waits are in the table above. ## How DataFlair pulls it Each program picks a schedule on the **Integrations** page: | Schedule | When DataFlair calls your endpoint | | --- | --- | | Daily | Once a day at 03:00 UTC. | | Hourly | Every hour, on the hour. | | Manual | Only when an operator clicks **Pull now**. | Every pull asks for a range of days, not one day. The range and how late data is corrected are on [Timezone and backfill](/stats/operator-api/timezone-and-backfill). ## Test this Save your endpoint and credential in the **Pull API** connection on the Integrations page. Click **Test connection**. DataFlair calls your endpoint for yesterday, and it reports what it received. Click **Pull now** to run a full pull. ## Verify Before you save the connection, run the [conformance check](/stats/operator-api/conformance) against your endpoint. It replays the [fixtures](/stats/operator-api/fixtures) and links each failure to the section of this page that explains it. --- --- url: https://docs.dataflair.ai/stats/operator-api/metrics.md description: >- What each metric in a daily row means, how DataFlair reads it, and what an omitted value becomes. --- # Operator API metrics Each row of the [Operator Reporting API](/stats/operator-api/contract) carries counts and amounts for one day and one affiliate. This page says what each metric means and how DataFlair stores it. ## Metrics | Metric | Type | Meaning | If you leave it out | | --- | --- | --- | --- | | `registrations` | integer | Player accounts created and attributed to the affiliate. | Stored as `0`. | | `ftds` | integer | First-time depositors. A player counts once, in the lifetime of the player. | Stored as `0`. | | `deposit_amount` | number | The sum of deposits posted on the report day. | Stored as no value. | | `commission_amount` | number | The amount you attribute to the affiliate for the day. | Stored as no value. | | `clicks` | integer | Clicks you track yourself. DataFlair tracks clicks on its own, through the tracking link. | Stored as `0`. | | `kyc_verified_ftds` | integer | First-time depositors who passed KYC that day. | Kept as "not reported". See below. | | `reported_ngr` | number | Net gaming revenue as you report it. | Stored as no value. | | `ggr` | number | Gross gaming revenue as you report it. | Stored as no value. | Send every metric you report on every pull. DataFlair replaces the stored values for a day and an affiliate with the latest ones, so a pull that leaves a count out resets it to `0`. ## Zero and blank are different For `kyc_verified_ftds`, an empty or `null` value means "not reported that day". A `0` means you reported zero. DataFlair keeps the two apart. Send `0` only when the real number is zero. ## Types Send counts as integers. Send amounts as numbers with up to two decimal places, for example `840.00`. DataFlair converts a value it can read as a number. A value it cannot read as a number becomes no value or `0`, as in the table. ## What DataFlair requires * `date` and `affiliate_id` on every row. A row without them is skipped. * At least one of the metrics `registrations`, `ftds`, `clicks`, `deposit_amount`, `reported_ngr`, `commission_amount` or `kyc_verified_ftds` on the first row of the response. Otherwise the whole pull fails with a schema mismatch. --- --- url: https://docs.dataflair.ai/stats/operator-api/timezone-and-backfill.md description: >- How DataFlair sets the date range of each pull, which timezone the dates are in, and how late data is corrected. --- # Timezone and backfill DataFlair does not ask for one day at a time. Each pull asks for a range of days and stores every row it gets. That is how late data is corrected. ## The date range Every scheduled pull sends: * `from`: today minus the program's backfill days. * `to`: today. Both are calendar dates in UTC, in the form `YYYY-MM-DD`, and both are inclusive. Read them as UTC dates. The backfill is set for each program. Its default is **14 days**. A program with the default asks for 15 calendar days: 14 days back and today. | Example (today is 2026-02-20 UTC) | `from` | `to` | | --- | --- | --- | | Backfill of 14 days | `2026-02-06` | `2026-02-20` | | Backfill of 3 days | `2026-02-17` | `2026-02-20` | ## Timezone DataFlair sends `from` and `to` as UTC dates. Return each row with the `date` of your own reporting day for that range. A program also has a timezone setting, and its default is UTC. The date range of a pull is worked out in UTC and does not use that setting. ## Late data DataFlair stores each row by program, date and affiliate. When a later pull returns the same day and affiliate, it replaces the stored numbers with the new ones. A conversion that reaches your reporting a day late is corrected by the next pull, as long as that day is still inside the backfill range. A day older than the backfill range is not asked for again on a scheduled pull. An operator can ask for a longer range by hand from the Integrations page, up to 3 years. The end of a manual range cannot be in the future. Send the full row every time. See [Metrics](/stats/operator-api/metrics). ## Days that are not finished Today is always inside the range, so DataFlair asks for a day that is still running. Return what you have. The next pull replaces it. Some operators refuse the current day. If your API cannot answer for today, return an empty `rows` array for it, or return `200` for the closed days only. DataFlair's own connection test asks for yesterday only for this reason. --- --- url: https://docs.dataflair.ai/stats/operator-api/fixtures.md description: >- The requests DataFlair Stats sends to your Operator Reporting API endpoint, each with an answer that passes. Download them, or replay them with the conformance check. --- # Operator API fixtures Fixtures are the documented examples as real files. Each one is a request that DataFlair Stats sends and an answer that passes. The [contract page](/stats/operator-api/contract) shows the same file, so this folder, the page and the [conformance check](/stats/operator-api/conformance) cannot disagree. [Download all fixtures (.zip)](/downloads/operator-api-fixtures.zip) ## What is in the folder | Folder | What it holds | | --- | --- | | `exemplary/` | The happy path. | | `supplemental/` | Errors and edge cases. | Each case is a folder with three files: | File | What it holds | | --- | --- | | `request.json` | The `from` and `to` that DataFlair sends as query parameters. | | `expected-response.json` | An answer that passes. | | `case.json` | How the check uses the case: the status it expects, the rules it applies, the docs section that explains it, and any value that was made up. | ## The cases | Case | Call | What it checks | Status | | --- | --- | --- | --- | | `exemplary/affiliate-daily` | `GET /api/reports/affiliate-daily` | Returns daily rows per affiliate | 200 | | `supplemental/single-day` | `GET /api/reports/affiliate-daily` | A range of one day returns rows for that day only | 200 | | `supplemental/invalid-date` | `GET /api/reports/affiliate-daily` | A date that cannot be read is a 400 or 422 | 400 or 422 | | `supplemental/missing-credentials` | `GET /api/reports/affiliate-daily` | A request with no credential is a 401 or 403 | 401 or 403 | | `supplemental/wrong-credentials` | `GET /api/reports/affiliate-daily` | A wrong credential is a 401 or 403 | 401 or 403 | The path is the convention. The check calls the URL you give it, as DataFlair calls the URL you save on the Integrations page. ## Values that were made up Every value comes from an example already in these docs. Where a value had to be invented, the `madeUp` list in that case's `case.json` says so. These are all of them: **`supplemental/invalid-date`** * from is "not-a-date", chosen only to be unreadable * the contract says 400 and other 4xx. The checker accepts 400 and 422, the two usual answers * the expected message is an example. Only the status is checked **`supplemental/missing-credentials`** * the expected message is an example. Only the status is checked **`supplemental/single-day`** * from and to are both the example day 2026-02-01. Test connection asks for a one-day range, and both ends of a range are inclusive **`supplemental/wrong-credentials`** * the wrong credential is generated by the checker * the expected message is an example. Only the status is checked An error case checks the status only. The contract does not give an error body shape. ## Use them * Replay them with the [conformance check](/stats/operator-api/conformance). * Or replay one by hand. Send the `from` and `to` in `request.json` to your endpoint with `curl`, with your credential in the header. Compare the answer with `expected-response.json`. Your affiliates and numbers will differ. The fields must match. --- --- url: https://docs.dataflair.ai/stats/operator-api/conformance.md description: >- Replay the DataFlair fixtures against your Operator Reporting API endpoint and see which rule a failing answer breaks. It runs on your machine and needs no DataFlair account. --- # Operator API conformance check The conformance check replays the [fixtures](/stats/operator-api/fixtures) against your endpoint. It prints one line for each case. A failing line says what is wrong and links to the section of the [contract](/stats/operator-api/contract) that explains it. It runs on your own machine. It does not need a DataFlair account, and it does not need a program. It sends read-only `GET` requests. ::: warning The checker is not published to npm yet `npx` cannot fetch it yet. It is in the DataFlair docs repository, in `docs-site/tools/stats-check`. Run it from the `docs-site` folder, after `npm ci`. The fixtures work without it: send the `from` and `to` in each `request.json` with `curl` and compare the answer with `expected-response.json`. ::: ## Run it ```bash node tools/stats-check/bin/stats-check.mjs https://operator.example.com/api/reports/affiliate-daily --key YOUR_KEY ``` Use the URL you save on the program's **Integrations** page. Use a test credential. A command line stays in your shell history, so you can set `STATS_CHECK_KEY` instead of `--key`. | Option | Meaning | | --- | --- | | `--key ` | The credential to send. Or set `STATS_CHECK_KEY`. | | `--auth ` | How to send it, as when you save the connection: `api-key` sends `X-API-Key`, and `bearer` sends `Authorization: Bearer`. The default is `api-key`. | | `--docs ` | Where the failure links point. The default is `https://docs.dataflair.ai`. | | `--fixtures ` | Replay a folder of fixtures instead of the bundled ones. | The exit code is 0 when your endpoint is ready to connect, 1 when a case failed, and 2 when the command was used wrongly. ## A passing run ```text ✓ GET /api/reports/affiliate-daily 200 Returns daily rows per affiliate ✓ GET /api/reports/affiliate-daily 422 A date that cannot be read is a 400 or 422 ✓ GET /api/reports/affiliate-daily 403 A request with no credential is a 401 or 403 ✓ GET /api/reports/affiliate-daily 200 A range of one day returns rows for that day only ✓ GET /api/reports/affiliate-daily 401 A wrong credential is a 401 or 403 5 passed · 0 failed. Ready to connect. ``` ## A failing run Here an endpoint ignores the `from` and `to` it was sent and answers with fixed rows: ```text ✗ GET /api/reports/affiliate-daily 200 Returns daily rows per affiliate rows[0].date is 2026-01-01, outside the range you were asked for (2026-02-01 to 2026-02-07). rows[1].date is 2026-03-01, outside the range you were asked for (2026-02-01 to 2026-02-07). → See https://docs.dataflair.ai/stats/operator-api/contract#what-we-send ✓ GET /api/reports/affiliate-daily 422 A date that cannot be read is a 400 or 422 ✓ GET /api/reports/affiliate-daily 403 A request with no credential is a 401 or 403 ✗ GET /api/reports/affiliate-daily 200 A range of one day returns rows for that day only rows[0].date is 2026-01-01, outside the range you were asked for (2026-02-01 to 2026-02-01). rows[1].date is 2026-03-01, outside the range you were asked for (2026-02-01 to 2026-02-01). → See https://docs.dataflair.ai/stats/operator-api/contract#what-we-send ✓ GET /api/reports/affiliate-daily 401 A wrong credential is a 401 or 403 3 passed · 2 failed. Not ready to connect. ``` ## What it sends The request is the URL you gave, with the query string removed and `from` and `to` added. DataFlair does the same. The path is kept exactly as you wrote it. The `from` and `to` come from the fixtures: `2026-02-01` to `2026-02-07`, and one day for the single-day case. The check sends five requests. The credential-free case sends no credential. The wrong-credential case sends a generated one. Nothing is written on your side, because the endpoint is read-only. ## What it checks Every case checks that your endpoint answered and that the status is the expected one. The cases that expect `200` also check the body against the schema, and these rules: | Rule | What it checks | Explained in | | --- | --- | --- | | `operator.firstRowHasMetric` | The first row has at least one of `registrations`, `ftds`, `clicks`, `deposit_amount`, `reported_ngr`, `commission_amount` or `kyc_verified_ftds`. Without one, DataFlair fails the whole pull as a schema mismatch. | [Fields](/stats/operator-api/contract#fields) | | `operator.rowsInRange` | Every row is for a day between `from` and `to`, both inclusive. | [What we send](/stats/operator-api/contract#what-we-send) | The schema requires `date` and `affiliate_id` on every row, a `date` in the form `YYYY-MM-DD`, integer counts, and numeric amounts. DataFlair skips a row that has no `date` or `affiliate_id`, so the check fails it instead of letting it pass. If your URL redirects, the check stops at the redirect. DataFlair follows up to five redirects when it pulls, but the check does not follow any, so run it against the final URL. ## What it does not check * It does not check that your numbers are right, or that the timezone of your days is UTC. See [Timezone and backfill](/stats/operator-api/timezone-and-backfill). * It does not test a large range, a `429`, a `5xx` or a slow answer. DataFlair retries a `429` and a `5xx`. The check does not. * It does not test that an affiliate sees only their own rows. That happens inside DataFlair. If a case fails, follow its link. The [contract](/stats/operator-api/contract) covers the rest. --- --- url: https://docs.dataflair.ai/stats.md description: >- Affiliate tracking, conversion attribution and reporting. Postbacks, the Operator Reporting API and tracking links. --- # DataFlair Stats Affiliate tracking, conversion attribution and reporting. Not covered here: affiliate onboarding, programs and reporting screens. You use those in the Stats app. --- --- url: https://docs.dataflair.ai/stats/tracking.md description: >- How a DataFlair tracking link redirects a click to your landing page, which parameters it adds, and what to store to send a postback later. --- # Tracking links An affiliate shares a DataFlair tracking link. When a player clicks it, DataFlair records the click and sends the player on to your landing page. It adds parameters to the landing URL. You store them, and you send them back in the postback when the player converts. ## How a click travels ```text Player clicks https://{stats-host}/go/{slug} DataFlair logs the click, then answers 302 Player lands https://your-landing-page.example.com/?affiliate_id=AFF-00042&tracking_id=TL-00091&sub_id=dfc_9f2c1a7b3d4e5f60 ``` `GET /go/{slug}` accepts three kinds of slug. All three reach the same link: * The permanent internal slug, in the form `TL-` followed by letters or digits. * The affiliate's current custom slug, for example `john-mega-bonus`. * A retired custom slug. Old shares keep working after a rename. DataFlair sends your landing page the `tracking_id`, and not the custom slug. You do not see the slug the player clicked. ## Parameters your landing page receives | Parameter | Always sent | Meaning | | --- | --- | --- | | `affiliate_id` | yes | The DataFlair affiliate ID, for example `AFF-00042`. | | `tracking_id` | yes | The tracking link, in the form `TL-` followed by letters or digits. | | `campaign_id` | when set | The affiliate's own campaign label. | | `sub_id` | on redirect links | A DataFlair click id, in the form `dfc_` followed by 16 hexadecimal characters. | Any query string already on your landing URL stays, and DataFlair adds its parameters after it. ## What you store When the player lands, read the parameters and keep them with the visit: 1. Read `affiliate_id`, `tracking_id` and `sub_id` from the landing URL. 2. Save them in a first-party cookie or a server session at once. 3. When the player registers, save them on the player record. 4. Use the stored values for every later event of that player. If you do not keep them at registration, a later postback has no affiliate to attribute the conversion to. ## What you send back Put the stored values in the postback: | Landing parameter | Postback field | | --- | --- | | `affiliate_id` | `affiliate_id` | | `tracking_id` | `tracking_id` | | `sub_id` | `sub_id` | The `sub_id` is what links a conversion to the click that produced it. See the [Postback contract](/stats/postbacks/contract). ## Response codes | Status | Meaning | | --- | --- | | `302` | The click was recorded and the player was sent to the landing page. | | `404` | The slug does not match any link. | | `410` | The traffic source is disabled, the landing page is gone, or DataFlair could not build the destination URL. | | `429` | The click rate limit was reached. | --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/overview.md description: >- The five endpoints your own ad server exposes so DataFlair can list inventory, forecast it, book drafts and pull delivery. --- # Ad Server API overview You run your own ad server. You build a small REST and JSON API on it. DataFlair calls that API. You do not call DataFlair. ::: info What is live today When you connect, and each time you click Verify, DataFlair calls `GET /health` and `GET /inventory`. The forecast, booking and reporting endpoints are part of the contract. DataFlair does not call them yet, and this page will say when each one goes live. ::: ## Roles | Actor | Who | Role in the integration | | --- | --- | --- | | **DataFlair** | The platform your advertiser demand comes from | The client of your API. It calls your endpoints. You do not call it. | | **Your ad server** | Your custom platform | The server. It owns inventory, serves ads and counts delivery. You expose the API this section describes. | | **Advertiser** | A buyer on DataFlair | Browses your inventory, books it and pays into a DataFlair wallet. The advertiser does not talk to your platform. | | **Your ad-ops** | A person on your side | Reviews the draft DataFlair pushes and takes it live in your own console. | ## The pull model DataFlair starts every request, over HTTPS. Your platform is a normal REST and JSON server that answers. There are no webhooks for you to call and no DataFlair SDK to embed. The core flow needs no callback into DataFlair. ```text Advertiser demand Your ad server (you build this API) ───────────────── ──────────────────────────────────── DataFlair ── HTTPS ──▶ GET /health (connect and verify) ── HTTPS ──▶ GET /inventory (map ad slots) ── HTTPS ──▶ POST /forecast (availability) ── HTTPS ──▶ POST /orders (push a draft booking) ── HTTPS ──▶ POST /reports (pull delivery) ``` That is the whole surface. Five endpoints. Four are read-only or read-mostly. One, `POST /orders`, writes a draft. All five are required, and a production integration implements the full set. During onboarding, an operator can enter slot ids by hand until `GET /inventory` is live (see [Inventory identity](/marketplace/ad-server-api/inventory)). Treat that as a stopgap. ## Lifecycle 1. **Connect** (once). DataFlair calls your API. * [`GET /health`](/marketplace/ad-server-api/operations/health) with your credential returns `200` with the account, timezone, currency and capabilities. * [`GET /inventory`](/marketplace/ad-server-api/operations/inventory) returns your ad slots with their ids and sizes. * An operator maps DataFlair placements to your slot ids. 2. **An advertiser is booking.** DataFlair calls your API. * [`POST /forecast`](/marketplace/ad-server-api/operations/forecast) with a slot, dates and geo returns `available_impressions` and `forecasted_impressions`. 3. **You approve the booking in DataFlair.** DataFlair calls your API. * [`POST /orders`](/marketplace/ad-server-api/operations/orders) with the advertiser and the lines. Your API creates the order and the line items as drafts, and returns `201` with `order_id`, the `line_item_id` values and `status: DRAFT`. 4. **Go live** (a person, on your side). Your ad-ops review the draft and activate it in your own console. 5. **Reconcile** (repeating). DataFlair calls your API. * [`POST /reports`](/marketplace/ad-server-api/operations/reports/) with line ids and a date range returns impressions and clicks per line. ## The three capabilities 1. **Availability and forecast.** `POST /forecast`. Given a slot, a flight window and optional targeting, return how many impressions are available and forecasted. This lets an advertiser book a sensible amount. See [POST /forecast](/marketplace/ad-server-api/operations/forecast). 2. **Campaign reporting.** `POST /reports`. Given the line ids DataFlair created, or an order id, and a date range, return impressions and clicks per line. DataFlair reconciles a campaign by matching these lines to the booked slots. See [POST /reports](/marketplace/ad-server-api/operations/reports/). 3. **Save a reservation as a draft.** `POST /orders`. Given an approved booking, create an order and one draft line item per booked inventory line (ad slot, geo and month), and return your stable ids. See [POST /orders](/marketplace/ad-server-api/operations/orders). Two more pieces connect these: [authentication](/marketplace/ad-server-api/authentication) (a key you issue to DataFlair) and [inventory identity](/marketplace/ad-server-api/inventory) (a stable id per bookable ad slot). ## The draft-only rule ::: danger DRAFT ONLY `POST /orders` creates draft, paused or inactive objects only. DataFlair does not call activate, approve, go-live, unpause or publish. None of them is in the adapter, by design. A person in your console decides whether inventory serves. ::: DataFlair follows the same rule for Google Ad Manager. It creates DRAFT orders and does not call `performOrderAction`. It follows the rule for Revive too. It creates banners as `INACTIVE` and does not link them to a zone. Your platform is the third ad server under the same rule. The full rule is on [POST /orders](/marketplace/ad-server-api/operations/orders#draft-only). ## Environments Give DataFlair two base URLs if you can: a **sandbox** that is safe for test drafts, and **production**. DataFlair marks a capability as verified only after it has exercised it against your API. A sandbox lets both sides prove the integration before real money moves. One environment also works. The first verification then runs against production. --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/concepts.md description: >- The vocabulary DataFlair and your ad server share, and how DataFlair entities map onto your platform's objects. --- # Concepts and entity mapping DataFlair and your ad server use different nouns for the same reality. This page fixes the vocabulary and maps one side onto the other, so the JSON fields in the endpoint pages are unambiguous. ## Glossary | DataFlair term | Meaning | Your platform's likely equivalent | | --- | --- | --- | | **Publisher** | The organisation that owns the inventory. In this integration, that is you. | Your account or network | | **Property** | A website, app or channel a publisher owns. A grouping only. | Site or domain (often no direct object) | | **Placement** | A durable, named ad slot on a property, for example "Homepage Leaderboard 728x90". | **Ad slot, ad unit or zone** | | **Inventory cell** | A bookable slice: one placement, one geo (or region) and one calendar month, with an impression pool and a price. | No single object. It becomes one line item's targeting, flight and goal. | | **Advertiser or brand** | The buyer a campaign is booked for. | **Advertiser** | | **Reservation** | A booking request an advertiser submits and you approve. It lives entirely in DataFlair. | No object. It comes before anything reaches your platform. | | **Campaign** | What a reservation becomes once approved. DataFlair pushes it to your ad server. | **Order** (a container for line items) | | **Line item** | One booked inventory cell, trafficked into your platform. | **Line item, flight, or banner with targeting** | | **Delivery report** | Impressions and clicks pulled back from your ad server to reconcile a campaign. | **Report or statistics** | ## The core mental model > A DataFlair campaign is one order on your platform. Each booked inventory cell is one line item inside it. Each line item targets one ad slot, for one month, with one impression goal, and optionally one geo. DataFlair already uses this model for Google Ad Manager (campaign to order, cell to line item, placement to ad unit) and for Revive (campaign to campaign, cell and creative to banner, placement to zone). Your platform has the same shape with your own names. ## Entity mapping | DataFlair entity | Maps to (your platform) | How it links | | --- | --- | --- | | Publisher **connection** (your base URL and credential) | Your **account or network** | One connection per publisher, made when you connect ([Authentication](/marketplace/ad-server-api/authentication)) | | **Placement** | **Ad slot** | DataFlair stores your slot's `inventory_id` on the placement ([Inventory identity](/marketplace/ad-server-api/inventory)) | | **Inventory cell** (placement, geo, month) | No single object | Becomes one line item's `inventory_id` target, `flight`, `goal_impressions` and `geo` | | **Advertiser or brand** | **Advertiser** | You resolve or create an advertiser by the `name` DataFlair sends | | **Campaign** | **Order** | One campaign is one order. You return an `order_id`. | | Booked cell (**line item**) | **Line item** | You return a stable `line_item_id`. DataFlair stores it. | | **Delivery report** | **Report or statistics** | DataFlair pulls impressions and clicks per `line_item_id` | ### What has no object on your side * **Property** and **reservation** do not reach your platform. A reservation is a booking inside DataFlair, before approval. DataFlair pushes a campaign only after you approve it. A property is a grouping of placements on the DataFlair side. * **The inventory cell** has no object of its own. It is a line item that targets your ad slot for one month with one impression goal and an optional geo. DataFlair does not create a "cell" on your platform. ## The two ids that make reconciliation work Two identifiers cross the boundary and must be **stable**. 1. **`inventory_id`** is your id for a bookable ad slot. DataFlair sends it in `POST /forecast` and in each line of `POST /orders`. You define it. DataFlair stores it against a placement. See [Inventory identity](/marketplace/ad-server-api/inventory). 2. **`line_item_id`** is your id for a line you created in `POST /orders`. You return it when you create the line. DataFlair stores it and sends it back in `POST /reports`. DataFlair reconciles delivery by this id, so it must survive renames and edits. Do not recycle it or change it for a booked line. DataFlair reconciles Google Ad Manager by the immutable `LINE_ITEM_ID` for the same reason. A line item can be renamed in GAM without breaking reconciliation. Your `line_item_id` plays the same role. ## Statuses you should know You do not need to mirror these statuses. They explain when each call happens. * A **reservation** moves from `pending_review` to `approved` (or `rejected`). DataFlair pushes a campaign to you only when it is `approved`. * A **campaign** is pushed while it is in a traffickable state: `awaiting_creative`, `scheduled` or `live`. A future-dated booking is still pushed as a draft, and its flight starts later. * On your side, the one status change DataFlair cares about is draft to live. DataFlair does not perform it. Your ad-ops do. --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/authentication.md description: >- The API key you issue to DataFlair, what DataFlair stores about your connection, and how the two systems first connect and verify. --- # Authentication and connection How DataFlair proves who it is to your API, and how the two systems first shake hands. ## The credential you issue to DataFlair Authentication is a **single bearer token**. It is a static API key that you generate on your platform and give to DataFlair. There is no OAuth flow, no token endpoint and no login. DataFlair stores the key encrypted and sends it on every request: ```http Authorization: Bearer sk_live_9f2c… (opaque, issued by you) ``` Requirements for the key: * **You issue it.** You generate the key on your own platform and give it to DataFlair. DataFlair does not generate it, and does not ask a person to type a password into your platform. * **Revocable.** You can revoke and reissue it without downtime, so a leaked key can be rotated. * **Least privilege.** Scope it to read, create-draft and report only. It must not be able to activate inventory or move money. * **One key per publisher.** One key authenticates one account. DataFlair holds it encrypted and keeps it out of URLs, log lines and API responses. * **HTTPS only.** DataFlair refuses a base URL that does not start with `https://`. Its Revive integration enforces the same rule. * Treat the key like a password. Hash it at rest on your side and do not log it in plaintext. ## What DataFlair stores For each connected publisher, DataFlair keeps a small connection record. | Field | Who supplies it | Notes | | --- | --- | --- | | `base_url` | You | For example `https://ads.example.com/api/v1`. `https://` is enforced. | | `api_key` | You | The bearer token. Encrypted at rest and not shown back to a user. | | `account_name` | Your `/health` response | Display only. | | `timezone`, `currency` | Your `/health` response | Used to read flight dates and to validate the pricing currency. | | `capabilities` | The verify probe | `inventory_read`, `forecast`, `reporting` and `draft_booking`, each `confirmed` or `not_verified`. | | `status`, `last_error` | The verify probe | `connected` or `error`, and the last failure reason. | ## The connect-and-verify handshake When an operator connects your platform, DataFlair runs a short read-only probe. It creates nothing. 1. DataFlair calls [`GET /health`](/marketplace/ad-server-api/operations/health) to confirm the credential works, that it reached the right account, and that it can read your account context. 2. If that succeeds, DataFlair calls [`GET /inventory?limit=1`](/marketplace/ad-server-api/operations/inventory) once. ### The capability checklist DataFlair confirms a capability only by using it. It does not assume. One cheap probe, `GET /inventory?limit=1`, is enough to move `inventory_read` from `not_verified` to `confirmed`. Reporting and draft booking can be confirmed only by real use. A freshly connected account that shows "not verified" next to them is normal until the first real report and the first real draft happen. `inventory_read` differs from the other three. You cannot turn it off. It is either implemented or it is not, so there is nothing to declare for it in the `capabilities` object of `/health`. That object carries only `forecast`, `reporting` and `draft_booking`. Implementing `GET /inventory` is part of the required contract, like the rest. DataFlair confirms `inventory_read` the first time a real `GET /inventory` call succeeds. ## Failure behavior Return a clear HTTP status and a JSON `{ code, message }` body (see [Conventions](/marketplace/ad-server-api/conventions#error-model)). DataFlair shows the `message` to the operator on the connection card. The operator fixes the problem and retries in place. * `401`: bad or missing credential. * `403`: the credential authenticated but lacks a required scope. * `5xx` or unreachable: DataFlair reports "could not reach your platform" and the operator retries. --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/inventory.md description: >- How a bookable ad slot on your platform is identified, and how DataFlair learns about your slots so an operator can map them to placements. --- # Inventory identity How a bookable ad slot on your platform is identified. Also how DataFlair learns about your slots, so an operator can map them to DataFlair placements. ## The `inventory_id` Every bookable ad slot has a **stable, opaque id**. DataFlair calls it `inventory_id`. It is the one reference that ties a DataFlair placement to a real slot in your ad server. It appears in: * [`POST /forecast`](/marketplace/ad-server-api/operations/forecast): "how many impressions can this slot deliver?" * [`POST /orders`](/marketplace/ad-server-api/operations/orders): "book this slot for this advertiser." Requirements: * **Stable.** Once DataFlair has mapped a placement to an `inventory_id`, that id must keep pointing at the same slot. Do not recycle ids across deleted and recreated slots. * **Opaque.** It can be any string: a numeric id, a slug or a UUID. DataFlair treats it as a token and does not parse it. * **Yours.** You mint it. DataFlair stores it against a placement. It plays the same role as `gam_ad_unit_id` for GAM or `revive_zone_id` for Revive. ## Listing your inventory To map placements without an operator copying ids by hand, expose a read endpoint that lists your bookable slots: [`GET /inventory`](/marketplace/ad-server-api/operations/inventory). It is part of the required contract. While you are still building it, an operator can enter `inventory_id` values by hand during placement mapping. Treat that as an onboarding stopgap. DataFlair uses the equivalent read on Google Ad Manager (reading ad units) and on Revive (listing zones). ## Sizes and formats * **Sizes** matter because a line item's creative must fit the slot. DataFlair checks the booked creative size against the slot's `sizes` before it traffics the creative. Its GAM integration does an exact pixel check. Return every size a slot accepts. * **Format** lets DataFlair keep a video slot out of a display booking. Keep the vocabulary consistent across slots. ## Mapping happens in DataFlair You expose the ids. An operator does the mapping of placements to slots inside DataFlair after connecting, on the Placements screen. Your job is to make the ids **discoverable** (`GET /inventory`) and **stable**. DataFlair skips an unmapped placement at booking time. It does not guess, so nothing books against the wrong slot. --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/operations/health.md description: >- The read-only probe DataFlair runs to confirm your credential and read your account timezone, currency and capabilities. --- # GET /health: Connect and verify DataFlair calls this endpoint first, when an operator connects your platform. It is a cheap, read-only request. It confirms three things at once: your credential works, DataFlair reached the right account, and DataFlair can read your account context. ## What we send ::: code-group ```http [HTTP] GET /health HTTP/1.1 Host: ads.example.com Authorization: Bearer sk_live_9f2c… ``` ```bash [curl] curl "$BASE_URL/health" \ -H "Authorization: Bearer $API_KEY" ``` ::: DataFlair appends `/health` to the base URL you entered. A base URL of `https://ads.example.com/api/v1` gives `https://ads.example.com/api/v1/health`. If your base URL has a query string, DataFlair keeps it and puts the path before it. ## What you return Status `200` with a JSON body: ::: code-group ```json \[exemplary/health/expected-response.json] { "account_id": "acct_1029", "account_name": "Example Media Network", "timezone": "Europe/Berlin", "currency": "EUR", "capabilities": { "forecast": true, "reporting": true, "draft_booking": true } } ``` ::: The JSON on this page is read from the [fixture files](/marketplace/ad-server-api/fixtures), so it cannot drift from the test suite. ## Fields | Field | Type | Required | Meaning | | --- | --- | --- | --- | | `account_id` | string | yes | Your id for the account. It must not be empty. | | `account_name` | string | no | Display only. DataFlair shows it on the connection card. | | `timezone` | string | yes | The IANA timezone your platform books flights in, for example `Europe/Berlin`. DataFlair sends and reads all flight dates in this zone. A value that is not a real IANA identifier, such as `UTC+2`, is rejected. | | `currency` | string | yes | ISO-4217 code in three uppercase letters, for example `EUR`. DataFlair checks that a booking's currency matches it before it pushes an order. A value such as `EURO` is rejected. | | `capabilities` | object | yes | Three flags, each a JSON boolean. | | `capabilities.forecast` | boolean | yes | Whether `POST /forecast` works. Declare `true`. `false` is a degraded state: DataFlair then shows the availability you list instead of a live forecast. | | `capabilities.reporting` | boolean | yes | Declare `true`. A response with `false` is rejected. | | `capabilities.draft_booking` | boolean | yes | Declare `true`. A response with `false` is rejected. | `inventory_read` is not in this object. You cannot turn `GET /inventory` off, so there is nothing to declare. DataFlair confirms it the first time a real `GET /inventory` call succeeds. ## Errors | Status | What caused it | What the operator sees in DataFlair | What to do | | --- | --- | --- | --- | | `401` | Missing, invalid or expired key. | State `auth_error`: "DataFlair could not authenticate. Check your API key, then reconnect." | Check the key. Issue a new one if needed. | | `403` | The key authenticated but lacks a required scope. | State `forbidden`: "Your API key doesn't have the required scope. Update its permissions on your ad server, then reconnect." | Give the key read, create-draft and report scope. | | `400`, `404`, `429`, any other `4xx`, any `5xx`, or no answer | Malformed request, wrong base URL, rate limit, server error, or a timeout. | State `unreachable`: "Your ad server could not be reached. Check the base URL, then reconnect." | Check the base URL, then check your server's logs. | | `200` with a body that does not match the fields above | A missing or empty `account_id`, an invalid `timezone` or `currency`, a `capabilities` flag that is not a boolean, or `reporting` or `draft_booking` set to `false`. | State `invalid_response`: "Your ad server responded, but not in the documented /health shape. Check your /health endpoint, then reconnect." | Fix the body. See Fields. | DataFlair also stores the `message` from your error body as the last error, with credentials redacted. Return a JSON body of the form `{ "code": "...", "message": "..." }`, as described in [Conventions](/marketplace/ad-server-api/conventions#error-model). Do not put secrets in `message`. DataFlair does not follow redirects. Serve `/health` at the exact base URL you entered. ## Timeouts and retries DataFlair waits up to 10 seconds to connect and 30 seconds in total for this call. These are the platform's configured defaults. DataFlair does not retry a failed `/health` call. The operator presses **Re-verify** in DataFlair to try again. DataFlair resolves your host on every call. The host must resolve to public addresses only. A host that resolves to a private, loopback or internal address is refused. ## Verify Run the [conformance check](/marketplace/ad-server-api/conformance) with `--only health` to check this endpoint against the [fixtures](/marketplace/ad-server-api/fixtures). --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/operations/inventory.md description: >- The cursor-paginated list of ad slots DataFlair maps placements to. Each slot has a stable inventory_id. --- # GET /inventory: List bookable ad slots DataFlair calls this endpoint to learn about your ad slots. An operator then maps DataFlair placements to your `inventory_id` values without copying ids by hand. For what an `inventory_id` is, see [Inventory identity](/marketplace/ad-server-api/inventory). ## What we send ::: code-group ```http [HTTP] GET /inventory?limit=100&cursor=eyJsYXN0X2lkIjoic2xvdF80MTAifQ HTTP/1.1 Host: ads.example.com Authorization: Bearer sk_live_9f2c… ``` ```bash [curl] curl "$BASE_URL/inventory?limit=100" \ -H "Authorization: Bearer $API_KEY" ``` ::: | Query parameter | Required | Meaning | | --- | --- | --- | | `limit` | no | Page size. Default 50, minimum 1, maximum 200. | | `cursor` | no | The opaque cursor from the previous response's `next_cursor`. | When DataFlair connects your platform, it calls `GET /inventory?limit=1`. It needs only a successful answer with a `data` array to confirm the `inventory_read` capability. ## What you return Status `200` with a JSON body: ::: code-group ```json \[exemplary/inventory/expected-response.json] { "data": [ { "inventory_id": "slot_728x90_home", "name": "Homepage Leaderboard", "sizes": [ "728x90", "970x250" ], "format": "display", "status": "active" }, { "inventory_id": "slot_300x250_article", "name": "Article MPU", "sizes": [ "300x250" ], "format": "display", "status": "active" } ], "next_cursor": "eyJsYXN0X2lkIjoic2xvdF8zMDAifQ" } ``` ::: The JSON on this page is read from the [fixture files](/marketplace/ad-server-api/fixtures), so it cannot drift from the test suite. ## Fields | Field | Type | Required | Meaning | | --- | --- | --- | --- | | `data` | array | yes | The slots on this page. | | `data[].inventory_id` | string | yes | The stable, opaque id described on [Inventory identity](/marketplace/ad-server-api/inventory). | | `data[].name` | string | yes | A human label shown to the operator while mapping. | | `data[].sizes` | array of strings | yes | Accepted creative sizes, each as `WIDTHxHEIGHT`, for example `728x90`. DataFlair uses them for size compatibility checks. | | `data[].format` | string | no | `display`, `video`, `native` and so on. Free-form, so keep it consistent across slots. | | `data[].status` | string | no | `active` or `archived`, so DataFlair can hide dead slots. | | `next_cursor` | string or null | no | The cursor for the next page. Leave it out, or return `null`, on the last page. | Paging follows the cursor rules in [Conventions](/marketplace/ad-server-api/conventions#pagination). Read `next_cursor` until it is absent. ## Errors | Status | What caused it | What DataFlair does | What to do | | --- | --- | --- | --- | | `400` | Malformed request. | Treats it as a bug, logs it and shows it. | Check the query parameters. | | `401` | Missing, invalid or expired key. | Marks the connection as `error`. The operator re-checks the credential. | Check the key. | | `403` | The key lacks a required scope. | Marks the connection as `error`. The operator widens the scope. | Give the key read scope. | | `429` | Rate limit exceeded. | Backs off and retries per `Retry-After`. | Send a `Retry-After` header in seconds. | | `5xx` | Server error. | Retries with backoff. A persistent `5xx` shows as "your platform is unreachable". | Check your server's logs. | Today, DataFlair calls this endpoint in one place: the connect step, with `limit=1`. A failure there does not fail the connection. It leaves Inventory read as **Not verified**. The reactions in the table come from the contract in [Conventions](/marketplace/ad-server-api/conventions#http-status-usage). Every error body has the shape described in [Conventions](/marketplace/ad-server-api/conventions#error-model). ## Timeouts and retries DataFlair waits up to 10 seconds to connect and 30 seconds in total for this call. These are the platform's configured defaults. DataFlair does not retry a failed `GET /inventory` probe. ## Verify Run the [conformance check](/marketplace/ad-server-api/conformance) with `--only inventory` to check this endpoint against the [fixtures](/marketplace/ad-server-api/fixtures). --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/operations/forecast.md description: >- How many impressions a slot can deliver over a flight. DataFlair shows it to an advertiser before they book. --- # POST /forecast: Availability and forecast When an advertiser assembles a booking, DataFlair shows how many impressions each slot can realistically deliver over the chosen flight. The advertiser then books a sensible amount and does not over-commit or under-commit. DataFlair does this with `ForecastService.getAvailabilityForecast` on Google Ad Manager. This endpoint is your equivalent. The call is read-only and creates nothing. It asks "what if I booked this?" ## What we send ::: code-group ```http [HTTP] POST /forecast HTTP/1.1 Host: ads.example.com Authorization: Bearer sk_live_9f2c… Content-Type: application/json ``` ```bash [curl] curl -X POST "$BASE_URL/forecast" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d @request.json ``` ::: The body, saved as `request.json`: ::: code-group ```json \[exemplary/forecast/request.json] { "inventory_id": "slot_728x90_home", "flight": { "start_date": "2026-08-01", "end_date": "2026-08-31" }, "targeting": { "geo": [ "DE", "AT" ] }, "sizes": [ "728x90" ] } ``` ::: The JSON on this page is read from the [fixture files](/marketplace/ad-server-api/fixtures), so it cannot drift from the test suite. | Field | Type | Required | Meaning | | --- | --- | --- | --- | | `inventory_id` | string | yes | The slot being forecast. See [Inventory identity](/marketplace/ad-server-api/inventory). | | `flight.start_date`, `flight.end_date` | string | yes | Inclusive date range, `YYYY-MM-DD`, read in your account timezone from `/health`. | | `targeting.geo` | array of strings | no | ISO-3166-1 alpha-2 country codes. Absent or empty means no geo restriction (worldwide). | | `sizes` | array of strings | no | Creative sizes under consideration. They improve accuracy. Google Ad Manager's own forecast is more precise when the creative placeholder size is supplied. | ## What you return Status `200` with a JSON body: ::: code-group ```json \[exemplary/forecast/expected-response.json] { "inventory_id": "slot_728x90_home", "available_impressions": 1420000, "forecasted_impressions": 2100000, "unit_type": "IMPRESSIONS" } ``` ::: | Field | Type | Required | Meaning | | --- | --- | --- | --- | | `inventory_id` | string | yes | Echoes the slot, so DataFlair can match the response to the request. | | `available_impressions` | integer | yes | Impressions **still reservable** for this slot over the flight, after existing commitments. An advertiser can book against this number. | | `forecasted_impressions` | integer | yes | Total impressions the slot is **predicted to deliver** over the flight, before existing commitments. It is always greater than or equal to `available_impressions`. | | `unit_type` | string | no | Defaults to `IMPRESSIONS`. It is there so the contract can extend to other units later. Only `IMPRESSIONS` is used today. | ### How this maps to Google Ad Manager GAM's forecast returns four counts: `availableUnits`, `matchedUnits`, `possibleUnits` and `reservedUnits`. DataFlair needs two of them, and this endpoint asks for those two directly. * `available_impressions` is GAM `availableUnits`: what is left to sell. * `forecasted_impressions` is GAM `matchedUnits`: the total the targeting predicts. You do not need GAM's other counts. If your platform models availability differently, map your closest concepts onto these two and describe the mapping in your handoff notes. ## If you cannot forecast Pick one of two options. Do not invent a number. 1. **Preferred.** Declare `"forecast": false` in [`GET /health`](/marketplace/ad-server-api/operations/health). DataFlair then does not call `POST /forecast` and shows the availability the publisher listed. The booking still works. It is not checked against a live prediction. DataFlair takes the same position with Revive today. 2. If `forecast` is `true` but one slot cannot be forecast, return `501 Not Implemented` with `{ "code": "forecast_unsupported", "message": "…" }`. DataFlair treats that slot as "no forecast available" and degrades gracefully. It does not block the booking. ## Errors | Status | What caused it | What DataFlair does | What to do | | --- | --- | --- | --- | | `400` | Malformed request. | Treats it as a bug, logs it and shows it. | Check the body against the fields above. | | `401` | Missing, invalid or expired key. | Marks the connection as `error`. | Check the key. | | `403` | The key lacks a required scope. | Marks the connection as `error`. | Widen the key's scope. | | `404` | Unknown `inventory_id`. | Skips the item and reports it. | Return `404` only for a slot that does not exist. | | `422` | Valid shape, invalid values, such as a bad date range or an incompatible size. | Shows your `message`, so the operator can fix the booking. | Return a clear `message`. | | `429` | Rate limit exceeded. | Backs off and retries per `Retry-After`. | Send `Retry-After` in seconds. | | `501` | This slot cannot be forecast. | Degrades gracefully. | See "If you cannot forecast". | | `5xx` | Server error. | Retries with backoff. | Check your server's logs. | Every error body has the shape described in [Conventions](/marketplace/ad-server-api/conventions#error-model). ## Accuracy and performance * **Caching is welcome.** DataFlair caches forecast results briefly on its side. Its GAM path caches per slot, geo and month for a few minutes. A forecast that is a few minutes old is fine. A slow forecast that blocks the booking screen is not. Aim to answer in well under a second. * **Rounding is fine.** DataFlair rounds forecast numbers before it shows them to advertisers. You do not need an exact-to-the-impression figure. * **Batching is optional.** DataFlair may forecast several slots while an advertiser browses. A single-slot endpoint is enough, because DataFlair runs the calls in parallel. If you can offer a batch variant that accepts an array of `inventory_id` values, tell DataFlair, and it can use it to cut round trips. ## Timeouts and retries DataFlair does not call this endpoint yet. The platform's configured timeouts for calls to your server are 10 seconds to connect and 30 seconds in total. ## Verify Run the [conformance check](/marketplace/ad-server-api/conformance) with `--only forecast` to check this endpoint against the [fixtures](/marketplace/ad-server-api/fixtures). --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/operations/orders.md description: >- DataFlair pushes an approved reservation into your ad server as an order with draft line items. It does not activate anything. --- # POST /orders: Create draft order When you approve an advertiser's reservation in DataFlair, DataFlair pushes it into your ad server as an **order with one draft line item for each booked inventory line**. An inventory line is an ad slot, a geo and a month. A booking that covers two months, or two countries priced separately, arrives as several line items on the same slot. Your ad-ops team then reviews the draft and takes it live. This is the same operation as GAM's "create a DRAFT order and line items" and Revive's "create an inactive campaign and banners". DataFlair triggers this call when a reservation is approved. It can call it again for the same campaign. See [Idempotency and recovery](#idempotency-and-recovery). ## Draft only {#draft-only} ::: danger DRAFT ONLY DataFlair does not call activate, approve, go-live, unpause or publish. None of them is in the adapter, by design. Everything this endpoint creates must be a draft that does not serve. ::: * Create the order and its line items in your platform's **draft, paused or inactive** state. * Do not activate, approve, unpause or start serving anything as a side effect of this call. * Return the objects in that non-serving state, and echo `status: "DRAFT"`. Going live is a human action in your console. DataFlair applies the same rule to Google Ad Manager, where it does not call `performOrderAction`. It applies it to Revive, where it creates banners as `INACTIVE` and does not link them to a zone. If your `POST /orders` made inventory serve at once, it would break the guarantee DataFlair gives every publisher. ## What we send ::: code-group ```http [HTTP] POST /orders HTTP/1.1 Host: ads.example.com Authorization: Bearer sk_live_9f2c… Content-Type: application/json Idempotency-Key: DF-CMP-VDWZQ4IT ``` ```bash [curl] curl -X POST "$BASE_URL/orders" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: DF-CMP-VDWZQ4IT" \ -d @request.json ``` ::: The body, saved as `request.json`: ::: code-group ```json \[exemplary/orders/request.json] { "idempotency_key": "DF-CMP-VDWZQ4IT", "advertiser": { "name": "Acme Corp", "external_ref": "brand_5501" }, "order": { "name": "Summer Launch (DF-CMP-VDWZQ4IT)", "external_ref": "CMP-VDWZQ4IT" }, "line_items": [ { "external_ref": "DF-CMP-VDWZQ4IT-329", "inventory_id": "slot_728x90_home", "flight": { "start_date": "2026-08-01", "end_date": "2026-08-31" }, "goal_impressions": 100000, "targeting": { "geo": [ "DE", "AT" ] }, "sizes": [ "728x90" ], "creatives": [ { "type": "third_party_tag", "width": 728, "height": 90, "tag": "" } ] } ] } ``` ::: The JSON on this page is read from the [fixture files](/marketplace/ad-server-api/fixtures), so it cannot drift from the test suite. ### Fields | Field | Type | Required | Meaning | | --- | --- | --- | --- | | `idempotency_key` | string | yes | Unique to **this call**, not to the campaign. Reuse it only to retry the exact same request. A retry with the same key and a different body is rejected (see below). DataFlair also sends it as the `Idempotency-Key` header. | | `advertiser.name` | string | yes | The buyer. **Resolve or create** an advertiser by this name (see below). | | `advertiser.external_ref` | string | no | DataFlair's opaque id for the advertiser, if you want to store it. | | `order.name` | string | yes | A human label, for example `Summer Launch (DF-CMP-VDWZQ4IT)`. It is deterministic, so a lost response can be recovered by name. | | `order.external_ref` | string | yes | DataFlair's campaign reference, stable for the life of the campaign. Match on this, not on `idempotency_key`, to recognize an update to an order you already created (see below). | | `line_items[].external_ref` | string | yes | DataFlair's stable id for **this booked line**. It is unique even when a booking splits one slot into several lines by geo or month, for example `DF-{campaign}-{line}` and not `DF-{campaign}-{slot}`. Two lines can share a slot, and they cannot share an `external_ref`. Return it in the response so DataFlair can match your `line_item_id` to it. | | `line_items[].inventory_id` | string | yes | The slot to book. See [Inventory identity](/marketplace/ad-server-api/inventory). | | `line_items[].flight` | object | yes | Inclusive `start_date` and `end_date`, `YYYY-MM-DD`, in your account timezone. | | `line_items[].goal_impressions` | integer | yes | The impression goal the advertiser bought for this line. Feed it into your delivery and pacing engine. | | `line_items[].targeting.geo` | array of strings | no | ISO-3166-1 alpha-2 countries. Absent or empty means worldwide. | | `line_items[].sizes` | array of strings | yes | Creative size or sizes for the line. They must fit the slot. | | `line_items[].creatives` | array | recommended | The creative or creatives to serve: a ready-made tag from DataFlair, stored and served as sent. See [How creatives work](#how-creatives-work). | ### How creatives work You do not receive an image to host. DataFlair supplies a **ready-to-serve tag that points back to DataFlair**, and your platform stores and serves that tag as sent. The creative asset lives on DataFlair's CDN. An advertiser can swap the approved creative mid-flight, and nothing on your side changes. DataFlair sends the creative in one of two shapes, depending on what your platform's creative model supports. **A. Third-party or HTML tag (primary).** An ` ``` Store it as a third-party or HTML creative and serve it into the slot. GAM stores it as a `ThirdPartyCreative`. Revive stores it as banner HTML. When it renders, the frame loads the current approved creative from DataFlair's CDN. It also fires its click (`/go/{code}`) and impression (`/imp/{code}`) tracking from inside the frame. You do not build the iframe. DataFlair builds it, and you store it as it arrives. ::: tip Treat the tag as an opaque string The `/ad`, `/go` and `/imp` paths are DataFlair's live production endpoints. The link code (`ABC123`), the asset host and the query parameters (`df_source=…`, DataFlair's source attribution tag, minted per platform when the adapter is built) are illustrative here. The live tag arrives ready-made for each line item. Store it and serve it exactly as received. ::: **B. Image fields (fallback).** If your platform only accepts a hosted image with a click-through and cannot serve an HTML tag, DataFlair sends the pieces as structured fields. ```json { "type": "image", "width": 728, "height": 90, "image_url": "https://cdn.dataflair.ai/c/ABC123.png", "click_url": "https://t.dataflair.ai/go/ABC123?df_source=custom&df_ad_server=custom_platform", "impression_pixel_url": "https://t.dataflair.ai/imp/ABC123?df_source=custom&df_ad_server=custom_platform" } ``` Wire the creative's click-through to `click_url`, and fire `impression_pixel_url` when it renders. Rules for both shapes: * **Keep the `/go/{code}` click link exactly as sent.** Do not rewrite it or strip it. Every DataFlair campaign must serve through it, because that is how clicks are attributed and reconciled. A line that bypasses it cannot be reconciled. * **You still count impressions natively.** The impression count your ad server keeps is what you return in [reports](/marketplace/ad-server-api/operations/reports/). The in-frame pixel is DataFlair's independent cross-check. It does not replace your count. * **No approved creative yet?** Create the line item empty, as a draft. When the creative is approved, expect a follow-up `POST /orders` that carries it. It has a **new** `idempotency_key` and the **same** `order.external_ref` and `line_items[].external_ref`. Match on `external_ref` and update the existing line in place (see below). * **Optional cache-buster.** If your platform can fill a cache-buster macro inside a third-party tag, as GAM does with `%%CACHEBUSTER%%`, tell DataFlair, and it will append one for tighter impression reconciliation. If your platform cannot, DataFlair behaves as it does for Revive, with no macro. Where each party sees the creative: your **ad-ops** see the tag, with a preview, on the line item in your console. The **advertiser** manages the actual asset inside DataFlair. The **site visitor** sees the rendered ad once a person takes the line live. ## What you return Status `201` when you create the order. A repeat or an update returns `200` with the same ids. ::: code-group ```json \[exemplary/orders/expected-response.json] { "order_id": "ord_55021", "status": "DRAFT", "line_items": [ { "external_ref": "DF-CMP-VDWZQ4IT-329", "line_item_id": "li_88012", "status": "DRAFT" } ] } ``` ::: | Field | Type | Required | Meaning | | --- | --- | --- | --- | | `order_id` | string | yes | Your stable id for the order container. DataFlair stores it. | | `status` | string | yes | Echo `"DRAFT"`. | | `line_items[].external_ref` | string | yes | The same `external_ref` DataFlair sent, so it can match the pair. | | `line_items[].line_item_id` | string | yes | **Your stable id for the line you created.** DataFlair stores it and reconciles delivery against it in [reports](/marketplace/ad-server-api/operations/reports/). It must not change for this booked line. | | `line_items[].status` | string | yes | `"DRAFT"`. | ## Advertiser resolution Resolve or create the advertiser from `advertiser.name`. If an advertiser with that name exists on your platform, reuse it. Otherwise create it. DataFlair does the same on GAM (`getOrCreateAdvertiser`) and on Revive (find or add by a deterministic name), so a repeat booking from the same brand does not create duplicate advertiser records. You do not need to store DataFlair's `external_ref` unless it helps you. ## Idempotency and recovery {#idempotency-and-recovery} DataFlair can call `POST /orders` more than once for the same campaign. It happens on an automatic retry, when a publisher clicks "re-push", and when a later creative approval re-traffics. There are two cases. They use different keys. * **Literal retry: the same `idempotency_key`.** DataFlair repeats the exact same call, for example because the response to the first attempt was lost. Return the same result and create nothing new. If the same key arrives with a **different** body, something upstream is broken. Reject it with `409`. Do not guess which body is the right one. See [Conventions](/marketplace/ad-server-api/conventions#idempotency). * **Update: a new `idempotency_key` and the same `external_ref`.** A later creative approval, or any other change to an order or line you already created, arrives as a new call. It has a fresh `idempotency_key` and the same `order.external_ref` and `line_items[].external_ref` values you were sent at first. Match on `external_ref`. Update only the fields that changed and leave everything else alone. Return the same `order_id` and `line_item_id` values with `200`. * In both cases, create only the lines that are genuinely missing. Do not duplicate an advertiser, an order or a line. DataFlair records `external_ref` as a pending mapping before it calls you. It fills in your `line_item_id` when the response arrives. If the response is lost, it recovers the mapping by looking up the same `external_ref` on the retry. So re-pushing is safe as long as your side keys retry safety on `idempotency_key` and update matching on `external_ref`. The GAM and Revive integrations use the same deterministic naming recovery. ## Currency DataFlair checks that the booking currency matches your account `currency` (from [`GET /health`](/marketplace/ad-server-api/operations/health)) before it pushes. You do not receive a price on the line, because billing stays entirely in DataFlair. The flight, the goal, the targeting and the creative are everything your ad server needs to serve once a person activates the line. ## Errors | Status | What caused it | What DataFlair does | What to do | | --- | --- | --- | --- | | `400` | Malformed request. | Treats it as a bug, logs it and shows it. | Check the body against the fields above. | | `401` | Missing, invalid or expired key. | Marks the connection as `error`. | Check the key. | | `403` | The key lacks a required scope. | Marks the connection as `error`. | Give the key create-draft scope. | | `404` | Unknown `inventory_id`. | Skips the item and reports it. | Return `404` only for a slot that does not exist. | | `409` | The same `idempotency_key` arrived with a different body. | Logs it. DataFlair does not retry it blindly. | Do not apply the new body. Reject the call. | | `422` | Valid shape, invalid values, such as a bad date range or an incompatible size. | Shows your `message`, so the operator can fix the booking. | Return a clear `message`. | | `429` | Rate limit exceeded. | Backs off and retries per `Retry-After`. | Send `Retry-After` in seconds. | | `5xx` | Server error. | Retries with backoff. | Check your server's logs. | Every error body has the shape described in [Conventions](/marketplace/ad-server-api/conventions#error-model). ## Timeouts and retries DataFlair does not call this endpoint yet. The platform's configured timeouts for calls to your server are 10 seconds to connect and 30 seconds in total. ## Verify Run the [conformance check](/marketplace/ad-server-api/conformance) with `--only orders` to check this endpoint against the [fixtures](/marketplace/ad-server-api/fixtures). --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/operations/reports.md description: >- Impressions and clicks per booked line over a date range. DataFlair uses them to reconcile and settle a campaign. --- # POST /reports: Delivery report After a campaign goes live, DataFlair pulls delivery from your ad server. It compares what was booked with what served, and it settles the campaign. DataFlair needs **impressions and clicks for each booked line**, so every number ties back to the slot the advertiser paid for. Google Ad Manager's REST reporting does the same job with a report grouped by `LINE_ITEM_ID`. Revive does it with `bannerDailyStatistics`, which gives impressions and clicks per banner. ## What we send DataFlair names the lines it wants by the `line_item_id` values you returned from `POST /orders`, or by the `order_id`. Support at least the `line_item_ids` form. ::: code-group ```http [HTTP] POST /reports HTTP/1.1 Host: ads.example.com Authorization: Bearer sk_live_9f2c… Content-Type: application/json ``` ```bash [curl] curl -X POST "$BASE_URL/reports" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d @request.json ``` ::: The body, saved as `request.json`: ::: code-group ```json \[exemplary/reports/request.json] { "line_item_ids": [ "li_88012", "li_88013" ], "date_range": { "start_date": "2026-08-01", "end_date": "2026-08-31" }, "granularity": "total" } ``` ::: The JSON on this page is read from the [fixture files](/marketplace/ad-server-api/fixtures), so it cannot drift from the test suite. | Field | Type | Required | Meaning | | --- | --- | --- | --- | | `line_item_ids` | array of strings | one of these two | The lines to report on: the ids you returned from `POST /orders`. | | `order_id` | string | one of these two | Report on every line in an order instead. Some publishers accept this in addition. Do not rely on it without agreeing it with the publisher first. | | `date_range.start_date`, `date_range.end_date` | string | yes | Inclusive, `YYYY-MM-DD`, in your account timezone. | | `granularity` | string | no | `total` (one row per line, the default) or `daily` (one row per line per day). | | `limit` | integer | no | Page size when the result is large enough to page. Default 50, maximum 200. See [Pagination](/marketplace/ad-server-api/conventions#pagination). | | `cursor` | string | no | The opaque cursor from a previous response's `next_cursor`. | ## What you return Status `200`. With `granularity: total`: ::: code-group ```json \[exemplary/reports/expected-response.json] { "rows": [ { "line_item_id": "li_88012", "impressions": 41830, "clicks": 51 }, { "line_item_id": "li_88013", "impressions": 12004, "clicks": 9 } ] } ``` ::: With `granularity: daily`: ::: code-group ```json \[supplemental/reports-daily/expected-response.json] { "rows": [ { "line_item_id": "li_88012", "date": "2026-08-01", "impressions": 1421, "clicks": 2 }, { "line_item_id": "li_88012", "date": "2026-08-02", "impressions": 1550, "clicks": 1 } ] } ``` ::: | Field | Type | Required | Meaning | | --- | --- | --- | --- | | `rows` | array | yes | One row per line for `total`, one per line per day for `daily`. Every row in a response has the same shape. | | `rows[].line_item_id` | string | yes | **The same stable id you returned when you created the line.** It is the reconciliation key. | | `rows[].date` | string | only for `daily` | `YYYY-MM-DD`. | | `rows[].impressions` | integer | yes | Ad-server impressions delivered for this line. | | `rows[].clicks` | integer | yes | Ad-server clicks recorded for this line. | | `next_cursor` | string or null | no | A top-level sibling of `rows`. Include it when the result is paged. Leave it out, or return `null`, on the last page. | ## The reconciliation key DataFlair matches every row to a booked slot **by `line_item_id`**. It does not match by name, position or slot. This is why the id must be stable (see [Concepts](/marketplace/ad-server-api/concepts#the-two-ids-that-make-reconciliation-work)). A line renamed or edited in your console must keep reporting under the same `line_item_id`. Google Ad Manager works the same way. Renaming a line in GAM does not break DataFlair's reconciliation. DataFlair sets aside a row whose `line_item_id` it does not recognize as "unmatched delivery". It does not force the row onto a booking. Return ids exactly as you issued them. ## Return ad-server-only numbers Return the impressions and clicks **your ad server served for this line**. Do not return blended totals that include other demand. GAM shows why. It exposes a blended `IMPRESSIONS` and `CLICKS` pair, which includes AdSense, Ad Exchange and yield-group fill. It also exposes an ad-server-only `AD_SERVER_IMPRESSIONS` and `AD_SERVER_CLICKS` pair. Only the ad-server-only pair matches a reserved line item. The blended pair absorbs unrelated network delivery. DataFlair reconciles a reserved booking, so it needs the ad-server-only figures. * **Impressions and clicks only.** DataFlair works out CTR itself and does not trust a reported CTR. No revenue or cost field is needed. Billing happens entirely in DataFlair. * **Counts, as integers.** Do not return currency values. ## Delivery is publisher-reported The numbers you return become the billing reference for the campaign. DataFlair cross-checks them against its own tracked counts: clicks through the `/go/{code}` link that every campaign serves through, and impressions through the pixel inside the ad frame. DataFlair reviews a large gap before it settles. Report the same figures your own console shows your ad-ops team. Reporting is a pure read, so DataFlair can call it as often as it needs without side effects. ## Push alternative The primary model is pull. DataFlair calls `POST /reports` on a schedule. Its GAM reimport runs every 15 minutes in production, and a similar cadence is fine here. If you prefer to push daily delivery to DataFlair, raise it with DataFlair. A webhook-style ingest is a possible addition. The pull model is the supported default, and it needs nothing from you beyond this endpoint. ## Errors | Status | What caused it | What DataFlair does | What to do | | --- | --- | --- | --- | | `400` | Malformed request. | Treats it as a bug, logs it and shows it. | Check the body against the fields above. | | `401` | Missing, invalid or expired key. | Marks the connection as `error`. | Check the key. | | `403` | The key lacks a required scope. | Marks the connection as `error`. | Widen the key's scope. | | `404` | Unknown `line_item_id` or `order_id`. | Skips the item and reports it. | Return `404` only for an id that does not exist. | | `422` | Valid shape, invalid values, such as a bad date range. | Shows your `message`, so the operator can fix it. | Return a clear `message`. | | `429` | Rate limit exceeded. | Backs off and retries per `Retry-After`. | Send `Retry-After` in seconds. | | `5xx` | Server error. | Retries with backoff. | Check your server's logs. | Every error body has the shape described in [Conventions](/marketplace/ad-server-api/conventions#error-model). ## Timeouts and retries DataFlair does not call this endpoint yet. The platform's configured timeouts for calls to your server are 10 seconds to connect and 30 seconds in total. ## Verify Run the [conformance check](/marketplace/ad-server-api/conformance) with `--only reports` to check this endpoint against the [fixtures](/marketplace/ad-server-api/fixtures). --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/testing.md description: >- How to check your ad server API before you connect it, with the conformance check and the fixtures, and where the Verify button is in DataFlair. --- # Testing your implementation Check your API in three steps. Do the first two before you connect. The third is the real check, and it runs in DataFlair. ## 1. Run it locally, before you deploy Run the [conformance check](/marketplace/ad-server-api/conformance) against your server. It replays the fixtures and prints a pass or a fail for each case, with a link to the section that explains each failure. It runs against localhost and needs no DataFlair account. ```bash node tools/adserver-check/bin/adserver-check.mjs http://localhost:3000/api/v1 --key sk_test_local ``` The checker is not published to npm yet. [Conformance check](/marketplace/ad-server-api/conformance) says where it is and how to run it. Add `--only orders` to check one endpoint. A failing case shows the value it found and links to the rule. This is the first failing case of a server that returns its orders as `ACTIVE`: ```text ✗ POST /orders 201 Creates a draft order Order "ord_55021" returned as "ACTIVE". It must come back as DRAFT and must not serve. Line item "li_88012" returned as "ACTIVE". Every line item must come back as DRAFT and must not serve. → See https://docs.dataflair.ai/marketplace/ad-server-api/operations/orders#draft-only ``` ### Or by hand Send each request to your own server with `curl`. Every endpoint page has a `curl` tab that you can copy: | Endpoint | Page | | --- | --- | | `GET /health` | [Health](/marketplace/ad-server-api/operations/health) | | `GET /inventory` | [Inventory](/marketplace/ad-server-api/operations/inventory) | | `POST /forecast` | [Forecast](/marketplace/ad-server-api/operations/forecast) | | `POST /orders` | [Orders](/marketplace/ad-server-api/operations/orders) | | `POST /reports` | [Reports](/marketplace/ad-server-api/operations/reports/) | Set `BASE_URL` and `API_KEY` first: ```bash export BASE_URL=http://localhost:3000/api/v1 export API_KEY=sk_test_local ``` Check each answer against the fields table on the page. Then check these rules, which are the ones that break most often: * `GET /health` returns `timezone` as a real IANA name, `currency` as three uppercase letters, and `capabilities` as booleans with `reporting` and `draft_booking` set to `true`. * `POST /orders` returns `status: "DRAFT"` for the order and every line. It does not activate anything. See [Draft only](/marketplace/ad-server-api/operations/orders#draft-only). * A repeat of the same `idempotency_key` and body returns the same ids. The same key with a different body returns `409`. * `line_item_id` values do not change when a line is renamed. ## 2. Compare with the fixtures The [fixtures](/marketplace/ad-server-api/fixtures) are the requests DataFlair sends, each with an answer that passes. `exemplary/` holds the happy path. `supplemental/` holds errors and edge cases. [Download all fixtures (.zip)](/downloads/ad-server-api-fixtures.zip) ## 3. Connect and verify in DataFlair The Verify button is in the DataFlair Marketplace app. Only workspace owners and admins can use it. 1. Go to **Settings**, then **Ad server**. 2. Choose the **Custom ad server** card. Its description is "Connect over a bearer API key." 3. Enter your **Base URL**, for example `https://ads.example.com`. It is the HTTPS root DataFlair calls `/health` against. 4. Enter your **API key**. 5. Click **Connect & verify**. DataFlair then runs a short read-only probe. It creates nothing. 1. It calls `GET /health` with your key. This decides whether the connection is **Connected**. 2. If `/health` succeeds, it calls `GET /inventory?limit=1`. This confirms **Inventory read**. The card then shows your account name, timezone and currency, the time of the last verification, and a capability checklist. | Capability | State today | | --- | --- | | Inventory read | **Confirmed** when `GET /inventory` returns a `data` array. Otherwise **Not verified**. | | Forecast | **Not verified**. "Not built yet." | | Reporting | **Not verified**. "Not built yet." | | Draft booking | **Not verified**. "Not built yet." | After you change your API, click **Re-verify** on the card. To start again, click **Disconnect**. If the card shows an error, see [Errors](/marketplace/ad-server-api/errors#what-the-dataflair-connection-card-shows) and [Troubleshooting](/marketplace/ad-server-api/troubleshooting). --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/fixtures.md description: >- The requests DataFlair sends to your ad server, each with an answer that passes. Download them, or replay them with the conformance check. --- # Fixtures Fixtures are the documented examples as real files. Each one is a request that DataFlair sends and an answer that passes. The endpoint pages show these same files, so this folder, the pages and the [conformance check](/marketplace/ad-server-api/conformance) cannot disagree. [Download all fixtures (.zip)](/downloads/ad-server-api-fixtures.zip) ## What is in the folder | Folder | What it holds | | --- | --- | | `exemplary/` | The happy path. One case for each endpoint. | | `supplemental/` | Errors and edge cases. | Each case is a folder with three files: | File | What it holds | | --- | --- | | `request.json` | What DataFlair sends. | | `expected-response.json` | An answer that passes. | | `case.json` | How the check uses the case: the status it expects, the rules it applies, the docs section that explains it, and any value that was made up. | ## The cases | Case | Call | What it checks | Status | | --- | --- | --- | --- | | `exemplary/health` | `GET /health` | Returns the account and its capabilities | 200 | | `exemplary/inventory` | `GET /inventory` | Lists the bookable slots | 200 | | `exemplary/forecast` | `POST /forecast` | Returns available and forecasted impressions | 200 | | `exemplary/orders` | `POST /orders` | Creates a draft order | 201 | | `exemplary/reports` | `POST /reports` | Returns impressions and clicks per line | 200 | | `supplemental/health-bad-key` | `GET /health` | A wrong key is a 401 with an error body | 401 | | `supplemental/forecast-unknown-slot` | `POST /forecast` | An unknown inventory\_id is a 404 with an error body | 404 | | `supplemental/orders-draft-only` | `POST /orders` | A booking with no creative still returns draft line items that do not serve | 201 or 200 | | `supplemental/orders-replay` | `POST /orders` | A repeat of the same idempotency\_key and body returns the same ids | 200 | | `supplemental/orders-conflict` | `POST /orders` | The same idempotency\_key with a different body is a 409 | 409 | | `supplemental/orders-update` | `POST /orders` | A new idempotency\_key with the same external\_ref updates the existing order | 200 | | `supplemental/orders-unknown-slot` | `POST /orders` | An unknown inventory\_id is a 404 with an error body | 404 | | `supplemental/reports-daily` | `POST /reports` | granularity daily returns a date on every row | 200 | ## The draft-only rule as a test The [draft-only rule](/marketplace/ad-server-api/operations/orders#draft-only) is a test here, not only a paragraph. The case `supplemental/orders-draft-only` sends a booking with no creative. The check then asserts that the order and every line item come back as `DRAFT`. An order that comes back as `ACTIVE` fails the case. ## Values that were made up Every value comes from an example already in these docs or in the [OpenAPI file](/marketplace/ad-server-api/api-reference). Where a value had to be invented, the `madeUp` list in that case's `case.json` says so. These are all of them: **`supplemental/health-bad-key`** * the wrong key value is generated by the checker * the expected error code unauthorized and message are examples. The contract does not fix them **`supplemental/orders-conflict`** * line\_items\[0].goal\_impressions is 200000, chosen only so the body differs from the exemplary case * the expected error code idempotency\_conflict and message are examples. The contract does not fix them **`supplemental/orders-draft-only`** * the DRAFT key, order name, external\_refs and the ids in the expected response are derived by suffixing the example values **`supplemental/orders-unknown-slot`** * the idempotency key, order name and external\_refs are derived by suffixing the example values The check tests the shape of an error, not its wording. It does not compare your `code` or `message` with the examples. ## Use them * Replay them with the [conformance check](/marketplace/ad-server-api/conformance). It swaps in a slot from your own `GET /inventory`, so the example slot ids do not have to exist on your server. * Or replay one by hand. Send `request.json` to your server with `curl`, and compare the answer with `expected-response.json`. Your ids and numbers will differ. The fields and the states must match. --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/conformance.md description: >- Replay the DataFlair fixtures against your ad server and see which rule a failing answer breaks. It runs on your machine and needs no DataFlair account. --- # Conformance check The conformance check replays the [fixtures](/marketplace/ad-server-api/fixtures) against your ad server. It prints one line for each case. A failing line says what is wrong and links to the section that explains it. It runs on your own machine, against localhost or a staging server. It does not need a DataFlair account. It checks each answer against the schemas in the [OpenAPI file](/marketplace/ad-server-api/api-reference), the same file the API reference is built from, so the check and the reference cannot disagree. ::: warning The checker is not published to npm yet `npx` cannot fetch it yet. It is in the DataFlair docs repository, in `docs-site/tools/adserver-check`. Run it from the `docs-site` folder, after `npm ci`. The fixtures work without it: send each `request.json` with `curl` and compare the answer with `expected-response.json`. ::: ## Run it ```bash node tools/adserver-check/bin/adserver-check.mjs http://localhost:3000/api/v1 --key sk_test_local ``` Use the base URL you give DataFlair, and a test key. A command line stays in your shell history, so you can set `ADSERVER_CHECK_KEY` instead of `--key`. | Option | Meaning | | --- | --- | | `--key ` | The key to send as a Bearer token. Or set `ADSERVER_CHECK_KEY`. | | `--only ` | Run one endpoint: `health`, `inventory`, `forecast`, `orders` or `reports`. | | `--slot ` | Book against this slot, instead of the first slot that `GET /inventory` lists. | | `--docs ` | Where the failure links point. The default is `https://docs.dataflair.ai`. | | `--fixtures ` | Replay a folder of fixtures instead of the bundled ones. | The exit code is 0 when your server is ready to connect, 1 when a case failed, and 2 when the command was used wrongly. ## A passing run Every case passes against a server that follows the contract: ```text ✓ GET /health 200 Returns the account and its capabilities ✓ GET /health 401 A wrong key is a 401 with an error body ✓ GET /inventory 200 Lists the bookable slots ✓ POST /forecast 200 Returns available and forecasted impressions ✓ POST /forecast 404 An unknown inventory_id is a 404 with an error body ✓ POST /orders 201 Creates a draft order ✓ POST /orders 409 The same idempotency_key with a different body is a 409 ✓ POST /orders 201 A booking with no creative still returns draft line items that do not serve ✓ POST /orders 200 A repeat of the same idempotency_key and body returns the same ids ✓ POST /orders 404 An unknown inventory_id is a 404 with an error body ✓ POST /orders 200 A new idempotency_key with the same external_ref updates the existing order ✓ POST /reports 200 Returns impressions and clicks per line ✓ POST /reports 200 granularity daily returns a date on every row 13 passed · 0 failed. Ready to connect. ``` ## A failing run Here a server returns its orders as `ACTIVE`. The check was run with `--only orders`. Each failing line names the value and links to the [draft-only rule](/marketplace/ad-server-api/operations/orders#draft-only): ```text ✗ POST /orders 201 Creates a draft order Order "ord_55021" returned as "ACTIVE". It must come back as DRAFT and must not serve. Line item "li_88012" returned as "ACTIVE". Every line item must come back as DRAFT and must not serve. → See https://docs.dataflair.ai/marketplace/ad-server-api/operations/orders#draft-only ✓ POST /orders 409 The same idempotency_key with a different body is a 409 ✗ POST /orders 201 A booking with no creative still returns draft line items that do not serve Order "ord_55022" returned as "ACTIVE". It must come back as DRAFT and must not serve. Line item "li_88013" returned as "ACTIVE". Every line item must come back as DRAFT and must not serve. → See https://docs.dataflair.ai/marketplace/ad-server-api/operations/orders#draft-only ✗ POST /orders 200 A repeat of the same idempotency_key and body returns the same ids Order "ord_55021" returned as "ACTIVE". It must come back as DRAFT and must not serve. Line item "li_88012" returned as "ACTIVE". Every line item must come back as DRAFT and must not serve. → See https://docs.dataflair.ai/marketplace/ad-server-api/operations/orders#draft-only ✓ POST /orders 404 An unknown inventory_id is a 404 with an error body ✗ POST /orders 200 A new idempotency_key with the same external_ref updates the existing order Order "ord_55021" returned as "ACTIVE". It must come back as DRAFT and must not serve. Line item "li_88012" returned as "ACTIVE". Every line item must come back as DRAFT and must not serve. → See https://docs.dataflair.ai/marketplace/ad-server-api/operations/orders#draft-only 2 passed · 4 failed. Not ready to connect. ``` ## What it sends and changes The requests are the fixtures. The check changes three things and nothing else: 1. **The campaign token.** The examples use the token `VDWZQ4IT` in every key, order name and `external_ref`. Each run swaps it for a fresh one, so you can run the check again without replaying the last run's keys. 2. **The slot.** The examples book `slot_728x90_home`. The check reads `GET /inventory` and books the first slot that is not archived, and it prefers a slot that takes `728x90`. Pass `--slot` to choose one. The unknown-slot cases keep `slot_x`. 3. **The line item ids in the reports request.** The check sends the ids your server returned from `POST /orders`, not the example ids. The wrong-key case sends a generated key. Nothing else in a request is changed. The booking cases create draft orders on your server, named `Summer Launch (DF-CMP-...)` and similar. Delete them when you are done. With `--only`, the check first reads what the endpoint needs, without printing it. `--only forecast`, `--only orders` and `--only reports` read `GET /inventory` for a slot. `--only forecast` also reads `GET /health`. `--only reports` then books one draft order, because reports asks about a line item. ## What it checks Every case checks that your server answered, that it did not redirect, that the status is the expected one, and that the body fits the schema. DataFlair does not follow redirects, so a redirect fails the case. If your server does not answer the first request at all, the check stops there and marks the rest as not run. If `GET /health` says `forecast` is `false`, the forecast cases are skipped. DataFlair does not call `POST /forecast` then. A skipped case is not a pass. A run where every case was skipped is not ready to connect. These rules apply on top of the schema: | Rule | What it checks | Explained in | | --- | --- | --- | | `health.requiredCapabilities` | `reporting` and `draft_booking` are not `false`. | [Fields](/marketplace/ad-server-api/operations/health#fields) | | `forecast.echoesSlot` | The answer names the slot that was asked about. | [What you return](/marketplace/ad-server-api/operations/forecast#what-you-return) | | `forecast.ordering` | `available_impressions` is not higher than `forecasted_impressions`. | [What you return](/marketplace/ad-server-api/operations/forecast#what-you-return) | | `orders.draftOnly` | The order and every line item come back as `DRAFT`. | [Draft only](/marketplace/ad-server-api/operations/orders#draft-only) | | `orders.echoesExternalRefs` | Every `external_ref` that was sent comes back, and no other. | [What you return](/marketplace/ad-server-api/operations/orders#what-you-return) | | `orders.sameIdsAsCreate` | A repeat or an update returns the same `order_id` and `line_item_id` values as the first call. | [Idempotency and recovery](/marketplace/ad-server-api/operations/orders#idempotency-and-recovery) | | `reports.rowsForRequestedLines` | Every row is for a line that was asked about, with one row per line, or one per line per day. | [What you return](/marketplace/ad-server-api/operations/reports/#what-you-return) | | `reports.dailyHasDate` | With `granularity` set to `daily`, every row has a `date`. | [What you return](/marketplace/ad-server-api/operations/reports/#what-you-return) | ## What it does not check * It reads only the first page of `GET /inventory`. * It waits up to 30 seconds for each answer. It does not measure speed. * It reads the state your server says it returned. It cannot tell whether a draft really stays out of serving. Look at the drafts in your console. * It does not test how DataFlair behaves. It tests your server. If a case fails, follow its link. [Errors](/marketplace/ad-server-api/errors) and [Troubleshooting](/marketplace/ad-server-api/troubleshooting) cover the rest. --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/troubleshooting.md description: >- Common failures when you connect your ad server to DataFlair, listed by what you see, with the cause and the fix. --- # Troubleshooting Find the symptom, read the cause, apply the fix. Every message below is what DataFlair shows on the **Custom ad server** card in **Settings**, then **Ad server**. ## The form will not save | What you see | Cause | Fix | | --- | --- | --- | | The base URL is rejected because it is not `https`. | DataFlair accepts `https://` URLs only, so the key is not sent in clear text. | Use an `https://` base URL. | | "This URL cannot be used." The message says the URL must resolve to a public address. | The host is `localhost`, a private or internal address, a name ending in `.local`, `.internal`, `.lan` or `.localhost`, or a numeric host. | Use a public hostname. Expose your sandbox on a public URL if you need to test. | | "This URL cannot include a # fragment." | DataFlair adds `/health` and `/inventory` after the URL, so anything after `#` would swallow them. | Remove the `#` and everything after it. | | The API key is rejected because it has characters that cannot be sent in an HTTP request. | The key has a line break or another control character. | Paste the key again with no trailing newline. | | The base URL is longer than 255 characters. | The base URL has a limit of 255 characters. | Use a shorter URL. | | "Only workspace owners and admins can connect the ad server." | Your role cannot connect. | Ask an owner or an admin. | | "Another connection change for this organization is in progress. Please try again shortly." | Two changes to the same organization ran at once. | Wait a moment and try again. | ## The connection fails | What you see | State | Cause | Fix | | --- | --- | --- | --- | | "DataFlair could not authenticate. Check your API key, then reconnect." | `auth_error` | `GET /health` answered `401`. | Check the key on your ad server. Reconnect. | | "Your API key doesn't have the required scope. Update its permissions on your ad server, then reconnect." | `forbidden` | `GET /health` answered `403`. | Give the key read, create-draft and report scope. Reconnect. | | "Your ad server could not be reached. Check the base URL, then reconnect." | `unreachable` | No answer within 10 seconds to connect or 30 seconds in total, a DNS failure, or a `4xx` or `5xx` other than `401` and `403`. | Check the base URL and your server. Look at your server's logs for the `GET /health` request. | | "Your ad server responded, but not in the documented /health shape. Check your /health endpoint, then reconnect." | `invalid_response` | The body of `GET /health` is missing a field or has a bad value. | Check every field. See [GET /health](/marketplace/ad-server-api/operations/health#fields). | The four fields DataFlair checks most often are `account_id` (not empty), `timezone` (a real IANA name, not `UTC+2`), `currency` (three uppercase letters, not `EURO`), and the `capabilities` flags (booleans, with `reporting` and `draft_booking` set to `true`). ### My server redirects DataFlair does not follow redirects. A redirect from `http` to `https`, or from `/health` to `/health/`, is not followed. Enter the final URL as the base URL, and serve `/health` without a redirect. ## The connection works, but a capability shows "Not verified" | Capability | Why | What to do | | --- | --- | --- | | **Inventory read** | The card confirms it when `GET /inventory?limit=1` returns `200` with a `data` array. A failed call here does not fail the connection. | Check that `GET /inventory` returns a `data` array. Click **Re-verify**. | | **Forecast**, **Reporting**, **Draft booking** | DataFlair does not call these endpoints yet. The card says "not built yet". | Nothing. This is the expected state today. See the [overview](/marketplace/ad-server-api/overview). | ## The card says "Re-verify needed" The last verification failed. The card shows the reason from the tables above. Fix the cause and click **Reconnect & verify**. If a reconnect fails, DataFlair keeps your last working base URL and key. ## Still stuck Send DataFlair support the exact text on the card and the time of the attempt. Do not send the key. --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/go-live.md description: >- A checklist to finish before you connect your ad server for real bookings. Each item names the problem it prevents. --- # Go live checklist Work through this list before real advertisers book your inventory. Each item leads with the problem it prevents. ## Connection * \[ ] **A rejected connection.** `GET /health` returns a body that DataFlair accepts. The card shows **Connected**, not **Re-verify needed**. See [GET /health](/marketplace/ad-server-api/operations/health). * \[ ] **A leaked key.** The key is least privilege (read, create-draft and report), it is revocable without downtime, and it is stored hashed on your side. See [Authentication](/marketplace/ad-server-api/authentication). * \[ ] **A refused base URL.** The base URL starts with `https://`, has no `#` fragment, and resolves to a public address. * \[ ] **A broken call after a redirect.** Your endpoints answer at the exact base URL, with no redirect. DataFlair does not follow redirects. ## Inventory * \[ ] **Bookings that start a day early or late.** `timezone` in `/health` is the real IANA timezone your platform books flights in. See [Conventions](/marketplace/ad-server-api/conventions#dates-timezone-and-flights). * \[ ] **A mapping that breaks.** Each `inventory_id` is stable. You do not reuse an id for a different slot. See [Inventory identity](/marketplace/ad-server-api/inventory). * \[ ] **A creative that does not fit.** Each slot lists every size it accepts in `sizes`. * \[ ] **A blocked booking.** `currency` in `/health` matches the currency you book in. DataFlair stops on a mismatch. * \[ ] **A dead slot that shows as bookable.** A slot you no longer sell returns `status: "archived"`, so DataFlair can hide it. ## Booking * \[ ] **Inventory that serves before a person approves it.** `POST /orders` creates draft, paused or inactive objects only. It returns `status: "DRAFT"` for the order and every line. See [Draft only](/marketplace/ad-server-api/operations/orders#draft-only). * \[ ] **A duplicate order.** A repeat of the same `idempotency_key` and body returns the same ids. The same key with a different body returns `409`. A new key with the same `external_ref` updates the existing order and line. * \[ ] **A lost reconciliation.** `line_item_id` does not change for a booked line, even when a person renames the line in your console. * \[ ] **A broken click.** You store the creative tag exactly as sent and keep the `/go/{code}` link. See [How creatives work](/marketplace/ad-server-api/operations/orders#how-creatives-work). * \[ ] **A surprise in your console.** Your ad-ops team knows that DataFlair creates drafts, and that a person must review and activate them. ## Reporting * \[ ] **Billing on the wrong numbers.** `POST /reports` returns the impressions and clicks that **your ad server** served for each line. It does not return blended totals. See [POST /reports](/marketplace/ad-server-api/operations/reports/). * \[ ] **A report that cannot be matched.** Each row carries the same `line_item_id` you returned when you created the line. ## Errors and load * \[ ] **A retry storm.** You return `429` with a `Retry-After` header when you need DataFlair to slow down. * \[ ] **A failure nobody can read.** Every error has a JSON body with `code` and `message`, and `message` holds no secrets. See [Errors](/marketplace/ad-server-api/errors). * \[ ] **A slow answer that counts as down.** Each call connects within 10 seconds and finishes within 30. See [Retries and idempotency](/marketplace/ad-server-api/retries-and-idempotency). ## Before you tell DataFlair you are ready * \[ ] **A surprise about what runs today.** You have read what is live today on the [overview](/marketplace/ad-server-api/overview). DataFlair calls `GET /health` and `GET /inventory` when you connect. It does not call forecast, booking or reporting yet. * \[ ] **A gap in your own testing.** You have followed [Testing your implementation](/marketplace/ad-server-api/testing). --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/conventions.md description: >- Rules that apply to every endpoint of the Ad Server API. Transport, dates, currency, idempotency, pagination, rate limits and the error model. --- # Conventions Rules that apply to every endpoint. Get these right and the integration works on the unhappy paths as well as the happy one. ## Transport and versioning * **HTTPS only.** DataFlair rejects plain HTTP before it sends a request. * **JSON in, JSON out.** Send `Content-Type: application/json` on requests with a body. Return `application/json`. * **Version in the path.** Root your API at a versioned base, for example `https://ads.example.com/api/v1`. Ship breaking changes as `/v2`. Do not change the meaning of a field in place. ## Dates, timezone and flights * All dates are `YYYY-MM-DD`. They are calendar dates, not timestamps, unless a field is explicitly a datetime. * Dates are read in the **account timezone** you return from [`GET /health`](/marketplace/ad-server-api/operations/health). DataFlair sends flight `start_date` and `end_date` in that zone. A flight of `2026-08-01` to `2026-08-31` means the whole of August in your timezone, including both ends. * Declare the timezone. GAM and Revive both drive line item flights from the network timezone. A silent mismatch is the classic "the campaign started a day early or late" bug. ## Currency * Use ISO-4217 codes (`EUR`, `USD` and so on), declared once in `GET /health`. * DataFlair checks a booking's currency against it before it pushes an order. It stops on a mismatch and does not guess. Its GAM integration follows the same discipline. * You do not receive prices on the wire. Billing belongs to DataFlair. ## Units * Impressions and clicks are integers. * Impression goals and forecast numbers are impression counts (`unit_type: "IMPRESSIONS"`). ## Idempotency {#idempotency} * Write calls (`POST /orders`) carry an `idempotency_key` in the body and an `Idempotency-Key` header with the same value. * `idempotency_key` protects one call against being replayed. It is not a stable identifier for the life of the order. A repeat with the **same key and the same body** must return the **same result** (`200`) and create nothing new. A repeat with the **same key and a different body** is a `409`. It means something upstream retried incorrectly. Do not apply the new body. * To update an order or line item you already created, for example to attach a creative once it clears approval, DataFlair sends a **new** `POST /orders`. It carries a **fresh** `idempotency_key` and the **same** `order.external_ref` and `line_items[].external_ref` as the original call. Match on `external_ref`, update only what changed, and return `200` with the existing `order_id` and `line_item_id` values. The full recovery contract is on [POST /orders](/marketplace/ad-server-api/operations/orders#idempotency-and-recovery). * Read calls (`/health`, `/inventory`, `/forecast`, `/reports`) are idempotent by nature. DataFlair can repeat them freely. ## Pagination List responses (`GET /inventory`, and any large `POST /reports`) use **cursor** pagination, not offset. * **Request.** `limit` and `cursor` travel as query parameters on a `GET` (`?limit=&cursor=`). On a `POST` they travel as body fields next to the rest of the JSON payload, because that call already has a body. Use a sane default for `limit` (for example 50) and a cap (for example 200). * **Response.** Include `next_cursor`. Leave it out, or return `null`, on the last page. * Keyset paging, not `OFFSET`, keeps DataFlair from missing or double-reading rows when data changes in the middle of a page. ## Rate limiting * If DataFlair goes over a budget, return `429 Too Many Requests` with a `Retry-After` header in seconds. * DataFlair backs off and retries per `Retry-After`. It does not hammer your server. Publish your per-credential budget in your handoff notes so DataFlair can pace itself. ## Error model {#error-model} Every non-2xx response returns a JSON body: ::: code-group ```json \[supplemental/forecast-unknown-slot/expected-response.json] { "code": "inventory_not_found", "message": "No ad slot exists for inventory_id 'slot_x'.", "details": { "inventory_id": "slot_x" } } ``` ::: | Field | Type | Required | Meaning | | --- | --- | --- | --- | | `code` | string | yes | A stable, machine-readable snake\_case slug. | | `message` | string | yes | Human-readable text. DataFlair keeps it as the last error. Do not put secrets in it. | | `details` | object | no | Optional structured context. | ## HTTP status usage {#http-status-usage} | Status | When | DataFlair's reaction | | --- | --- | --- | | `200`, `201` | Success | Proceeds | | `400` | Malformed request | Treats it as a bug, logs it and shows it | | `401` | Missing, invalid or expired credential | Marks the connection as `error`. The operator re-checks the credential. | | `403` | Authenticated, but missing a scope | Marks the connection as `error`. The operator widens the credential's scope. | | `404` | Unknown `inventory_id`, `order_id` or `line_item_id` | Skips that item and reports it. It does not force the item onto a booking. | | `409` | Idempotency conflict (same key, different payload) | Logs it. DataFlair does not retry it blindly. | | `422` | Valid shape, invalid values (a bad date range, an incompatible size) | Shows your `message`, so the operator can fix the booking | | `429` | Rate limited | Backs off per `Retry-After` and retries | | `501` | A capability is not implemented (for example forecast for one slot) | Degrades gracefully. See [POST /forecast](/marketplace/ad-server-api/operations/forecast#if-you-cannot-forecast). | | `5xx` | Server error | Retries with backoff. A persistent `5xx` shows as "your platform is unreachable". | ## Security expectations * Hash credentials at rest. Do not log them in plaintext. DataFlair holds the keys it stores to the same rule. * Do not echo the credential, or any secret, in a response body or an error. * Do not put credentials or personal data in a URL or query string. DataFlair sends credentials only in the `Authorization` header. * The credential you issue must be **revocable**, so either side can rotate it if it leaks, without downtime. --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/end-to-end.md description: >- One booking followed from first connection to final reconciliation, with real request and response bodies and consistent ids. --- # End-to-end example One booking, from first connection to final reconciliation, with real request and response bodies. The ids are consistent in every step, so you can trace them. * Account timezone: `Europe/Berlin`. Currency: `EUR`. * Ad slot: `slot_728x90_home` ("Homepage Leaderboard"). * Advertiser: `Acme Corp`. * DataFlair campaign reference: `CMP-VDWZQ4IT`. ## Step 1: Connect (once) An operator enters your `base_url` and the credential you issued. DataFlair probes with [`GET /health`](/marketplace/ad-server-api/operations/health). ::: code-group ```http [HTTP] GET /health Authorization: Bearer sk_live_9f2c… ``` ```bash [curl] curl "$BASE_URL/health" -H "Authorization: Bearer $API_KEY" ``` ::: Your response: ::: code-group ```json \[exemplary/health/expected-response.json] { "account_id": "acct_1029", "account_name": "Example Media Network", "timezone": "Europe/Berlin", "currency": "EUR", "capabilities": { "forecast": true, "reporting": true, "draft_booking": true } } ``` ::: DataFlair records the account, the timezone and the currency, and marks the connection **connected**. ## Step 2: Discover and map inventory DataFlair calls [`GET /inventory`](/marketplace/ad-server-api/operations/inventory). ::: code-group ```http [HTTP] GET /inventory?limit=100 Authorization: Bearer sk_live_9f2c… ``` ```bash [curl] curl "$BASE_URL/inventory?limit=100" -H "Authorization: Bearer $API_KEY" ``` ::: Your response: ```json { "data": [ { "inventory_id": "slot_728x90_home", "name": "Homepage Leaderboard", "sizes": ["728x90"], "format": "display", "status": "active" } ], "next_cursor": null } ``` The operator maps the DataFlair placement "Homepage Leaderboard" to `slot_728x90_home`. DataFlair stores that mapping once and reuses it for every future booking of this slot. ## Step 3: An advertiser forecasts before booking Acme Corp is considering all of August, targeting Germany and Austria. DataFlair calls [`POST /forecast`](/marketplace/ad-server-api/operations/forecast). ::: code-group ```http [HTTP] POST /forecast Authorization: Bearer sk_live_9f2c… Content-Type: application/json ``` ```bash [curl] curl -X POST "$BASE_URL/forecast" -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" -d @request.json ``` ::: The request body: ::: code-group ```json \[exemplary/forecast/request.json] { "inventory_id": "slot_728x90_home", "flight": { "start_date": "2026-08-01", "end_date": "2026-08-31" }, "targeting": { "geo": [ "DE", "AT" ] }, "sizes": [ "728x90" ] } ``` ::: Your response: ::: code-group ```json \[exemplary/forecast/expected-response.json] { "inventory_id": "slot_728x90_home", "available_impressions": 1420000, "forecasted_impressions": 2100000, "unit_type": "IMPRESSIONS" } ``` ::: DataFlair shows Acme that about 1.42M impressions are bookable. Acme books 100,000, pays into their DataFlair wallet and submits the reservation. ## Step 4: You approve the reservation (inside DataFlair) Your team reviews the reservation in DataFlair and approves it. This is the trigger. Nothing has reached your ad server yet. The approval causes the next call. ## Step 5: DataFlair pushes the booking as a draft DataFlair calls [`POST /orders`](/marketplace/ad-server-api/operations/orders). ::: code-group ```http [HTTP] POST /orders Authorization: Bearer sk_live_9f2c… Content-Type: application/json Idempotency-Key: DF-CMP-VDWZQ4IT ``` ```bash [curl] curl -X POST "$BASE_URL/orders" -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" -H "Idempotency-Key: DF-CMP-VDWZQ4IT" \ -d @request.json ``` ::: The request body: ::: code-group ```json \[exemplary/orders/request.json] { "idempotency_key": "DF-CMP-VDWZQ4IT", "advertiser": { "name": "Acme Corp", "external_ref": "brand_5501" }, "order": { "name": "Summer Launch (DF-CMP-VDWZQ4IT)", "external_ref": "CMP-VDWZQ4IT" }, "line_items": [ { "external_ref": "DF-CMP-VDWZQ4IT-329", "inventory_id": "slot_728x90_home", "flight": { "start_date": "2026-08-01", "end_date": "2026-08-31" }, "goal_impressions": 100000, "targeting": { "geo": [ "DE", "AT" ] }, "sizes": [ "728x90" ], "creatives": [ { "type": "third_party_tag", "width": 728, "height": 90, "tag": "" } ] } ] } ``` ::: Your platform resolves or creates the advertiser "Acme Corp". It creates a **draft** order and one **draft** line item. The line item targets `slot_728x90_home` for August in DE and AT, with a goal of 100,000 impressions. It attaches the DataFlair tag and returns your ids: ::: code-group ```json \[exemplary/orders/expected-response.json] { "order_id": "ord_55021", "status": "DRAFT", "line_items": [ { "external_ref": "DF-CMP-VDWZQ4IT-329", "line_item_id": "li_88012", "status": "DRAFT" } ] } ``` ::: DataFlair stores `DF-CMP-VDWZQ4IT-329 → li_88012`. Nothing is serving yet. ## Step 6: A person takes it live (on your side) Your ad-ops open the draft order `ord_55021` in your own console, review it and activate it. DataFlair takes no part in this step. The line starts serving on 1 August. ## Step 7: DataFlair pulls delivery to reconcile Part way through the flight, and after it, DataFlair calls [`POST /reports`](/marketplace/ad-server-api/operations/reports/). ::: code-group ```http [HTTP] POST /reports Authorization: Bearer sk_live_9f2c… Content-Type: application/json ``` ```bash [curl] curl -X POST "$BASE_URL/reports" -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" -d @request.json ``` ::: The request body: ```json { "line_item_ids": ["li_88012"], "date_range": { "start_date": "2026-08-01", "end_date": "2026-08-31" }, "granularity": "total" } ``` Your response: ```json { "rows": [ { "line_item_id": "li_88012", "impressions": 98240, "clicks": 173 } ] } ``` DataFlair matches `li_88012` to the booked line `DF-CMP-VDWZQ4IT-329`. It records 98,240 delivered impressions against the goal of 100,000. It cross-checks its own tracking counts: clicks from the `/go` link and impressions from the in-frame pixel. Then it settles the campaign and pays the publisher net of commission. Reconciliation is done. ## Idempotency in action Now take a different run of the same booking. Acme's creative was still in review when you approved the reservation. The first `POST /orders` carried no `creatives` array, and the creative followed once it was approved. That is an **update**, not a retry. The second push uses a **new** `idempotency_key` with the **same** order and line `external_ref` values: ```json { "idempotency_key": "DF-CMP-VDWZQ4IT-2", "order": { "external_ref": "CMP-VDWZQ4IT" }, "...": "same line external_refs, now with the creative" } ``` Your platform matches on `external_ref` and does **not** create a second order or line. It updates the existing line `li_88012` with the creative and returns the same ids with `200`. DataFlair's stored mapping does not change. Posting the *original* `idempotency_key` again with a changed body is a different case. That is a `409`, and it is not an update path. See [POST /orders](/marketplace/ad-server-api/operations/orders#idempotency-and-recovery) and [Conventions](/marketplace/ad-server-api/conventions#idempotency). --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/errors.md description: >- Every error code and HTTP status in the Ad Server API, what causes it, what DataFlair does, and how to fix it. --- # Errors Use this page when something breaks. There are two tables. The first is what **your API** returns and how DataFlair reacts. The second is what the **DataFlair connection card** shows when a connection fails. ## What your API returns Every non-2xx response has a JSON body. `code` is a stable, machine-readable slug that you choose. `message` is text for a person. See the [error model](/marketplace/ad-server-api/conventions#error-model). ::: code-group ```json \[supplemental/forecast-unknown-slot/expected-response.json] { "code": "inventory_not_found", "message": "No ad slot exists for inventory_id 'slot_x'.", "details": { "inventory_id": "slot_x" } } ``` ::: | HTTP status | Example `code` | What caused it | What DataFlair does | How to fix it | | --- | --- | --- | --- | --- | | `400` | `bad_request` | Invalid JSON, a wrong field type or a missing field. | Treats it as a bug, logs it and shows it. On `GET /health`, the connection shows `unreachable`. | Check the request against the endpoint's field table. | | `401` | `unauthorized` | Missing, invalid or expired key. | Marks the connection as `error`, with the state `auth_error`. | Check the key. Issue a new one if you must, then reconnect in DataFlair. | | `403` | `forbidden` | The key authenticated but lacks a scope. | Marks the connection as `error`, with the state `forbidden`. | Give the key read, create-draft and report scope. | | `404` | `inventory_not_found` | An unknown `inventory_id`, `order_id` or `line_item_id`. | Skips that item and reports it. It does not force the item onto a booking. | Return `404` only for an id that does not exist on your side. | | `409` | `idempotency_conflict` | The same `idempotency_key` arrived with a different body. | Logs it. Does not retry it blindly. | Reject the call. Do not apply the new body. See [Retries and idempotency](/marketplace/ad-server-api/retries-and-idempotency). | | `422` | `invalid_date_range` | The shape is valid and a value is not: a bad date range, or a size that does not fit the slot. | Shows your `message` so the operator can fix the booking. | Return a clear `message` that names the field and the problem. | | `429` | `rate_limited` | DataFlair went over your budget. | Backs off per `Retry-After` and retries. | Send `Retry-After` in seconds. Publish your budget in your handoff notes. | | `501` | `forecast_unsupported` | One slot cannot be forecast. | Shows "no forecast available" for that slot. It does not block the booking. | See [If you cannot forecast](/marketplace/ad-server-api/operations/forecast#if-you-cannot-forecast). | | `5xx` | `server_error` | Your server failed. | Retries with backoff. A persistent `5xx` shows as "your platform is unreachable". | Check your server's logs. | The `code` values other than `inventory_not_found` and `forecast_unsupported` are examples. The contract does not fix them. Pick stable slugs, and keep them. ## What the DataFlair connection card shows When you connect or re-verify, DataFlair calls `GET /health`. If it fails, the card shows one of four states. The state comes from the HTTP status and the body. | State | HTTP status | What caused it | How to fix it | | --- | --- | --- | --- | | `auth_error` | `401` | The key is wrong, missing or expired. | Check the key on your ad server. Enter it again and click **Reconnect & verify**. | | `forbidden` | `403` | The key has no scope for what DataFlair calls. | Widen the key's scope on your ad server. Then click **Reconnect & verify**. | | `unreachable` | `400`, `404`, `429`, any other `4xx`, any `5xx`, or no answer | Your server was down, too slow, or answered with an error. A wrong base URL also lands here. A host that does not resolve to a public address is refused. | Check the base URL. Check your logs. DataFlair waits 10 seconds to connect and 30 seconds in total. | | `invalid_response` | `200` | The body does not match the `/health` fields. | Fix the body. See [GET /health](/marketplace/ad-server-api/operations/health#fields). | DataFlair also stores the `message` from your error body as the last error, with credentials redacted. Do not put secrets in `message`. DataFlair does not follow redirects. Serve every endpoint at the exact base URL you entered. For symptoms and step-by-step fixes, see [Troubleshooting](/marketplace/ad-server-api/troubleshooting). --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/retries-and-idempotency.md description: >- What DataFlair does when your server is slow or returns an error, the real timeouts, where no retry exists, and how a repeated booking stays safe. --- # Retries and idempotency This page says what DataFlair does when your server is slow, returns an error, or receives the same request twice. It separates what the platform does **today** from what the **contract** describes for calls that are not live yet. ## Timeouts DataFlair waits up to **10 seconds** to connect to your server and up to **30 seconds** in total for one call. These are the platform's configured defaults. They apply to every call to your server. ## Retries today Today DataFlair calls two endpoints: [`GET /health`](/marketplace/ad-server-api/operations/health) and [`GET /inventory`](/marketplace/ad-server-api/operations/inventory), when an operator connects or re-verifies. **Neither call is retried.** If it fails, DataFlair records the failure, and the operator presses **Re-verify** in DataFlair after fixing the cause. Other facts about how DataFlair makes these calls: * DataFlair does not follow redirects. A `3xx` answer is not followed. * DataFlair resolves your host on every call. The host must resolve to public addresses only. * Only `GET /health` decides the connection state. A failed `GET /inventory` leaves Inventory read as **Not verified** and does not fail the connection. ## Calls that are not live yet DataFlair does not call `POST /forecast`, `POST /orders` or `POST /reports` yet. No retry behaviour exists for them in the platform. The contract asks you to expect the reactions below when they go live. Treat the table as the contract and not as current behaviour. | Your answer | Contract: what DataFlair does | | --- | --- | | `429` with `Retry-After` | Backs off for that many seconds, then retries. | | `5xx`, or no answer | Retries with backoff. A persistent failure shows as "your platform is unreachable". | | `409` | Logs it. Does not retry it blindly. | | `404` | Skips that item and reports it. | | `422` | Shows your `message` to the operator. Does not retry. | | `501` on `POST /forecast` | Treats the slot as "no forecast available". | The contract also says DataFlair may send `POST /orders` again for the same campaign. It happens when a publisher clicks "re-push", and when a later creative approval re-traffics. It can also happen on an automatic retry. ## Idempotency for `POST /orders` A booking can arrive more than once. Your API must make a repeat safe. There are two cases, and they use different keys. | Case | What arrives | What you do | | --- | --- | --- | | **A literal retry** | The same `idempotency_key` and the same body. | Return the same result with `200`. Create nothing new. | | **A conflict** | The same `idempotency_key` and a different body. | Reject it with `409`. Do not guess which body is right. | | **An update** | A new `idempotency_key` and the same `order.external_ref` and `line_items[].external_ref`. | Match on `external_ref`. Update only what changed. Return the same `order_id` and `line_item_id` values with `200`. | Read calls (`/health`, `/inventory`, `/forecast`, `/reports`) are idempotent by nature. DataFlair can repeat them freely. The full rules and an example are on [POST /orders](/marketplace/ad-server-api/operations/orders#idempotency-and-recovery) and in [Conventions](/marketplace/ad-server-api/conventions#idempotency). ## What to build now * Answer every call inside 30 seconds, and connect inside 10 seconds. A slow answer counts as a failure. * Return `429` with `Retry-After` when you need DataFlair to slow down. * Make `POST /orders` idempotent as described above, even though DataFlair does not call it yet. --- --- url: https://docs.dataflair.ai/marketplace/ad-server-api/api-reference.md description: >- The full OpenAPI contract for the Ad Server API. Browse and search every endpoint, request and response. --- # API reference The full OpenAPI contract for the Ad Server API. The [overview](/marketplace/ad-server-api/overview) walks through the same endpoints in prose. The server URL below uses a host variable. Set it to the host of your own ad server. DataFlair calls your server. You do not call DataFlair. --- --- url: https://docs.dataflair.ai/marketplace.md description: >- Sell ad inventory to advertisers. Ad tags, Google Ad Manager, Revive Adserver and the Custom Ad Server API. --- # DataFlair Marketplace Sell ad inventory to advertisers. Not sure which path fits? See [Choosing your ad server path](/marketplace/ad-server-paths). Not covered here: publisher registration and account setup. You do those in the Marketplace app. --- --- url: https://docs.dataflair.ai/marketplace/ad-server-paths.md description: >- Four ways to serve Marketplace campaigns on your websites and apps. Ad tags only, Google Ad Manager, Revive Adserver or your own ad server. --- # Choosing your ad server path There are four ways to serve Marketplace campaigns on your websites and apps. Social bookings are not served by an ad server. You post the content yourself. You choose **one path per organization** in **Settings → Ad server**. You can switch later. ## Path 1. No ad server: paste our ad tag You do not need an ad server at all. Every approved campaign gives you one tag. Paste it once where the ad should appear and you are serving. The tag keeps itself up to date, so a creative change does not need a new paste. Delivery is reported monthly from your dashboard, with a screenshot or an analytics export attached as evidence. This suits you if you sell direct and do not run ad-serving infrastructure. **See:** [Ad tags and tracking](/marketplace/ad-tags/) ## Path 2. Google Ad Manager Enter your **network code** and grant access inside GAM. You do not type any credentials. Once connected: * Delivery numbers import automatically, ready for one-click confirmation. * Campaigns arrive in GAM as **draft orders that your ad-ops team activates**. * DataFlair does not serve ads, does not activate anything and does not change your targeting. **See:** [Google Ad Manager](/marketplace/google-ad-manager/) ## Path 3. Revive Adserver Connect your self-hosted or managed Revive instance with its address and a dedicated login you create for DataFlair. Campaigns arrive as inactive campaigns and banners for you to activate in Revive. Delivery imports for one-click confirmation. Revive has no forecasting. Availability is not pre-checked when an advertiser books. **See:** [Revive Adserver](/marketplace/revive/) ## Path 4. Your own ad server If you run in-house ad serving, your engineers implement DataFlair's small integration API. You connect it with an address and a key you issue. From there it works like the other connected paths: availability checks, draft bookings your team activates, and automatic delivery reporting. **See:** [Ad Server API](/marketplace/ad-server-api/overview). The overview says which calls are live today. ## Side by side | Path | You provide | Availability | Monthly delivery | | --- | --- | --- | --- | | **Ad tags only** | Nothing: paste one tag per placement | Your listed numbers | Manual report and evidence | | **Google Ad Manager** | Network code and an access grant | Live forecast | Imported, one-click confirm | | **Revive Adserver** | Address and an API user | Your listed numbers | Imported, one-click confirm | | **Your own ad server** | A small API and a key you issue | Live forecast | Imported | ## Switching later An organization has one ad server. Switching disconnects the current provider first. Then you connect the new one. Your choice here only changes how ads get served and how delivery numbers reach DataFlair. Booking, payments, creative review and settlement are identical on every path. --- --- url: https://docs.dataflair.ai/marketplace/ad-tags.md description: >- The two ad tag formats, web page and ad server, and how impression and click tracking work inside the ad frame. --- # Ad tags and tracking DataFlair serves every ad through one stable surface: the **ad frame**, `GET /ad/{code}`. You embed a tag that loads that frame. The frame shows the currently approved creative, with click and impression tracking built in. If the creative changes during a campaign, the same code keeps working. You do not need to paste the tag again. The same tracking link comes in **two formats**. Both are offered from the Install Kit in your DataFlair Marketplace dashboard. **Both formats serve the same creative.** Only the embed wrapper differs. ## 1. Web page (default) Paste this where the ad should appear on a normal web page, a CMS or a template. ```html ``` Freshness is guaranteed on the server. A raw web page has no macro engine, so this format uses **no cache-buster and needs none**. ## 2. Ad server or GAM Use this format when you traffic the tag as a **third-party creative** inside your own ad server, for example Google Ad Manager. It is the web tag with one addition: a cache-buster macro in the `src`. ```html ``` * **`%%CACHEBUSTER%%`** is replaced by your ad server with a fresh random number on every serve. Each fill becomes a unique URL, so every serve counts as a fresh impression. This keeps your ad server's impression counts in line with DataFlair's. * The ad frame ignores the value of the cache-buster. It exists only to make the URL unique. ### Clicks with iframe creatives The click happens **inside a cross-origin iframe**, so your ad server cannot wrap the click itself. With the ad server format: * **Impressions** reconcile through the cache-buster, as above. * **Clicks** are counted and shown in DataFlair's own reporting, not in your ad server's. This is a general limit of iframe-based ad tags. It applies to every tag of that kind. Fixed-size display, the format DataFlair uses, works correctly without click tracking in your ad server. It only changes where you see click totals. --- --- url: https://docs.dataflair.ai/marketplace/google-ad-manager.md description: >- Step-by-step setup for a Google Ad Manager admin. Turn on API access, add the service account, grant permissions, then connect and verify. --- # Connect your Google Ad Manager to DataFlair This is a step-by-step guide for publishers. It is written for your Google Ad Manager (GAM) admin. Setup takes about 10 minutes and happens almost entirely inside your own GAM network. You do not share a password, an API key or any credential with DataFlair. ::: info Verified in production The setup steps below (API access, service account, permissions, connect and verify) were walked through end to end with a live publisher network on July 7, 2026, against Google Ad Manager as it looks today. ::: ## What DataFlair can and cannot do in your network DataFlair connects to your network with a Google service account that **you** add as a user, with a role that **you** control. The integration is narrow on purpose. | DataFlair does | DataFlair does not do | | --- | --- | | Read your ad units, to map them to Marketplace placements | Change your ad units, placements or network settings | | Run availability forecasts, to show advertisers what is bookable | See or touch anything outside the permissions you grant | | Create **DRAFT** orders and line items when a campaign is booked | **Activate anything.** Drafts wait for your ad-ops team to review and approve | | Run delivery reports, to reconcile campaign delivery | Move money or bill through GAM. All billing stays in DataFlair | The only value you type into DataFlair is your **network code**. Access is granted entirely on your side. You can revoke it at any time by removing the service account user from your network. ## Before you start You need: 1. **Admin access** to your Google Ad Manager network. You will add a user and, if needed, create a role. 2. Your **network code**, the number in your GAM URL. If your browser shows `admanager.google.com/123456789#home`, your network code is `123456789`. ## Step 1: Turn on API access 1. Sign in to [admanager.google.com](https://admanager.google.com). 2. Go to **Admin → Global settings → Network settings**. 3. Find **API access** and switch it to **Enabled**. 4. Click **Save**. If API access is already on, skip ahead. ## Step 2: Add the DataFlair service account as a user 1. Go to **Admin → Global settings → Network settings**. 2. Click **Add a service account user**. 3. Enter this email address exactly: ```text dataflair-gam@dataflair-marketplace-gam.iam.gserviceaccount.com ``` You can also copy this email from the connect screen in DataFlair (Settings → Ad server: Google Ad Manager). 4. Assign the role from Step 3, then click **Save**. ## Step 3: Grant the right permissions The service account needs a role with the permissions below. All of them are **standard-tier** GAM permissions. Nothing here requires Ad Manager 360. You can use an existing role that covers them, or create a dedicated role, for example "DataFlair", so the grant is explicit and auditable. ### Required permissions (all standard tier) | Permission | Why DataFlair needs it | | --- | --- | | View ad units, placements and labels | Map your ad units to Marketplace placements | | View and edit creatives | Attach booked campaign creatives to the draft line items. Grant the full Edit creatives set, see the creative handoff note below. | | View my orders and line items | Recover and reconcile the draft orders DataFlair created | | Edit orders and line items, and submit for approval | Create the DRAFT orders and line items themselves | | Run availability forecasts | Show advertisers real availability before they book | | View and edit companies and contacts | Create the advertiser company record a GAM order requires | | Basic reporting (create and run reports) | Pull delivery numbers to reconcile campaigns | ### Not needed: leave these off DataFlair uses none of the following. Leaving them off keeps the grant minimal. * View and edit audience segments, third-party segment approvals (360: Audience solutions) * View and edit DMP links (360) * View Data Transfer reports (360) * Ad Exchange interface, AdX experiments, review and block AdX creatives (requires an Ad Exchange account) * Bidders and Open Bidding (360) * **Any approval or activation permission beyond "submit for approval".** DataFlair does not activate orders, so it does not need that permission. ## Step 4: Connect and verify in DataFlair 1. In DataFlair Marketplace, go to **Settings → Ad server (Google Ad Manager)**. Only workspace owners and admins can connect the ad server. 2. Enter your **network code** in the field. 3. Click **Connect & verify**. DataFlair immediately runs three read-only checks against your network: 1. **Network check.** It calls your network and confirms the network code matches. 2. **Access check.** It confirms the service account resolves to a real user on your network. This proves Step 2 worked. 3. **Inventory read.** It reads a single ad unit. This proves the role from Step 3 works. On success, the card changes to **Connected** and shows your network name, with a capability checklist: | Capability | What it means | When it confirms | | --- | --- | --- | | Inventory read | Read active ad units from the network | Immediately, during Connect & verify | | Reporting | Pull delivery reports | On the first real delivery report | | Draft trafficking | Create DRAFT orders and line items | On the first real campaign draft | "Not verified" next to Reporting or Draft trafficking right after you connect is normal. DataFlair marks a capability as confirmed only after it has used it against your network. It does not guess. ## What happens after you connect 1. **Placement mapping.** Your Marketplace placements are mapped to your GAM ad units. This drives forecasting and trafficking accuracy. 2. **Campaigns arrive as drafts.** When an advertiser books and you approve a campaign, DataFlair creates one **DRAFT order** in your GAM, named after the campaign, for example `Summer Launch (DF-1042)`. Each booked inventory slice becomes its own DRAFT line item with the sold impression goal. 3. **You stay in control.** Your ad-ops team reviews the draft in GAM and activates it when ready. Nothing serves until you activate it. 4. **Creative handoff.** DataFlair attaches the booked campaign's creative to each draft line item automatically, so the draft arrives complete. This changes nothing about control. Under Google's own delivery rules, a line item serves only after your team approves the order in GAM. A future-dated flight then waits for its booked start date. A flight already under way begins at approval. Handoff needs the role's full Edit creatives permission set. If GAM refuses the automatic attach, the draft still arrives and your ad-ops add the creative by hand. 5. **Delivery reporting.** DataFlair reads delivery reports periodically and reconciles them to campaigns by line item ID. Renaming a line item in GAM does not break reconciliation. ## Troubleshooting Each error appears on the connect card, and the form stays open so you can fix the cause and reconnect in place. | Error | What it means | Fix | | --- | --- | --- | | A different GAM network answered for this code | The code you entered reaches another network | Re-check the number in your `admanager.google.com` URL and reconnect | | DataFlair could not authenticate | GAM rejected the service account. Either API access is off or the service account was not added | Redo Step 1 and Step 2, then reconnect. GAM reports both causes the same way, so check both | | Authenticated, but the inventory read was refused | The user exists but its role is missing read permissions | Re-check the role against the Step 3 table, then reconnect | | Google Ad Manager could not be reached | A temporary network problem between DataFlair and Google | Wait a moment and reconnect | Still stuck? Contact DataFlair support with your network code and the exact error shown. Do not send credentials of any kind. We do not ask for them. ## Security notes * DataFlair owns the service account and its key. They are held outside the web root under strict access controls and do not leave DataFlair's infrastructure. DataFlair stores only your network name and network code. * Access tokens are short-lived. They are held only in a brief server-side cache and refreshed automatically. There is no OAuth consent screen and no refresh token. * You can revoke access at any time. Remove the service account user from your network (Admin → Global settings → Network settings), or downgrade its role. * Every order DataFlair creates is a DRAFT. Activation permission is not requested, not granted and not used. --- --- url: https://docs.dataflair.ai/marketplace/revive.md description: >- Connect a self-hosted or managed Revive Adserver instance to DataFlair Marketplace with its base URL and a dedicated API user. --- # Connect your Revive Adserver to DataFlair This is a short guide for publishers who run [Revive Adserver](https://www.revive-adserver.com/), self-hosted or on a managed hosted plan (for example [revive-adserver.net](https://www.revive-adserver.net/), or a third-party host of the same open-source software). Unlike Google Ad Manager, there is no shared DataFlair account here. Each publisher connects their own Revive instance with their own API credentials. ## What you need * Your Revive instance's **base URL**. It must start with `https://`. * An **API user** on that instance: a username and password with API access. Revive does not support scoped or read-only API keys. Use a dedicated user, not a personal admin login, if you want to be able to revoke access on its own later. If you are on a managed hosted plan, confirm two things with your hosting provider before you connect: * That XML-RPC API access is included on your plan. It is not guaranteed on every tier. * The correct host to use as your `base_url`. Some hosted setups split the admin console and ad delivery onto separate domains, and the API lives on the admin or console domain. ## Connect 1. In DataFlair Marketplace, go to **Settings → Ad server** and choose **Revive Adserver**. 2. Enter your instance's base URL and your API user's username and password. 3. Click **Connect & verify**. DataFlair runs a read-only check. It looks up your agency and publisher records to confirm the credentials work. Nothing is created or changed in your Revive instance at this step. Once connected, an admin on the DataFlair side maps your ad slots (Zones) to Marketplace placements before any campaign can traffic to them. ## What DataFlair does and does not do * **DataFlair creates campaigns and banners as inactive, unlinked objects first.** When an advertiser's reservation is approved, DataFlair creates the campaign and banner records in your Revive instance. It leaves them inactive and not linked to a zone. Nothing serves yet. * **DataFlair does not activate or link a banner.** Go-live happens entirely outside DataFlair, inside your own Revive admin panel, by your own ad-ops person. DataFlair has no code path that can activate a Revive banner. Once a banner is created, it is up to you to link it to a zone and switch it on. * **DataFlair touches only what it created.** It manages only the campaigns and banners it created through this integration. It does not create or change a zone link at all. That is entirely your Revive admin's responsibility. * **DataFlair stores only your API credentials and the data your instance returns.** Your username and password are stored encrypted, and they are not displayed back to you after you save them. ## Troubleshooting | Symptom | Likely cause | Fix | | --- | --- | --- | | "Authentication failed" on connect | Wrong username or password | Re-check your API user's credentials | | "Unreachable" on connect | Wrong base URL, the instance is down, or the URL does not start with `https://` | Confirm the URL is reachable in a browser and starts with `https://` | | "No agency access" on connect | The API user has no agency assigned | Give the API user access to at least one agency in Revive's admin | | A booked cell is not trafficking | The placement is not mapped to a Zone yet, or there is no approved creative for that booking | Ask your DataFlair contact to confirm the zone mapping, and check that a creative has been approved | | A banner was created but is not serving | It has been trafficked but not yet linked to a zone | This is expected. Link and activate the banner inside your own Revive admin panel |