Skip to content

Docs / Reseller API

SMS & eSIM Reseller API.

Buy digital products from your Portproof wallet and deliver them through your own service. Portproof provides the API, billing, fulfilment and integration support; your customers use the experience you build.

One Portproof account and a scoped API key are enough. You do not need a separate supplier account. You can add your own resale margin; it does not change your Portproof purchase price.

Quick start

  1. Create a Portproof account, then fund your wallet with an available payment method. Wait for the deposit to be confirmed.
  2. Open API keys, create or select a project and create a key. Select digital:read, digital:write and billing:read. Copy the secret when shown; it is displayed once.
  3. Keep the key in a server-side secret manager or environment variable. Send Authorization: Bearer YOUR_PORTPROOF_API_KEY. Never put it in browser JavaScript, mobile applications, public repositories or customer messages.
  4. Browse the catalogue, review the current final price, then submit one isolated wallet purchase for each intended end-customer order. Save its key, exact request, Portproof order ID and your own customer mapping durably.

API base: https://api.portproof.org/v1. The website is https://portproof.org; API requests go to the API host. OpenAPI reference · JSON specification.

There is no public sandbox. Every issued key acts on your real wallet, whatever its prefix. Purchase and paid-action examples spend funds when you execute them. Use synthetic responses for development until you intend to buy. This page does not execute requests.

digital:read reads catalogue, current prices, owned assets, installation details, SMS and operations; it also allows manual status refresh. digital:write purchases and requests/executes supported actions. billing:read reads orders, wallet and purchase documents. Resources belong to the account; scopes limit each key. Project keys do not isolate sub-customers from one another. Enforce your own end-customer access controls.

Catalogue and selling prices

Use GET /digital/reseller/catalog?family=sms or family=esim for the complete imported reseller catalogue, independent of the website’s featured or manually listed products. The merchandising endpoint /digital/catalog remains available for website listings.

Read all pages until has_more:false; limit is at most 60 and page starts at 1. Filters: country (uppercase country code), digital_kind, service_name (exact), duration_days, q (search), and available_only=true. Sort by price_asc (default), price_desc or title, with stable SKU tie-breaks. Country filtering includes regional/global plans covering that country. Facets return discovered countries, durations and services.

bashbash
# Production only. Placeholder key; there is no free sandbox.
export PORTPROOF_API_KEY='YOUR_PORTPROOF_API_KEY'
export API='https://api.portproof.org/v1'
# cURL 7.76+, jq and uuidgen. Restrict files containing customer delivery data.
umask 077
# Browse service/country choices. Requires digital:read.
curl --fail-with-body -sS -H "Authorization: Bearer $PORTPROOF_API_KEY" "$API/digital/reseller/catalog?family=sms&digital_kind=sms_activation&limit=60" > catalog.json
jq '.facets, (.products[] | {sku,title,sell_cents,currency,available,digital_details})' catalog.json
# eSIM: select an available compatible plan, then use the same purchase steps.
curl --fail-with-body -sS -H "Authorization: Bearer $PORTPROOF_API_KEY" "$API/digital/reseller/catalog?family=esim&limit=60" > esim-catalog.json
jq '.products[] | {sku,title,sell_cents,currency,available,digital_details}' esim-catalog.json
bashbash
# Download both families, including unavailable products. Requires digital:read.
for FAMILY in sms esim; do
  PAGE=1
  while :; do
    curl --fail-with-body -sS -H "Authorization: Bearer $PORTPROOF_API_KEY" "$API/digital/reseller/catalog?family=$FAMILY&limit=60&page=$PAGE&sort=price_asc" > "$FAMILY-$PAGE.json" || exit 1
    jq '.products[] | {sku,title,sell_cents,currency,available,availability_note,digital_details}' "$FAMILY-$PAGE.json"
    [ "$(jq -r .has_more "$FAMILY-$PAGE.json")" = true ] || break
    PAGE=$((PAGE + 1))
  done
done

The catalogue is a saved snapshot refreshed approximately every 15 minutes. catalogue.last_success_at and catalogue.status distinguish a current, stale or unknown refresh from product availability. A failed/partial refresh retains the previous snapshot. An empty family after a populated snapshot is treated as a failed refresh. Discontinued records remain visible as unavailable; existing orders and assets remain accessible. Page results can change during a refresh: deduplicate by SKU and restart a complete scan if the timestamp changes.

