The shapes the JSON API returns, and how to tell success from failure reliably.
Every /api/v1 response has the same top-level structure:
{
"success": true,
"data": [ ... ],
"meta": { ... }
}
Check the HTTP status first, then success. Note that /api/track does
not return JSON — it issues a redirect.
{
"success": true,
"data": [
{
"offer_id": 123,
"name": "Complete a sign-up",
"description": "Sign up for our newsletter",
"payout": 1.2500,
"payout_local": 1.14,
"currency": "EUR",
"category": "apps",
"link": "https://preads.hansaldev.com/api/track?offerId=123&apiKey=...&userId=user_8812",
"image_url": "https://.../logo.png",
"countries": ["US", "CA"],
"platforms": ["web"],
"steps": []
}
],
"meta": {
"total": 142,
"returned": 20,
"limit": 20,
"offset": 0,
"detected": {
"country": "US",
"device": "desktop",
"os": "Windows"
},
"timestamp": 1759000000,
"request_id": "req_68f2a1b0c3d4e"
}
}
| Field | Meaning |
|---|---|
offer_id |
Pass back as offerId to /api/track. |
payout |
Your earnings from this offer, in USD. |
payout_local |
The end user's reward, converted into your app's currency at your app's exchange rate. This is the figure to show to your user. |
currency |
Your app's currency code, matching payout_local. |
link |
A ready-made tracking URL for this offer, already carrying your API key and the user ID. Send the user here. |
countries, platforms |
Where the offer is available. Useful for explaining why an offer may not convert. |
steps |
Sub-tasks for multi-step offers. Empty for single-step offers. |
meta.detected |
The country and device Preads detected for the caller. This is what filtering defaults to when
you do not pass country or platform — so an empty result set is
often a geo mismatch rather than an empty catalogue.
|
meta.total |
Total matching offers before pagination, not the size of data.
|
meta.request_id |
Quote this when reporting a problem — it identifies the exact request in the server logs. |
payout and payout_local are not two views of one number. The first
is what you earn; the second is what your user receives. They differ by your exchange rate.
Use limit (1–100, default 20) and offset (default 0). Iterate with
offset += limit while offset < meta.total, reading meta.total
rather than assuming a page size.
Errors use the same envelope with success: false and an error
object:
{
"success": false,
"error": {
"code": "INVALID_API_KEY",
"message": "The provided API key or secret is invalid"
},
"meta": {
"timestamp": 1759000000,
"request_id": "req_68f2a1b0c3d4e"
}
}
Validation errors carry the field-level detail in message:
{
"success": false,
"error": {
"code": "INVALID_PARAMETERS",
"message": {
"limit": ["The limit may not be greater than 100."],
"sort": ["The selected sort is invalid."]
}
},
"meta": { "timestamp": 1759000000, "request_id": "req_..." }
}
| Status | Code | Cause | What to do |
|---|---|---|---|
401 |
INVALID_API_KEY |
Key or secret did not match, or the app is not approved. | Check both credentials and the app's approval status. Do not retry unchanged. |
422 |
INVALID_PARAMETERS |
A parameter failed validation. | Fix the fields named in error.message. |
429 |
— | Rate limit exceeded. | Back off and cache. Do not retry immediately. |
404 |
— | On /api/track: the offer is gone or inactive. |
Re-fetch the offer list. |
403 |
— |
On /api/track and the wall endpoints: the app exists but is not approved, or
fraud detection refused the click.
|
Distinguish by the message: "Invalid API key or app not approved" versus "Access Denied: Suspicious Activity Detected". |
302 |
— |
On the wall and tracking endpoints: the request failed validation — a missing or
unknown apiKey, or a missing userId on a click.
|
The browser is sent back to the home page rather than shown an error, so a typo presents as "the wall does not work". Check the credentials and the user ID. |
403 or 404, or a 302 redirect when validation fails. Parse
defensively, and read the body to tell a configuration problem from a fraud refusal.