REST API: The HTTP API used by the SDKs, for custom integrations # POST /ads/request > Request an ad for a placement and receive a fill with a signed impression token, or a no-fill. ```http POST /api/v1/ads/request Content-Type: application/json ``` ## Request [Section titled “Request”](#request) | Field | Type | Required | Notes | | ------------------------------- | ---------------------------------------- | -------- | ---------------------------------------------------------------------------------------------- | | `app_key` | string | yes | Must be the app the session was issued for | | `placement` | string, max 64 | yes | Placement code (`[a-z0-9_]{2,64}`) | | `format` | `banner` \| `interstitial` \| `rewarded` | yes | Must match the placement’s format | | `session_token` | string | yes | From `/sdk/initialize` | | `language` | string, max 35 | no | Content language such as `ka` or `en-US` (primary subtag used). Defaults to the app’s language | | `android_version` | string | no | `14` or `14.0.1`; the major version is used for targeting | | `app_version`, `sdk_version` | string | no | Informational | | `screen_width`, `screen_height` | integer 0..20000 | no | Pixels. Used to pick a banner that fits and an interstitial matching the orientation | | `test_mode` | boolean | no | Serve the built-in test ad (also implied by a test session) | | `test_force_no_fill` | boolean | no | Always answer `no_fill` with reason `test_no_fill` | ```json {"app_key":"app_xxxxxxxxxxxxxxxxxxxxxxxx","placement":"game_end","format":"interstitial", "session_token":"","language":"ka","android_version":"14","app_version":"1.0.0", "sdk_version":"0.3.0","screen_width":1080,"screen_height":2400} ``` ## Response 200: fill [Section titled “Response 200: fill”](#response-200-fill) ```json { "status": "fill", "request_id": "req_01k9xyz...", "ad": { "id": "ad_3f2a9c01d4e5b6a7", "campaign_id": "cmp_12", "creative_id": "cr_34", "format": "interstitial", "creative_type": "image", "creative_url": "https://cdn.example.com/creatives/12/01k....png", "click_url": "https://advertiser.example", "width": 1080, "height": 1920, "duration_seconds": null, "impression_token": "", "expires_at": "2026-10-07T18:40:00Z", "test": false } } ``` | Field | Meaning | | ----------------------- | -------------------------------------------------------------------------------- | | `request_id` | Send with every event for this ad | | `ad.creative_type` | `image` (PNG, JPEG, WebP, GIF) or `video` (MP4, H.264) | | `ad.creative_url` | Download before reporting the ad as loaded | | `ad.click_url` | Advertiser landing page, opened on tap after the click event is queued | | `ad.width`, `ad.height` | Creative size in pixels | | `ad.duration_seconds` | Video length, `null` for images | | `ad.impression_token` | Opaque, signed, single-use | | `ad.expires_at` | 30 minutes after the request. Never show the ad after this | | `ad.test` | `true` for test ads (`campaign_id` `cmp_test`, `creative_id` `cr_test_{format}`) | ## Response 200: no fill [Section titled “Response 200: no fill”](#response-200-no-fill) ```json {"status":"no_fill","request_id":"req_01k9xyz...","fallback":"admob","reason":"no_eligible_campaign"} ``` `fallback` is the placement’s provider: `admob` or `none`. | Reason | Meaning | | ---------------------- | -------------------------------------------------------------------------------------- | | `no_eligible_campaign` | No campaign matched, or none had budget left | | `serving_disabled` | Global, publisher or app kill switch (or publisher/app not approved outside test mode) | | `placement_disabled` | Placement paused, switched off, or disabled by an admin | | `frequency_capped` | The placement’s frequency cap for this session is reached | | `test_no_fill` | `test_force_no_fill` was set | ## Selection [Section titled “Selection”](#selection) 1. Session verified, app key matches the session, app approved (unless test). 2. Placement found and format matches; kill switches; placement frequency cap. 3. Test mode returns the test ad. Otherwise: 4. Eligible campaigns: active, within schedule, budget left, advertiser approved with balance, country matches (or any), at least one approved creative of the requested format. 5. Targeting: formats, languages, app ids, app categories, Android versions. Empty means any. A campaign that targets Android versions is skipped when the request has none. 6. Campaign frequency cap for this session. 7. Highest CPM bid first (ties random), with daily budget pacing. 8. The cost of one impression is reserved atomically; if a campaign cannot reserve, the next one is tried. Country comes from the network address at Qartvelo Ads’ edge, never from the device. The IP address is not stored. ## Errors [Section titled “Errors”](#errors) | HTTP | Code | When | | ---- | --------------------- | ------------------------------------------------------ | | 401 | `invalid_session` | Token malformed, forged or issued for another app | | 401 | `session_expired` | Initialize again | | 403 | `app_not_approved` | App not approved and not a test session | | 404 | `placement_not_found` | No placement with that code in this app | | 422 | `format_mismatch` | `format` differs from the placement | | 422 | `validation_failed` | Missing or invalid fields | | 429 | `rate_limited` | See [rate limits](/api/errors-and-limits/#rate-limits) | # Errors and limits > Error envelope and codes, rate limits and fraud rules of the Qartvelo Ads API. ## Error envelope [Section titled “Error envelope”](#error-envelope) Every non-2xx response except event rejections uses: ```json {"error":{"code":"validation_failed","message":"The placement field is required.","fields":{"placement":["The placement field is required."]}}} ``` `fields` is present only for `validation_failed`. Branch on `code`; `message` is human-readable and may change. | Code | HTTP | When | What to do | | --------------------- | ---- | -------------------------------------------------- | ------------------------------------------- | | `invalid_app_key` | 401 | Unknown app key | Check the key on the app page | | `invalid_session` | 401 | Session token malformed, forged or for another app | Initialize again | | `session_expired` | 401 | Session token expired | Initialize again | | `package_mismatch` | 403 | Package name differs from the registered app | Fix the `applicationId` or the registration | | `app_not_approved` | 403 | App not approved and not in test mode | Use test mode until approved | | `placement_not_found` | 404 | No placement with that code in this app | Create it, or fix the code | | `format_mismatch` | 422 | Requested format differs from the placement | Use the matching load method | | `validation_failed` | 422 | Missing or invalid fields | See `error.fields` | | `rate_limited` | 429 | A rate limit was exceeded | Wait for `Retry-After` seconds | | `server_error` | 500 | Unexpected error | Retry with backoff; fall back | Event endpoints answer rejections with `{"status":"rejected","reason":"..."}` instead; see [Events](/api/events/). ## Rate limits [Section titled “Rate limits”](#rate-limits) Per minute. “Network” means the client’s IPv4 /24 or IPv6 /48 (hashed). Network limits are generous because mobile carriers put many users behind one carrier-grade NAT. | Endpoint | Dimension | Limit | | --------------------------------------- | ----------------- | ----------- | | `/sdk/initialize` | network | 600 | | `/sdk/initialize` | app key | 3000 | | `/ads/request` | session | 120 | | `/ads/request` | network | 6000 | | `/ads/request` | app key | 20000 | | `/events/impression`, `click`, `reward` | network | 12000 | | `/events/impression`, `click`, `reward` | request id | 20 | | `/events/fallback` | session / network | 120 / 12000 | A `429` carries a `Retry-After` header. Contact Qartvelo Ads if a large app needs higher app-key limits. ## Fraud rules [Section titled “Fraud rules”](#fraud-rules) Traffic that breaks these rules is not billed and does not earn revenue. Rejections are logged for review. | Rule | Default | Effect | | ------------------------------------------------ | -------------------------------------------------------------------------------- | -------------------------------- | | Ad requests per session per minute | 30 | Further requests get `no_fill` | | Impressions per placement per session per hour | Banners: `3600 / refresh seconds x 1.5` (180 for a 30 s banner). Full-screen: 60 | Impression rejected `suspicious` | | Impressions per session per hour, all placements | 720 | Impression rejected `suspicious` | | Time between impression and click | At least 1 s | Click rejected `suspicious` | | Clicks per session per hour | 20 | Click rejected `suspicious` | | Session click-through rate | Above 50% once the session has 10+ impressions | Click rejected `suspicious` | | Replays and duplicates | Always | `409 duplicate` | | Package mismatch | Always | `403 package_mismatch` | # Events > Report impressions, clicks, rewards and fallbacks. Every event is validated, de-duplicated and fraud-checked. ## POST /events/impression, /events/click [Section titled “POST /events/impression, /events/click”](#post-eventsimpression-eventsclick) ```json {"request_id":"req_01k9xyz...","impression_token":""} ``` | Field | Type | Required | | ------------------ | ---------------- | -------- | | `request_id` | string, max 64 | yes | | `impression_token` | string, max 2048 | yes | ## POST /events/reward [Section titled “POST /events/reward”](#post-eventsreward) Adds a required `completion` flag: ```json {"request_id":"req_01k9xyz...","impression_token":"","completion":true} ``` ## Responses [Section titled “Responses”](#responses) | Response | Meaning | | ------------------------------------------------------- | ----------------------------------------------------------------------------------- | | 200 `{"status":"accepted"}` | Impression or click recorded | | 200 `{"status":"accepted","rewarded":true}` | Reward recorded; `rewarded` mirrors `completion` | | 409 `{"status":"rejected","reason":"duplicate"}` | This token was already used for this event type | | 422 `{"status":"rejected","reason":"invalid_token"}` | Bad signature or payload, wrong token type, or a reward on a non-rewarded placement | | 422 `{"status":"rejected","reason":"expired_token"}` | Impression after the ad expired, or click/reward more than one hour after that | | 422 `{"status":"rejected","reason":"request_mismatch"}` | The token belongs to another `request_id` | | 422 `{"status":"rejected","reason":"no_impression"}` | Click or reward without an accepted impression for this token | | 422 `{"status":"rejected","reason":"suspicious"}` | A fraud rule rejected the event | | 422 `{"error":{"code":"validation_failed",...}}` | Missing fields | | 429 `{"error":{"code":"rate_limited",...}}` | Rate limited | Treat every `rejected` response as **final**: retrying cannot succeed. Rejected events are never billed. ## Rules [Section titled “Rules”](#rules) * **Impression**: accepted only before the ad’s `expires_at`, exactly once per token. This is the billing event: the campaign is charged one impression (CPM bid / 1000) and the publisher is credited their revenue share. Test tokens are validated the same way but never billed. * **Click**: requires the accepted impression for the same request and token, at most one per impression, up to one hour after the ad’s expiry. Clicks are counted, not billed. * **Reward**: requires the accepted impression on a **rewarded** placement; only one reward event per impression is ever stored and the first report wins (`completion:false` cannot be upgraded later). Grant the in-app reward on confirmed completion; the server response is for reporting and auditing. ## POST /events/fallback [Section titled “POST /events/fallback”](#post-eventsfallback) Fire-and-forget telemetry that the client fell back to another network. It feeds the **Fallbacks** column in publisher reports. Test sessions are ignored. ```json {"session_token":"","placement":"game_end","reason":"timeout"} ``` | Field | Values | | --------------- | ------------------------------------------------ | | `session_token` | From `/sdk/initialize` | | `placement` | Placement code | | `reason` | `no_fill`, `timeout`, `error`, `creative_failed` | Response 200 `{"status":"accepted"}`. Errors: `invalid_session` / `session_expired` 401, `placement_not_found` 404, `validation_failed` 422. The SDK maps its internal `disabled` reason to `no_fill`. # POST /sdk/initialize > Validate the app key and package name, and receive a session token and remote configuration. ```http POST /api/v1/sdk/initialize Content-Type: application/json ``` ## Request [Section titled “Request”](#request) | Field | Type | Required | Notes | | -------------- | --------------- | -------- | ----------------------------------------------------------------- | | `app_key` | string, max 64 | yes | `app_` + 24 alphanumerics, from the publisher dashboard | | `package_name` | string, max 255 | yes | Android application id, compared exactly with the registered one | | `sdk_version` | string, max 32 | no | | | `app_version` | string, max 64 | no | | | `platform` | string | no | only `android` is accepted | | `os_version` | string, max 32 | no | | | `test_mode` | boolean | no | Skips the app approval check; the session only ever gets test ads | ```json {"app_key":"app_xxxxxxxxxxxxxxxxxxxxxxxx","package_name":"com.example.app","sdk_version":"0.3.0", "app_version":"1.0.0","platform":"android","os_version":"14","test_mode":false} ``` ## Response 200 [Section titled “Response 200”](#response-200) ```json { "session_token": "", "session_expires_at": "2026-10-07T19:40:00Z", "config": { "serving_enabled": true, "request_timeout_ms": 800, "fallback_enabled": true, "test_mode": false, "config_ttl_seconds": 3600 }, "placements": [ { "code": "game_end", "format": "interstitial", "ourads_enabled": true, "fallback_provider": "admob", "admob_ad_unit_id": "ca-app-pub-3940256099942544/1033173712", "request_timeout_ms": 800, "frequency_cap_count": null, "frequency_cap_period": null, "banner_refresh_seconds": 60 } ] } ``` | Field | Meaning | | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `session_token` | Opaque, signed. Send it with ad requests and fallback telemetry. Valid until `session_expires_at` (one hour) | | `config.serving_enabled` | `false` when a kill switch applies to the whole app (global, publisher, or app). Go straight to the fallback | | `config.request_timeout_ms` | Default request budget for placements | | `config.fallback_enabled` | Whether fallback is allowed at all; decide per placement with `fallback_provider` | | `config.test_mode` | Echo of the request. Clients must take test mode from their local option and never cache it | | `config.config_ttl_seconds` | How long the client may cache this configuration | | `placements[].code` / `format` | Placement code and its format | | `placements[].ourads_enabled` | `false` when the placement is paused, switched off by the publisher or an admin, or the app is blocked. The name is historical; it means “Qartvelo Ads enabled”. Re-check at most every 5 minutes while off | | `placements[].fallback_provider` | `admob` or `none` | | `placements[].admob_ad_unit_id` | The publisher’s AdMob unit, or `null` | | `placements[].request_timeout_ms` | Per-placement request budget (100..5000) | | `placements[].frequency_cap_count` / `frequency_cap_period` | Placement cap per session id: `session`, `hour`, `day`, or `null` | | `placements[].banner_refresh_seconds` | Banner refresh interval, never below 30 | ## Errors [Section titled “Errors”](#errors) | HTTP | Code | When | | ---- | ------------------- | --------------------------------------------------------------------- | | 401 | `invalid_app_key` | Unknown app key | | 403 | `package_mismatch` | `package_name` differs from the registered app (logged as suspicious) | | 403 | `app_not_approved` | App not approved and `test_mode` is false | | 422 | `validation_failed` | Missing or invalid fields (`error.fields`) | | 429 | `rate_limited` | Too many initializations (see `Retry-After`) | # REST API overview > The HTTP API the SDKs use, for custom clients, server-side tests and debugging. The official SDKs implement this API for you. Use it directly to build a client for another platform, to write integration tests, or to debug what the SDK sends. A machine-readable description is available as [OpenAPI 3.1](/openapi.yaml). | | | | -------------- | ------------------------------------------------------------------ | | Base URL | `https://ads.qartvelo.com/api/v1` | | Format | JSON request and response bodies, `Content-Type: application/json` | | Methods | All endpoints are `POST` | | Authentication | The public app key plus a session token; no cookies, no API keys | | Caching | Responses carry `Cache-Control: no-store, private` | ## Endpoints [Section titled “Endpoints”](#endpoints) | Endpoint | Purpose | | ----------------------------------------------------------- | --------------------------------------------------------------------------------- | | [`POST /sdk/initialize`](/api/initialize/) | Validate app key + package name, get a session token and the remote configuration | | [`POST /ads/request`](/api/ad-request/) | Get an ad for a placement, or an explicit no-fill with the fallback to use | | [`POST /events/impression`](/api/events/) | Record (and bill) one impression | | [`POST /events/click`](/api/events/) | Record one click for an accepted impression | | [`POST /events/reward`](/api/events/) | Record the rewarded outcome, exactly once | | [`POST /events/fallback`](/api/events/#post-eventsfallback) | Telemetry: the client fell back to another network | ## Flow [Section titled “Flow”](#flow) ```plaintext initialize ──> session_token (1 h) + config │ ads/request (placement, format, session_token) ──> fill: request_id + ad.impression_token │ └─> no_fill: fallback = admob | none impression (request_id, impression_token) once, while the ad is valid (30 min) click (request_id, impression_token) at most once, after the impression reward (request_id, impression_token, completion) rewarded placements only ``` ## Conventions [Section titled “Conventions”](#conventions) * Timestamps are ISO-8601 UTC with `Z`, for example `2026-10-07T18:40:00Z`. * Public ids are prefixed strings: campaign `cmp_12`, creative `cr_34`, ad `ad_3f2a9c01d4e5b6a7`, request `req_01k9...`. * Session and impression tokens are **opaque**. Do not parse, modify or log them; pass them back verbatim. * Money never reaches the client. * Compress responses at the edge; keep HTTP keep-alive enabled. The SDKs use a single connection pool. * Errors use one envelope, except event rejections (see [Errors and limits](/api/errors-and-limits/)): ```json {"error":{"code":"placement_not_found","message":"Placement [game_end] does not exist in this app."}} ``` ## Try it with curl [Section titled “Try it with curl”](#try-it-with-curl) Every call below runs in test mode, so nothing is billed. Replace the app key and package with yours. ```bash B=https://ads.qartvelo.com/api/v1 KEY=app_xxxxxxxxxxxxxxxxxxxxxxxx PKG=com.example.app # 1. Initialize in test mode S=$(curl -s -X POST $B/sdk/initialize -H 'Content-Type: application/json' \ -d "{\"app_key\":\"$KEY\",\"package_name\":\"$PKG\",\"platform\":\"android\",\"test_mode\":true}" \ | jq -r .session_token) # 2. Request an interstitial AD=$(curl -s -X POST $B/ads/request -H 'Content-Type: application/json' \ -d "{\"app_key\":\"$KEY\",\"placement\":\"game_end\",\"format\":\"interstitial\",\"session_token\":\"$S\",\"test_mode\":true,\"screen_width\":1080,\"screen_height\":2400}") echo "$AD" | jq . RID=$(echo "$AD" | jq -r .request_id); TOK=$(echo "$AD" | jq -r .ad.impression_token) # 3. Impression, then a click at least one second later curl -s -X POST $B/events/impression -H 'Content-Type: application/json' \ -d "{\"request_id\":\"$RID\",\"impression_token\":\"$TOK\"}" sleep 1 curl -s -X POST $B/events/click -H 'Content-Type: application/json' \ -d "{\"request_id\":\"$RID\",\"impression_token\":\"$TOK\"}" # 4. A replay is rejected: {"status":"rejected","reason":"duplicate"} (HTTP 409) curl -s -X POST $B/events/impression -H 'Content-Type: application/json' \ -d "{\"request_id\":\"$RID\",\"impression_token\":\"$TOK\"}" # 5. Force a no-fill to exercise the fallback path curl -s -X POST $B/ads/request -H 'Content-Type: application/json' \ -d "{\"app_key\":\"$KEY\",\"placement\":\"game_end\",\"format\":\"interstitial\",\"session_token\":\"$S\",\"test_mode\":true,\"test_force_no_fill\":true}" # {"status":"no_fill","request_id":"req_...","fallback":"admob","reason":"test_no_fill"} ``` ## Client requirements [Section titled “Client requirements”](#client-requirements) A custom client must behave like the SDKs to keep traffic valid: * Send the impression only when the creative is actually on screen, once, and before the ad’s `expires_at`. * Send a click only after a user tap, at most once per impression, and not within one second of the impression. * Send a reward event only for rewarded placements, once, with the real completion state; grant the reward only on completion. * Treat every `rejected` event response as final; retry only network errors, `5xx`, `408` and `429` (honour `Retry-After`). * Never show an ad after `expires_at`, and do not reuse a `request_id` or token. * Initialize again when a call returns `session_expired` or `invalid_session`.