sku is the stable Portproof purchase identifier; treat it as opaque. Never construct supplier IDs. sell_cents is Portproof’s selling price for one unit in the stated currency, before account VAT. GET /digital/prices/{sku} returns unit_cents, applicable vat_cents and final total_cents in EUR cents. For example, 500 cents means EUR 5.00; this is illustrative, not a fixed product price.

Newly imported products use the existing Portproof pricing calculation. Existing product markup and floors are preserved. SMS activation prices round upward to EUR 0.05; other digital products to EUR 0.10. Applicable VAT rounds to the cent. One purchase buys quantity 1; send separate uniquely identified purchases for additional units. The isolated endpoint has no coupon field; website checkout retains its existing coupon rules. No wholesale discount is implied.

A price read is a snapshot, with no reservation or guaranteed validity window. The purchase rechecks availability, current pricing, account tax rules and funds under locks; expected_total_cents must equal the final server total. A changed total is refused before charging. The paid order freezes the accepted price; a later fulfilment refusal does not silently increase it. Availability may change between listing, payment and provisioning. Incomplete product facts or invalid pricing are labelled unavailable.

SMS verification integration

Select a sms_activation SKU for the desired country and service from the live catalogue. Service-specific activations and fixed-term sms_rental products have different lifetimes; sms_dedicated products are sold for a monthly term. Use the returned country/service combinations rather than assuming every service exists in every destination.

Review terms and acceptable use. accept_terms:true records acceptance and requests immediate service start. Obtain the acceptance required for your own customer relationship separately.

bashbash
# Replace with an available Portproof SKU from the catalogue. One product per request.
export SKU='REPLACE_WITH_SELECTED_PORTPROOF_SKU'
curl --fail-with-body -sS -H "Authorization: Bearer $PORTPROOF_API_KEY" \
  "$API/digital/prices/$SKU" > price.json || exit 1
jq . price.json
# Review total_cents (EUR cents, including applicable VAT) before proceeding.
# Prepare ONCE per intended purchase and save durably. Do not overwrite on a retry.
KEY=$(uuidgen)
jq -n --arg key "$KEY" --slurpfile p price.json \
  '{key:$key,request:{sku:$p[0].sku,expected_total_cents:$p[0].total_cents,accept_terms:true}}' > purchase-intent.json

# SPENDS WALLET FUNDS. accept_terms accepts the linked policies and immediate start.
# For network failure/429/5xx, retry this exact saved intent with the same key.
# Never generate a fresh key to recover an uncertain purchase.
jq '.request' purchase-intent.json > purchase-request.json
curl --fail-with-body -sS --max-time 30 -X POST "$API/digital/purchases" \
  -H "Authorization: Bearer $PORTPROOF_API_KEY" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(jq -r .key purchase-intent.json)" \
  --data-binary @purchase-request.json > purchase.json || exit 1
jq '{method,order_id:.order.id,status:.order.status,total_cents:.order.total_cents,replayed}' purchase.json
ORDER_ID=$(jq -er .order.id purchase.json)
# Read the current order. 201/paid means payment accepted, not delivered.
curl --fail-with-body -sS -H "Authorization: Bearer $PORTPROOF_API_KEY" "$API/orders/$ORDER_ID" > order.json

POST /digital/purchases returns HTTP 201 with {method:"wallet",order,payment_id,replayed}. It never reads, edits or empties your browser cart. The required Idempotency-Key is 1–200 characters, scoped to this account and endpoint. Repeating the same key, SKU and confirmed total returns the current original order with replayed:true, including concurrent duplicates. A changed SKU or total conflicts. Keep purchase identities for the life of your records; this purchase service does not use the generic 24-hour response-cache contract.

Read GET /orders/{order_id}. Once an item’s fulfilment_ref is a dga_… ID, read GET /digital/assets/{asset_id}. phone_number is the allocated number, when present. An asset with status:processing and an allocated number is waiting for SMS; before a number exists, provisioning is still incomplete. Payment acceptance alone is not proof of number allocation.

