offerprobe.com · Developer page (your key, quick start) · openapi.yaml · Plans

OfferProbe API

offerprobe v0.1 · async REST · authenticate every call (except /healthz) with X-API-Key, or from the web app with a session's Authorization: Bearer token

The machine-readable contract is openapi.yaml (OpenAPI 3.1), also served by the API at GET /openapi.yaml and /openapi.json; generate a client or a Postman collection from it. This page is the same contract with the reasoning behind each field.

Model

A test browses one tracking link through a proxy matching the requested targeting and returns what a real user would reach. Tests run asynchronously: submitting returns 202 with an id; poll GET /v1/tests/{id} or receive a webhook. Status flow: queued → running → done | failed. done means the engine produced a result — the result's own success/verdict say how the offer fared; failed means the engine could not run at all. The owner's first GET of a finished test counts as having read it: an account gets a result mail only for a finished test nobody fetched within a minute (and for every full-mode test), so a client that polls gets no mail.

POST /v1/tests

{
  "url": "https://track.example/click?id=1",   // required, public http(s)
  "country": "BR",                             // required: the country the visitor comes from
  "carrier": "Claro",                          //   carrier name (or ASN name fragment)
  "device_type": "mobile",                     //   mobile | residential | datacenter
  "expect": "com.some.app",                    // expected destination → verdict (see below)
  "os": "Android",                             // how the visitor should look; pick one of
  "os_version": "12",                          //   the entries in /v1/options → devices[]
  "browser": "Chrome Mobile",
  "fresh": true,                               // guarantee an unused fingerprint for this test
  "state": "California",                       // precision targeting: the server provisions a
  "city": "Los Angeles",                       //   matching exit for this test and releases it
  "asn": "AS7922",                             //   afterwards (requires country)
  "isp": "deutsche telekom ag",
  "mode": "standard",                          // lite | standard | full, see below
  "screenshot": true,                          // default true in standard and full; lite never captures
  "skip_visits": 2,                            // 0–20, cloaker bypass; standard and full only, 400 with mode lite
  "webhook_url": "https://you.example/hook",   // optional completion push
  "reference": "camp-7"                        // your own label: echoed on the test and in the webhook, a list filter
}

→ 202 {"id": "…", "status": "queued", "queue_position": 3, "estimated_wait_seconds": 90}. Unknown fields are rejected (a typo must fail loudly). Send an Idempotency-Key header (any string unique to the request, up to 200 characters, scoped to your key and kept 24 hours) and a retry after a timeout replays the first answer with Idempotent-Replayed: true instead of queueing and billing a second test; the same key with another body is 409 idempotency_conflict, and while the first request is still in flight 409 idempotency_in_progress. A refused request releases the key, so the same retry succeeds once the refusal's cause is gone. A test with no country is refused with 400 country_required: a visit comes from somewhere, and a browse straight from our host is an operator's tool. queue_position is 1 for the next test to run; the wait is the position over the number of browser slots times the median recent run, an estimate, and both fields also appear on GET while the test is queued.

mode — how much evidence, and how long the slot is held

modeYou getTypical time
liteRedirect chain, final URL, page title, verdict against expect. No screenshots, no CTA funnel.Fastest; the free landing-page test runs in this mode
standard (default)Everything in lite plus screenshots and the CTA funnel walk.The figures on this page
fullEverything in standard plus a session recording.Longest

You describe the visit you want, not the machinery that makes it. provider, proxy_id and profile_id are refused with 400: which supplier is used, which exit is provisioned and which stored fingerprint is loaded are server decisions. Everything above is the full targeting vocabulary.

expect — what the link should reach

FormExampleMatch
App packagecom.whatsappterminal app destination
Apple store id310633997 / id310633997terminal app destination
Store URL / deep linkhttps://play.google.com/store/apps/details?id=…, market://…, itms-apps://…terminal app destination
Landing URLhttps://lander.example/lpfinal page's domain (subdomains match)

GET /v1/tests/{id}

