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 | The USER's two-letter ISO 3166-1 country code. Nothing is inferred from your server's connection, so without this every country-restricted offer is hidden and the response carries a warning saying so. |
ua
|
No | The user's own User-Agent header, forwarded verbatim. Answers device, OS and browser in one string — the one most integrations already hold. |
device
|
No |
Explicit device, when you know better than 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 · trial
|
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 },
"targeting": { "country": "DE", "device": "android", "os": "android" },
"warnings": [],
"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"
}
]
}
country means no
country-restricted offers at all, which usually looks like "my PTC
offers disappeared". Pass country and forward the user's
own ua; warnings in the response names
anything you left out.
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 8 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. |
trial |
Cost Per Trial | You pay when the user starts your free trial. |
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.