bashbash
# Serial cached polling, at most 40 attempts, for either product family.
# HTTP errors stop this cURL loop. For 429, wait Retry-After before resuming.
READY=false
for attempt in $(seq 1 40); do
  curl --fail-with-body -sS --max-time 20 -H "Authorization: Bearer $PORTPROOF_API_KEY" "$API/orders/$ORDER_ID" > order.json || exit 1
  if jq -e '.order.status as $s | (["failed","refunded","failed_refunded","cancelled","expired"] | index($s)) != null' order.json >/dev/null || \
     jq -e 'any(.order.items[]; .status == "held" or .status == "failed" or .status == "refunded")' order.json >/dev/null; then
    echo 'Stop: inspect order or contact Portproof; do not repurchase.'; exit 1
  fi
  ASSET_ID=$(jq -r '[.order.items[].fulfilment_ref | select(. != null and startswith("dga_"))][0] // empty' order.json)
  if [ -n "$ASSET_ID" ]; then
    curl --fail-with-body -sS --max-time 20 -H "Authorization: Bearer $PORTPROOF_API_KEY" "$API/digital/assets/$ASSET_ID" > asset.json || exit 1
    if jq -e '.asset.status as $s | (["expired","cancelled","refunded","unknown"] | index($s)) != null' asset.json >/dev/null; then
      echo 'Asset ended or needs review'; exit 1
    fi
    # eSIM installation: never query the SMS endpoint for an eSIM.
    if [ "$(jq -r .asset.kind asset.json)" = esim_plan ]; then
      if [ "$(jq -r .asset.installation_available asset.json)" = true ]; then
        curl --fail-with-body -sS -H "Authorization: Bearer $PORTPROOF_API_KEY" "$API/digital/assets/$ASSET_ID/installation" > installation.json || exit 1
        READY=true; break
      fi
    else
      # phone_number is in private asset.json, even while status is processing.
      curl --fail-with-body -sS -H "Authorization: Bearer $PORTPROOF_API_KEY" "$API/digital/assets/$ASSET_ID/messages" > messages.json || exit 1
      if jq -e 'any(.data[]; .content_withheld == false and ((.code // "") != "" or (.text // "") != ""))' messages.json >/dev/null; then
        READY=true; break
      fi
      if jq -e '.asset.status == "completed" or .asset.status == "code_received"' asset.json >/dev/null; then
        echo 'Polling stopped; inspect saved history or contact support'; exit 1
      fi
    fi
  fi
  sleep 15
done
[ "$READY" = true ] || { echo 'Still waiting; save order and resume later, no new purchase.'; exit 1; }
# Treat all files as private; deliver only to your authorised end customer.
# Follow messages.next_cursor (URL-encoded) until null for additional saved history.
# qr_url is relative to the API origin, requires auth, and must be fetched server-side.

Read /digital/assets/{asset_id}/messages for {data:[{id,text,code,content_withheld,received_at}],next_cursor,last_synced_at}. Follow URL-encoded next_cursor until null. Messages and codes are private; nullable values and content_withheld:true must be respected. An unchecked or stale empty history does not establish that no SMS has arrived. Never expose another customer’s number or history.

Handle code_received, completed, expired, cancelled and refunded according to the asset and order records. An expired activation cannot be assumed refundable or reusable. Completion, reuse and cancellation depend on asset.actions and the action response. Customer cancellation that would refund a charged purchase requires owner review; refund_review_required directs the account to refund requests. Cancellation, approval and a settled refund are distinct states.

eSIM purchase and secure delivery

Select an esim_plan SKU whose digital_details.countries, regional coverage, allowance, validity, activation policy, routing and capabilities fit your service. Check device compatibility and unlock status. A data-only plan does not automatically include a phone number, calls or SMS; absent metadata is unknown.

Use the exact same price-check and isolated purchase steps above with that eSIM SKU and a new saved intent. Wait for the owned asset to report installation_available:true. An order or purchase operation may succeed while installation is still processing; do not submit another purchase.