{
  "id": "…", "status": "done",
  "request": { …as submitted… },
  "verdict": "working",                        // indexed from the result
  "final_url": "https://play.google.com/…",
  "created_at": "…", "started_at": "…", "finished_at": "…",
  "result_expired_at": "…",                    // present once the plan's history window passed: result gone, verdict kept
  "result": {
    "success": true, "verdict": "working", "verdict_reason": "…",
    "expected": "app:com.some.app", "destination_app": "app:com.some.app",
    "outcome": "captured", "final_url": "…", "page_title": "…",
    "redirect_chain": [{"url":"…","source_url":"…","status_code":302,"kind":"http"}],
    "capture": {"frames": [{"name":"lander","url":"…",
                            "screenshot_url":"https://api…/v1/tests/…/frames/0?exp=…&sig=…",
                            "timing_ms":1200,"cta_text":"CLAIM NOW"}]},
    "capture_mode": "deep", "capture_stop_reason": "reached_terminal",
    "capture_stop_detail": "…", "transport_verdict": "…",
    "error_code": "…", "errors": ["…"],
    "timing": {"total_ms": 18400, "redirects_count": 3},
    "recording_url": "https://api…/v1/tests/…/recording?exp=…&sig=…",   // mode full
    "recording_format": "webm", "recording_duration_ms": 30000,
    "recording_continuations": [{"url":"…","recording_url":"…/recording/0?exp=…&sig=…","duration_ms":5000}],
    "evidence_expires_at": "2026-09-20T12:00:00Z",
    "visit": {
      "country": "BR", "carrier": "Claro", "connection": "mobile",
      "exit_ip": "177.44.x.x",                 // masked
      "asn": "AS28573",                        // only if the test targeted asn/isp
      "match": "fallback",                     // exact | fallback
      "requested": {"country":"BR","carrier":"Claro","device_type":"mobile"},
      "device": {"os":"Android 12","browser":"Chrome Mobile 131",
                 "device_type":"mobile","screen":"720x1600","pixel_ratio":3,
                 "touch":true,"language":"pt-BR","timezone":"America/Sao_Paulo",
                 "user_agent":"Mozilla/5.0 …"}
    }
  }
}

visit is how the offer saw you. It is deliberately descriptive rather than operational: there is no proxy id, credential, supplier name or fingerprint id in it, and error on a failed test is a sentence chosen by its error_code, never the engine's own text — error_code is the field to branch on. visit.match is exact when the country, carrier and device type you asked for all served, fallback when another carrier in the country (or another device type) did, with visit.requested naming what you asked for; a test with no targeting carries neither. A carrier is best effort: the queue creates the exit at whichever supplier lists the carrier (pick it from /v1/directory/carriers, or type any name), and only when none does, or every exit for it is dead, does the test run from another carrier in the same country, saying so here rather than failing. A carrier named without a device_type is served as a mobile exit: a carrier is a mobile network, and the carrier list defaults to mobile.

Screenshots and recordings

Evidence is never inlined. Every screenshot_url and recording_url is a signed link that serves the object with no API key, so an <img> or <video> element can load it directly; the recording routes honour Range, so a player can seek. The links share one deadline, evidence_expires_at (default one hour after the GET); fetching the test again mints fresh ones. A frame without an image has no screenshot_url.

RouteServes
GET /v1/tests/{id}/frames/{n}frame n of capture.frames, image/png
GET /v1/tests/{id}/recordingthe session video, video/webm (or as recording_format says)
GET /v1/tests/{id}/recording/{n}continuation n of recording_continuations

The same paths also answer to the owner's X-API-Key with no exp/sig, which is what a script wants. A tampered or foreign link is 403 evidence_link_invalid; a link past its deadline is 403 evidence_link_expired; neither credential is 401. The stored result keeps object keys, not bytes, and the key is never published.

capture_stop_reason says why the click-through ended. Besides the terminal ones (reached_app_store_intent, reached_deep_offer_domain, user_input_required) and the failures, two are budgets: max_clicks (the per-mode click cap, 8 for standard and 12 for full by default) and revisited_page, set when a click navigated back to a page already captured — the funnel was cycling, and the frames already show everything it has.

Verdicts

verdictMeaningBill?
workingreached exactly the expected destinationyes
wrong_destinationreached a destination, not the expected oneyes
no_destinationpages captured, but no app destination where one was expectedyes
brokenthe offer's side failed (error page, dead final URL)yes
unverifieddestination reached but not checkable (no expectation, or Apple id-space mismatch)yes
inconclusiveour proxy/infra failed; the offer was never observedno

