# ipvolt auth.md

You are an agent. ipvolt (https://ipvolt.com) sells dedicated mobile proxies (a real phone and SIM per device) that customers pay for from a prepaid USD balance. This service supports **agentic registration** for one method, `service_auth`: you name the customer's email address, the customer signs in to ipvolt and types a 6-digit code you give them, and you receive an access token for their account. Follow the steps in order.

Audience: AI agents acting for an ipvolt customer, or for a person who will become one. A person must take part once, to sign in and confirm. Registering sends no email; ipvolt notifies the account by email only after the customer connects you.

## Step 1 — Discover

- Protected Resource Metadata: `GET https://ipvolt.com/.well-known/oauth-protected-resource`. Any 401 from the API carries `WWW-Authenticate: Bearer resource_metadata="https://ipvolt.com/.well-known/oauth-protected-resource"`.
- Authorization Server metadata: `GET https://ipvolt.com/.well-known/oauth-authorization-server`. Its `agent_auth` block names the endpoints below.

The resource is `https://ipvolt.com`; the API lives under `https://ipvolt.com/beta/api/v1/`. Scopes:

- `account:read`: balance, orders, rented proxies with their connection details, the rental catalog, usage and sessions.
- `proxies:buy`: rent and renew mobile proxies from the customer's existing balance, up to a spending cap the customer sets. You can never withdraw or change the account. You can add funds only by paying with your own USDC (see "Add funds" below); you can never add funds from the customer's money.

## Step 2 — Pick a method

Use `service_auth`. You need only the customer's email address. Anonymous registration answers `anonymous_not_enabled`, and ID-JAG identity assertions answer `issuer_not_enabled` (no issuers are trusted yet).

Before registering, tell the person that you will ask for access to their ipvolt account, which scopes you need, and that they will choose the spending limit themselves. Register only with their agreement.

## Step 3 — Register

```http
POST https://ipvolt.com/beta/api/agent/identity
Content-Type: application/json

{"type": "service_auth", "login_hint": "customer@example.com", "client_name": "Your agent's name"}
```

`client_name` is optional (1-64 characters). It is shown to the customer and labelled as not verified.

Response (200):

```json
{
  "registration_id": "…",
  "registration_type": "service_auth",
  "claim_url": "https://ipvolt.com/beta/api/agent/identity/claim",
  "claim_token": "clm_…",
  "claim_token_expires": "…",
  "post_claim_scopes": ["account:read", "proxies:buy"],
  "claim": {
    "user_code": "123456",
    "expires_in": 600,
    "verification_uri": "https://ipvolt.com/dashboard/claim?claim_attempt_token=cat_…",
    "interval": 5
  }
}
```

`claim_token` is returned once. Keep it in memory for the ceremony and do not store or log it. The registration stays open for 24 hours.

## Step 4 — Claim ceremony

### 4a. Hand off to the person

Show `verification_uri` and `user_code` together, for example:

> Open this link, sign in to ipvolt with **customer@example.com** (or create the account), and enter this 6-digit code: **123456**
> https://ipvolt.com/dashboard/claim?claim_attempt_token=…

The code goes into the ipvolt page they land on, not back to you. They must sign in as the email address you registered with. On that page they choose what you may do: view only, or also spend up to $10, $25, $50, $100, $250 from their balance. Access lasts 30 days or until they disconnect it in Account settings.

### 4b. Poll for completion

```http
POST https://ipvolt.com/beta/api/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:workos:agent-auth:grant-type:claim&claim_token=clm_…
```

While waiting: `{"error": "authorization_pending"}`. Polling faster than every 5 seconds returns `slow_down`. Success, returned exactly once:

```json
{
  "access_token": "ipvolt_at_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "account:read proxies:buy",
  "identity_assertion": "…",
  "assertion_expires": "…"
}
```

`scope` is what the customer granted: `account:read` alone if they chose view only. Keep `identity_assertion` privately for refreshing access tokens.

If the code expires (`expired_token`), ask for a new one with the same `claim_token` and the same email, then show the new code and keep polling:

```http
POST https://ipvolt.com/beta/api/agent/identity/claim
Content-Type: application/json

{"claim_token": "clm_…", "email": "customer@example.com"}
```

The response carries a `claim_attempt` block shaped like `claim`. `claim_expired` (410) means the 24-hour window closed: register again.

## Step 5 — Exchange the assertion

When an access token expires (after 60 minutes), get another:

```http
POST https://ipvolt.com/beta/api/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<identity_assertion>&resource=https://ipvolt.com
```

`invalid_grant` means the grant ended (expired, disconnected by the customer, or the account was disabled): start again at Step 3.

## Step 6 — Use the access token

Send `Authorization: Bearer <access_token>` to `https://ipvolt.com/beta/api/v1/`:

| Request | Scope | Returns |
| --- | --- | --- |
| `GET agent` | any | Your granted scopes, spending cap, amount spent and grant expiry |
| `GET balance` | account:read | Available and held balance in US cents |
| `GET catalog` | account:read | Mobile rental offers: `id` (tariffId), country, carrier, period, stock, `priceMinor`, `version` |
| `GET orders` | account:read | The customer's orders |
| `GET orders/<orderId>`, `GET orders/by-intent/<intentId>` | account:read | One order: its `status`, `proxyIds` once fulfilled and `reason` once released |
| `GET proxies` | account:read | Rented devices with connection details (treat them as secrets) |
| `GET usage`, `GET sessions`, `GET dashboard?days=7` | account:read | Metered gateway usage |
| `POST purchase` | proxies:buy | Rent from the balance. Body: `{"intentId": "<new UUID>", "tariffId": "…", "quantity": 1, "offerVersion": <version>}` |
| `POST renewal-quote` | proxies:buy | Body: `{"proxyId": "…", "days": 1 or 30}`. Returns a `quote` valid for 2 minutes and its `priceMinor` |
| `POST renew` | proxies:buy | Body: `{"intentId": "<new UUID>", "quote": "<quote>"}` |

POST bodies are JSON (`Content-Type: application/json`). Purchases and renewals charge the customer's balance and count against your spending cap. Use a fresh `intentId` for each new order and reuse it only to retry the same order: a retry never charges twice. Before spending, confirm the item and price with the person unless they told you otherwise. Errors you should expect: `spend_cap_exceeded` (409, with `remainingMinor`), `insufficient_balance` (409; the customer adds funds in the dashboard, or you add funds with your own USDC, see below), `offer_changed_refresh` (409; read the catalog again), `stock_unavailable` (409), `insufficient_scope` (403).

A purchase or renewal answers `{"orderId": "…", "status": "paid", "replayed": false}`: the amount is held while ipvolt places the order. Poll `GET orders/<orderId>` every 15 seconds or slower until `status` changes:

- `fulfilled`: done. `proxyIds` names the devices; `GET proxies` has their connection details.
- `payment_failed`: not fulfilled. The hold went back to the balance and to the spending cap of the agent that placed the order; `reason` says why (for example `stock_or_price_changed`). Place a new order with a new `intentId` if the person still wants it.
- `review_required`: ipvolt checks the order by hand and the amount stays held. Poll every few minutes, or tell the person to contact hello@ipvolt.com.
- `refunded`: ipvolt returned `refundedMinor` to the balance, and that much to the spending cap of the agent that placed the order.

An order that ipvolt has not sent to the supplier within 30 minutes is released (`payment_failed`). Once it has been sent, it stays `paid` until ipvolt confirms the supplier's answer, which can take longer, or it moves to `review_required`; keep polling. A partial refund keeps status `fulfilled` and raises `refundedMinor`.

If a purchase or renewal response is lost, read `GET orders/by-intent/<intentId>`. A 404 means no order exists yet, and the request may still be running: retry the same request with the same `intentId`, which never charges twice, before telling the person nothing was bought. Another customer's order is always 404.

## Traffic packs: residential and mobile IPs per GB

`GET catalog` also lists `trafficPacks`: prepaid GB of proxy traffic on one key that works on residential IPs and on mobile IPs from a shared pool. A pack has an `id`, `gb`, `priceMinor` (US cents) and `version`. Later packs add GB to the same key, no expiry date is set and nothing renews automatically.

| Request | Scope | Returns |
| --- | --- | --- |
| `POST purchase` | proxies:buy | Buys one pack. Body: `{"intentId": "<new UUID>", "tariffId": "<pack id>", "quantity": 1, "offerVersion": <pack version>}`. One pack per order, and one traffic order at a time |
| `GET traffic` | account:read | The key's status, GB bought, used and left, the gateway host and ports, and the countries each network has right now. No secret |
| `POST traffic/connection` | account:read | Body: `{"network": "residential" or "mobile", "country": "US", "protocol": "http" or "socks5"}`. Returns host, port, username and the key as password (treat them as secrets) |

Follow the order like a rental order: `fulfilled` means the GB are on the key, and `payment_failed` means the hold went back to the balance and to your spending cap. A pack is bought under the Terms (https://ipvolt.com/terms) and the Payments and Refunds Policy (https://ipvolt.com/refunds), which the dashboard asks the customer to accept at each pack purchase, so confirm the pack and its price with the person first. Refusals you may see, all with nothing charged: `residential_order_pending` (a traffic order is still in progress), `residential_capacity_unavailable` (try later), `residential_key_held` (the key is paused; the customer writes to hello@ipvolt.com), `residential_pack_too_small` (choose a larger pack) and, for a connection, `traffic_location_unavailable` (that network has no IPs in that country right now). Each connection is a new selection; sticky sessions and targeting are set up in the dashboard at https://ipvolt.com/dashboard/residential. The ACP checkout below takes a pack id as its line item too, with quantity 1.

## ACP checkout

The same purchase is also offered through the Agentic Commerce Protocol, API version `2026-04-17`. `GET https://ipvolt.com/.well-known/acp.json` names the API at `https://ipvolt.com/beta/api/acp`. It takes the same access token (scope `proxies:buy`), charges the same balance and counts against the same spending cap as `POST purchase`. Send `API-Version: 2026-04-17` on every call and a new `Idempotency-Key` on every POST; a retry with the same key and body returns the first answer.

1. `POST https://ipvolt.com/beta/api/acp/checkout_sessions` with `{"line_items": [{"id": "<offer id from GET catalog>", "quantity": 1}], "currency": "usd", "capabilities": {}}`. One offer per session, quantity 1 to 20. The session is priced from the catalog and lasts 30 minutes.
2. Read `status`. With `ready_for_payment`, `capabilities.payment.handlers` lists the one payment handler, the prepaid balance (`dev.acp.seller_backed.store_credit`, id `ipvolt_balance`). With `not_ready_for_payment`, `messages` says why: usually the balance or your spending cap does not cover the total, and the customer adds funds or raises the limit in the dashboard. `GET https://ipvolt.com/beta/api/acp/checkout_sessions/<id>` reads it again.
3. `POST https://ipvolt.com/beta/api/acp/agentic_commerce/delegate_payment` with `{"handler_id": "ipvolt_balance", "payment_method": {"type": "seller_backed_store_credit"}, "allowance": {"reason": "one_time", "max_amount": <the total>, "currency": "usd", "checkout_session_id": "<id>", "merchant_id": "ipvolt", "expires_at": "<RFC 3339 time>"}}`. The answer's `id` (`vt_…`) is a single-use payment token for that session. Send no card data: none is accepted.
4. `POST https://ipvolt.com/beta/api/acp/checkout_sessions/<id>/complete` with `{"payment_data": {"handler_id": "ipvolt_balance", "instrument": {"type": "seller_backed_store_credit", "credential": {"type": "spt", "token": "vt_…"}}}}`. The answer has `status: completed` and `order.id`, which is the `orderId` to follow with `GET orders/<orderId>` as described above.

`POST https://ipvolt.com/beta/api/acp/checkout_sessions/<id>` changes the items or prices the session again after `offer_changed_refresh`; `POST …/cancel` cancels it. Errors are flat: `{"type": "…", "code": "…", "message": "…", "param": "…"}`, with the purchase codes above (`insufficient_balance` and `spend_cap_exceeded` answer 402, and nothing is charged). 409 means only that a request with the same `Idempotency-Key` is still running, and 422 that the key was used with another body. Cards, 3-D Secure, carts, discount codes and order webhooks are not offered.

## Add funds with your own USDC

You can top up the customer's prepaid balance by paying native USDC on Base (`eip155:8453`, token `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`) from a wallet you control. 1 USDC adds 1.00 USD. This is the only way you can add funds: you cannot move the customer's money, and nothing can be withdrawn.

1. `GET https://ipvolt.com/beta/api/v1/fund?amountMinor=<US cents>` (100 to 50000; default 1000). The answer is `402` with an x402 v2 `PAYMENT-REQUIRED` header and an MPP `WWW-Authenticate: Payment` challenge (method `evm`, intent `charge`, credential type `authorization`). Both ask for an EIP-3009 `transferWithAuthorization` of exactly that amount to the address they name, valid for at most 300 seconds.
2. Sign it and repeat the request with the payment (x402: `PAYMENT-SIGNATURE`; MPP: `Payment-Authorization: Payment …`, with the authorization nonce set to keccak256(challenge id, realm)) **and** `Authorization: Bearer <access_token>`. Any scope is enough. The token names the account that is credited; an API key works too. A payment without a valid credential answers `401` and is not settled.
3. `200` means the transfer was submitted: `{"paymentId": "…", "status": "confirming", "statusUrl": "…"}`. `202` means ipvolt could not confirm the submission and is checking the chain: do not pay again, poll. `402` again means nothing was charged; `error` says why. A refused payment is not retried with the same authorization: sign a new one.
4. The balance is credited after ipvolt has confirmed the transfer on chain itself, usually within 3 minutes. Poll `GET https://ipvolt.com/beta/api/v1/fund/<paymentId>` until `status` is `credited` (or `failed` or `expired`: nothing was charged; or `review`: ipvolt checks it by hand).

Limits: $1.00 to $500.00 per payment, 10 payments per account per 24 hours (and 30 attempts, counting refused ones), 3 unconfirmed at a time. Sending the same signed authorization again returns the same payment and never charges twice. Payments are not refunded on chain: what you pay stays on the account as balance. Before paying, confirm the amount with the person unless they told you otherwise. Funding does not count against your spending cap, and does not raise it.

## Revocation

Revoke an access token with `POST https://ipvolt.com/beta/api/oauth2/revoke` and form body `token=<access_token>` (always 200). The customer can change your spending limit or disconnect you at any time on the Agents page of their dashboard. A changed limit applies to the tokens you already hold: read `GET agent` for the current scopes and limit, and expect `insufficient_scope` if you were made view only. After a disconnect, every token and the identity assertion answer 401 / `invalid_grant`.

## Errors

Registration and claim errors: `anonymous_not_enabled`, `issuer_not_enabled`, `invalid_login_hint`, `invalid_request`, `invalid_claim_token`, `claimed_or_in_flight`, `claim_expired`, `rate_limited` (429). Token endpoint errors follow RFC 6749: `invalid_grant`, `unsupported_grant_type`, `invalid_target`, `authorization_pending`, `slow_down`, `expired_token`, `access_denied`. Every error body is `{"error": "…", "error_description": "…"}`. On 5xx, back off and retry; on 4xx, do not resend the same request unchanged.

Help: hello@ipvolt.com · Terms: https://ipvolt.com/terms