bashbash
# Read saved state; digital:read. These calls do not buy anything.
curl --fail-with-body -sS -H "Authorization: Bearer $PORTPROOF_API_KEY" "$API/digital/assets/$ASSET_ID" > asset.json
# Execute only when asset.installation_available is true. Treat output as a secret.
curl --fail-with-body -sS -H "Authorization: Bearer $PORTPROOF_API_KEY" "$API/digital/assets/$ASSET_ID/installation" > installation.json
# {installation:{activation_code,smdp_address,lpa_uri,qr_url,iccid}}
# For the authenticated QR image:
curl --fail-with-body -sS -H "Authorization: Bearer $PORTPROOF_API_KEY" "$API/digital/assets/$ASSET_ID/qr.png" -o installation-private.png

Deliver installation details through an authenticated customer portal or another access-controlled channel. Keep them out of logs, analytics, support screenshots and public URLs. qr_url is relative to the API origin, and requires the same customer authentication; never give your API key to an end customer to load it. Retrieve the PNG on your backend and deliver it under your own customer authorization.

The asset exposes expires_at, plan validity and usage with remaining_gb, used_gb, total_gb and updated_at when known. Unknown usage is null, not zero. Do not infer an activation date or validity deadline from purchase time. Top-up options belong to a specific owned eSIM and may change; see the action flow below. Automatic renewals and re-rent are not offered.

Supported follow-up actions

Only request actions returned in asset.actions. POST /digital/assets/{id}/quotes creates a five-minute quote. For eSIM top-ups, use the opaque product_id returned by /topups. For supported SMS actions, use the action name. Review total_cents, refund_cents and expires_at before execution.

bashbash
# Read-only eligible top-ups; product_id is Portproof's opaque option ID.
curl --fail-with-body -sS -H "Authorization: Bearer $PORTPROOF_API_KEY" \
  "$API/digital/assets/$ASSET_ID/topups" > topups.json
TOPUP_ID=$(jq -er '.data[0].product_id' topups.json) || exit 1
jq -n --arg id "$TOPUP_ID" '{action:"esim_topup",product_id:$id}' > quote-request.json
curl --fail-with-body -sS -X POST -H "Authorization: Bearer $PORTPROOF_API_KEY" \
  -H 'Content-Type: application/json' --data-binary @quote-request.json \
  "$API/digital/assets/$ASSET_ID/quotes" > quote.json || exit 1
jq . quote.json
# Review total_cents and expires_at. Quote expires after 5 minutes; no stock reservation.
# SPENDS WALLET FUNDS. Save key + body before execution; reuse both on retry.
ACTION_KEY=$(uuidgen)
jq '{quote_id:.quote.id,expected_total_cents:.quote.total_cents,confirm:true,accept_terms:true}' quote.json > action-request.json
printf '%s' "$ACTION_KEY" > action-key.txt
curl --fail-with-body -sS -X POST -H "Authorization: Bearer $PORTPROOF_API_KEY" \
  -H 'Content-Type: application/json' -H "Idempotency-Key: $(cat action-key.txt)" \
  --data-binary @action-request.json "$API/digital/assets/$ASSET_ID/execute" > operation.json || exit 1
OP_ID=$(jq -er .operation.id operation.json)
curl --fail-with-body -sS -H "Authorization: Bearer $PORTPROOF_API_KEY" "$API/digital/operations/$OP_ID"
# SMS lifecycle actions use the same quote/execute contract, without product_id:
# {"action":"sms_complete"}, {"action":"sms_reuse"}, {"action":"sms_paid_reuse"}
# Only request actions listed in asset.actions; inspect every quote before execute.
# sms_cancel can require owner refund review; do not assume an automatic refund.

/execute requires quote_id, the exact expected_total_cents, confirm:true, accept_terms:true and Idempotency-Key. Persist that body/key before sending it. Paid actions use the ordinary wallet, order and purchase-document services. Same request replay returns its durable operation; changed requests conflict. Expired/changed quotes require a newly reviewed quote, never a blind higher-price retry. If an outcome is pending or unknown, keep the original identity and ask Portproof to reconcile it.

Read GET /digital/operations/{operation_id} for operation.state, attention, related order and asset. States are pending, submitted, succeeded, failed or unknown. Stop at succeeded/failed; stop automatic retries at unknown and contact support. Submitted is awaiting confirmation. Even a succeeded provisioning operation does not guarantee that eSIM installation is ready.

Status updates, limits and polling