A failed test (engine could not run) is billed like inconclusive: never.

Targeting data — build the test form from these

CallReturns
GET /v1/optionseverything in one call: countries[] (~248, id+code+name), devices[] (os, os_version, browser, screen, class, label — send the first three back on a test), device_types[]. Cached 10 min
GET /v1/directory/states?country_id=Nstates for the cascading select
GET /v1/directory/cities?state_id=Ncities
GET /v1/directory/asns?country=UScarrier ASNs (SX.ORG)
GET /v1/directory/isps?country=DE&type=mobileISPs (ProxyEmpire), type mobile|residential
GET /v1/directory/carriers?country=DZ&type=mobilethe carrier list to offer a customer: every network either supplier can express for the country, once, under the name a customer knows (name, the value to send as carrier) with the suppliers' own spellings as also_known_as; type mobile|residential

Precision targeting

country, carrier and device_type are matched against exits that already exist. state, city, asn and isp are finer than any pool can be held at, so putting one of them on a test makes the server provision an exit that matches, run the test pinned to it, and release it when the test finishes. Nothing extra to call, and no proxy lifecycle to manage from the client — that machinery is operator-side.

Operators: proxy provisioning, the fingerprint template library, tests pinned to a specific exit and the raw engine JSON live on the admin plane, which listens on loopback only and requires an operator key (key create --role operator).

Other endpoints

CallNotes
POST /v1/test-batches{"tests": [{…}, …]}, 1–100 entries; validates all before queueing any and reports every invalid one at once (400 with errors: [{"index", "error", "error_code"}]; the top-level error_code is the shared one when every item failed the same way, else validation_error); inserted in one transaction → 202 {"ids": […], "batch_id": "…", "status": "queued", "queue_position", "estimated_wait_seconds"}. Takes Idempotency-Key like a single test
GET /v1/test-batches/{id}the batch as a group → {"id", "created_at", "total", "finished", "counts": {"queued", "running", "done", "failed", "cancelled"}, "verdicts": {"working", "failing", "unverified", "ours"}, "tests": [summaries]}; finished once nothing is queued or running. Every test of a batch carries batch_id
GET /v1/tests?status=done&reference=camp-7&batch_id=…&limit=50&offset=0summaries (no result body) incl. verdict_reason and error_code, newest first, scoped to the calling key; reference and batch_id narrow it
POST /v1/tests/{id}/cancelstops a queued or running test (kills the browser run); 409 if already finished. A cancelled test is never billed and sends no webhook
GET /v1/usage{"tests_today", "tests_month", "tests_total", "billable_month", "active_now", "plan": {"id", "name"}, "limits": {"monthly_tests", "max_concurrent", "max_batch"}, "remaining_month", "period_end"} for the account (or the plan-less key). billable_month is what the plan counts: tests this UTC calendar month that ran to a verdict; remaining_month is null on an unlimited plan; plan, limits and the rest are absent for guests and operators. Tests are the billing unit; proxy bandwidth is a supplier cost and stays operator-side
GET /v1/plansunauthenticated; the catalogue → {"plans": [{"id", "name", "tagline", "monthly_tests", "daily_tests", "max_concurrent", "max_batch", "modes", "price_usd_month", "features", "highlight", "default"}], "default": "free"}. monthly_tests and daily_tests null are unlimited, modes absent is every mode, trial_days (when present) is the free trial the plan's checkout starts with, retention_days is how long a finished test keeps its result body and evidence (the verdict and the usage record stay; result_expired_at on the test says when they went), price_usd_month null is not for sale; max_concurrent is tests running at once (what is queued waits its turn)
GET /healthzunauthenticated liveness
GET /readyzunauthenticated readiness: the database answers and the engine reports its capabilities → {"status":"ok","engine_version":"…"}, else 503 naming the failed check
POST /v1/guest-tokensunauthenticated; mints a short-lived token for the landing page's free test → 201 {"token": "ots_g_…", "expires_at": "…", "tests_remaining": 3}. Use it as X-API-Key. Per client IP per day; 429 guest_limit past the cap. A guest token may only create lite tests (400 guest_mode otherwise), read and cancel its own tests, and read the targeting data; listing, batches and usage answer 403 guest_forbidden

