openapi: 3.1.0 # The customer API, as the projection in internal/httpapi/publicview.go # serves it. This file is the contract a client is generated from: # internal/httpapi/openapi_test.go checks that every customer route is here # and that the Test, Request, Result, Usage, Plan, Account and Key schemas # name exactly the fields the projection structs carry. The API serves it # at GET /openapi.yaml and /openapi.json; the site at /docs/openapi.yaml. info: title: OfferProbe API version: "1.0" summary: Where an affiliate or tracking link really lands, tested in a real browser from a chosen country, carrier and device. description: | Tests run asynchronously: `POST /v1/tests` answers `202` with an id; poll `GET /v1/tests/{id}` or name a `webhook_url` and receive the completion event. Status flow: `queued → running → done | failed`. `done` means the engine produced a result and the result's own `verdict` says how the offer fared; `failed` means the engine could not run. A `failed` test and a `done` test whose `result.success` is false are never billed, nor is a verdict of `inconclusive`. Authenticate with `X-API-Key` (a key from the Developer page, or a guest token from `POST /v1/guest-tokens`), or from the web app with a session's `Authorization: Bearer` token. Every response carries `X-Request-Id`; every error body carries `error`, `error_code` and `request_id`. `error_code` is the field to branch on: the sentence in `error` may change. The human-readable reference with the reasoning behind each field is at https://offerprobe.com/docs. contact: email: support@offerprobe.com termsOfService: https://offerprobe.com/terms servers: - url: https://api.offerprobe.com tags: - name: Tests description: Queue, read, list and cancel tests; fetch their evidence. - name: Targeting description: The countries, devices, states, cities, carriers, ASNs and ISPs a test may name. - name: Plans and usage - name: Guests description: The landing page's free test. - name: Account description: Session only. Keys and the account itself. - name: Billing description: Session only. Checkout, plan switches and the provider's portal. - name: Service security: - ApiKey: [] - Session: [] paths: /v1/tests: post: tags: [Tests] summary: Queue a test description: | Describe the visit you want; the server picks the supplier and the exit. `country` is required (`400 country_required`): a visit comes from somewhere, and a browse from the API's own host is an operator's tool. `provider`, `proxy_id` and `profile_id` are refused with `400`. Unknown fields are refused. The response, accepted or refused, carries the headroom headers. Send `Idempotency-Key` to make a retry after a timeout safe. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TestRequest" responses: "202": description: Queued. headers: X-Usage-Limit: { $ref: "#/components/headers/X-Usage-Limit" } X-Usage-Remaining: { $ref: "#/components/headers/X-Usage-Remaining" } X-Usage-Reset: { $ref: "#/components/headers/X-Usage-Reset" } Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" } content: application/json: schema: $ref: "#/components/schemas/Accepted" "400": description: "`validation_error` (the message names the field), `country_required`, `guest_mode`." content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "409": { $ref: "#/components/responses/IdempotencyRefused" } "402": description: "`payment_past_due`: a failed payment holds new tests until the card is updated." content: application/json: schema: { $ref: "#/components/schemas/Error" } "403": description: "`plan_feature`: the plan does not run this mode; `upgrade` names the cheapest plan that does." content: application/json: schema: { $ref: "#/components/schemas/Error" } "429": { $ref: "#/components/responses/Limited" } "503": { $ref: "#/components/responses/QueueFull" } callbacks: completion: "{$request.body#/webhook_url}": post: summary: The completion event description: | One POST when the test finishes (one retry after 30 s). `error` and `error_code` are present exactly on the runs that are not billed: a `failed` test, and a `done` test whose `result.success` is false. Fetch the full result with `GET /v1/tests/{id}`; the event stays small. requestBody: content: application/json: schema: { $ref: "#/components/schemas/WebhookEvent" } responses: "200": description: Delivered. Any 2xx counts. get: tags: [Tests] summary: List tests description: Summaries without the result body, newest first, scoped to the account (every key of it) or to the key. Guests answer `403 guest_forbidden`. parameters: - name: status in: query schema: { type: string, enum: [queued, running, done, failed, cancelled] } - name: reference in: query schema: { type: string } description: Only tests sent with this `reference`. - name: batch_id in: query schema: { type: string } description: Only the tests of this batch. - name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } - name: offset in: query schema: { type: integer, minimum: 0, default: 0 } responses: "200": description: The page. content: application/json: schema: type: object properties: tests: type: array items: { $ref: "#/components/schemas/Test" } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: "`guest_forbidden`." content: application/json: schema: { $ref: "#/components/schemas/Error" } /v1/test-batches: post: tags: [Tests] summary: Queue up to 100 tests as one batch description: | Validated whole before anything queues, every invalid test reported at once in `errors`; admitted whole under the plan's caps (`400 batch_limit` past the plan's batch size) or refused whole; inserted in one transaction. The answer carries `batch_id`, the handle for `GET /v1/test-batches/{id}`, and every test carries it too. Send `Idempotency-Key` to make a retry after a timeout safe. parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: type: object required: [tests] properties: tests: type: array minItems: 1 maxItems: 100 items: { $ref: "#/components/schemas/TestRequest" } responses: "202": description: Every test queued. Position and wait are the last test's. headers: X-Usage-Limit: { $ref: "#/components/headers/X-Usage-Limit" } X-Usage-Remaining: { $ref: "#/components/headers/X-Usage-Remaining" } X-Usage-Reset: { $ref: "#/components/headers/X-Usage-Reset" } Idempotent-Replayed: { $ref: "#/components/headers/Idempotent-Replayed" } content: application/json: schema: { $ref: "#/components/schemas/BatchAccepted" } "400": description: "`validation_error` with `errors` listing every invalid test (`index`, `error`, `error_code`); the shared code when every item failed the same way, such as `country_required`; `batch_limit`." content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: "`plan_feature`, or `guest_forbidden` for a guest token." content: application/json: schema: { $ref: "#/components/schemas/Error" } "409": { $ref: "#/components/responses/IdempotencyRefused" } "429": { $ref: "#/components/responses/Limited" } "503": { $ref: "#/components/responses/QueueFull" } /v1/test-batches/{id}: get: tags: [Tests] summary: A batch as a group description: Counts by state and by verdict group, and the summaries of its tests, scoped to the caller. Guests answer `403 guest_forbidden`. parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: "200": description: The batch. content: application/json: schema: { $ref: "#/components/schemas/Batch" } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: "`guest_forbidden`." content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } /v1/tests/{id}: parameters: - $ref: "#/components/parameters/TestID" get: tags: [Tests] summary: Read a test description: | The full test with its result once finished. The owner's first read of a finished test counts as having read it: an account gets a result mail only for a test nobody fetched within a minute. Every `screenshot_url` and `recording_url` in the result is a signed link valid until `result.evidence_expires_at`; fetching the test again mints fresh ones. responses: "200": description: The test. content: application/json: schema: { $ref: "#/components/schemas/Test" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /v1/tests/{id}/cancel: parameters: - $ref: "#/components/parameters/TestID" post: tags: [Tests] summary: Cancel a queued or running test description: Kills the browser run. A cancelled test is never billed and sends no webhook. responses: "200": description: Cancelled. content: application/json: schema: type: object properties: id: { type: string } status: { type: string, const: cancelled } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "409": description: "`already_finished`." content: application/json: schema: { $ref: "#/components/schemas/Error" } /v1/tests/{id}/frames/{n}: parameters: - $ref: "#/components/parameters/TestID" - name: n in: path required: true description: Index into `result.capture.frames`. schema: { type: integer, minimum: 0 } - $ref: "#/components/parameters/Exp" - $ref: "#/components/parameters/Sig" get: tags: [Tests] summary: A screenshot description: The frame as PNG. Served to the signed link (`exp`, `sig`, as the result names it) or to the owner's key. security: - ApiKey: [] - Session: [] - {} responses: "200": description: The image. content: image/png: schema: { type: string, format: binary } "403": description: "`evidence_link_invalid` (tampered or foreign) or `evidence_link_expired`." content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } /v1/tests/{id}/recording: parameters: - $ref: "#/components/parameters/TestID" - $ref: "#/components/parameters/Exp" - $ref: "#/components/parameters/Sig" get: tags: [Tests] summary: The session recording description: Mode `full` only. `video/webm` (or as `result.recording_format` says); honours `Range`. security: - ApiKey: [] - Session: [] - {} responses: "200": description: The video. content: video/webm: schema: { type: string, format: binary } "206": description: A range of it. "403": description: "`evidence_link_invalid` or `evidence_link_expired`." content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } /v1/tests/{id}/recording/{n}: parameters: - $ref: "#/components/parameters/TestID" - name: n in: path required: true description: Index into `result.recording_continuations`. schema: { type: integer, minimum: 0 } - $ref: "#/components/parameters/Exp" - $ref: "#/components/parameters/Sig" get: tags: [Tests] summary: A recording continuation security: - ApiKey: [] - Session: [] - {} responses: "200": description: The video segment. content: video/webm: schema: { type: string, format: binary } "403": description: "`evidence_link_invalid` or `evidence_link_expired`." content: application/json: schema: { $ref: "#/components/schemas/Error" } "404": { $ref: "#/components/responses/NotFound" } /v1/usage: get: tags: [Plans and usage] summary: Your plan, its limits and the headroom left description: Guests answer `403 guest_forbidden`. `plan`, `limits` and the rest are absent for a key outside the catalogue (an operator's). responses: "200": description: Usage. content: application/json: schema: { $ref: "#/components/schemas/Usage" } "401": { $ref: "#/components/responses/Unauthorized" } /v1/events: post: tags: [Service] summary: Report a funnel moment from the web app description: An allow-listed name, counted in `offerprobe_web_events_total`; no cookie, no id, no free text. Guests included. Not for API clients. requestBody: required: true content: application/json: schema: type: object required: [event] properties: event: type: string enum: [free_test_started, gate_shown, signup_started, rerun_clicked, checkout_clicked] responses: "204": description: Counted. "400": description: An unknown event. content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } /v1/plans: get: tags: [Plans and usage] summary: The plan catalogue security: [] responses: "200": description: Every plan and the one accounts start on. content: application/json: schema: type: object required: [plans, default] properties: plans: type: array items: { $ref: "#/components/schemas/Plan" } default: type: string description: The plan every account starts on and returns to after a cancellation. /v1/options: get: tags: [Targeting] summary: Countries, devices and connection types in one call description: Cached ten minutes. Send a device's `os`, `os_version` and `browser` back on a test. responses: "200": description: The options. content: application/json: schema: type: object properties: countries: type: array items: type: object properties: id: { type: integer } code: { type: string, description: ISO 3166-1 alpha-2 } name: { type: string } devices: type: array items: { $ref: "#/components/schemas/Device" } device_types: type: array items: { type: string, enum: [mobile, residential, datacenter] } "401": { $ref: "#/components/responses/Unauthorized" } "503": description: The targeting data could not be loaded. content: application/json: schema: { $ref: "#/components/schemas/Error" } /v1/directory/{kind}: get: tags: [Targeting] summary: States, cities, ASNs or ISPs description: | `states?country_id=N` and `cities?state_id=N` for the cascading select; `asns?country=US` (carrier ASNs) and `isps?country=DE&type=mobile` (ISP names) for exact targeting. The answer is an object with one key named after `kind` holding the list (`{"states": [...]}`). parameters: - name: kind in: path required: true schema: { type: string, enum: [states, cities, asns, isps] } - name: country_id in: query schema: { type: integer } description: For `states`; an id from `/v1/options`. - name: state_id in: query schema: { type: integer } description: For `cities`. - name: country in: query schema: { type: string } description: For `asns` and `isps`; a two-letter code. - name: type in: query schema: { type: string, enum: [mobile, residential] } description: For `isps`. responses: "200": description: The list under its kind. content: application/json: schema: type: object properties: states: type: array items: type: object properties: id: { type: integer } name: { type: string } dir_country_id: { type: integer } cities: type: array items: type: object properties: id: { type: integer } name: { type: string } dir_state_id: { type: integer } asns: type: array items: type: object properties: asn: { type: integer } title: { type: string } isps: type: array items: { type: string } "400": description: A missing or malformed filter. content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: Unknown kind. content: application/json: schema: { $ref: "#/components/schemas/Error" } /v1/directory/carriers: get: tags: [Targeting] summary: The carriers a country's tests may name description: Every network either supplier can express for the country, once, under the name a customer knows; `name` is the value to send as `carrier`. parameters: - name: country in: query required: true schema: { type: string } description: A two-letter code. - name: type in: query schema: { type: string, enum: [mobile, residential], default: mobile } responses: "200": description: The carriers. content: application/json: schema: type: object properties: carriers: type: array items: type: object required: [name] properties: name: { type: string } also_known_as: type: array items: { type: string } description: The suppliers' own spellings. "400": description: Missing country. content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "503": description: The catalogue is not loaded on this server. content: application/json: schema: { $ref: "#/components/schemas/Error" } /v1/guest-tokens: post: tags: [Guests] summary: Mint a guest token for one free test description: | Unauthenticated; per client IP per UTC day. Send the token as `X-API-Key`. A guest token creates `lite` tests only, targets a country, connection and carrier (no state, city, ASN or ISP: `400 guest_mode`), uses no webhook, and may read and cancel its own tests and read the targeting data; listing, batches and usage answer `403 guest_forbidden`. security: [] responses: "201": description: The token. content: application/json: schema: { $ref: "#/components/schemas/GuestToken" } "404": description: Guest tests are switched off on this server. content: application/json: schema: { $ref: "#/components/schemas/Error" } "429": description: "`guest_limit`: the day's allowance for this client is spent." headers: Retry-After: { $ref: "#/components/headers/Retry-After" } content: application/json: schema: { $ref: "#/components/schemas/Error" } /v1/account: get: tags: [Account] summary: The signed-in account security: - Session: [] responses: "200": description: What the session asserted, and the account's mail switches. content: application/json: schema: { $ref: "#/components/schemas/Account" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } patch: tags: [Account] summary: Change the account's mail switches description: Each field optional; unknown fields are refused. Billing and quota mails have no switch. security: - Session: [] requestBody: required: true content: application/json: schema: type: object properties: mail: type: object properties: results: { type: boolean } digest: { type: boolean } responses: "200": description: The account as `GET` answers it. content: application/json: schema: { $ref: "#/components/schemas/Account" } "400": description: "`validation_error`." content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } delete: tags: [Account] summary: Delete the account's data description: | Every test of every key (evidence objects first), the keys and the account row. 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 afterwards, through a mailed confirmation. security: - Session: [] responses: "200": description: Deleted. content: application/json: schema: type: object properties: deleted: type: object properties: tests: { type: integer } keys: { type: integer } objects: { type: integer } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } "409": description: "`subscription_active`." content: application/json: schema: { $ref: "#/components/schemas/Error" } /v1/account/webhook-secret: post: tags: [Account] summary: Rotate the webhook signing secret description: Mints a new `whsec_…`; events from now on are signed with it and the old one stops at once. security: - Session: [] responses: "200": description: The new secret. content: application/json: schema: type: object properties: webhook_secret: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } /v1/webhooks/test: post: tags: [Tests] summary: Send a test event to a URL description: One `webhook.test` event, signed with the caller's account secret (unsigned for a key without an account), held to the same URL and address rules as a real delivery, no retry. Answers what the receiver did; the request itself is `200` either way. Guests answer `403 guest_forbidden`. requestBody: required: true content: application/json: schema: type: object required: [url] properties: url: { type: string, format: uri } responses: "200": description: The outcome. content: application/json: schema: type: object properties: event_id: { type: string } signed: { type: boolean } delivered: { type: boolean, description: The receiver answered 2xx. } status: { type: integer, description: The receiver's status, when it answered. } error: { type: string, description: Why not, when not delivered. } "400": description: "`validation_error`: no URL, or one the target rule refuses." content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } "403": description: "`guest_forbidden`." content: application/json: schema: { $ref: "#/components/schemas/Error" } /v1/account/export: get: tags: [Account] summary: Everything the API holds for the account, as one download security: - Session: [] responses: "200": description: The account, its keys (names only) and every test summary. headers: Content-Disposition: schema: { type: string } description: "`attachment; filename=\"offerprobe-export.json\"`" content: application/json: schema: type: object properties: account: { $ref: "#/components/schemas/Account" } keys: type: array items: { $ref: "#/components/schemas/Key" } tests: type: array items: { $ref: "#/components/schemas/Test" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } /v1/account/claim: post: tags: [Account] summary: Take the tests run as a guest into the account description: Holding the token is the proof; it is spent by the claim, its tests stay free, and an expired token still claims. Unknown or already claimed tokens claim nothing and are not an error. security: - Session: [] requestBody: required: true content: application/json: schema: type: object required: [guest_tokens] properties: guest_tokens: type: array maxItems: 10 items: { type: string } responses: "200": description: What moved. content: application/json: schema: type: object properties: claimed: { type: integer } test_ids: type: array items: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } /v1/account/unsubscribe: get: tags: [Account] summary: The one-click link in a result mail or a digest description: Signed; no sign-in. Turns the named switch off and answers a page. `POST` with `List-Unsubscribe=One-Click` does the same for a mail client. security: [] parameters: - { name: a, in: query, required: true, schema: { type: string }, description: The account. } - { name: k, in: query, required: true, schema: { type: string, enum: [results, digest] }, description: The switch. } - { name: s, in: query, required: true, schema: { type: string }, description: The signature. } responses: "200": description: Done. content: text/html: schema: { type: string } "403": description: The link does not verify. content: application/json: schema: { $ref: "#/components/schemas/Error" } post: tags: [Account] summary: One-click unsubscribe from a mail client security: [] parameters: - { name: a, in: query, required: true, schema: { type: string } } - { name: k, in: query, required: true, schema: { type: string, enum: [results, digest] } } - { name: s, in: query, required: true, schema: { type: string } } responses: "200": description: Done. "403": description: The link does not verify. /v1/keys: get: tags: [Account] summary: The account's enabled keys description: No secrets; storage holds a SHA-256. security: - Session: [] responses: "200": description: Oldest first. content: application/json: schema: type: object properties: keys: type: array items: { $ref: "#/components/schemas/Key" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } post: tags: [Account] summary: Mint a key description: The secret appears in this response and nowhere else. Up to 20 enabled keys (`409 key_limit`). security: - Session: [] requestBody: content: application/json: schema: type: object properties: name: { type: string } responses: "201": description: The key, once. content: application/json: schema: { $ref: "#/components/schemas/NewKey" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } "409": description: "`key_limit`." content: application/json: schema: { $ref: "#/components/schemas/Error" } /v1/keys/{id}: delete: tags: [Account] summary: Revoke a key description: The secret stops at once; the tests it created stay in the account's history. security: - Session: [] parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: "200": description: Revoked. content: application/json: schema: type: object properties: id: { type: string } disabled: { type: boolean, const: true } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } "404": { $ref: "#/components/responses/NotFound" } /v1/billing/checkout: post: tags: [Billing] summary: Start a purchase description: "`400 plan_not_for_sale`, `409 already_subscribed` (switch instead), `409 plan_cancelled` (resume instead), `502 billing_unavailable`, `503 billing_disabled`." security: - Session: [] requestBody: required: true content: application/json: schema: type: object required: [plan_id] properties: plan_id: { type: string } responses: "200": description: The provider's checkout page. content: application/json: schema: type: object properties: url: { type: string, format: uri } plan_id: { type: string } "400": { $ref: "#/components/responses/BillingRefused" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } "409": { $ref: "#/components/responses/BillingRefused" } "502": { $ref: "#/components/responses/BillingRefused" } "503": { $ref: "#/components/responses/BillingRefused" } /v1/billing/switch: post: tags: [Billing] summary: Move a subscriber to another plan description: An upgrade is made now, prorated, and the plan follows by webhook; a downgrade waits for the period end (`pending_plan_id`, `effective_at`). Choosing the current plan withdraws a pending downgrade. `409 plan_cancelled` while a cancellation is scheduled. security: - Session: [] requestBody: required: true content: application/json: schema: type: object required: [plan_id] properties: plan_id: { type: string } responses: "200": description: Where the plan stands. content: application/json: schema: type: object properties: plan_id: { type: string } status: { type: string } pending_plan_id: { type: string } effective_at: { type: string, format: date-time } "400": { $ref: "#/components/responses/BillingRefused" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } "404": { $ref: "#/components/responses/BillingRefused" } "409": { $ref: "#/components/responses/BillingRefused" } "502": { $ref: "#/components/responses/BillingRefused" } "503": { $ref: "#/components/responses/BillingRefused" } /v1/billing/cancel: post: tags: [Billing] summary: End the subscription when the paid period does security: - Session: [] responses: "200": description: Scheduled; the plan stays in force until `period_end`. content: application/json: schema: type: object properties: status: { type: string } period_end: { type: string, format: date-time } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } "404": { $ref: "#/components/responses/BillingRefused" } "502": { $ref: "#/components/responses/BillingRefused" } "503": { $ref: "#/components/responses/BillingRefused" } /v1/billing/resume: post: tags: [Billing] summary: Lift a scheduled cancellation security: - Session: [] responses: "200": description: Billing continues. content: application/json: schema: type: object properties: status: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } "409": { $ref: "#/components/responses/BillingRefused" } "502": { $ref: "#/components/responses/BillingRefused" } "503": { $ref: "#/components/responses/BillingRefused" } /v1/billing/transactions: get: tags: [Billing] summary: The provider's charges and refunds for the account security: - Session: [] responses: "200": description: Newest first, at most 20; amounts in minor units. content: application/json: schema: type: object properties: transactions: type: array items: { $ref: "#/components/schemas/Transaction" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } "502": { $ref: "#/components/responses/BillingRefused" } "503": { $ref: "#/components/responses/BillingRefused" } /v1/billing/portal: post: tags: [Billing] summary: The provider's portal for the card and invoices security: - Session: [] responses: "200": description: Where to go. content: application/json: schema: type: object properties: url: { type: string, format: uri } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/SessionRequired" } "404": { $ref: "#/components/responses/BillingRefused" } "502": { $ref: "#/components/responses/BillingRefused" } "503": { $ref: "#/components/responses/BillingRefused" } /webhooks/billing: post: tags: [Service] summary: The payment provider's endpoint description: Authenticated by the provider's signature, idempotent by event id. Not for clients. security: [] responses: "200": description: Received. "401": description: Signature invalid. /healthz: get: tags: [Service] summary: Liveness security: [] responses: "200": description: The process answers. /readyz: get: tags: [Service] summary: Readiness description: The database answers and the engine reports its capabilities. security: [] responses: "200": description: Ready. content: application/json: schema: type: object properties: status: { type: string } engine_version: { type: string } "503": description: Naming the failed check. /openapi.yaml: get: tags: [Service] summary: This document security: [] responses: "200": description: The specification, YAML. content: application/yaml: schema: { type: string } /openapi.json: get: tags: [Service] summary: This document, as JSON security: [] responses: "200": description: The specification, JSON. content: application/json: schema: { type: object } components: securitySchemes: ApiKey: type: apiKey in: header name: X-API-Key description: A key from the Developer page (`ots_…`), or a guest token (`ots_g_…`). Session: type: http scheme: bearer bearerFormat: JWT description: The web app's 15-minute session token from the identity service. parameters: TestID: name: id in: path required: true schema: { type: string } IdempotencyKey: name: Idempotency-Key in: header required: false schema: { type: string, maxLength: 200 } description: | Any string unique to this request, scoped to your key and kept 24 hours. The same key with the same body replays the first answer (`Idempotent-Replayed: true`) instead of queueing a second test; with another body it is `409 idempotency_conflict`; 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. Exp: name: exp in: query required: false schema: { type: integer } description: The signed link's expiry, as the result named it. Sig: name: sig in: query required: false schema: { type: string } description: The signed link's signature. headers: Retry-After: description: Seconds until a retry can succeed. On every 429 and on `503 queue_full`. schema: { type: integer } X-Usage-Limit: description: "The cap that binds: the month's tests, else the day's, else a guest token's allowance. Absent for an operator key." schema: { type: integer } X-Usage-Remaining: description: What is left of it after everything queued or running, the accepted test included. schema: { type: integer } X-Usage-Reset: description: When the count resets, RFC 3339. schema: { type: string, format: date-time } Idempotent-Replayed: description: "`true` when this is the stored answer of an earlier request with the same `Idempotency-Key`." schema: { type: string } responses: IdempotencyRefused: description: "`idempotency_conflict`: the key was used with another body; `idempotency_in_progress`: the same request is still being answered." content: application/json: schema: { $ref: "#/components/schemas/Error" } Unauthorized: description: "`unauthorized`: missing or invalid key; `session_expired`, `session_invalid`, `session_disabled` for a bearer token." content: application/json: schema: { $ref: "#/components/schemas/Error" } NotFound: description: "`not_found`: not yours, or no such thing." content: application/json: schema: { $ref: "#/components/schemas/Error" } SessionRequired: description: "`session_required`: a key must never do this; `email_unverified`." content: application/json: schema: { $ref: "#/components/schemas/Error" } Limited: description: | `concurrency_limit` (one slot and it is busy), `quota_exceeded` (the month's tests), `daily_limit` (the day's), `guest_limit`. Every 429 carries `Retry-After`; a plan refusal names `plan`, `resets_at` and `upgrade`. headers: Retry-After: { $ref: "#/components/headers/Retry-After" } X-Usage-Limit: { $ref: "#/components/headers/X-Usage-Limit" } X-Usage-Remaining: { $ref: "#/components/headers/X-Usage-Remaining" } X-Usage-Reset: { $ref: "#/components/headers/X-Usage-Reset" } content: application/json: schema: { $ref: "#/components/schemas/Error" } QueueFull: description: "`queue_full`: the whole queue is at its cap; or `unavailable` while the API restarts." headers: Retry-After: { $ref: "#/components/headers/Retry-After" } content: application/json: schema: { $ref: "#/components/schemas/Error" } BillingRefused: description: A billing refusal; `error_code` says which. content: application/json: schema: { $ref: "#/components/schemas/Error" } schemas: Error: type: object required: [error, error_code, request_id] properties: error: type: string description: One sentence for a person. May change; branch on `error_code`. error_code: type: string description: Stable. The specific codes are listed per response; every status has a generic one (`validation_error`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `internal`, `unavailable`). request_id: type: string description: Also the `X-Request-Id` header. Quote it when reporting a problem. plan: $ref: "#/components/schemas/PlanRef" description: On a plan refusal. resets_at: type: string format: date-time description: On `quota_exceeded` and `daily_limit`. upgrade: $ref: "#/components/schemas/Upgrade" errors: type: array description: On a refused batch, every invalid test at once. items: type: object properties: index: { type: integer } error: { type: string } error_code: { type: string } Upgrade: type: object description: The cheapest plan that lifts the refused limit. properties: id: { type: string } name: { type: string } price_usd_month: { type: integer } monthly_tests: { type: [integer, "null"], description: "null: unlimited" } max_concurrent: { type: integer } max_batch: { type: integer } PlanRef: type: object properties: id: { type: string } name: { type: string } TestRequest: type: object additionalProperties: false required: [url, country] properties: url: type: string format: uri description: The tracking link, public http(s). Localhost, private and link-local addresses, dotless hosts and `.local` are refused. country: type: string description: ISO 3166-1 alpha-2, the country the visitor comes from. mode: type: string enum: [lite, standard, full] default: standard description: "`lite`: chain, final URL and verdict, fastest. `standard`: plus screenshots and the CTA funnel. `full`: plus a session recording." expect: type: string description: The expected destination, which enables the verdict; an app package, an Apple store id, a store URL or deep link, or a landing URL. carrier: type: string description: A carrier name from `/v1/directory/carriers`; best effort, `visit.match` says whether it served. Without a `device_type` it is served as mobile. device_type: type: string enum: [mobile, residential, datacenter] state: type: string description: Exact targeting; the server provisions a matching exit for this test. Requires `country`. city: type: string description: Exact targeting; requires `country`. asn: type: string description: "Exact targeting, e.g. `AS7922`; requires `country`." isp: type: string description: Exact targeting, an ISP name from `/v1/directory/isps`; requires `country`. os: type: string description: "How the visitor should look: a device from `/v1/options`." os_version: type: string browser: type: string fresh: type: boolean description: Guarantee an unused fingerprint for this test. screenshot: type: boolean description: Default true in standard and full; lite never captures. skip_visits: type: integer minimum: 0 maximum: 20 description: Cloaker bypass; standard and full only. webhook_url: type: string format: uri description: Receives the completion event. Public http(s) only. reference: type: string maxLength: 200 description: Your own label for the test; stored, echoed on the test and in the webhook event, and a list filter. EchoedRequest: type: object description: The request as it was understood, on every test. properties: url: { type: string } mode: { type: string, enum: [lite, standard, full] } country: { type: string } state: { type: string } city: { type: string } asn: { type: string } isp: { type: string } carrier: { type: string } device_type: { type: string } os: { type: string } os_version: { type: string } browser: { type: string } fresh: { type: boolean } expect: { type: string } screenshot: { type: boolean } skip_visits: { type: integer } webhook_url: { type: string } Accepted: type: object required: [id, status] properties: id: { type: string } status: { type: string, const: queued } queue_position: type: integer description: 1 is the next test to run. estimated_wait_seconds: type: integer description: The position over the number of browser slots times the median recent run; an estimate. BatchAccepted: type: object required: [ids, status] properties: ids: type: array items: { type: string } batch_id: type: string description: The handle for `GET /v1/test-batches/{id}`; stamped on every test of the batch. status: { type: string, const: queued } queue_position: { type: integer, description: The last test's. } estimated_wait_seconds: { type: integer } Test: type: object required: [id, status, created_at] properties: id: { type: string } status: type: string enum: [queued, running, done, failed, cancelled] queue_position: { type: integer, description: While queued. } estimated_wait_seconds: { type: integer, description: While queued. } request: { $ref: "#/components/schemas/EchoedRequest" } result: $ref: "#/components/schemas/Result" description: Once done. Absent from list summaries, and gone once `result_expired_at` is set. error: type: string description: One sentence chosen by `error_code`, never the engine's own text. Present exactly on the runs that are not billed. verdict: type: string enum: [working, wrong_destination, no_destination, broken, unverified, inconclusive] description: "`inconclusive` is our side's failure and is never billed; `failed` tests carry none." verdict_reason: { type: string } error_code: type: string description: "On a failed test: `engine_failed`, `timeout`, `provisioning_failed`. On a done test that failed before observing the offer, the engine's token, such as `no_matching_proxy`, `tunnel_failed`, `proxy_transport_failed`. Tokens are only ever added." final_url: { type: string } created_at: { type: string, format: date-time } started_at: { type: string, format: date-time } finished_at: { type: string, format: date-time } result_expired_at: type: string format: date-time description: Present once the plan's history window passed; the result body and evidence are gone, the verdict stays. reference: type: string description: The customer's own label, as sent. batch_id: type: string description: The batch the test was queued in, for `GET /v1/test-batches/{id}` and the list filter. Batch: type: object description: A batch as a group. required: [id, created_at, total, finished, counts, verdicts, tests] properties: id: { type: string } created_at: { type: string, format: date-time } total: { type: integer } finished: type: boolean description: True once no test of the batch is queued or running. counts: { $ref: "#/components/schemas/BatchCounts" } verdicts: { $ref: "#/components/schemas/BatchVerdicts" } tests: type: array description: Summaries, without result bodies. items: { $ref: "#/components/schemas/Test" } BatchCounts: type: object properties: queued: { type: integer } running: { type: integer } done: { type: integer } failed: { type: integer } cancelled: { type: integer } BatchVerdicts: type: object description: The dashboard's four groups. properties: working: { type: integer } failing: { type: integer, description: wrong destination, no destination or broken } unverified: { type: integer } ours: { type: integer, description: failed, inconclusive or an engine error; never billed } Result: type: object properties: success: type: boolean description: False is the engine's error envelope; the verdict is then `inconclusive` and nothing is billed. verdict: { type: string } verdict_reason: { type: string } expected: { type: string } destination_app: { type: string } outcome: { type: string } final_url: { type: string } page_title: { type: string } redirect_chain: type: array items: { $ref: "#/components/schemas/Redirect" } capture: type: object properties: frames: type: array items: { $ref: "#/components/schemas/Frame" } capture_mode: { type: string } capture_stop_reason: type: string description: Why the click-through ended; `max_clicks` and `revisited_page` are budgets, the rest terminal states or failures. capture_stop_detail: { type: string } transport_verdict: { type: string } error_code: { type: string } errors: type: array items: { type: string } timing: type: object properties: total_ms: { type: integer } redirects_count: { type: integer } recording_url: { type: string, format: uri, description: Mode full; a signed link. } recording_format: { type: string } recording_duration_ms: { type: integer } recording_continuations: type: array items: type: object properties: url: { type: string } recording_url: { type: string, format: uri } duration_ms: { type: integer } evidence_expires_at: type: string format: date-time description: Every signed link in this result is valid until then; fetch the test again for fresh ones. visit: { $ref: "#/components/schemas/Visit" } Redirect: type: object properties: url: { type: string } source_url: { type: string } status_code: { type: integer } kind: { type: string } Frame: type: object properties: name: { type: string } url: { type: string } screenshot_url: { type: string, format: uri, description: A signed link; absent for a frame without an image. } timing_ms: { type: integer } cta_text: { type: string, description: What was clicked. } Visit: type: object description: How the offer saw the visit; descriptive, never operational (no proxy id, credential or supplier). properties: country: { type: string } carrier: { type: string } connection: { type: string, enum: [mobile, residential, datacenter] } exit_ip: { type: string, description: Masked. } asn: { type: string, description: Only when the test targeted an ASN or ISP. } match: type: string enum: [exact, fallback] description: Whether the country, carrier and device type asked for all served. requested: type: object description: What was asked for, when `match` is not exact. properties: country: { type: string } carrier: { type: string } device_type: { type: string } device: type: object properties: os: { type: string } browser: { type: string } device_type: { type: string } screen: { type: string } pixel_ratio: { type: number } touch: { type: boolean } language: { type: string } timezone: { type: string } user_agent: { type: string } WebhookEvent: type: object description: | Posted with `Content-Type: application/json`, `User-Agent: OfferProbe-Webhook/1`, `X-OfferProbe-Event-Id` and, for an account's tests, `X-OfferProbe-Signature: t=,v1=` where `v1` is HMAC-SHA256 over `.` keyed with the account's `webhook_secret` (`GET /v1/account`). Verify the signature and refuse a `t` older than a few minutes. The POST goes to public addresses only and follows no redirect. required: [id, event, created_at, test_id, status, verdict] properties: id: { type: string, description: "`evt_…`, unique per delivery attempt pair." } event: { type: string, enum: [test.finished, webhook.test] } created_at: { type: string, format: date-time } test_id: { type: string } status: { type: string, enum: [done, failed] } verdict: { type: string } error: { type: string, description: On an unbilled run. } error_code: { type: string } reference: { type: string, description: As sent on the test. } batch_id: { type: string } Usage: type: object required: [tests_today, tests_month, tests_total, billable_month, active_now] properties: tests_today: { type: integer } tests_month: { type: integer } tests_total: { type: integer } billable_month: type: integer description: What the plan counts; tests this UTC calendar month that ran to a verdict. active_now: { type: integer, description: Queued plus running. } plan: { $ref: "#/components/schemas/PlanRef" } limits: type: object properties: monthly_tests: { type: [integer, "null"], description: "null: unlimited" } daily_tests: { type: [integer, "null"], description: "null: unlimited" } max_concurrent: { type: integer } max_batch: { type: integer } modes: type: array items: { type: string } description: Absent means every mode. remaining_month: { type: [integer, "null"], description: "null: unlimited" } period_end: { type: string, format: date-time, description: When the month's count resets. } remaining_day: { type: integer, description: For a plan with a daily cap. } day_end: { type: string, format: date-time } subscription: type: object description: Present once billing has spoken for the account. properties: status: { type: string, description: The provider's word. } past_due: { type: boolean } period_end: { type: string, format: date-time, description: When a cancelled plan lapses or a pending downgrade takes effect. } pending_plan: { $ref: "#/components/schemas/PlanRef" } Plan: type: object required: [id, name, tagline, monthly_tests, daily_tests, max_concurrent, max_batch, price_usd_month, features, highlight, default] properties: id: { type: string } name: { type: string } tagline: { type: string } monthly_tests: { type: [integer, "null"], description: "null: unlimited" } daily_tests: { type: [integer, "null"], description: "null: unlimited" } max_concurrent: { type: integer } max_batch: { type: integer } modes: type: array items: { type: string } description: Absent means every mode. price_usd_month: { type: [integer, "null"], description: "null: not for sale" } retention_days: type: integer description: How long a finished test keeps its result body and evidence; 0 is forever. trial_days: type: integer description: A free trial the plan's checkout starts with; absent when none. features: type: array items: { type: string } highlight: { type: boolean } default: { type: boolean } Device: type: object properties: os: { type: string } os_version: { type: string } browser: { type: string } screen: { type: string } class: { type: string, enum: [mobile, desktop] } label: { type: string } GuestToken: type: object required: [token, expires_at, tests_remaining] properties: token: { type: string } expires_at: { type: string, format: date-time } tests_remaining: { type: integer } Account: type: object required: [email] properties: email: { type: string } name: { type: string } mail: type: object description: The mails the account may turn off. properties: results: { type: boolean, description: A test that finished after you left the page, and every full-mode test. } digest: { type: boolean, description: Monday's digest of the week's verdicts. } webhook_secret: type: string description: "`whsec_…`; signs the account's webhook events. Rotate it with `POST /v1/account/webhook-secret`." created_at: type: string format: date-time description: When the API first saw the account, the first request after its address was confirmed. Key: type: object required: [id, name, created_at] properties: id: { type: string } name: { type: string } created_at: { type: string, format: date-time } NewKey: allOf: - $ref: "#/components/schemas/Key" - type: object required: [key] properties: key: { type: string, description: The secret, shown once. } Transaction: type: object properties: id: { type: string } created_at: { type: string, format: date-time } description: "{ type: string }" amount_cents: { type: integer } refunded_cents: { type: integer } currency: { type: string } status: { type: string } type: { type: string } period_start: { type: string, format: date-time } period_end: { type: string, format: date-time }