Owned-asset, message and order GETs read saved Portproof data. Background digital synchronization runs about every 60 seconds over bounded resources; this is not a delivery-time guarantee. Poll serially around every 15 seconds, use request timeouts and an overall deadline, and back off on failures. Stop after delivery, a terminal state, a held/unknown outcome or the deadline. Resume an existing order later when needed.

API keys have separate budgets of 600 reads and 120 writes per 300 seconds. Check X-RateLimit-Limit and X-RateLimit-Remaining. HTTP 429 includes Retry-After; wait at least that long. POST /digital/assets/{id}/refresh requires digital:read but consumes the write budget and performs an immediate external read. Reserve it for occasional manual recovery; it needs no idempotency key and spends no wallet funds. Catalogue reads should use the saved snapshot, not force refreshes.

Stop SMS polling at expired/cancelled/refunded/completed, or once the needed messages have been delivered. Fetch saved history once at code_received, then stop or explicitly handle a supported reuse action. Stop eSIM provisioning polling when installation is available; status/usage can be checked later on demand. Treat unknown or stale state as unconfirmed.

Portproof does not currently expose verified incoming-SMS webhooks to reseller customers. Use polling for SMS and installation readiness. Existing order events are not incoming-SMS events.

Errors and safe retries

Errors use application/problem+json with a machine-readable code and a useful detail. Do not parse human text for business decisions. Responses do not expose supplier credentials, costs or raw diagnostic bodies.

  • 401 unauthorized: missing, expired or revoked key. Fix credentials. 403 insufficient_scope/forbidden: select the required scopes or resolve account/access restrictions. A different account’s resource returns 404.
  • 400 validation_failed/idempotency_key_required: correct the request or supply the required key. A missing purchase header can be caught by schema validation.
  • 402 insufficient_funds: fund the wallet and wait for confirmation. No successful purchase charge is created by a refused balance check.
  • 409 out_of_stock/no_inventory or 402 budget_exceeded: inventory or purchasing capacity could not be confirmed. Refresh the catalogue later; do not promise stock. 409 conflict: inspect the detail; a changed price requires a new price check and explicit acceptance before changing the saved purchase.
  • 409 idempotency_conflict: the key identifies a different request. Retrieve the existing order and investigate your mapping. Never change keys simply to get past an uncertain result.
  • 429 or transient 5xx/network failure: use bounded exponential backoff and Retry-After. For purchases, resubmit only the identical saved key and body. If a response was lost after commit, replay returns the same order instead of another charge.
  • Paid/provisioning, held, submitted or unknown: payment and fulfilment are separate. Keep the original order/operation. A timeout is not proof of failure and must not trigger a replacement purchase. Contact Portproof for reconciliation.

Product categories and fields

CategorySelection and deliveryFollow-up limits
sms_activationCountry + service; allocated phone_number, expiry, saved incoming SMS and code when supplied.Completion/reuse/paid reuse/cancellation only when supported by the asset. Service acceptance is not guaranteed.
sms_rentalAdvertised service and fixed 3/7/14/30-day options; number and saved messages.Current supplier contract supports US rentals. No automatic renewal or re-rent. Refund-related cancellation requires review.
sms_dedicatedCountry options returned by the catalogue; number and incoming messages during the monthly term.One current term, not an automatically renewing subscription. Countries and availability can change.
esim_planSingle-destination, regional/global coverage when supplied; allowance, validity, activation/routing/network facts and feature flags; private installation and usage when ready.Data-only unless calls/SMS are explicitly included. Top-up availability is checked for the owned plan. Unknown metadata stays unknown.

Catalogue facts are descriptive, not a coverage or delivery guarantee. Read digital_details.has_calls, has_sms, has_hotspot, has_5g, supports_topup and any allowance/speed notes. A feature flag does not create a corresponding SMS inbox or voice API for an eSIM. Portproof supplies partner digital services; this guide makes no claim of an owned mobile network.

Node.js quickstart

This uses the reviewed purchase-intent.json created above for either category. It retries the same purchase identity, checks errors, polls with a deadline and keeps delivery secrets out of console output.

