Offer API v1

The same offers the hosted wall shows, as JSON, with the same targeting already applied. Use it when you want to render the wall yourself.

Base URL https://trovewall.com/api/v1 Auth HMAC-SHA256 request signature Format JSON, UTF-8 Errors Non-2xx with {"status":false,"error":"…"} Machine-readable openapi.json — OpenAPI 3.1, no key needed

Signing a request

Every call carries app_key, a unix ts and a sig. The signature is an HMAC-SHA256 over the query parameters — excluding sig itself — sorted by key and joined as a query string, keyed with your app_secret.

It is a signature rather than a bearer token because these calls are made from your server, and a static token ends up in a log, then in a repository, then in someone else's hands. A signature that expires limits the damage of all three. A signature is accepted within ±300 seconds of our clock, so keep your server's time synchronised — outside that window the call is rejected with 401 even when the signature itself is correct.

PHP

function tw_sign(array $params, string $secret): string {
    unset($params['sig']);
    ksort($params);

    return hash_hmac('sha256', http_build_query($params), $secret);
}

$params = [
    'app_key' => 'pk_live_a1b2c3',
    'user_id' => 'abc123',
    'country' => 'DE',
    'ts'      => time(),
];
$params['sig'] = tw_sign($params, $appSecret);

$url  = 'https://trovewall.com/api/v1/offers?' . http_build_query($params);
$feed = json_decode(file_get_contents($url), true);

Node

const crypto = require('crypto');

function twSign(params, secret) {
  const query = new URLSearchParams(
    Object.entries(params).sort(([a], [b]) => a < b ? -1 : 1)
  );

  const mac = crypto.createHmac('sha256', secret);

  return mac.update(query.toString()).digest('hex');
}

const params = {
  app_key: 'pk_live_a1b2c3',
  user_id: 'abc123',
  country: 'DE',
  ts: Math.floor(Date.now() / 1000),
};
params.sig = twSign(params, appSecret);

const base = 'https://trovewall.com/api/v1';
const url  = base + '/offers?' + new URLSearchParams(params);

const { offers } = await (await fetch(url)).json();

Python

import hashlib, hmac, time, urllib.parse, urllib.request, json

def tw_sign(params, secret):
    query = urllib.parse.urlencode(sorted(params.items()))

    mac = hmac.new(secret.encode(), query.encode(), hashlib.sha256)

    return mac.hexdigest()

params = {
    'app_key': 'pk_live_a1b2c3',
    'user_id': 'abc123',
    'country': 'DE',
    'ts': int(time.time()),
}
params['sig'] = tw_sign(params, app_secret)

base = 'https://trovewall.com/api/v1'
url  = base + '/offers?' + urllib.parse.urlencode(params)

offers = json.load(urllib.request.urlopen(url))['offers']

Shell

# Sorted by key, no sig, joined exactly as it will be sent.
TS=$(date +%s)
QS="app_key=pk_live_a1b2c3&country=DE&ts=$TS&user_id=abc123"
SIG=$(printf '%s' "$QS" \
    | openssl dgst -sha256 -hmac "$APP_SECRET" -hex \
    | awk '{print $NF}')

curl -s "https://trovewall.com/api/v1/offers?$QS&sig=$SIG"
The string you sign is the string you send. Sort by key, URL-encode once, leave sig out, and do not re-encode the result. A mismatch here is the single most common integration problem, and it always looks like a wrong secret.

Rate limits

120 requests per minute per app key. Over it, the answer is 429 with a Retry-After header — back off for that many seconds rather than retrying immediately. The offer feed is designed to be called when a user opens your wall, not on a timer: cache a response for the length of a session rather than polling it.

GET /offers

Offers this user is eligible for, right now, on this device, in this country.

ParameterRequiredNotes
app_key Yes The app's public key.
ts Yes Unix seconds. Rejected more than 300 seconds from our clock.
sig Yes HMAC-SHA256 of the sorted query string, keyed with the app secret.
user_id Yes Your identifier for the end user. Opaque to us; never send an email address or a name.
country No Two-letter ISO 3166-1 country code. Overrides the IP lookup.
device No Overrides the User-Agent. one of desktop · android · ios · tablet
os No Operating system and version, used against an offer's minimum-version rule.
category No Category slug. Omit for every category.
type No Ad format. Omit for every format. one of cpe · cps · cpl · cpi · video · ptc · research
q No Substring match on the offer name.
sort No Ordering within the featured-first grouping. one of payout · newest · popular · name, default payout
limit No How many offers to return. 1–200, default 50
offset No How many to skip, for paging. default 0

Response

{
  "status": true,
  "currency": { "name": "Coins", "rate": 1000 },
  "count": 1,
  "offers": [
    {
      "id":            4821,
      "name":          "Vault Rivals — reach level 12",
      "description":   "Build your base and reach level 12.",
      "instructions":  "Install, register, and reach level 12 within 7 days.",
      "disclaimer":    "New users only.",
      "icon_url":      "https://cdn.example/vault.png",
      "image_url":     null,
      "category":      "games",
      "type":          "cpi",
      "payout":        2.94,
      "payout_cents":  294,
      "currency_amount": 2940,
      "variable_payout": false,
      "click_url": "https://trovewall.com/go/pk_live_a1b2c3/4821?user_id=abc123"
    }
  ]
}
payout is what you earn, not what the advertiser pays. It is already net of the revenue share, so you never have to apply your own rate. currency_amount is the same figure converted with your app's exchange rate — show that to the user. Cents are authoritative; the decimal beside them is a convenience.
variable_payout means the figure is a ceiling. A few offers price each conversion themselves — a survey wall, where one survey is worth four times another. For those, print up to €x rather than a promise, and read the real amount from the postback, which carries what that conversion actually paid.

