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
| mode | You get | Typical time |
|---|---|---|
lite | Redirect 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 |
full | Everything 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
| Form | Example | Match |
|---|---|---|
| App package | com.whatsapp | terminal app destination |
| Apple store id | 310633997 / id310633997 | terminal app destination |
| Store URL / deep link | https://play.google.com/store/apps/details?id=…, market://…, itms-apps://… | terminal app destination |
| Landing URL | https://lander.example/lp | final 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.
| Route | Serves |
|---|---|
GET /v1/tests/{id}/frames/{n} | frame n of capture.frames, image/png |
GET /v1/tests/{id}/recording | the 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
| verdict | Meaning | Bill? |
|---|---|---|
| working | reached exactly the expected destination | yes |
| wrong_destination | reached a destination, not the expected one | yes |
| no_destination | pages captured, but no app destination where one was expected | yes |
| broken | the offer's side failed (error page, dead final URL) | yes |
| unverified | destination reached but not checkable (no expectation, or Apple id-space mismatch) | yes |
| inconclusive | our proxy/infra failed; the offer was never observed | no |
Targeting data — build the test form from these
| Call | Returns |
|---|---|
GET /v1/options | everything 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=N | states for the cascading select |
GET /v1/directory/cities?state_id=N | cities |
GET /v1/directory/asns?country=US | carrier ASNs (SX.ORG) |
GET /v1/directory/isps?country=DE&type=mobile | ISPs (ProxyEmpire), type mobile|residential |
GET /v1/directory/carriers?country=DZ&type=mobile | the 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.
Other endpoints
| Call | Notes |
|---|---|
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=0 | summaries (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}/cancel | stops 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/plans | unauthenticated; 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 /healthz | unauthenticated liveness |
GET /readyz | unauthenticated readiness: the database answers and the engine reports its capabilities → {"status":"ok","engine_version":"…"}, else 503 naming the failed check |
POST /v1/guest-tokens | unauthenticated; 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.
| Call | Notes |
|---|---|
GET /v1/account | session 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/account | session only; {"mail": {"results": false}}, each field optional, unknown fields 400 → the account as GET answers it |
GET|POST /v1/account/unsubscribe | the 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-secret | session only → {"webhook_secret": "whsec_…"}, a new signing secret; the old one stops at once |
GET /v1/account/export | session only; one JSON document with the account, its keys (names only) and every test summary, as a download |
DELETE /v1/account | session 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/claim | session 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/keys | session only → {"keys": [{"id", "name", "created_at"}]}, enabled keys, oldest first. No secrets: storage holds a SHA-256 |
POST /v1/keys | session 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.
| Call | Notes |
|---|---|
POST /v1/billing/checkout | session 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/switch | session 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/cancel | session 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/resume | session 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/transactions | session 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/portal | session 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/billing | the 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
- Every error body carries an
error_code: the specific ones below, else the generic one for the status:400 validation_error(the message names the field),401 unauthorized(missing or invalid key or session token),403 forbidden,404 not_found(not yours, or no such thing),409 conflict(already_finishedon a cancel),500 internal,503 unavailable(the API is restarting:Retry-After10, from the edge). - Back pressure.
503 queue_fullwhen the whole queue is at its cap,429 concurrency_limitwhen a single test would only wait behind your plan's one busy slot (an account's queued tests wait their turn; the queue runs at most the plan'smax_concurrentof them at once, so a batch of the plan's size is always admitted; a plan-less key keeps a queued-plus-running cap),429 guest_limitwhen a guest token's allowance is spent. Every 429 carriesRetry-Afterin seconds. A batch is admitted whole or refused whole. - Plan limits.
429 quota_exceededwhen the plan's billable tests for the UTC calendar month are spent, counting tests queued or running as already spent (Retry-Afterruns to the first of next month);429 daily_limitlikewise for a plan with a daily cap (Retry-Afterto midnight UTC);403 plan_featurefor a mode the plan does not run (the free plan runsliteonly; the message names the cheapest plan that runs it);400 batch_limitwhen a batch is larger than the plan allows;402 payment_past_duewhile a failed payment holds the account. Inconclusive, failed and cancelled tests never count. Each of these refusals also carriesplan({"id", "name"}),resets_at(quota and daily limits: when the count resets, RFC 3339) and, where the catalogue has one,upgrade: the cheapest plan that lifts the limit, as{"id", "name", "price_usd_month", "monthly_tests", "max_concurrent", "max_batch"}, so a client can offer one next step without reading the catalogue. - Headroom on every create.
POST /v1/testsandPOST /v1/test-batchesanswer, accepted or refused, withX-Usage-Limit(the cap that binds: the month's, else the day's; a guest token's allowance),X-Usage-Remaining(what is left after everything queued or running, the accepted test included) andX-Usage-Reset(when the count resets, RFC 3339), so a client branches before the wall. Operator keys have no cap and no headers.GET /v1/usageshows the plan'smodes, caps and headroom before you hit any of them:remaining_monthandperiod_end, and for a plan with a daily capremaining_dayandday_end. error_codeon a test names why a run failed before observing the offer: on afailedtest one ofengine_failed,timeout,provisioning_failed; on adonetest withresult.successfalse the engine's own token, such asno_matching_proxy(no exit for that country, carrier and device type could be found or created right now),tunnel_failed,proxy_conn_reset,dns_error,tls_error,timeout,browser_engine_missingorunknown; on adonetest whose browser opened but whose exit died before the offer answered,proxy_transport_failedorproxy_auth_failed(result.successstays true, the verdict isinconclusive, nothing is billed). Tokens are only ever added.error_codeaccompanies every refusal above, the guest rules (guest_mode: free tests run the guest mode, use no webhook and target no state, city, ASN or ISP;guest_forbidden),country_required, and the evidence links (evidence_link_invalid,evidence_link_expired); it is the field to branch on, theerrorsentence may change.request_idis in every error body, and every response carries it as theX-Request-Idheader (exposed to browser code). Quote it when reporting a problem: it names the one line in our log that describes your request.- Target and webhook URLs must be public http(s): localhost, private and link-local IPs, dotless hosts and
.localare rejected. - Request bodies are capped (
max_body_bytes, default 1 MB); batches at 100 tests. - Keys are minted by a signed-in account on
POST /v1/keys, or by the operator withofferprobe key create --name …; shown once, storage holds only a SHA-256.