javascriptjavascript
// Node.js 22+. Run only after reviewing/saving purchase-intent.json above.
// SPENDS FUNDS by submitting that saved purchase; reruns use the same identity.
import { readFile, writeFile } from 'node:fs/promises';
const API = 'https://api.portproof.org/v1';
const token = process.env.PORTPROOF_API_KEY;
if (!token) throw new Error('Set PORTPROOF_API_KEY securely');
const intent = JSON.parse(await readFile('purchase-intent.json', 'utf8'));
const deadline = Date.now() + 10 * 60 * 1000;
const pause = ms => {
  if (Date.now() + ms >= deadline) throw new Error('Deadline reached; save order and resume later');
  return new Promise(resolve => setTimeout(resolve, ms));
};
async function call(path, body, key) {
  for (let attempt = 0; attempt < 4; attempt++) {
    if (Date.now() >= deadline) throw new Error('Deadline reached; resume saved order later');
    let r;
    try { r = await fetch(API + path, { method: body ? 'POST' : 'GET',
      headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json',
        ...(key ? { 'Idempotency-Key': key } : {}) },
      body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(Math.max(1, Math.min(20000, deadline - Date.now()))) }); }
    catch { if (attempt === 3) throw new Error('Outcome unconfirmed; keep saved key and request');
      await pause(2000 * 2 ** attempt); continue; }
    if (r.status === 429 || r.status >= 500) {
      if (attempt === 3) throw new Error('Service pending/unavailable; resume same saved request');
      const retry = Number(r.headers.get('retry-after'));
      await pause(Math.max(2 ** attempt * 2000, Number.isFinite(retry) ? retry * 1000 : 0)); continue;
    }
    const data = await r.json();
    if (!r.ok) throw new Error('HTTP ' + r.status + ': ' + (data.code || 'request_failed'));
    return data;
  }
}
const paid = await call('/digital/purchases', intent.request, intent.key);
const id = paid.order.id;
console.log('Saved order', id); // store this against your own customer order
for (let n = 0; n < 40; n++) {
  const { order } = await call('/orders/' + id);
  if (['failed','refunded','failed_refunded','cancelled','expired'].includes(order.status) ||
      order.items.some(i => ['held','failed','refunded'].includes(i.status)))
    throw new Error('Stop: order needs review; do not buy again');
  const assetId = order.items.find(i => i.fulfilment_ref?.startsWith('dga_'))?.fulfilment_ref;
  if (assetId) {
    const { asset } = await call('/digital/assets/' + assetId);
    if (['expired','cancelled','refunded','unknown'].includes(asset.status))
      throw new Error('Stop: ' + asset.status);
    if (asset.kind === 'esim_plan' && asset.installation_available) {
      const details = await call('/digital/assets/' + assetId + '/installation');
      // Deliver through your authenticated customer portal; never log installation secrets.
      await writeFile('installation-private.json', JSON.stringify(details), { mode: 0o600, flag: 'wx' });
      console.log('Installation ready'); break;
    }
    if (asset.kind !== 'esim_plan') {
      let cursor = null, received = false;
      do {
        const page = await call('/digital/assets/' + assetId + '/messages' +
          (cursor ? '?cursor=' + encodeURIComponent(cursor) : ''));
        received ||= page.data.some(m => !m.content_withheld && (m.code || m.text));
        // Process privately here; phone_number is on asset. Do not log messages.
        cursor = page.next_cursor;
      } while (cursor && !received);
      if (received) { console.log('SMS available for authorised delivery'); break; }
      if (['completed','code_received'].includes(asset.status))
        throw new Error('Automatic polling stopped; inspect saved history or contact support');
    }
  }
  if (n === 39) throw new Error('Still waiting; save order and resume later, no new purchase');
  await pause(15000);
}

Python quickstart

This example uses the Python standard library. Keep the saved intent and key secure; rerunning this example resumes the same purchase. Implement your own private end-customer delivery at the indicated point.

pythonpython
# Python 3.10+, standard library only. Same reviewed purchase-intent.json as cURL.
# SPENDS FUNDS once; retries and reruns submit the identical saved intent.
import json, os, time
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
API = 'https://api.portproof.org/v1'
HEADERS = {'Authorization': 'Bearer ' + os.environ['PORTPROOF_API_KEY']}
deadline = time.monotonic() + 600
def pause(seconds):
    if time.monotonic() + seconds >= deadline:
        raise RuntimeError('Deadline reached; resume saved order later')
    time.sleep(seconds)
