| Client | Status | Mode | Tracker | Accounts | Token |
|---|
ExtractorURL → campaign ID
Sheet configFor sheet-backed clients
| Name | Client | Tracker | Next run | Status |
|---|
HH:MM times in the schedule timezone. Example: 09:00, 13:00, 17:30| Name | Client ID | Secret ref | Updated |
|---|
| Name | API key | Access key | Updated |
|---|
| Name | Mode | Account | Agency | Auth | Updated |
|---|
| Name | API token | Scope | Account id | Updated |
|---|
| Name | partner_name | access_token | Updated |
|---|
partner_name
is a numeric id (issued by SmartNews as int). access_token
is a UUID v4, static — rotate by issuing a new value from SmartNews.
| Name | API token | media_source | channel | Updated |
|---|
media_source is the network identifier on advertisers' AppsFlyer attribution links —
the value of the pid query parameter, fixed by AppsFlyer when our partner account
was set up. For P2W this is point2web_int (the value also used by clients on their
attribution-link templates). Cost rows are matched to clicks via this identifier — wrong
media_source → 0% match rate.
channel field on every cost row. Surfaces in client AppsFlyer
dashboards as a sub-dimension under media_source — letting clients see
the upstream source (e.g. mintegral) even though all cost lands under
point2web_int. Cap: 20 characters. Leave blank to omit.
On this page63 sections
SmartNews and Mintegral Clients-API — Technical & User Documentation
Point2Web Technical Department
2026-04-28
SmartNews and Mintegral Clients-API — Technical & User Documentation
Overview
SmartNews Cost Import API is an internal service for collecting advertising costs from SmartNews, validating client-specific campaign ID extraction rules, aggregating costs by external tracker campaign ID, and delivering the result either through an API response or through an auto-import worker into supported trackers.
The service is designed for Cloud Run and Firestore-backed configuration. It supports multiple clients, separate SmartNews authentication profiles, API-key based client access, Google SSO for the admin interface, manager/admin roles, scheduled auto-imports, and audit logging.
Main supported workflows:
Manual SmartNews cost import through API.
All-time SmartNews cost import when dateFrom and dateTo are omitted.
RedTrack hourly/window-based cost push.
ClickFlare manual cost upload through CSV multipart request.
Client and auto-import configuration through the web interface.
Firestore-based audit trail for user and worker actions.
The current production context used during setup:
| Parameter | Value |
|---|---|
| GCP project | leads-cf |
| Cloud Run region | europe-west1 |
| Main service | smartnews-cost-import-api |
| Firestore database | webhooks |
| Configuration backend | firestore |
| Default app timezone | Europe/Kyiv or service env value |
| Manager/admin UI | /admin |
CHAPTER 1. High-level architecture
Step 1. Main data flow
The core import flow is:
A client, manager, admin, or worker triggers an import.
The service authenticates the caller.
The service loads the client document from Firestore.
The service resolves the SmartNews auth profile from Firestore and Secret Manager.
SmartNews insights are requested for the configured ad account IDs and date range.
Each SmartNews row is normalized into an internal cost record.
The configured extractor reads the external tracker campaign ID from landing_page_url.
Costs are aggregated by external tracker campaign ID.
The service returns JSON rows or pushes data into the configured tracker.
The normalized API response always uses the external tracker campaign ID, not the internal SmartNews campaign ID:
[
{
"campaign_id": "68e7c3f319b0b70012c4f1ac",
"cost": 123.45
}
]
Step 2. Runtime components
| Component | Responsibility |
|---|---|
| app_factory.py | Creates Flask app, exposes public import API and health endpoints. |
| admin_routes.py | Exposes admin UI and admin API endpoints. |
| services.py | Authenticates clients and coordinates cost import. |
| sources.py | Reads data from Google Sheets or SmartNews API and normalizes records. |
| smartnews.py | SmartNews OAuth, pagination, insights and campaign API client. |
| extractors.py | Extracts external campaign IDs from landing page URLs. |
| auto_import_worker.py | Executes scheduled tracker pushes. |
| firestore.py | Reads/writes Firestore clients, profiles, auto-imports, settings and audit logs. |
| secret_manager.py | Resolves Secret Manager references. |
| models.py | Dataclasses for configs, requests and normalized records. |
| utils.py | Date parsing, hashing, secure compare and helper functions. |
Step 3. Supported source modes
| Source mode | Description | Typical use |
|---|---|---|
| smartnews-api-backed | Reads costs directly from SmartNews API insights. | Current preferred mode for new clients. |
| sheet-backed | Reads an existing Google Sheet export and optionally writes result sheets. | Legacy or client-specific flows. |
For smartnews-api-backed, allowed_ad_account_ids is mandatory. If the API request does not specify ad_account_id, the service uses all IDs from allowed_ad_account_ids.
For sheet-backed, ad account discovery and sync are available from the admin UI because source rows are available in the configured Google Sheet.
CHAPTER 2. API endpoints
Step 1. Health check
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| GET | /healthz | None | Checks whether the service is running and returns basic runtime info. |
Example:
curl "$SERVICE_URL/healthz"
Successful response:
{
"ok": true,
"service": "smartnews-cost-import-api",
"config_backend": "firestore",
"firestore_clients_collection": "smartnews_cost_import_clients",
"firestore_database": "webhooks"
}
Step 2. Legacy sync endpoints
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| GET or POST | / | Authorization: Bearer <HTTP_BEARER_TOKEN> | Runs legacy sync. |
| GET or POST | /legacy/sync-smartnews-costs | Authorization: Bearer <HTTP_BEARER_TOKEN> | Runs legacy sync using explicit path. |
These endpoints are kept for backward compatibility. New integrations should use /costs/{client_slug}/smartnews/data/import.
Step 3. SmartNews cost import endpoint
| Method | Endpoint | Auth |
|---|---|---|
| POST | /costs/<client_slug>/smartnews/data/import | X-Api-Key: <client_api_key> |
This endpoint imports SmartNews costs for one client and returns aggregated rows grouped by external tracker campaign ID.
Supported query parameters:
| Parameter | Required | Description |
|---|---|---|
| dateFrom | No | Start datetime. Must be sent together with dateTo. |
| dateTo | No | End datetime. Must be sent together with dateFrom. |
| allTimeSince | No | Start datetime for all-time mode. Used only when dateFrom and dateTo are both omitted. Default: 2020-01-01 00:00:00. |
| all_time_since | No | Alternative snake_case name for allTimeSince. |
| timezone | No | Request timezone. Example: America/New_York. |
| timeZone | No | Alternative camelCase name for timezone. |
| ad_account_id | No | Optional whitelist-filtered ad account ID. Can be repeated. If omitted, all configured allowed accounts are used. |
| debug | No | Boolean. If true, returns rows plus debug metadata, warnings and raw records. |
Rules for dates:
If both dateFrom and dateTo are provided, the endpoint uses the explicit range.
If both are omitted, the endpoint enters all-time mode.
If only one of dateFrom or dateTo is provided, the endpoint returns 400.
If dateFrom > dateTo, the endpoint returns 400.
Explicit date range example:
curl -sS -X POST \ "$SERVICE_URL/costs/leadxpression/smartnews/data/import?dateFrom=2026-04-28%2000:00:00&dateTo=2026-04-28%2023:59:59&timezone=America/New_York" \ -H "X-Api-Key: $LEADXPRESSION_API_KEY" | jq .
All-time mode example:
curl -sS -X POST \ "$SERVICE_URL/costs/leadxpression/smartnews/data/import?timezone=America/New_York" \ -H "X-Api-Key: $LEADXPRESSION_API_KEY" | jq .
All-time mode with custom start date:
curl -sS -X POST \ "$SERVICE_URL/costs/leadxpression/smartnews/data/import?timezone=America/New_York&allTimeSince=2026-04-01%2000:00:00" \ -H "X-Api-Key: $LEADXPRESSION_API_KEY" | jq .
Expected normal response:
[
{
"campaign_id": "68e7c3f319b0b70012c4f1ac",
"cost": 123.45
}
]
Expected debug response shape:
{
"rows": [
{
"campaign_id": "68e7c3f319b0b70012c4f1ac",
"cost": 123.45
}
],
"debug": {
"client_slug": "leadxpression",
"source_mode": "smartnews-api-backed",
"effective_ad_account_ids": ["110565805", "110707857"],
"skipped_records": 0,
"warnings": [],
"raw_records": [],
"smartnews_campaign_rows": []
}
}
CHAPTER 3. Admin API endpoints
Step 1. Session and runtime endpoints
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /admin | Browser | Returns the admin UI. |
| GET | /admin/api/login-bootstrap | None | Returns Google OAuth client ID and allowed domain. |
| POST | /admin/api/session/login | Google ID token + access token | Creates signed admin session. |
| GET | /oauth/redirect/start | None | Starts PointBoard SSO (no access token needed). |
| GET/POST | /auth/google-oauth | Google ID token | Google SSO: GET starts the flow, Google posts the ID token back (no access token needed). |
| GET | /admin/api/session/me | Admin session bearer | Returns current session. |
| GET | /admin/api/runtime-info | Admin session bearer | Returns runtime Firestore and service info. |
Admin session auth header:
Authorization: Bearer <admin_session_token>
The admin session token is the browser session token stored after successful Google sign-in and access token validation. It is not the same as ADMIN_BEARER_TOKEN.
Step 2. Client endpoints
| Method | Endpoint | Role | Description |
|---|---|---|---|
| GET | /admin/api/clients | manager/admin | Lists clients. |
| POST | /admin/api/clients | manager/admin | Creates client. Generates client API token if not provided. |
| GET | /admin/api/clients/<client_slug> | manager/admin | Reads one client. |
| PUT | /admin/api/clients/<client_slug> | manager/admin | Updates one client. |
| POST | /admin/api/clients/<client_slug>/regenerate-token | admin | Regenerates client API token. |
| DELETE | /admin/api/clients/<client_slug> | admin | Deletes client. |
| GET | /admin/api/clients/<client_slug>/discover-ad-accounts | manager/admin | Discovers account IDs from sheet-backed source. |
| POST | /admin/api/clients/<client_slug>/sync-ad-accounts | manager/admin | Writes discovered sheet account IDs to the client. |
Important behavior:
Client API keys are stored only as SHA-256 hashes.
Plaintext client API key is shown only once on create/regenerate.
Manager role cannot delete clients or regenerate client tokens.
Manager role does not see or edit SmartNews auth profiles.
Step 3. Auth profile endpoints
| Method | Endpoint | Role | Description |
|---|---|---|---|
| GET | /admin/api/auth-profiles | admin | Lists SmartNews auth profiles. |
| POST | /admin/api/auth-profiles | admin | Creates profile. |
| PUT | /admin/api/auth-profiles/<name> | admin | Updates profile. |
Recommended profile storage uses Secret Manager references instead of plaintext credentials.
Step 4. Audit logs and templates
| Method | Endpoint | Role | Description |
|---|---|---|---|
| GET | /admin/api/audit-logs | manager/admin | Reads audit logs. Supports filters. |
| GET | /admin/api/tracker-templates | manager/admin | Returns tracker extractor templates. |
| GET | /admin/api/auto-import-schemas | manager/admin | Returns auto-import tracker presets. |
Audit log filters:
| Query parameter | Description |
|---|---|
| limit | Maximum rows, capped at 500. |
| action | Exact action filter. Example: auto_import_run. |
| client_slug | Exact client filter. |
| level | info or error. |
Step 5. Auto-import endpoints
| Method | Endpoint | Role | Description |
|---|---|---|---|
| GET | /admin/api/auto-imports | manager/admin | Lists auto-import presets. |
| POST | /admin/api/auto-imports | manager/admin | Creates preset. |
| GET | /admin/api/auto-imports/<config_slug> | manager/admin | Reads preset. |
| PUT | /admin/api/auto-imports/<config_slug> | manager/admin | Updates preset. |
| DELETE | /admin/api/auto-imports/<config_slug> | admin | Deletes preset. |
| POST | /admin/api/auto-imports/<config_slug>/run-now | manager/admin | Runs one preset immediately. |
| POST | /admin/api/auto-imports/run-due | manager/admin | Runs due presets from UI/admin API. |
| POST | /admin/api/worker/auto-imports/run-due | worker token | Scheduler endpoint. |
run-now is the safest manual test because it forces one config to execute immediately.
Worker endpoint auth header:
Authorization: Bearer <WORKER_BEARER_TOKEN>
Step 6. Access settings endpoints
| Method | Endpoint | Role | Description |
|---|---|---|---|
| GET | /admin/api/settings/auth | admin | Reads admin/manager token status. |
| POST | /admin/api/settings/auth/rotate | admin | Rotates admin or manager access token. |
Rotation request body:
{
"role": "manager"
}
The new access token is returned only once and stored as a hash in Firestore settings.
CHAPTER 4. Firestore configuration
Step 1. Collections
The service uses these Firestore collections:
| Collection | Default name | Purpose |
|---|---|---|
| Clients | smartnews_cost_import_clients | Per-client import configuration. |
| Auth profiles | smartnews_auth_profiles | SmartNews OAuth credentials or secret refs. |
| Auto-imports | smartnews_cost_import_auto_imports | Scheduled tracker push presets. |
| Audit logs | smartnews_cost_import_audit_logs | Operational and admin audit trail. |
| Settings | smartnews_cost_import_settings | Access-token hashes and global admin settings. |
In the current setup, the Firestore database is webhooks.
Step 2. Client document structure
Document ID must equal client_slug.
Typical smartnews-api-backed ClickFlare client:
{
"client_slug": "leadxpression",
"display_name": "Leadxpression",
"api_key_sha256": "sha256-of-client-api-key",
"is_active": true,
"platform": "smartnews",
"source_mode": "smartnews-api-backed",
"tracker_type": "clickflare",
"allowed_ad_account_ids": ["110565805", "110707857"],
"smartnews_auth_profile": "default",
"strict_extraction": true,
"allow_internal_id_fallback": false,
"sheet_config": null,
"extractor": {
"kind": "path_segment",
"query_param": null,
"query_params": [],
"regex": "^[A-Za-z0-9]{24}$",
"path_segment_index": -1,
"decode_passes": 3,
"allow_path_fallback": false,
"path_regex": "^[A-Za-z0-9]{24}$"
}
}
Field descriptions:
| Field | Description |
|---|---|
| client_slug | Stable machine ID. Also used in endpoint path. |
| display_name | Human-readable name in UI. |
| api_key_sha256 | SHA-256 hash of the client API key. |
| is_active | If false, client API access is rejected. |
| platform | Usually smartnews. |
| source_mode | smartnews-api-backed or sheet-backed. |
| tracker_type | redtrack, clickflare, voluum, keitaro, binom, or custom name. |
| allowed_ad_account_ids | Whitelist of SmartNews ad accounts. Store IDs as strings. |
| smartnews_auth_profile | Name of auth profile used for SmartNews OAuth. |
| strict_extraction | If true, records without extracted external ID are skipped. |
| allow_internal_id_fallback | Should normally be false. Prevents SmartNews internal IDs from being returned as tracker IDs. |
| sheet_config | Required only for sheet-backed clients. |
| extractor | Rules for extracting external campaign ID from landing page URL. |
Step 3. Extractor configurations
ClickFlare URL format:
https://tracking-domain.example/cf/r/68e7c3f319b0b70012c4f1ac?click_id=...
Recommended ClickFlare extractor:
{
"kind": "path_segment",
"query_param": null,
"query_params": [],
"regex": "^[A-Za-z0-9]{24}$",
"path_segment_index": -1,
"decode_passes": 3,
"allow_path_fallback": false,
"path_regex": "^[A-Za-z0-9]{24}$"
}
RedTrack or mixed URL format:
https://domain.example/path?cmpid=69d788a779179f70ec9691e2 https://domain.example/path?rtkcmpid=69d788a779179f70ec9691e2 https://domain.example/cf/r/69d788a779179f70ec9691e2?click_id=...
Recommended mixed extractor:
{
"kind": "query_param",
"query_param": null,
"query_params": ["cmpid", "rtkcmpid"],
"regex": "^[A-Za-z0-9]{24}$",
"path_segment_index": -1,
"decode_passes": 3,
"allow_path_fallback": true,
"path_regex": "^[A-Za-z0-9]{24}$"
}
Why there are two regex fields:
regex validates values extracted from query parameters.
path_regex validates a path segment when path fallback is enabled.
Both can be identical because they validate the same 24-character ID in different extraction branches.
Step 4. Auth profile document
Document ID is the profile name, for example default.
Recommended version:
{
"name": "default",
"client_id_secret_ref": "smartnews-client-id",
"client_secret_secret_ref": "smartnews-client-secret"
}
Plaintext version is supported but not preferred:
{
"name": "default",
"client_id": "plain-client-id",
"client_secret": "plain-client-secret"
}
Secret refs can be short names or fully-qualified Secret Manager refs. Short name example:
leadxpression-clickflare-api-key
Fully-qualified example:
projects/leads-cf/secrets/leadxpression-clickflare-api-key/versions/latest
Step 5. Auto-import document structure
Document ID must equal config_slug.
RedTrack example:
{
"config_slug": "redtrack-clickgency-hourly",
"name": "Clickgency → RedTrack",
"client_slug": "clickgency",
"is_active": true,
"tracker_schema": "redtrack",
"tracker_name": "RedTrack",
"tracker_api_key": "",
"tracker_api_key_secret_ref": "clickgency-redtrack-api-key",
"schedule": {
"mode": "cron",
"timezone": "America/New_York",
"interval_minutes": 60,
"run_times": ["00:15"],
"cron_expression": "15 * * * *"
},
"request": {
"endpoint": "https://api.redtrack.io/tracks/cost",
"method": "POST",
"query_fields": [
{"name": "api_key", "required": true},
{"name": "time_from", "required": true},
{"name": "time_to", "required": true},
{"name": "cost", "required": true},
{"name": "campaign_id", "required": true}
]
},
"request_defaults": {
"currency": "USD"
}
}
ClickFlare manual cost example:
{
"config_slug": "leadxpression-clickflare",
"name": "Leadxpression → ClickFlare",
"client_slug": "leadxpression",
"is_active": true,
"tracker_schema": "clickflare",
"tracker_name": "ClickFlare",
"tracker_api_key": "",
"tracker_api_key_secret_ref": "leadxpression-clickflare-api-key",
"schedule": {
"mode": "cron",
"timezone": "America/New_York",
"interval_minutes": 60,
"run_times": ["00:15"],
"cron_expression": "15 * * * *"
},
"request": {
"endpoint": "https://public-api.clickflare.io/api/campaigns/manual/cost",
"method": "PATCH",
"query_fields": [],
"json_body": {}
},
"request_defaults": {
"currency": "USD",
"cost_mode": "all_time",
"all_time_since": "2020-01-01 00:00:00",
"timezone": "America/New_York",
"file_field": "file",
"filename": "smartnews_costs.csv"
},
"notes": "ClickFlare manual cost upload via CSV."
}
Important for ClickFlare:
The endpoint must be /api/campaigns/manual/cost.
Do not use /api/campaigns/{id} for cost import; that endpoint edits campaign settings.
The worker sends one multipart CSV file per run.
The CSV has columns campaign_id,cost.
api-key is sent as an HTTP header.
startDate, endDate, and timezone are sent as query parameters.
With cost_mode: all_time, the worker imports SmartNews costs from all_time_since to the scheduled run time.
Step 6. Worker state
Each auto-import document can contain worker_state.
Common fields:
| Field | Description |
|---|---|
| last_run_started_at | UTC timestamp when worker started. |
| last_run_finished_at | UTC timestamp when worker finished. |
| last_run_status | running, success, or error. |
| last_error | Error text if run failed. |
| last_http_status | Tracker response status, if a tracker request was sent. |
| last_response_excerpt | First 1000 characters from tracker response. |
| last_scheduled_for | Scheduled occurrence that was executed. |
| last_successful_scheduled_for | Last occurrence completed successfully. |
| last_window_from | Effective import window start, UTC. |
| last_window_to | Effective import window end, UTC. |
| last_rows | Number of imported/aggregated rows. |
| last_requests_sent | Number of tracker requests sent. |
| next_due_at | Next calculated due time, UTC. |
If rows = 0 and requests_sent = 0, the worker did not push anything to tracker. This usually means SmartNews returned no cost rows or the extractor did not find external tracker campaign IDs.
CHAPTER 5. Auto-import worker behavior
Step 1. Scheduling logic
The worker supports these schedule modes:
| Mode | Description |
|---|---|
| manual | No automatic due run. Only run-now works. |
| cron | Uses cron_expression. Example: 15 * * * *. |
| daily_times | Uses run_times, e.g. ["09:00", "18:00"]. |
| interval | Runs by interval buckets from midnight in schedule timezone. |
Worker due calculation uses the schedule timezone, then stores state timestamps in UTC.
Step 2. RedTrack push behavior
RedTrack receives one HTTP request per cost row.
Request format:
POST https://api.redtrack.io/tracks/cost
Query params include:
api_key=<tracker_api_key> time_from=<UTC ISO time> time_to=<UTC ISO time> cost=<cost rounded to 0.01> campaign_id=<external campaign id> currency=USD
RedTrack is window-based, so hourly or scheduled windows are valid.
Step 3. ClickFlare manual cost behavior
ClickFlare receives one batch request per run.
Request format:
PATCH https://public-api.clickflare.io/api/campaigns/manual/cost
Headers:
Accept: application/json api-key: <tracker_api_key>
Query params:
startDate=YYYY-MM-DD HH:mm:ss endDate=YYYY-MM-DD HH:mm:ss timezone=America/New_York
Multipart file:
campaign_id,cost 68e7c3f319b0b70012c4f1ac,123.45
The worker filters invalid CSV rows before upload:
missing campaign_id is skipped; campaign ID not 24 alphanumeric characters is skipped; missing cost is skipped; non-numeric cost is skipped;
cost less than or equal to 0 is skipped.
If no valid positive-cost rows remain, the run fails with a validation error.
Step 4. All-time cost mode
For ClickFlare, use all-time mode because the manual cost endpoint is designed around a reporting period and CSV upload.
Required defaults:
{
"cost_mode": "all_time",
"all_time_since": "2020-01-01 00:00:00",
"timezone": "America/New_York"
}
When enabled, the worker changes the import window from normal scheduled window to:
all_time_since → scheduled_for
Example:
all_time_since: 2020-01-01 00:00:00 America/New_York scheduled_for: 2026-04-28T15:15:00Z
The worker imports SmartNews costs for that full range, aggregates by ClickFlare campaign ID, generates CSV, and uploads it to ClickFlare.
CHAPTER 6. Web interface documentation
Step 1. Login flow
The admin panel is available at:
/admin
Login requires two steps:
Google sign-in with an account from the allowed Google Workspace domain.
Admin or manager access token.
After successful login, the browser stores a signed session token in local storage. This token is used as Authorization: Bearer <session> for admin API calls.
Step 2. Roles
| Role | Capabilities |
|---|---|
| manager | View/edit clients, view/edit auto-imports, run imports, view logs. |
| admin | All manager capabilities plus auth profiles, deletes, token regeneration and settings. |
Manager restrictions:
no Auth profiles screen; no Settings screen; no delete actions; no client token regeneration;
no direct SmartNews auth profile editing.
Step 3. Overview screen
The Overview screen shows runtime status and shortcuts:
refresh all data; open clients; create new client; open auto-imports; run due auto-imports; open audit logs;
reload runtime info.
Runtime badges show backend, database, number of clients, number of auto-imports, role and user.
Step 4. Clients screen
The Clients screen lists Firestore client configs.
Displayed fields:
client slug; display name; active/inactive status; source mode; tracker type;
number of allowed ad accounts.
Available actions:
refresh client list; create new client; open selected client in editor; discover/sync ad accounts for sheet-backed clients;
regenerate client token, admin only.
Step 5. Client editor
Default manager-facing fields:
Client selector; Client slug for new clients; Display name for new clients; Tracker dropdown; Active checkbox; Notes;
Allowed ad account IDs.
Advanced mode is available for admins. It exposes:
source mode; SmartNews auth profile; strict extraction; internal ID fallback; extractor configuration; sheet config;
client token override.
Important behavior after the latest patch:
Existing advanced config is loaded into editor fields when selecting a client.
If advanced mode is not opened, saving does not overwrite the existing extractor or sheet config with defaults.
allowed_ad_account_ids are loaded and saved as strings.
path_segment_index = -1 is preserved.
Step 6. Auto-import screen
The Auto-import screen manages scheduled tracker imports.
Main fields:
client slug; tracker preset; tracker API key; schedule picker;
active checkbox.
Tracker presets configure hidden technical fields such as endpoint, method, query fields and request defaults.
For ClickFlare, the preset should generate:
{
"request": {
"endpoint": "https://public-api.clickflare.io/api/campaigns/manual/cost",
"method": "PATCH",
"query_fields": [],
"json_body": {}
},
"request_defaults": {
"currency": "USD",
"cost_mode": "all_time",
"all_time_since": "2020-01-01 00:00:00",
"timezone": "America/New_York",
"file_field": "file",
"filename": "smartnews_costs.csv"
}
}
For RedTrack, the preset should generate:
{
"request": {
"endpoint": "https://api.redtrack.io/tracks/cost",
"method": "POST"
},
"request_defaults": {
"currency": "USD"
}
}
Step 7. Auth profiles screen
This screen is admin-only.
It allows creating and updating SmartNews auth profiles. Prefer Secret Manager refs instead of plaintext credentials.
Required values:
either client_id or client_id_secret_ref;
either client_secret or client_secret_secret_ref.
Step 8. Audit logs screen
The logs screen shows service and admin actions.
Common actions:
| Action | Meaning |
|---|---|
| cost_import | Direct cost import API call. |
| auto_import_run | Worker executed an auto-import. |
| auto_import_run_now | User manually triggered an auto-import. |
| auto_import_run_due | Admin API triggered due auto-imports. |
| client_create | Client was created. |
| client_update | Client was updated. |
| client_token_regenerate | Client token was regenerated. |
| auth_profile_create | Auth profile was created. |
| auth_access_token_rotate | Admin or manager access token was rotated. |
Use response_excerpt and last_error in audit details for tracker troubleshooting.
Step 9. Settings screen
This screen is admin-only.
Functions:
view whether admin and manager access tokens are configured; rotate admin access token;
rotate manager access token.
New tokens are displayed only once.
CHAPTER 7. Project structure
Step 1. Root files
| Path | Purpose |
|---|---|
| app.py | Cloud Run entrypoint. Imports and exposes Flask app. |
| Procfile | Runtime process command for Gunicorn. |
| requirements.txt | Python dependencies. |
| README.md | Project summary and basic usage. |
| DEPLOY.md | Cloud Run deployment guide. |
| openapi.yaml | Public OpenAPI subset for main API endpoints. |
| .gcloudignore | Files ignored by Cloud Run source deployment. |
| admin.js | Legacy or auxiliary admin asset depending on packaging. |
Step 2. smartnews_cost_api/ package
| File | Purpose |
|---|---|
| __init__.py | Package marker. |
| app_factory.py | Creates app, error handlers and public endpoints. |
| admin_routes.py | Admin UI/API blueprint, auth, CRUD, worker triggers. |
| auto_import_worker.py | Scheduler/worker execution logic. |
| config.py | Runtime env config and Firestore/env config loading. |
| errors.py | API error classes. |
| extractors.py | URL campaign ID extraction logic. |
| firestore.py | Firestore repositories for config and logs. |
| google_sheets.py | Gspread factory for sheet-backed flows. |
| legacy_sync.py | Legacy sync implementation. |
| models.py | Dataclasses used across the service. |
| secret_manager.py | Secret Manager resolver with resolve() and resolve_secret() compatibility. |
| services.py | Client auth and cost import aggregation. |
| smartnews.py | SmartNews OAuth and API client. |
| sources.py | Sheet-backed and SmartNews API-backed sources. |
| utils.py | Parsing, hashing, formatting and helpers. |
Step 3. Static admin assets
| Path | Purpose |
|---|---|
| smartnews_cost_api/static/admin/index.html | Self-contained web admin UI. |
The current UI is mostly a single HTML file with embedded CSS and JavaScript.
Step 4. Config examples
| Path | Purpose |
|---|---|
| config/clients.example.json | Local/env client config example. |
| config/auth_profiles.example.json | Local/env auth profile example. |
| config/deploy.env.example | Deployment env example. |
| config/firestore_clients.seed.example.json | Firestore seed example for clients. |
| config/firestore_auth_profiles.seed.example.json | Firestore seed example for auth profiles. |
Step 5. Scripts
| Path | Purpose |
|---|---|
| scripts/seed_firestore.py | Seeds Firestore clients and auth profiles. |
| scripts/run_auto_import_worker.py | CLI runner for due auto-imports. |
| scripts/deploy_example.sh | Example deployment script. |
CHAPTER 8. Environment variables and deployment
Step 1. Required production variables
| Variable | Description |
|---|---|
| CONFIG_BACKEND | Should be firestore in production. |
| FIRESTORE_PROJECT_ID | GCP project ID. Example: leads-cf. |
| FIRESTORE_DATABASE | Firestore database. Example: webhooks. |
| FIRESTORE_CLIENTS_COLLECTION | Client collection name. |
| FIRESTORE_AUTH_PROFILES_COLLECTION | Auth profile collection name. |
| FIRESTORE_AUTO_IMPORTS_COLLECTION | Auto-import collection name. |
| FIRESTORE_AUDIT_LOGS_COLLECTION | Audit log collection name. |
| FIRESTORE_SETTINGS_COLLECTION | Settings collection name. |
| SECRET_MANAGER_PROJECT_ID | Project used for Secret Manager refs. |
| ADMIN_SESSION_SECRET | Secret used to sign admin browser sessions. |
| GOOGLE_OAUTH_CLIENT_ID | Google OAuth client ID for admin login. |
| GOOGLE_ALLOWED_DOMAIN | Allowed Google Workspace domain. |
| WORKER_BEARER_TOKEN | Token used by Cloud Scheduler worker endpoint. |
Step 2. Optional variables
| Variable | Default | Description |
|---|---|---|
| APP_TIMEZONE | America/New_York in current code default | Used when request/schedule timezone is not provided. |
| LOG_LEVEL | INFO | Python logging level. |
| HTTP_BEARER_TOKEN | empty | Legacy sync bearer token. |
| ADMIN_BEARER_TOKEN | legacy token fallback | Initial admin access token. |
| MANAGER_BEARER_TOKEN | empty | Initial manager access token. |
| ADMIN_SESSION_MAX_AGE_SECONDS | 43200 | Admin session TTL. |
| CONFIG_CACHE_TTL_SECONDS | 300 | AppConfig cache duration. |
| ALLOW_ENV_CONFIG_FALLBACK | true | Allows fallback to env/file config if Firestore loading fails. Production should use false. |
Step 3. Deployment smoke checks
After deployment:
export SERVICE_URL="https://smartnews-cost-import-api-jvwl2xgpva-ew.a.run.app" curl "$SERVICE_URL/healthz"
Check direct SmartNews import:
curl -sS -X POST \ "$SERVICE_URL/costs/leadxpression/smartnews/data/import?timezone=America/New_York&allTimeSince=2026-04-01%2000:00:00" \ -H "X-Api-Key: $LEADXPRESSION_API_KEY" | jq .
Check admin config:
curl -sS \ "$SERVICE_URL/admin/api/auto-imports/leadxpression-clickflare" \ -H "Authorization: Bearer $ADMIN_SESSION_TOKEN" | jq .
Run one auto-import manually:
curl -sS -X POST \ "$SERVICE_URL/admin/api/auto-imports/leadxpression-clickflare/run-now" \ -H "Authorization: Bearer $ADMIN_SESSION_TOKEN" | jq .
Step 4. Cloud Scheduler worker
Scheduler should call:
POST /admin/api/worker/auto-imports/run-due?limit=50
Required header:
Authorization: Bearer <WORKER_BEARER_TOKEN>
Example schedule:
gcloud scheduler jobs create http smartnews-auto-import-worker \ --project leads-cf \ --location europe-west1 \ --schedule "*/5 * * * *" \ --uri "$SERVICE_URL/admin/api/worker/auto-imports/run-due?limit=50" \ --http-method POST \ --headers "Authorization=Bearer $WORKER_BEARER_TOKEN"
CHAPTER 9. Testing and troubleshooting
Step 1. Test all-time API without tracker push
curl -i -sS -X POST \ "$SERVICE_URL/costs/leadxpression/smartnews/data/import?timezone=America/New_York&allTimeSince=2026-04-01%2000:00:00" \ -H "X-Api-Key: $LEADXPRESSION_API_KEY"
Expected:
HTTP 200; JSON array or debug payload;
non-empty rows if SmartNews has spend and extractor works.
If the response is [], check:
the date range; SmartNews ad account access; allowed_ad_account_ids; landing page URL format; extractor config;
whether SmartNews has spend in the requested range.
Step 2. Test old date range mode
curl -sS -X POST \ "$SERVICE_URL/costs/leadxpression/smartnews/data/import?dateFrom=2026-04-28%2000:00:00&dateTo=2026-04-28%2023:59:59&timezone=America/New_York" \ -H "X-Api-Key: $LEADXPRESSION_API_KEY" | jq .
This verifies backward compatibility.
Step 3. Test invalid partial range
curl -i -sS -X POST \ "$SERVICE_URL/costs/leadxpression/smartnews/data/import?dateFrom=2026-04-28%2000:00:00&timezone=America/New_York" \ -H "X-Api-Key: $LEADXPRESSION_API_KEY"
Expected: HTTP 400 because only one of dateFrom and dateTo was provided.
Step 4. Test ClickFlare auto-import
curl -sS -X POST \ "$SERVICE_URL/admin/api/auto-imports/leadxpression-clickflare/run-now" \ -H "Authorization: Bearer $ADMIN_SESSION_TOKEN" | jq .
Expected success shape:
{
"ok": true,
"result": {
"config_slug": "leadxpression-clickflare",
"client_slug": "leadxpression",
"tracker_schema": "clickflare",
"rows": 1,
"requests_sent": 1,
"success": true,
"last_http_status": 200,
"tracker_endpoint": "https://public-api.clickflare.io/api/campaigns/manual/cost"
}
}
Step 5. Common errors
| Symptom | Likely cause | Fix |
|---|---|---|
| rows: 0, requests_sent: 0 | No SmartNews rows or extraction failed. | Test direct SmartNews endpoint with debug=true. |
| SecretResolver object has no attribute resolve_secret | Old resolver/worker mismatch. | Use patched secret_manager.py with resolve_secret() alias or call resolve(). |
| ClickFlare HTTP 400 | Wrong multipart field, CSV format, campaign has no visits, or invalid dates. | Check response_excerpt, verify file_field, startDate, endDate, timezone. |
| request.endpoint is required | Auto-import request config missing endpoint. | Reapply tracker preset or patch Firestore config. |
| Client editor wipes extractor | Old frontend version. | Use patched index.html that preserves advanced config. |
| New SmartNews account returns [] | Account not accessible or no spend in range. | Test account individually and confirm SmartNews auth profile access. |
| Requested ad_account_id values are not allowed | Request account is not whitelisted. | Add account ID to allowed_ad_account_ids. |
| Admin API 401/403 | Wrong token type. | Use browser session token for admin API, worker token only for worker endpoint. |
Step 6. Useful log query
gcloud logging read \ 'resource.type="cloud_run_revision" resource.labels.service_name="smartnews-cost-import-api" textPayload:"ClickFlare manual cost upload"' \ --project leads-cf \ --limit 20 \ --format json
CHAPTER 10. Operational examples
Step 1. Leadxpression client config
{
"client_slug": "leadxpression",
"display_name": "Leadxpression",
"source_mode": "smartnews-api-backed",
"tracker_type": "clickflare",
"allowed_ad_account_ids": ["110565805", "110707857"],
"smartnews_auth_profile": "default",
"strict_extraction": true,
"allow_internal_id_fallback": false,
"extractor": {
"kind": "path_segment",
"query_params": [],
"regex": "^[A-Za-z0-9]{24}$",
"path_segment_index": -1,
"decode_passes": 3,
"allow_path_fallback": false,
"path_regex": "^[A-Za-z0-9]{24}$"
}
}
Step 2. Clickgency client config
{
"client_slug": "clickgency",
"display_name": "P2W&Smartnews / Clickgency / Account-01",
"source_mode": "smartnews-api-backed",
"tracker_type": "redtrack",
"allowed_ad_account_ids": ["109138607", "110527074"],
"smartnews_auth_profile": "default",
"strict_extraction": true,
"allow_internal_id_fallback": false,
"extractor": {
"kind": "query_param",
"query_param": null,
"query_params": ["cmpid", "rtkcmpid"],
"regex": "^[A-Za-z0-9]{24}$",
"path_segment_index": -1,
"decode_passes": 3,
"allow_path_fallback": true,
"path_regex": "^[A-Za-z0-9]{24}$"
}
}
Step 3. Patch one client account list from CLI
python - <<'PY'
from datetime import datetime, timezone
from google.cloud import firestore
PROJECT_ID = "leads-cf"
DATABASE = "webhooks"
CLIENT_SLUG = "clickgency"
accounts = ["109138607", "110527074"]
db = firestore.Client(project=PROJECT_ID, database=DATABASE)
db.collection("smartnews_cost_import_clients").document(CLIENT_SLUG).set({
"allowed_ad_account_ids": accounts,
"updated_at": datetime.now(timezone.utc),
}, merge=True)
print("patched", CLIENT_SLUG, accounts)
PY
Step 4. Store tracker token in Secret Manager
export PROJECT_ID="leads-cf" export SECRET_NAME="leadxpression-clickflare-api-key" printf "Paste tracker token: " read -s TRACKER_TOKEN echo printf '%s' "$TRACKER_TOKEN" | gcloud secrets create "$SECRET_NAME" \ --project "$PROJECT_ID" \ --data-file=- 2>/dev/null || \ printf '%s' "$TRACKER_TOKEN" | gcloud secrets versions add "$SECRET_NAME" \ --project "$PROJECT_ID" \ --data-file=- unset TRACKER_TOKEN
Do not keep tracker tokens in plaintext Firestore fields if Secret Manager is available. Use:
{
"tracker_api_key": "",
"tracker_api_key_secret_ref": "leadxpression-clickflare-api-key"
}
CHAPTER 11. Mintegral client endpoints
Step 1. Overview and isolation model
All Mintegral client traffic is served through a single host path:
| Our path | Method | Purpose |
|---|---|---|
/platforms/mintegral/{client_slug}/proxy/{path} | ANY | Generic proxy to https://ss-api.mintegral.com/api/{path}, scoped to the client's allowed_campaign_ids. |
The proxy requires the client's X-Api-Key header. It auto-injects the three Mintegral auth headers (access-key, token, timestamp) from the bound Mintegral auth profile — callers must not send them.
Mintegral's Open API has no native sub-account scoping: the agency token sees every sub-user under the agency, and the API exposes no user_id filter on any endpoint, no owner field on any response, and the parent account/balance only returns the parent itself. Sub-user-level API keys are not issued by Mintegral. Isolation therefore runs on our side via a per-client allowlist (allowed_campaign_ids).
The proxy classifies every Mintegral path into one of three categories defined in mintegral_proxy.ENDPOINT_RULES:
| Category | Behavior |
|---|---|
per_campaign | Validates caller-supplied campaign_id against the allowlist; if absent, injects the full allowlist (chunked in ≤50-id batches and merged). Defence-in-depth response filter drops items whose campaign_id is not in the allowlist. |
reference | Pass-through — endpoint returns dictionaries / enums that are not campaign-scoped. |
account_only | Refused with 403 (would expose the agency's parent account). |
Any path not listed in ENDPOINT_RULES returns 403 by default-deny.
Step 3. account/balance — refused
GET /platforms/mintegral/{client_slug}/proxy/api/open/v1/account/balance
Returns 403 with error_code: account_only_endpoint. Mintegral's account/balance returns the agency parent only (is_sub_user: 2), regardless of which sub-user a client is mapped to. Exposing it through a client-scoped proxy is meaningless. Mintegral source: helpcenter / account-balance.
Step 4. Get Campaign List
GET /platforms/mintegral/{client_slug}/proxy/api/open/v1/campaign
Category: per_campaign. If the caller does not supply campaign_id, the proxy injects the client's allowlist (chunked into ≤50-id batches; responses are concatenated into one). If the caller supplies campaign_id, every value must be in the allowlist or the request is 403'd with isolation_denied before any upstream call.
| Param | Type | Required | Notes |
|---|---|---|---|
campaign_id | string (CSV) or int | auto-injected if missing | Comma-separated; max 50 per upstream call. Validated against allowed_campaign_ids. |
campaign_name | string | no | Filter by name. |
package_name | string | no | Filter by app bundle. |
offer_id | int | no | — |
offer_name / offer_uuid | string | no | — |
page | int | no | Default 1. |
limit | int | no | Default 10, max 50. |
Response — { code, msg, data: { page, limit, total, list[] } } where each list item carries campaign_id, campaign_name, is_coppa, promotion_type, alive_in_store, preview_url, product_name, package_name, description, icon, platform, category, app_size, min_version, maintain_by, status. Items whose campaign_id is not in the allowlist are dropped before the response is returned to the caller; data.total is recomputed accordingly. Mintegral source: api-retrieve-campaign-list.
Step 5. Retrieve Offer List
GET /platforms/mintegral/{client_slug}/proxy/api/open/v1/offers
Category: per_campaign. Returns offers under the client's allowed campaigns. Auto-injection and chunking apply.
| Param | Type | Notes |
|---|---|---|
campaign_id | int / CSV | Required for isolation; auto-injected if omitted. |
offer_id | int | Optional filter. |
page / limit | int | Default 1 / 10. Max limit 50. |
Response — { code, msg, data: { page, limit, total, list[] } }. Each item is an offer object including campaign_id, offer_id, offer_name, promote_timezone, start_time, end_time, budget, bid_rate, geos, OS targets, status. Mintegral source: api-retrieve-offer-list.
Step 6. Create / Manage Offer
POST /platforms/mintegral/{client_slug}/proxy/api/open/v1/offer (create)
PUT /platforms/mintegral/{client_slug}/proxy/api/open/v1/offer (update)
Category: per_campaign. The body must include campaign_id for create (POST). For update (PUT), the body uses offer_id; the proxy still validates that the bound campaign is in the allowlist by inspecting the body. Body must be JSON.
Common fields (POST):
| Field | Type | Required | Notes |
|---|---|---|---|
campaign_id | int | yes | Must be in allowed_campaign_ids. |
offer_name | string | yes | — |
promote_timezone | int | yes | UTC offset hours. |
start_time / end_time | int (unix) | yes | Promotion window. |
budget / bid_rate | number | yes | Daily budget and bid. |
geos | array<string> | yes | Two-letter country codes. |
os | string | yes | ANDROID, IOS. |
billing_type | string | yes | CPI, CPA, CPC, etc. |
Response — { code: 200, msg: "success", data: { offer_id } } on success; { code, msg } on error. Mintegral sources: api-create-offer, api-manage-offer.
Step 7. Update Offer Budget
PUT /platforms/mintegral/{client_slug}/proxy/api/open/v1/offer/budget
Category: per_campaign. Body must be JSON. The proxy validates campaign_id against the allowlist and runs an offer-id pre-flight (Mintegral upstream ignores campaign_id when offer_id is present, so we resolve the union of offer_ids belonging to this client's campaigns and refuse if the body's offer_id isn't in that set).
| Field | Type | Required | Notes |
|---|---|---|---|
campaign_id | int | yes | Must be in allowed_campaign_ids. |
offer_id | int | yes | Must belong to one of the client's allowed campaigns. |
daily_cap | number | yes (or total_budget) | Daily spend cap in campaign currency. |
daily_cap_type | string | no | BUDGET (default) or INSTALL. |
total_budget | number or "OPEN" | yes (or daily_cap) | Lifetime cap; "OPEN" disables the lifetime cap. |
total_budget_effective_time | YYYY-MM-DD | no | Date the lifetime cap starts counting from. |
total_budget_effective_timezone | int | no | UTC offset hours for the effective time. |
country_code | string | no | ALL or two-letter ISO. Per-geo budget rows when omitted apply to all geos. |
Returns { code, msg, data }. Mintegral source: api-update-budget.
Step 8. Manage Bid Rate
PUT /platforms/mintegral/{client_slug}/proxy/api/open/v1/offer/bid_rate
Category: per_campaign. Body must be JSON. Both campaign_id validation and offer_id pre-flight apply (see Step 7).
| Field | Type | Required | Notes |
|---|---|---|---|
campaign_id | int | yes | Must be in allowed_campaign_ids. |
offer_id | int | yes | Must belong to one of the client's allowed campaigns. |
bid_rate | number | yes (or bid_rate_by_location) | Single bid value in campaign currency. Used when no per-geo bids are set. |
bid_rate_by_location | array<{ country_code, bid_rate }> | yes (or bid_rate) | Per-geo overrides. Country codes are ISO-2. |
For OCPI/OCPA billing types, see Step 13 (target_goal) instead — Mintegral rejects raw bid_rate changes when an offer is on a target-goal billing type. Returns { code, msg }. Mintegral source: api-manage-bid.
Step 9. Update Offer Promotion Status
PUT /platforms/mintegral/{client_slug}/proxy/api/open/v1/offer/status
Category: per_campaign. Body JSON. Both campaign_id validation and offer_id pre-flight apply.
| Field | Type | Required | Notes |
|---|---|---|---|
campaign_id | int | yes | Must be in allowed_campaign_ids. |
offer_id | int | yes | Must belong to one of the client's allowed campaigns. |
status | string enum | yes | RUNNING, STOPPED, PAUSED. Other Mintegral states (CAMPAIGN_STOPPED, COLD_STARTUP_ONGOING) are read-only and cannot be set via this endpoint. |
Returns { code, msg }. See enumeration values for the full state machine. Mintegral source: api-update-promotion-status.
Step 10. Manage Publisher Targets
GET /platforms/mintegral/{client_slug}/proxy/api/open/v1/offer/target
PUT /platforms/mintegral/{client_slug}/proxy/api/open/v1/offer/target
Category: per_campaign. GET returns the configured publisher allowlist/blocklist for an offer; PUT replaces it. PUT also runs the offer-id pre-flight.
Query params (GET):
| Param | Type | Required | Notes |
|---|---|---|---|
campaign_id | int | yes | Validated against allowlist. |
offer_id | int | yes | Must belong to the campaign. |
Body fields (PUT):
| Field | Type | Required | Notes |
|---|---|---|---|
campaign_id | int | yes | — |
offer_id | int | yes | — |
target_publishers | array<string> | yes | List of mtg_id publisher identifiers (sub-publishers). |
target_type | string | yes | WHITELIST or BLACKLIST. |
Mintegral source: api-manage-publishers.
Step 11. Update Audience Targets
PUT /platforms/mintegral/{client_slug}/proxy/api/open/v1/offer/audience
Category: per_campaign. Body JSON. campaign_id validation + offer_id pre-flight apply. Binds an audience (created via Step 19) to an offer.
| Field | Type | Required | Notes |
|---|---|---|---|
campaign_id | int | yes | Must be in allowed_campaign_ids. |
offer_id | int | yes | Must belong to one of the client's allowed campaigns. |
audience_id | int | yes | The audience created in Step 19. |
audience_target_type | string | yes | INCLUDE or EXCLUDE. |
Mintegral source: api-update-audience-target.
Step 12. Apply Creatives to Offer
PUT /platforms/mintegral/{client_slug}/proxy/api/open/v1/offer/apply_creative
Category: per_campaign. Body JSON. campaign_id validation + offer_id pre-flight apply. Attaches creative sets (Step 15) to an offer.
| Field | Type | Required | Notes |
|---|---|---|---|
campaign_id | int | yes | Must be in allowed_campaign_ids. |
offer_id | int | yes | Must belong to one of the client's allowed campaigns. |
creative_set_ids | array<int> | yes | One or more creative-set ids from Step 15. |
Mintegral source: api-update-creative.
Step 13. Update Target Goals
PUT /platforms/mintegral/{client_slug}/proxy/api/open/v3/offer/target_goal
Category: per_campaign. Body JSON. campaign_id validation + offer_id pre-flight apply. Sets the offer's KPI goal (e.g. Target-CPE, Target-CPA, Target-ROAS). Use Step 18 first to discover which goal modes are supported for the campaign's tracking setup.
| Field | Type | Required | Notes |
|---|---|---|---|
campaign_id | int | yes | Must be in allowed_campaign_ids. |
offer_id | int | yes | Must belong to one of the client's allowed campaigns. |
bid_goal | string | yes | Goal mode (e.g. Target-CPE, Target-CPA, Target-ROAS). Must be one of the values returned by Step 18. |
target_goal | number | yes | Numeric KPI value in campaign currency. |
target_goal_window | string | no | Attribution window — D0, D1, D3, D7, D14, D30. |
target_mtg_event | array<string> | conditional | Mintegral event names (e.g. ["Purchase"], ["Start Trial"]). Required for event-based goals. |
target_original_event | string | conditional | Tracker-side event name (e.g. af_purchase) when tracking_method is APPSFLYER/ADJUST. |
Mintegral source: api-update-target-goal.
Step 14. Retrieve Creative Info
GET /platforms/mintegral/{client_slug}/proxy/api/open/v1/creative-ad/list
Category: per_campaign. Lists individual creatives currently bound under a campaign's offers. Auto-injection of campaign_id applies, but Mintegral additionally requires the caller to scope the query by ad ids.
| Param | Type | Required | Notes |
|---|---|---|---|
ad_ids | string (CSV) or int | yes | Comma-separated creative-ad ids to inspect. Mintegral returns The ad ids field is required. if omitted. |
campaign_id | string (CSV) or int | auto-injected if missing | Validated against allowed_campaign_ids. |
page / limit | int | no | Default 1 / 10. Max limit 50. |
Returns a paged list of creatives with creative_id, type (VIDEO, IMAGE, HTML, etc.), resource_url, md5, duration_ms (video only), width / height. Mintegral source: api-retrieve-creative-info.
Step 15. Create / Manage Creative Set
POST /platforms/mintegral/{client_slug}/proxy/api/open/v1/creative_set (create)
PUT /platforms/mintegral/{client_slug}/proxy/api/open/v1/creative_set (update)
Category: per_campaign. Body JSON. Creative sets are reusable bundles of creatives (videos + endcards + icons + titles + CTAs) attached to offers via Step 12.
Body fields:
| Field | Type | Required | Notes |
|---|---|---|---|
campaign_id | int | yes | Must be in allowed_campaign_ids. |
creative_set_id | int | PUT only | Existing creative-set id when updating. |
creative_set_name | string | yes (POST) | Free-form display name. |
language | string | yes (POST) | BCP-47 code (e.g. en, pt-BR). |
creatives | array<{ source_id, ad_type }> | yes (POST) | Array of source bindings; source_id from Step 17, ad_type per enumeration values. |
endcard_id | int | no | Optional endcard creative. |
title / description / cta | string | no | Optional ad copy overrides. |
Returns { code, msg, data: { creative_set_id } } on POST. Mintegral source: api-create-creative-set.
Step 16. Retrieve Creative Set Data
GET /platforms/mintegral/{client_slug}/proxy/api/open/v1/creative_sets
Category: per_campaign. Paginated list of creative sets bound to the client's campaigns. Auto-injection of campaign_id applies.
| Param | Type | Required | Notes |
|---|---|---|---|
campaign_id | string (CSV) or int | auto-injected if missing | Validated against allowed_campaign_ids. |
creative_set_id | int | no | Filter to a specific creative set. |
page / limit | int | no | Default 1 / 10. Max limit 50. |
Each entry exposes the embedded creatives with their source_id + dimensions + status. Mintegral source: api-retrieve-creative-set-data.
Step 17. Retrieve Creative Sources
GET /platforms/mintegral/{client_slug}/proxy/api/open/v1/creatives/source
⚠ Blocked for client proxy use. Category: account_only. The proxy returns 403 with error_code: account_only_endpoint for any client. Mintegral's source bucket is agency-wide and the response carries no campaign or sub-user attribution, so it cannot be safely scoped to one client. Admins can fetch source ids directly through Mintegral's UI; the path remains documented here because the value of source_id is referenced in Step 15.
Response shape (when fetched outside the proxy): paginated { source_id, type, md5, resource_url, width, height, duration_ms?, language?, file_size }. Mintegral source: api-retrieve-creative-list.
Step 18. Obtain Event Bid Goals
GET /platforms/mintegral/{client_slug}/proxy/api/open/v3/event/bid_goal_supports
Category: per_campaign. Returns the event taxonomy and supported bid-goal modes (e.g. ROAS, RETENTION, PURCHASE) for a given campaign. Used before Step 13 to discover which target_goal values are valid for the campaign's tracking setup.
| Param | Type | Required | Notes |
|---|---|---|---|
bid_goal | string | yes | Goal mode to query (ROAS, RETENTION, …). |
campaign_id | int | yes (or package_name) | Validated against allowlist. Must be a single integer — see warning below. |
package_name | string | yes (or campaign_id) | App bundle alternative when campaign_id is not known. |
⚠ CSV not supported by Mintegral on this endpoint. Unlike open/v1/campaign or open/v1/offers, Mintegral validates campaign_id here as a single integer and rejects comma-separated lists with { code: 13000, msg: "campaign_id: The campaign id must be an integer." }. The proxy's auto-injection feature emits CSV when a client has multiple allowed_campaign_ids, so for multi-campaign clients the caller must pass campaign_id explicitly (one id per request, in the allowlist). Single-campaign clients work without an explicit param because the auto-injected single value is a valid integer.
Mintegral source: event-api.
Step 19. Audience Operations
GET /platforms/mintegral/{client_slug}/proxy/api/open/v1/audience (list)
POST /platforms/mintegral/{client_slug}/proxy/api/open/v1/audience (create)
PUT /platforms/mintegral/{client_slug}/proxy/api/open/v1/audience (update)
Category: per_campaign. Manage custom audiences (device-id lists, look-alike seeds) attached to a campaign. Bind them to specific offers via Step 11.
Query params (GET — list):
| Param | Type | Required | Notes |
|---|---|---|---|
campaign_id | string (CSV) or int | auto-injected if missing | Validated against allowed_campaign_ids. |
audience_id | int | no | Filter to a specific audience. |
page / limit | int | no | Default 1 / 10. |
Body fields (POST — create):
| Field | Type | Required | Notes |
|---|---|---|---|
campaign_id | int | yes | Must be in allowed_campaign_ids. |
audience_name | string | yes | Free-form display name. |
audience_type | string | yes | CUSTOM_LIST, LOOKALIKE, etc. |
device_id_type | string | yes | IDFA, GAID, OAID. |
refresh_period | int | no | Refresh interval in days. |
description | string | no | — |
The actual device-id list is uploaded separately — Step 20 returns a presigned URL for direct upload to Mintegral storage. Mintegral source: audience-api.
Step 20. Audience Upload (Presigned)
GET /platforms/mintegral/{client_slug}/proxy/api/open/v1/audience/presigned-upload-data
⚠ Blocked for client proxy use. Category: account_only. The proxy returns 403 with error_code: account_only_endpoint for any client. Presigned URLs are issued at the agency level and have no campaign attribution, so the proxy cannot tie a returned URL to a specific client. Admins fetch presigned URLs out-of-band and upload the device-id list directly to the storage bucket; the resulting audience_id is then bound to the client's campaign via Step 19.
Mintegral source: audience-upload-api.
Step 21. Update Tracking URL
PUT /platforms/mintegral/{client_slug}/proxy/api/open/v1/tracking
Category: per_campaign. Body JSON. campaign_id validation + offer_id pre-flight apply. Updates impression / click / install postback URLs for an offer.
| Field | Type | Required | Notes |
|---|---|---|---|
campaign_id | int | yes | Must be in allowed_campaign_ids. |
offer_id | int | yes | Must belong to one of the client's allowed campaigns. |
tracking_method | string | yes | APPSFLYER, ADJUST, BRANCH, KOCHAVA, SINGULAR, MMP, or NONE. |
click_url | string (URL) | yes | Mintegral macros ({click_id}, {advertising_id}, …) supported. See tracking. |
impression_url | string (URL) | no | — |
install_postback_url | string (URL) | no | Postback fired when MMP confirms install. |
postback_event_url | string (URL) | no | Postback fired for in-app events (use {event_name} macro). |
universal_link | string (URL) | no | iOS Universal Link / Android App Link override. |
deep_link | string | no | Custom-scheme deep link. |
Mintegral source: api-update-tracking.
Step 22. Advanced Performance Reporting
GET /platforms/mintegral/{client_slug}/proxy/api/v2/reports/data
Category: per_campaign. Two-step async report. The cost endpoint (Step 2) wraps this; raw-proxy access is still allowed if a caller wants other dimensions or columns. Note that Mintegral does not accept campaign_id on this endpoint, so the request itself is not validated and auto-injection is skipped — isolation is enforced by filtering the response payload after the TSV is downloaded (the proxy drops rows whose Campaign Id column is not in the allowlist).
All five fields below are required by Mintegral; calling the endpoint without them returns { code: 10000, msg: "error", data: { start_time: "…should not be empty.", end_time: "…should not be empty." } }.
| Param | Type | Required | Notes |
|---|---|---|---|
type | int | yes | 1 — generate / poll until ready, 2 — download. |
start_time | YYYY-MM-DD | yes | Window start (≤7 days span per request). |
end_time | YYYY-MM-DD | yes | Window end inclusive. |
dimension_option | string | yes | One or more of Offer, Campaign, CampaignPackage, Creative, AdType, Sub, Package, Location, Endcard, AdOutputType — comma-separated. |
time_granularity | string | yes | hourly, daily, weekly, monthly. |
timezone | int | yes | UTC offset hours (e.g. +8, -5). |
Example (poll until ready, then download):
curl -G 'https://temp-api.p2w.tech/platforms/mintegral/{client_slug}/proxy/api/v2/reports/data' \
-H 'X-Api-Key: <client-token>' \
--data-urlencode 'type=1' \
--data-urlencode 'start_time=2026-04-25' \
--data-urlencode 'end_time=2026-04-30' \
--data-urlencode 'dimension_option=Campaign,Offer' \
--data-urlencode 'time_granularity=daily' \
--data-urlencode 'timezone=0'
Step 1 returns { code, msg } where code: 200 means the report is ready, 201/202 means still generating. Step 2 returns a TSV byte stream with columns Date, Timestamp, Offer Id, Offer Uuid, Offer Name, Campaign Id, Campaign Package, Creative Id, Creative Name, Ad Type, Sub Id, Package Name, Location, Endcard ID, Endcard Name, Ad Output Type, Currency, Impression, Click, Conversion, Ecpm, Cpc, Ctr, Cvr, Ivr, Spend. The proxy parses the TSV, drops rows whose Campaign Id is not in the allowlist, and re-serialises the result as JSON when X-Accept is JSON (default for a Flask client). Mintegral source: Advanced-ad-delivery-report-api.
Step 23. AppsFlyer cost auto-import via InCost API
What this is. For Mintegral clients whose MMP is AppsFlyer, the auto-import worker fetches Mintegral cost on a schedule and pushes it to AppsFlyer's InCost API — the partner-facing endpoint for ad networks to send cost data to advertiser accounts programmatically. This is the only auto-import tracker_schema currently supported for Mintegral clients. RedTrack/ClickFlare/Voluum/Keitaro/Binom are web trackers that cannot ingest Mintegral campaign IDs.
Premium gating. InCost API is part of AppsFlyer ROI360, an add-on subscription on the advertiser side. Even after our partner-account is set up, each individual advertiser must (a) hold ROI360, and (b) explicitly enable "Get Cost Data" for our network on each app they want cost data for. If either is missing, our pushes will fail at the per-app level with 404 / 415 / "media source not found" — see the troubleshooting section below.
Why we built this. The AppsFlyer Mintegral cost integration form (Dashboard → Integrated Partners → Mintegral → Cost) expects direct Mintegral credentials — username + API token — and routes traffic straight to ss-api.mintegral.com. It does not support a base URL, cannot be pointed at our proxy, and a single agency-token in that form would lump every sub-user under one AppsFlyer "partner" record, making per-client attribution impossible. This auto-import is the workaround: instead of letting AppsFlyer pull from Mintegral, we pull from Mintegral via our proxy (with proper sub-user scoping) and push directly to AppsFlyer's InCost API as a registered partner.
Architectural model: P2W intercepts Mintegral cost. Clients do not run their own Mintegral cost integration. Instead, every client's AppsFlyer account lists P2W (Point2Web) as an Active Integration with a Cost tab, and their attribution links carry pid=point2web_int (visible in Active Integrations → Point2Web → Attribution link). All cost flows through us:
Mintegral Reporting API
↓ (we pull, with per-client sub-user scoping)
P2W cost-import service
↓ (we re-emit via InCost API as media_source=point2web_int)
AppsFlyer (per-client account)
↓
client dashboards: cost attributed to "Point2Web" media source
Consequences:
- Cost lands under
point2web_int, notmintegral_int, because that's thepidon the client's attribution links. Sending undermintegral_intwould result inmatched_records_percentage=0— cost rows would not match any clicks. - Clients should NOT run Mintegral as a separate cost-partner in their AppsFlyer dashboard. Doing so would double-count cost (Mintegral pulling on its own AND P2W pushing). If a client previously had Mintegral cost integration enabled, disable the Cost toggle on their Mintegral integrated-partner page before activating P2W. Other Mintegral integration tabs (Integration, Attribution link, Permissions, in-app event postbacks) must stay enabled — Mintegral still needs postbacks to optimise campaigns; only the Cost tab is conflict.
- Upstream source visibility: the AppsFlyer profile carries an optional
channel_labelfield. When set (e.g. tomintegral), every cost row sent for that profile carrieschannel: "mintegral"in the JSON. AppsFlyer surfaces this as a sub-dimension undermedia_sourcein client dashboards — so a client's spend report shows Point2Web cost broken down intomintegral/tiktok/ etc. without changing the matchedpid. Emptychannel_label→ field omitted. - One global P2W partner credential covers every client. The InCost API token authenticates our partner account; advertisers grant per-app cost permission to P2W (not to Mintegral) in their AppsFlyer dashboard.
23.1. Initial setup (one-time, partner side)
Per the AppsFlyer partner guide, the InCost integration requires a seven-step onboarding process. P2W must complete steps 1–6 once before any production push will succeed:
- Apply for InCost. In AppsFlyer dashboard → Help → Contact our team → Partner assistant → "Enabling cost measurement". This opens a ticket and an AppsFlyer Partner Solution Engineer is assigned.
- Verify campaign hierarchies. Confirm that >90% of P2W's Mintegral attribution traffic carries
af_c_id(campaign_id) on the click URL. Optionally alsoaf_adset_id/af_ad_idfor offer/creative-level cost. Mintegral's standard click templates already includeaf_c_id; we just need to verify volume. - Get the API token. AppsFlyer dashboard → Account → Security Center → API Token V2.0. This is the token issued for our P2W partner account, NOT for any individual advertiser. Save it in Settings → AppsFlyer profiles (see 23.2).
- Implement the three API methods. All three are already implemented in this codebase:
GET /api/mng/apps?capabilities=cost— list apps the advertiser has authorised for our cost capability. Each item carriescurrencyandtime_zone.POST /api/incost-uploader/v1/data/app/{app_id}— upload cost JSON. Returns ajob_id.GET /api/incost-jobstatus/v1/data/app/{app_id}/job/{job_id}— poll job status. Look forstatus="Applied"andmatched_records_percentage.
- Test the integration. Tell AppsFlyer in the support ticket that the implementation is complete; they grant permission on two sandbox app IDs:
com.cost.app(Android sandbox)id888123456(iOS sandbox)
appsflyer_app_id_mappingspoint to the sandbox apps. - Confirm operational with AppsFlyer. Reply in the same ticket. AppsFlyer flips a flag that activates production cost ingestion for our partner account.
- Per-advertiser activation. Each P2W client (advertiser) must independently enable "Get Cost Data" for our network in their AppsFlyer dashboard → Integrated Partners → P2W → Cost tab. Without this,
/api/mng/apps?capabilities=costreturns the app as not authorised, and our push fails with 404 for that app. This is per-advertiser, per-app; if a client adds a new app, they must repeat the toggle.
23.2. AppsFlyer partner profile (Settings)
In Settings → AppsFlyer profiles, create one profile per partner credential set (typically just default):
| Field | Value |
|---|---|
name | Profile identifier. default for most setups; create additional profiles only if AppsFlyer provisioned multiple media_source IDs for our partner account, or to keep separate sandbox/production tokens. |
api_token | The V2.0 API token from AppsFlyer dashboard → Account → Security Center. Sent as Authorization: Bearer <token> on every InCost call. Stored encrypted in Secret Manager via api_token_secret_ref. |
media_source | Network identifier on advertisers' AppsFlyer attribution links — the value of the pid query parameter. For P2W this is point2web_int; you can verify by inspecting any client's Click attribution link in AppsFlyer dashboard → Active Integrations → Point2Web → Attribution link tab. Wrong value → InCost returns 415 "media source not found", or returns 200 but with matched_records_percentage=0 (cost doesn't link to any clicks). |
channel_label | Optional. Sent as the InCost channel field on every cost row (≤20 chars). Surfaces in client AppsFlyer dashboards as a sub-dimension under media_source: lets clients distinguish upstream sources within our partner cost (e.g. mintegral, tiktok, etc.) without changing the matched pid. Leave blank to omit the field entirely. |
⚠️ API V2 token revocation event. AppsFlyer revoked all API V2 tokens generated before March 10, 2026, 19:00 UTC. If we have a token from before this date, regenerate it before any auto-import runs.
23.3. Auto-import preset shape
| Field | Value |
|---|---|
tracker_schema | appsflyer |
appsflyer_partner_profile | Name of the AppsFlyer profile from 23.2 (default: default). The worker resolves api_token + media_source from this profile at push time. |
tracker_api_key | Ignored for AppsFlyer schema. The Tracker API key field is hidden in the editor when AppsFlyer is selected. |
request.endpoint | https://hq1.appsflyer.com/api/incost-uploader/v1/data/app/{app_id} — must contain the literal {app_id} placeholder. The worker substitutes it per row from appsflyer_app_id_mappings. |
request.method | POST |
request.defaults.currency | USD fallback when neither the Mintegral row nor the app's metadata supplies one. |
request.defaults.job_status_poll_delay_seconds | 60 — wait between POST and first job-status poll. |
request.defaults.job_status_poll_max_attempts | 3 — give up after this many polls. matched_records_percentage is recorded on the last successful poll. |
request.defaults.retroactive_days | 7 — each run includes the last 7 calendar days of cost. Per AppsFlyer guidance, single-day runs miss late corrections from Mintegral. |
schedule.run_times | Default: 00:00, 04:00, 08:00, 12:00, 16:00, 20:00 UTC — six runs per day, the AppsFlyer-recommended frequency for data freshness. |
23.4. Per-client app mappings
Each Mintegral client must have appsflyer_app_id_mappings populated in the client editor: a list of {campaign_id, app_id, derived_from?} entries mapping each allowed Mintegral campaign to the AppsFlyer app it promotes. One Mintegral sub-account can promote several apps; the worker routes each cost row to the right AppsFlyer endpoint based on this mapping.
| Platform | app_id format | Example |
|---|---|---|
| iOS | id<App-Store-numeric-id> | id6759177171 |
| Android | App package name | com.example.myapp |
Two ways to populate:
- Fetch from Mintegral (recommended): backend hits
POST /admin/api/clients/{slug}/derive-appsflyer-app-idswhich pulls the agency-wide campaign list and walks eachallowed_campaign_ids:- Android: uses
package_namedirectly (e.g.com.example.myapp). - iOS: regex
/id<digits>/againstpreview_urlfirst; falls back to the campaign's first offer'sclick_url(the AppsFlyer click attribution URL likehttps://app.appsflyer.com/id6759177171?...).
/api/mng/apps?capabilities=costand tags each derived mapping with one of:- 🟢 OK — derived correctly AND advertiser has authorised cost capability for this app on our network
- 🟡 Warning — derived correctly but app is NOT in the AppsFlyer cost-allowlist; advertiser must enable "Get Cost Data" before pushes succeed
- 🔴 Error — app_id derive failed (no
id<digits>pattern, missingpackage_name, etc.)
- Android: uses
- Configure mappings manually: opens a modal where each row carries
campaign_idandapp_idas plain inputs. Mix of derived + manual rows is supported; the source column shows which is which.
23.5. JSON body shape
Sent as Content-Type: application/json, one POST per distinct app_id bucket. The body is a JSON array of row objects:
[
{
"date": "2026-04-25",
"app_id": "id6759177171",
"media_source": "point2web_int",
"campaign_id": "170737",
"campaign_name": "VP_MINT_PORTUGAL_CPS_11.04",
"currency": "USD",
"spend": "12.5",
"adset_id": "532021",
"adset_name": "Offer A",
"ad_id": "9001",
"ad_name": "Creative X",
"geo": "US",
"channel": "mintegral"
},
...
]
Field-hygiene rules (per AppsFlyer guidance):
- Mandatory:
date,app_id,media_source,campaign_id(≤24 chars),campaign_name(≤100 chars),currency(ISO 4217 3-letter),spend. - Optional, paired:
adset_id+adset_name,ad_id+ad_name— sent only when Mintegral surfaced offer/creative metadata viadimension_option=Campaign,Offer,Creative. Empty values are omitted from the JSON, never sent as"". - Optional:
geo(ISO 3166 2-letter),site_id,channel(≤20 chars; populated from the AppsFlyer profile'schannel_labelwhen set — used to surface upstream source like "mintegral" as a sub-dimension undermedia_sourcein client dashboards),keywords,af_prt(agency name). spendformat: decimal number, up to 5 digits after the decimal point. No thousand separators. No quotation marks around the value (we send it as a JSON number-string, which AppsFlyer parses as decimal). Negative values rejected outright; zero is allowed but skipped by the worker to keep payloads compact. Example values:1,1.2,1234.20,0.05123.
23.6. Async upload cycle
InCost API is asynchronous. The POST returns immediately with a job_id:
{ "job_id": "abc-123-def" }
The data is NOT committed at this point. The worker waits job_status_poll_delay_seconds (default 60s), then polls GET /api/incost-jobstatus/v1/data/app/{app_id}/job/{job_id} up to job_status_poll_max_attempts times (default 3). Possible status values:
| Status | Meaning | Worker action |
|---|---|---|
InProgress | Still processing | Wait and re-poll |
Applied | Data committed, available in advertiser's reports | Stop polling. Record matched_records_percentage in audit row. |
Failed / Rejected / Error | Validation or processing error | Stop polling. Record error field in audit row. |
Reverted | Earlier upload was overwritten by another | Informational; not raised as error. |
matched_records_percentage below 100% indicates that some cost rows could not be matched to attribution data on the advertiser's side. Common causes: campaign/adset/ad IDs in our cost data don't match the IDs on the actual click URLs. Investigate the Mintegral click-URL templates if this drops below 90% consistently.
If polling exhausts before Applied, the worker logs a warning and moves on. The next scheduled run re-uploads the same window (retroactive_days=7), so an incomplete poll never loses data.
23.7. Multi-app dispatch
When a client's mappings span several app_id values, the worker emits one POST per app_id. Each POST is independent; failure of one does NOT roll back earlier successes (AppsFlyer commits per-app, not per-run). The audit row records every chunk in app_uploads[] with {app_id, rows, job_id, status, matched_records_percentage, applied} so partial failures are visible.
App-list pre-flight: before any upload, the worker calls GET /api/mng/apps?capabilities=cost once and skips apps that aren't in the response. Skipped apps are recorded in safe_payload.skipped_apps[] with the reason. This avoids burning rate-limit slots on apps we know will return 404.
23.8. Rate limits & data window
| Limit | Value |
|---|---|
| API calls per minute | 150 per partner-account token |
| API calls per day | 1000 per partner-account token |
| Body size per upload | 1 MB |
| Earliest date allowed | 90 days before current date |
| Latest date allowed | Current date (no future dates) |
| Recommended frequency | ≥6 sends per day for freshness |
| Recommended retroactive window | Last 7 days every run, for completion |
Default schedule (6 runs/day × ≤ ~30 apps × 1 upload + 3 polls each) = ~720 calls/day, well under the daily limit. If we grow beyond this, the worker would need explicit pacing.
23.9. Skipped rows (audited, not sent)
- Empty
campaign_idorreport_date - Non-numeric or negative
cost cost == 0(allowed by spec but skipped to compact payloads)campaign_idnot inappsflyer_app_id_mappings→ counted inunmapped_campaigns- App not in AppsFlyer cost-allowlist → entire app bucket recorded in
skipped_apps[]
23.10. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| HTTP 401 on every call | API token revoked (March 10 2026 mass-revocation) or expired | Regenerate the V2.0 token in AppsFlyer dashboard, update the AppsFlyer profile. |
| HTTP 415 "media source not found" | The media_source in the AppsFlyer profile is not registered to our partner account | Confirm the value with AppsFlyer's partner-development manager. For P2W it is point2web_int — same as the pid on every client's attribution link. |
HTTP 200 but matched_records_percentage stays at 0 | media_source mismatch with attribution-link pid | Check a client's AppsFlyer attribution link (Active Integrations → Point2Web → Attribution link). The pid query parameter is the canonical media_source. Update the AppsFlyer profile and re-run. |
| HTTP 404 on a specific app | Advertiser hasn't enabled "Get Cost Data" for our network on that app | Have the advertiser toggle Get Cost Data in their AppsFlyer dashboard → Integrated Partners → P2W → Cost tab. |
| HTTP 422 "incorrect JSON format" | Required field missing or wrong type | Check the audit row's response_excerpt for the specific field. Common: empty campaign_name or spend sent as a non-numeric string. |
Job stays InProgress through all polls | AppsFlyer is processing under heavy load | Not a worker bug. Next scheduled run re-uploads (retroactive 7-day window) and supersedes the in-flight job. |
matched_records_percentage consistently < 90% | Cost data IDs don't match attribution-link IDs | Verify Mintegral click-URL templates carry af_c_id, af_adset_id, af_ad_id matching the IDs reported in cost data. |
References:
- support.appsflyer.com — InCost API for ad networks (partner guide)
- dev.appsflyer.com — InCost API reference
- dev.appsflyer.com — App list API
Important Notes
Do not use ClickFlare /api/campaigns/{id} for cost import. It edits campaign configuration and cost model settings.
Use ClickFlare /api/campaigns/manual/cost for manual cost upload. The current worker sends CSV through multipart upload.
Store IDs as strings in Firestore. This avoids precision and formatting problems.
Keep allow_internal_id_fallback false. Returning internal SmartNews IDs as tracker IDs can corrupt tracker cost mapping.
For ClickFlare, prefer all-time mode. The worker uses all_time_since → scheduled_for to generate the manual cost CSV.
For RedTrack, use scheduled window mode. RedTrack /tracks/cost accepts time-windowed cost updates.
Do not paste real tokens into documentation, commits, screenshots, or Slack. Use Secret Manager references.
If rows = 0, the tracker was not called. Debug SmartNews data and extractor first.
After frontend patches, hard refresh the admin UI. Use Cmd + Shift + R to clear cached JS/HTML.
Summary
SmartNews Cost Import API centralizes SmartNews spend collection, client-specific campaign ID extraction, Firestore-based configuration, and tracker delivery. The public import endpoint supports both explicit date ranges and all-time imports. The admin panel provides controlled access for managers and admins, while Firestore acts as the source of truth for clients, auth profiles, auto-imports, audit logs and settings.
Current tracker behavior: RedTrack uses one request per campaign cost row and works with scheduled time windows. ClickFlare uses the manual cost endpoint with CSV multipart upload and should be configured with cost_mode: all_time.
Operational rule: when adding or editing clients, always verify allowed_ad_account_ids, extractor rules, and tracker-specific auto-import config before activating scheduled runs.
| Role | Source | Updated by | When | ||
|---|---|---|---|---|---|
Not loaded yet. | |||||
| Time | Action | Client | Level | Status |
|---|