The type field

One of seven ad formats. It is a stable string — safe to switch on, safe to filter with ?type=, and it will not be renamed under you.

typeFormatWhat the user does
cpe Cost Per Engagement You pay when the user does something real inside your product.
cps Cost Per Sale You pay when the user buys something.
cpl Cost Per Lead You pay when the user gives you their details.
cpi Cost Per Install You pay when the user installs your app and opens it.
video Instream Video You pay when the user watches your video ad through to the end.
ptc Paid To Click You pay when the user visits your page and actually stays on it.
research Market Research You pay when the user completes a survey or a study you qualify them for.

Two of them behave differently once the user clicks, and both are handled for you by click_url: ptc opens a timed page on our domain and credits when the countdown finishes, and video opens a player that credits on the VAST complete event. You do not need to special-case either — send the user to click_url exactly as you would for any other format and wait for the postback.

Send the user to click_url

Do not construct tracking links yourself and do not link the advertiser's URL directly. Only click_url mints a click, and a conversion without a click cannot be attributed — meaning it will not be paid.

GET /offer/{offer_id}

One offer in full: everything the feed returns, plus its targeting, its conversion window and every goal with its own payout. Use it for a detail screen, so the user sees the rules before they start.

ParameterRequiredNotes
offer_id in path Yes From the offer feed.
user_id Yes Your identifier for the end user. Opaque to us; never send an email address or a name.

Plus app_key, ts and sig, as everywhere.

What the feed does not carry

FieldMeaning
countriesIncluded countries. Empty means no country restriction.
devicesIncluded device types, same convention.
operating_systemsIncluded operating systems, same convention.
min_os_versionMinimum OS version, compared dotted-numerically.
hold_hoursHow long an accepted conversion is held before it becomes payable.
window_hoursHow long after the click a conversion is still attributed to it.
goals[]Each payable step: key, name, description and its own payout.

An offer the user is not eligible for answers 404, identically to one that does not exist. That is deliberate: telling you which offers your user was refused would map out the network's inventory.

GET /conversions

Reconciliation, and the reward history if you render your own. Newest first. This is a record of what happened, not a delivery mechanism — to be told about a conversion as it happens, use a postback.

ParameterRequiredNotes
user_id No Your identifier for the end user. Opaque to us; never send an email address or a name.
from No Earliest conversion date, inclusive. YYYY-MM-DD
to No Latest conversion date, inclusive. YYYY-MM-DD
status No Lifecycle state. Only `approved` has been paid into the balance. one of pending · approved · reversed · rejected
limit No How many to return. 1–500, default 100
offset No How many to skip, for paging. default 0

Plus app_key, ts and sig, as everywhere.

{
  "status": true,
  "count": 1,
  "conversions": [
    {
      "id": 99182,
      "offer_id": 4821,
      "offer_name": "Vault Rivals — reach level 12",
      "user_id": "abc123",
      "goal": "level_12",
      "payout": 2.94,
      "payout_cents": 294,
      "currency_amount": 2940,
      "status": "approved",
      "created_at": "2026-08-12T18:04:11+00:00"
    }
  ]
}
statusMeaning
pendingAccepted and inside its hold period. Not yet in your balance.
approvedThe hold elapsed. Paid into your available balance.
reversedApproved, then taken back — a chargeback, a refund, or fraud found late.
rejectedNever credited: a duplicate, a cap, or a failed fraud check.

GET /balance

Your current balance, pending amount and lifetime earnings, in cents and as decimals. Balances are per publisher, not per app: every app you own reports the same figures.

Takes only app_key, ts and sig. There is no user_id here — balances are yours, not your users'.

{
  "status": true,
  "balance": {
    "available": 184.20,
    "available_cents": 18420,
    "pending": 31.05,
    "pending_cents": 3105,
    "lifetime": 2914.66,
    "lifetime_cents": 291466,
    "currency": "EUR",
    "min_payout_cents": 2000
  }
}

Errors

Every failure has the same shape, whatever went wrong:

{
  "status": false,
  "error": "ts is outside the 300 second signing window. Check your server clock.",
  "code": 401
}

error is written for you, not for your users — it names the parameter and says what was wrong with it. Log it; do not show it.

StatusMeaningWhat to do
400Missing or malformed parameterFix the call; retrying will not help
401Bad signature, or ts outside the windowCheck the secret and your clock
403App or publisher not activeCheck the app's status in the dashboard
404Unknown app_key, or no such offer for this userFix the call
429Rate limitedBack off for Retry-After seconds
500Our faultRetry with backoff; tell us if it persists

OpenAPI

The whole surface is published as an OpenAPI 3.1 document at https://trovewall.com/api/v1/openapi.json. It needs no key, and it is assembled from our route table on every request — so it describes what is deployed, not what was true when someone last edited a page. Point a generator at it:

npx @openapitools/openapi-generator-cli generate \
    -i https://trovewall.com/api/v1/openapi.json \
    -g typescript-fetch -o ./trovewall

Signing is the one thing a generated client will not do for you: add ts and sig in a request interceptor, using the same sorted-query rule as above.