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.
{"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"
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.
| Parameter | Required | Notes |
|---|---|---|
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.
type | Format | What 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.
| Parameter | Required | Notes |
|---|---|---|
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
| Field | Meaning |
|---|---|
countries | Included countries. Empty means no country restriction. |
devices | Included device types, same convention. |
operating_systems | Included operating systems, same convention. |
min_os_version | Minimum OS version, compared dotted-numerically. |
hold_hours | How long an accepted conversion is held before it becomes payable. |
window_hours | How 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.
| Parameter | Required | Notes |
|---|---|---|
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"
}
]
}
status | Meaning |
|---|---|
pending | Accepted and inside its hold period. Not yet in your balance. |
approved | The hold elapsed. Paid into your available balance. |
reversed | Approved, then taken back — a chargeback, a refund, or fraud found late. |
rejected | Never 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.
| Status | Meaning | What to do |
|---|---|---|
400 | Missing or malformed parameter | Fix the call; retrying will not help |
401 | Bad signature, or ts outside the window | Check the secret and your clock |
403 | App or publisher not active | Check the app's status in the dashboard |
404 | Unknown app_key, or no such offer for this user | Fix the call |
429 | Rate limited | Back off for Retry-After seconds |
500 | Our fault | Retry 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.