with open('purchase-intent.json') as f:
    intent = json.load(f)
def call(path, body=None, key=None):
    for attempt in range(4):
        if time.monotonic() >= deadline:
            raise RuntimeError('Deadline reached; resume saved order later')
        try:
            request = Request(API + path,
                data=json.dumps(body).encode() if body is not None else None,
                headers={**HEADERS, 'Content-Type': 'application/json',
                         **({'Idempotency-Key': key} if key else {})},
                method='POST' if body is not None else 'GET')
            try:
                r = urlopen(request, timeout=max(0.1, min(20, deadline - time.monotonic())))
            except HTTPError as error:
                r = error
        except (URLError, TimeoutError, OSError):
            if attempt == 3:
                raise RuntimeError('Outcome unconfirmed; keep saved key and request')
            pause(2 * 2 ** attempt)
            continue
        status, headers = r.code, r.headers
        raw = r.read()
        r.close()
        if status == 429 or status >= 500:
            if attempt == 3:
                raise RuntimeError('Service pending/unavailable; resume same saved request')
            try:
                wait = float(headers.get('Retry-After', '0'))
            except ValueError:
                wait = 0
            pause(max(2 * 2 ** attempt, wait))
            continue
        data = json.loads(raw)
        if not 200 <= status < 300:
            raise RuntimeError(f"HTTP {status}: {data.get('code', 'request_failed')}")
        return data
paid = call('/digital/purchases', intent['request'], intent['key'])
order_id = paid['order']['id']
print('Saved order', order_id)
for n in range(40):
    order = call('/orders/' + order_id)['order']
    if order['status'] in ('failed','refunded','failed_refunded','cancelled','expired') or any(
            i['status'] in ('held','failed','refunded') for i in order['items']):
        raise RuntimeError('Stop: order needs review; do not buy again')
    asset_id = next((i['fulfilment_ref'] for i in order['items']
        if (i.get('fulfilment_ref') or '').startswith('dga_')), None)
    if asset_id:
        asset = call('/digital/assets/' + asset_id)['asset']
        if asset['status'] in ('expired','cancelled','refunded','unknown'):
            raise RuntimeError('Stop: ' + asset['status'])
        if asset['kind'] == 'esim_plan' and asset['installation_available']:
            details = call('/digital/assets/' + asset_id + '/installation')
            fd = os.open('installation-private.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
            with os.fdopen(fd, 'w') as f:
                json.dump(details, f)  # deliver privately through an authenticated portal
            print('Installation ready')
            break
        if asset['kind'] != 'esim_plan':
            cursor, received = None, False
            while True:
                from urllib.parse import quote
                path = '/digital/assets/' + asset_id + '/messages'
                page = call(path + ('?cursor=' + quote(cursor, safe='') if cursor else ''))
                received = any(not m['content_withheld'] and (m['code'] or m['text']) for m in page['data'])
                # Process privately here; phone_number is on asset. Never log messages.
                cursor = page['next_cursor']
                if received or not cursor:
                    break
            if received:
                print('SMS available for authorised delivery')
                break
            if asset['status'] in ('completed','code_received'):
                raise RuntimeError('Polling stopped; inspect history or contact support')
    if n == 39:
        raise RuntimeError('Still waiting; save order and resume later, no new purchase')
    pause(15)

Wallet, purchase documents and support

Portproof debits the confirmed EUR total when the wallet purchase is accepted, and fulfils through the existing delivery service. Read GET /wallet for balance and transaction history; GET /orders, GET /orders/{id} and GET /orders/{id}/invoice show your orders and purchase documents. All require billing:read.

Customers can deposit and spend; self-service withdrawals are disabled. Only the site owner can authorize supported withdrawals. Legitimate purchase refunds remain supported: confirmed delivery-failure refunds use the existing refund records; customer-requested refunds need confirmed account email and owner review through Refund requests. Approval is not payment. Check the refund policy; unused, expired, installed or rejected-by-a-third-party products are not automatically refundable.

For integration support, contact Portproof via the current support contacts or @portproof on Telegram. Include the Portproof order/operation ID, error code and timestamp. Never send API keys, full message content or installation secrets. Portproof is your billing and support contact.

All API documentation · SMS products · eSIM products · Create a scoped key