Accounts and keys

Accounts are held by the identity service at <app origin>/api/auth/* (Better Auth: sign-up with email and password confirmed by mail, magic-link sign-in, password reset, sessions). The API never sees a password. A signed-in browser fetches a 15-minute token from GET /api/auth/token and sends it as Authorization: Bearer …; the API verifies it against the service's JWKS and acts as the account's primary key, created on first sight. Everything below is scoped to the account: a session, or any of the account's keys, sees the tests every key of the account created, including keys since revoked.

CallNotes
GET /v1/accountsession only → {"email", "name", "mail": {"results", "digest"}, "webhook_secret"}. mail is the two mails the account may turn off: a result mail (a test that finished and nobody opened within a minute, and every full-mode test; never a test with a webhook_url) and the Monday digest of the week's verdicts. Mails about the plan (granted, cancelled, ended, a failed payment) and about the month's tests running out (80% and 100%) have no switch
PATCH /v1/accountsession only; {"mail": {"results": false}}, each field optional, unknown fields 400 → the account as GET answers it
GET|POST /v1/account/unsubscribethe one-click link in a result mail or a digest (a account, k switch, s signature); no sign-in, answers a page. 403 for a link that does not verify
POST /v1/account/webhook-secretsession only → {"webhook_secret": "whsec_…"}, a new signing secret; the old one stops at once
GET /v1/account/exportsession only; one JSON document with the account, its keys (names only) and every test summary, as a download
DELETE /v1/accountsession only; deletes every test of every key (evidence objects first), the keys and the account row, and answers {"deleted": {"tests", "keys", "objects"}}; the usage ledger and billing events stay (they are the invoices). 409 subscription_active while the provider still bills the account: cancel the plan first. The identity service's own user is deleted by the web app afterwards, through a mailed confirmation
POST /v1/account/claimsession only; {"guest_tokens": ["ots_g_…"]} (up to 10) → {"claimed": 1, "test_ids": ["…"]}. Takes the tests those guest tokens created into the account's history, which is how a test run before sign-up follows the visitor in. Holding the token is the proof; it is spent by the claim (401 afterwards), its tests stay free, and an expired token still claims. Unknown, already claimed or non-guest tokens claim nothing and are not an error
GET /v1/keyssession only → {"keys": [{"id", "name", "created_at"}]}, enabled keys, oldest first. No secrets: storage holds a SHA-256
POST /v1/keyssession only; {"name": "ci"} → 201 {"id", "name", "created_at", "key": "ots_…"}. The secret appears in this response and nowhere else. Up to 20 enabled keys (409 key_limit)
DELETE /v1/keys/{id}session only → {"id", "disabled": true}; the secret stops at once, the tests it created stay in the account's history

A key on any of these answers 403 session_required: a leaked key must not be able to mint a permanent one.

Billing

Plans are sold by a payment provider (Creem, a merchant of record that handles tax). The API starts the purchase, moves a subscriber to another plan and hands out the portal; the provider's webhook is the only thing that changes an account's plan.

CallNotes
POST /v1/billing/checkoutsession only; {"plan_id": "growth"} → {"url", "plan_id"}, the provider's checkout page. 400 plan_not_for_sale for a plan without a price, 409 already_subscribed while the provider is billing the account (a checkout would start a second subscription; switch instead), 409 plan_cancelled while a cancelled plan still runs (resume it instead), 502 billing_unavailable when the provider does not answer, 503 billing_disabled on a server without a provider key
POST /v1/billing/switchsession only; {"plan_id": "scale"} → {"plan_id", "status", "pending_plan_id", "effective_at"}. An upgrade (a dearer plan) is made at the provider now, prorated (the difference for the rest of the period is charged at once), and the plan follows by webhook: plan_id is the new plan, pending_plan_id empty. A downgrade takes effect at the end of the current period: plan_id stays the current plan, pending_plan_id names the next one and effective_at the moment; nothing is charged or refunded, and the next invoice is at the new price. Choosing the current plan withdraws a pending downgrade. 404 no_subscription without a live subscription, 409 plan_cancelled while a cancellation is scheduled (resume first), 400 plan_current for the plan the account is on with nothing pending, 409 period_end_unknown when the provider has not yet said when the period ends, 402 payment_past_due until a failed payment is settled in the portal, plus the checkout refusals
POST /v1/billing/cancelsession only → {"status", "period_end"}: the subscription ends when the paid period does; the plan stays in force until then and nothing is charged after. A pending downgrade is dropped. Answers the same for a subscription already set to end. 404 no_subscription without one
POST /v1/billing/resumesession only → {"status"}: lifts a scheduled cancellation before the period ends; billing continues as before. 409 not_cancelled when nothing is set to end
GET /v1/billing/transactionssession only → {"transactions": [{"id", "created_at", "description", "amount_cents", "refunded_cents", "currency", "status", "type", "period_start", "period_end"}]}: the provider's charges and refunds for the account, newest first, at most 20; empty before a first purchase. Amounts are in minor units; status and type are the provider's words
POST /v1/billing/portalsession only → {"url"}: card and invoices at the provider. The portal's own cancel button ends a subscription at once, so the web app never sends a customer there to cancel. 404 no_billing_customer before a first purchase
POST /webhooks/billingthe provider's endpoint, authenticated by its signature (401 otherwise); idempotent by event id. Paid or active grants the product's plan, a scheduled cancellation keeps it until the period ends, expired or paused returns the account to the default plan, a failed payment holds new tests: 402 payment_past_due on POST until the next payment. An event about another subscription of the customer, while the account's own is live, is answered 200 as stale and not applied: the old subscription's renewal or expiry cannot move the plan

After paying, the customer returns to /billing?checkout=success; after a switch they stay on the page. Either way the plan changes when the webhook lands, usually within seconds. GET /v1/usage then carries "subscription": {"status", "past_due", "period_end", "pending_plan"}: status is the provider's word (active, scheduled_cancel …), period_end is when a cancelled plan lapses or a pending downgrade takes effect, and pending_plan {"id", "name"} is present while a downgrade waits. Bearer refusals: 401 session_invalid, 401 session_expired (fetch a new token), 403 email_unverified, and 401 session_disabled on a server without auth.jwks_url.

Webhook

On completion the server POSTs a small event to webhook_url (one retry after 30 s):

POST <webhook_url>
Content-Type: application/json
User-Agent: OfferProbe-Webhook/1
X-OfferProbe-Event-Id: evt_…
X-OfferProbe-Signature: t=1790000000,v1=<hex>

{"id": "evt_…", "event": "test.finished", "created_at": "2026-09-26T12:00:00Z",
 "test_id": "…", "status": "done", "verdict": "working",
 "reference": "camp-7", "batch_id": "…"}          // reference and batch_id when the test has them

// when the engine could not run at all:
{…, "status": "failed", "verdict": "",
 "error": "the test engine could not run", "error_code": "engine_failed"}

// when it ran but failed before observing the offer:
{…, "status": "done", "verdict": "inconclusive",
 "error": "the exit could not be reached", "error_code": "tunnel_failed"}

Fetch the full result via GET /v1/tests/{id} — events stay small on purpose. error is a sentence chosen by error_code (one per code, never the engine's text). It is present exactly on the runs that are not billed: a failed test, and a done test whose result.success is false, where verdict_reason carries the same sentence; error_code is the field to branch on.

Verify the signature. v1 is HMAC-SHA256 over <t>.<raw body> keyed with the account's webhook_secret (GET /v1/account shows it, POST /v1/account/webhook-secret rotates it; the Developer page has both). Compute it over the bytes as received, compare in constant time, and refuse a t more than a few minutes from now, so a captured event cannot be replayed. In Python: hmac.compare_digest(v1, hmac.new(secret, f"{t}.".encode() + body, "sha256").hexdigest()). A test created by a key without an account (an operator's) is delivered unsigned.

What a delivery will not do. The POST is made from our host, so it is held to the target rule (public http(s) only) and, after DNS resolution, to public addresses only: a hostname that resolves to a private, loopback or link-local address is refused, and no redirect is followed (a 301 is a failed delivery, not a GET somewhere else). POST /v1/webhooks/test with {"url": "…"} sends one signed webhook.test event through the same rules, no retry, and answers {"event_id", "signed", "delivered", "status", "error"}, so a receiver is checked before a test depends on it.

Errors & limits