v1.0
OpenAPI spec Example clients (.zip)

Specto LCO API · version 1.0

Automate Watcho activations and renewals from your own software

The Specto API lets a Local Cable Operator's billing or CRM system activate new customers, renew subscriptions, schedule advance renewals, check wallet balance and plans, and look up subscriptions and transactions. It uses exactly the same wallet, pricing and records as the Specto LCO panel.

Your software talks only to Specto — never directly to Watcho. Specto remains responsible for your wallet and pricing, subscriber records, all Watcho provider communication, the transaction ledger, expiry dates and MSO commission. You send one signed request, and Specto does the rest atomically.
Base URLhttps://api.spectotv.com/api/v1Your Specto administrator confirms the URL when issuing credentials.
FormatJSON · UTF-8 · HTTPS onlyEvery response uses the same envelope.
AuthenticationAPI key + HMAC-SHA256Timestamp, nonce and IP whitelist on every call.

How it works

Your billing / CRM softwareHolds the API key and secret · runs on a whitelisted server
HTTPS + HMAC
→
Specto APIAuthentication · permissions · rate limits · idempotency
Specto coreLCO wallet & pricing · subscriber records · ledger · expiry · MSO commission
provider calls
→
WatchoReached only by Specto
ResponsibilityYour softwareSpecto
Collecting payment from your customer✔
Choosing plan and customer, sending the API request✔
Plan catalogue and prices✔ (any price you send is ignored)
Checking and debiting your LCO wallet✔
Calling Watcho, verifying the result, refunding on failure✔
Subscriber record, expiry date, transaction ledger, MSO commission✔
Wallet top-ups✔ (never through the API)

Quick start

  1. Receive credentials from Specto. You get an API key (spk_…) and an API secret (sps_…). The secret is shown to the Specto administrator once; store it in a server-side secret store.
  2. Send Specto the public IP address of the server that will call the API. Requests from other addresses are rejected.
  3. Check your signing against the reference test vector.
  4. Call a read-only endpoint:
export SPECTO_API_BASE=https://api.spectotv.com/api/v1
export SPECTO_API_KEY=spk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
export SPECTO_API_SECRET=sps_xxxxxxxx…

./curl.sh GET balance          # from the example bundle
./curl.sh GET plans

Expected response:

{
    "success": true,
    "request_id": "req_9ba40a67453c36ae4856",
    "code": "OK",
    "message": "Wallet balance",
    "data": {
        "lco_id": 12,
        "wallet_balance": 5000.0,
        "currency": "INR",
        "low_balance": false,
        "as_of": "2026-09-25T11:00:00+05:30",
        "note": "Read-only. Top-ups are made through Specto; the API cannot credit or debit the wallet directly."
    }
}

Onboarding checklist

StepWhoDetails
1. Request API accessLCOContact your Specto account manager. Say which operations you need: activate, renew, advance renew, and/or the read-only lookups.
2. WhitelistLCO → SpectoPublic IPv4/IPv6 address(es) of your API client server. Ranges from /24 (IPv4) or /48 (IPv6) are accepted.
3. CredentialsSpectoSpecto issues the API key and secret, and enables the permissions.
4. Build & testLCO developerImplement signing, idempotency and error handling. Test with read-only endpoints first.
5. Go liveLCORun one real low-priced activation, then check it with GET /transaction and in your wallet ledger.

Authentication & API keys

Every request is authenticated by four headers. The secret itself is never transmitted; it only signs the request.

HeaderRequiredValue
X-API-KeyalwaysYour API key: spk_ followed by 32 hex characters.
X-TimestampalwaysCurrent Unix time in seconds (UTC).
X-NoncealwaysA new random string for every request: 16–64 characters from [A-Za-z0-9_-].
X-SignaturealwaysLower-case hex HMAC-SHA256. See Request signing.
Content-TypePOSTapplication/json
Idempotency-KeyPOST8–100 characters from [A-Za-z0-9_.:-]. See Idempotency.

Key management

  • Specto stores only a hash of your key and an encrypted copy of your secret. Specto staff cannot read the secret back. If it is lost, Specto rotates your credentials.
  • Rotation: Specto issues a new key and secret and can keep the old key valid for up to 72 hours, so you can deploy without downtime.
  • Revocation takes effect immediately (CREDENTIAL_REVOKED). Ask Specto to revoke at once if you suspect a leak.
  • Credentials belong to one LCO account. They can only see and change that LCO's wallet, customers and transactions.

Request signing (HMAC-SHA256)

Build a string to sign of six lines joined by a single line feed (\n), with no trailing newline:

METHOD
PATH
CANONICAL_QUERY
TIMESTAMP
NONCE
BODY_SHA256
LineRuleExample
METHODUpper-case HTTP method.POST
PATHAlways starts at /api/v1/. No host and no query string./api/v1/activate
CANONICAL_QUERYEmpty for POST or when there is no query (the line is still present). See below.mobile=&subscriber_id=482915
TIMESTAMPThe exact X-Timestamp value.1790000000
NONCEThe exact X-Nonce value.3b1f0c9e8a7d6c5b4a39281706f5e4d3
BODY_SHA256Lower-case hex SHA-256 of the exact bytes of the body. For an empty body: e3b0c442…7852b855.9f86d081…
signature = lowercase_hex( HMAC_SHA256( key = api_secret , message = string_to_sign ) )

Canonical query string

  1. Split the raw query on &, then split each part on the first =.
  2. URL-decode the name and the value (+ means space).
  3. Re-encode both with RFC 3986: unreserved characters A–Z a–z 0–9 - _ . ~ stay as they are, everything else becomes %XX with upper-case hex, and a space becomes %20.
  4. Sort by name, then by value (byte order), and join as name=value pairs with &.

Example: b=2&a=hello+world&a=%41&c=it%27s becomes a=A&a=hello%20world&b=2&c=it%27s.

Sign the bytes you send. If you pretty-print, re-order or re-serialise the JSON after computing the signature, the server returns INVALID_SIGNATURE. Serialise once, sign that string, and send that same string.

Reference test vector

Before calling the API, check your implementation reproduces this signature.

secretsps_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
method / pathPOST · /api/v1/activate
query(empty)
timestamp / nonce1767225600 · n0nce-0000000000001
body{"full_name":"Ravi Kumar","mobile":"9876543210","plan_id":"171839"}
signature51ed2ebc09930c91b24ad1950a111f0c7f502a70f3ab899b3e5e4baa271eb60b
$sts = implode("\n", ['POST', '/api/v1/activate', '', $ts, $nonce, hash('sha256', $body)]);
$signature = hash_hmac('sha256', $sts, $apiSecret);
const crypto = require('crypto');
const sts = ['POST', '/api/v1/activate', '', ts, nonce,
  crypto.createHash('sha256').update(body, 'utf8').digest('hex')].join('\n');
const signature = crypto.createHmac('sha256', apiSecret).update(sts, 'utf8').digest('hex');
import hashlib, hmac
sts = "\n".join(["POST", "/api/v1/activate", "", ts, nonce, hashlib.sha256(body).hexdigest()])
signature = hmac.new(api_secret.encode(), sts.encode(), hashlib.sha256).hexdigest()
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.*= //')
SIG=$(printf 'POST\n/api/v1/activate\n\n%s\n%s\n%s' "$TS" "$NONCE" "$BODY_HASH" \
      | openssl dgst -sha256 -hmac "$SPECTO_API_SECRET" -hex | sed 's/^.*= //')

Timestamps, nonces & replay protection

  • Timestamp window: X-Timestamp must be within ±300 seconds of Specto's clock. Otherwise the response is 401 TIMESTAMP_EXPIRED, and its data.server_time shows the server clock. Keep your server synchronised with NTP.
  • Nonce: every request needs a new nonce. Specto remembers nonces per key for longer than the timestamp window, and a repeated nonce is rejected with 401 REPLAY_DETECTED. Retries need a new nonce, timestamp and signature, while keeping the same Idempotency-Key.
  • Because the signature covers method, path, query, timestamp, nonce and body hash, a captured request cannot be changed or replayed.
  • Repeated authentication failures from one IP are throttled (429 TOO_MANY_FAILED_ATTEMPTS).

IP whitelist

Specto accepts requests for your key only from the addresses registered for your account. An empty whitelist blocks every request.

  • Register the public IP of the server that makes the calls. Run curl https://ifconfig.me on that server to find it.
  • Single IPv4/IPv6 addresses and CIDR ranges are supported (at most /24 for IPv4 and /48 for IPv6; up to 20 entries).
  • A request from any other address fails with 403 IP_NOT_WHITELISTED, and data.ip shows the address Specto saw. Send that address to Specto if it is legitimate.
  • Cloud hosting: use a static or elastic IP, or a NAT gateway. Dynamic addresses break integrations.
  • Never call the API from browsers or mobile apps. The secret must stay on your server.

Rate limits

Each LCO has a per-minute request limit set by Specto (default 60 requests per minute, fixed one-minute windows). Every authenticated response carries:

X-RateLimit-LimitYour limit per minute
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetUnix time when the window resets
Retry-AfterSeconds to wait (on 429 responses only)

Over the limit, the API returns 429 RATE_LIMITED. Wait for Retry-After seconds, then retry with the same Idempotency-Key. Cache GET /plans for about 5 minutes instead of calling it before every operation.

Requests & responses

POST bodies are JSON objects whose values are strings. Every response, whether success or error, has this envelope:

{
    "success": true,
    "request_id": "req_9ba40a67453c36ae4856",
    "code": "OK",
    "message": "Subscription renewed",
    "data": {
        "…": "endpoint-specific data"
    }
}
successtrue only when the request fully succeeded.
request_idA unique ID for this request, also sent as the X-Request-Id header. Log it and quote it to Specto support.
codeA machine-readable result. Branch your code on code and the HTTP status, never on message.
messageHuman-readable text. It may change.
dataThe result object, or error details such as fields, shortfall or ip.

Amounts are Indian rupees (INR) as numbers with two decimals. Dates are YYYY-MM-DD HH:MM:SS in Specto server local time (IST), except as_of, which is ISO-8601.

Idempotency & retries

Every POST requires an Idempotency-Key header. It makes retries safe: the same key never charges twice.

  1. Create one key per business action, such as your order ID (order-100045). Store it before the first attempt.
  2. After a timeout or network error, retry with the same key. Each retry needs a new nonce, timestamp and signature. If Specto already processed the request, you receive the original result, marked with the Idempotent-Replayed: true header.
  3. Using the same key with a different body is rejected with 422 IDEMPOTENCY_KEY_REUSED.
  4. Rejections that happen before any money is reserved are not stored under the key: validation errors, INSUFFICIENT_BALANCE, SUBSCRIBER_EXISTS, SUBSCRIBER_NOT_FOUND, DUPLICATE_REQUEST, OPERATION_IN_PROGRESS, and authentication or permission errors. After fixing the cause you may retry with the same key.
  5. A PROVIDER_ERROR (Watcho rejected the request and you were refunded) is stored. To try again, use a new key.
  6. After PENDING_REVIEW, never start a new operation for that customer. Poll GET /transaction?idempotency_key=… instead.
attempt 1  POST /activate  Idempotency-Key: order-100045   → network timeout (you don't know the result)
attempt 2  POST /activate  Idempotency-Key: order-100045   → 200 OK  Idempotent-Replayed: true   (charged once)
later      GET  /transaction?idempotency_key=order-100045 → the same result, at any time

Duplicate protection

Several independent safeguards prevent double activations and double charges:

SafeguardResult
Same Idempotency-Key and same bodyThe original result is replayed; nothing is charged again.
Activation of a mobile number that already exists in Specto409 SUBSCRIBER_EXISTS (use /renew)
Same subscriber and same plan renewed within 2 minutes, even with a new key409 DUPLICATE_REQUEST with data.previous_transaction_id
Two requests for the same customer at the same momentOne runs; the other gets 409 OPERATION_IN_PROGRESS
Advance renewal onto the currently active plan409 PLAN_ALREADY_ACTIVE

Wallet behaviour

Your existing Specto LCO wallet is the only source of funds. The API can read it (GET /balance) and debit it only through activate, renew and advance-renew, at the price in the Specto catalogue. No API call can add money or debit arbitrary amounts. Top-ups are made through Specto as usual.

Insufficient balance

If your wallet is zero or below the plan price, the request fails immediately with 402 INSUFFICIENT_BALANCE. Watcho is not contacted and nothing is deducted.

{
    "success": false,
    "request_id": "req_9ba40a67453c36ae4856",
    "code": "INSUFFICIENT_BALANCE",
    "message": "Insufficient wallet balance",
    "data": {
        "wallet_balance": 50.0,
        "required_amount": 99.0,
        "shortfall": 49.0,
        "currency": "INR"
    }
}

Charging rules

OutcomeWalletLedger
200 OKDebited by the plan priceOne debit entry in your Specto wallet ledger (description ends with [API])
502 PROVIDER_ERRORNot charged. A reserved amount is returned at once (wallet.refunded: true).No entry
202 PENDING_REVIEWAmount held (wallet.held: true) while Specto verifies the result with WatchoAn entry is written only if the activation is confirmed; otherwise the hold is refunded.
Any 4xx before processingUntouchedNo entry

Wallet, ledger, expiry and MSO commission follow exactly the same rules as operations in the Specto LCO panel.

Activation & renewal flow

  1. Authenticate: HTTPS, key, timestamp, signature, nonce, account status, IP whitelist, rate limit, permission.
  2. Idempotency: a known key returns the stored result immediately.
  3. Validate the request and look up the plan price in the Specto catalogue.
  4. Business checks: subscriber exists or belongs to you, duplicates, and the plan rule for advance renewals.
  5. Balance check: zero or insufficient → 402. Watcho is never called.
  6. Reserve: Specto atomically debits the plan price and records the operation as processing.
  7. Specto calls Watcho and verifies the response strictly.
  8. Settle:
    • Success: subscriber record, expiry, ledger entry and MSO commission are written together → 200.
    • Rejected: the reservation is refunded → 502.
    • Unknown (timeout or gateway error): the amount is held and verified → 202.

Expiry rules

ActivationThe new expiry is 30 days from the moment of activation.
RenewalThe current expiry (or now, if already expired) + plan duration × 30 days.
Advance renewalThe next cycle starts the day after the current expiry, capped at today + 29 days (a Watcho limit). If the subscriber has already expired, it starts today. The response gives expiry.start_date.

Operation result object

Returned by /activate, /renew, /advance-renew and /transaction.

{
    "transaction_id": "SPX260925A1B2C3D4E5",
    "idempotency_key": "order-100045",
    "operation": "activate",
    "status": "success",
    "channel": "api",
    "customer": {
        "subscriber_id": "482915",
        "name": "Ravi Kumar",
        "mobile": "9876543210",
        "password": "k7Qm2xPa9w"
    },
    "provider": "watcho",
    "provider_transaction_id": "WT3202648084",
    "plan": {
        "id": "171839",
        "name": "Watcho Basic",
        "duration_months": 1
    },
    "amount": 99.0,
    "currency": "INR",
    "expiry": {
        "previous": null,
        "new": "2026-10-25 11:02:14",
        "start_date": null
    },
    "wallet": {
        "before": 5000.0,
        "after": 4901.0,
        "charged": 99.0,
        "refunded": false,
        "held": false
    },
    "warnings": [],
    "created_at": "2026-09-25 11:02:13",
    "completed_at": "2026-09-25 11:02:14"
}
FieldDescription
transaction_idThe Specto transaction ID (SPX…). Store it with your order.
statusOne of:
  • success
  • failed: nothing charged, or charged and refunded
  • needs_review: amount held pending verification
  • processing
customer.subscriber_idThe Specto subscriber ID. Use it for renewals.
customer.passwordActivation only. The customer's app password, returned once when you did not supply one. It is not included in replays or lookups.
provider_transaction_idWatcho's transaction number, when available.
planPlan ID, name and duration in months.
amountThe catalogue price charged, or that would have been charged, for this operation.
expiry.previous / new / start_dateThe expiry before the operation, the new expiry (on success), and the start date (advance renewal).
wallet.before / afterThe wallet balance immediately before and after this operation's debit.
wallet.charged / refunded / heldWhat happened to the money.
warningsCOMBO_SECONDARY_FAILED: in a combo plan, the add-on component failed at Watcho. The main plan is active and Specto follows up.

Endpoints

POST /api/v1/activate

Creates a new subscriber and activates a Watcho plan. Permission: Activate. Debits wallet

FieldTypeRequiredRules
full_namestringyes1–100 characters
mobilestringyes10 digits. It must not already exist in Specto.
plan_idstringyesFrom GET /plans
emailstringnoA valid email, up to 150 characters
passwordstringnoCustomer app password, up to 64 characters. If omitted, one is generated and returned once.
connection_typestringnoDefault OTT + Live TV
addressstringnoUp to 500 characters
subscriber_idstringnoPreferred 6-digit ID. Specto assigns one if it is omitted or taken.
POST /api/v1/activate HTTP/1.1
Host: api.spectotv.com
Content-Type: application/json
Idempotency-Key: order-100045
X-API-Key: spk_0f1e2d3c4b5a69788796a5b4c3d2e1f0
X-Timestamp: 1790000000
X-Nonce: 3b1f0c9e8a7d6c5b4a39281706f5e4d3
X-Signature: 7a0c…e91b

{"full_name":"Ravi Kumar","mobile":"9876543210","email":"ravi@example.com","plan_id":"171839"}

200 OK

{
    "success": true,
    "request_id": "req_9ba40a67453c36ae4856",
    "code": "OK",
    "message": "Subscriber activated",
    "data": {
        "transaction_id": "SPX260925A1B2C3D4E5",
        "idempotency_key": "order-100045",
        "operation": "activate",
        "status": "success",
        "channel": "api",
        "customer": {
            "subscriber_id": "482915",
            "name": "Ravi Kumar",
            "mobile": "9876543210",
            "password": "k7Qm2xPa9w"
        },
        "provider": "watcho",
        "provider_transaction_id": "WT3202648084",
        "plan": {
            "id": "171839",
            "name": "Watcho Basic",
            "duration_months": 1
        },
        "amount": 99.0,
        "currency": "INR",
        "expiry": {
            "previous": null,
            "new": "2026-10-25 11:02:14",
            "start_date": null
        },
        "wallet": {
            "before": 5000.0,
            "after": 4901.0,
            "charged": 99.0,
            "refunded": false,
            "held": false
        },
        "warnings": [],
        "created_at": "2026-09-25 11:02:13",
        "completed_at": "2026-09-25 11:02:14"
    }
}

Other results: 402 INSUFFICIENT_BALANCE · 409 SUBSCRIBER_EXISTS · 422 VALIDATION_ERROR · 422 INVALID_PLAN · 502 PROVIDER_ERROR · 202 PENDING_REVIEW.

{
    "success": false,
    "request_id": "req_9ba40a67453c36ae4856",
    "code": "VALIDATION_ERROR",
    "message": "One or more fields are invalid",
    "data": {
        "fields": {
            "mobile": "Must be a 10-digit mobile number"
        }
    }
}

POST /api/v1/renew

Renews one of your subscribers. Permission: Renew. Debits wallet

FieldTypeRequiredRules
subscriber_idstringyes, or mobileYour subscriber's Specto ID
mobilestringalternativeUsed only when subscriber_id is empty
plan_idstringyesMay differ from the current plan
{"subscriber_id":"482915","plan_id":"171850"}

200 OK

{
    "success": true,
    "request_id": "req_9ba40a67453c36ae4856",
    "code": "OK",
    "message": "Subscription renewed",
    "data": {
        "transaction_id": "SPX260925F0D3126F23",
        "idempotency_key": "renew-2026-10-482915",
        "operation": "renew",
        "status": "success",
        "channel": "api",
        "customer": {
            "subscriber_id": "482915",
            "name": "Ravi Kumar",
            "mobile": "9876543210"
        },
        "provider": "watcho",
        "provider_transaction_id": "WT3202648084",
        "plan": {
            "id": "171850",
            "name": "Watcho Quarterly",
            "duration_months": 3
        },
        "amount": 499.0,
        "currency": "INR",
        "expiry": {
            "previous": "2026-10-25 11:02:14",
            "new": "2027-01-23 11:02:14",
            "start_date": null
        },
        "wallet": {
            "before": 4901.0,
            "after": 4402.0,
            "charged": 499.0,
            "refunded": false,
            "held": false
        },
        "warnings": [],
        "created_at": "2026-09-25 11:02:13",
        "completed_at": "2026-09-25 11:02:14"
    }
}

Other results: 404 SUBSCRIBER_NOT_FOUND (also returned for other operators' subscribers) · 409 DUPLICATE_REQUEST · 402 · 502 · 202.

{
    "success": false,
    "request_id": "req_9ba40a67453c36ae4856",
    "code": "PROVIDER_ERROR",
    "message": "The provider rejected the request. No amount was deducted.",
    "data": {
        "transaction_id": "SPX260925E09A0D7C7E",
        "operation": "renew",
        "status": "failed",
        "wallet": {
            "before": 4402.0,
            "after": 4303.0,
            "charged": 0.0,
            "refunded": true,
            "held": false
        },
        "provider_message": "Subscriber rejected by Watcho",
        "reason": "PROVIDER_REJECTED",
        "…": "…"
    }
}

POST /api/v1/advance-renew

Books the subscriber's next cycle at Watcho before the current one ends. Permission: Advance Renew. Debits wallet

The body is the same as /renew. Choosing the plan that is currently active returns 409 PLAN_ALREADY_ACTIVE. The start date follows the rule under Expiry rules.

{"subscriber_id":"482915","plan_id":"171840"}
{
    "success": true,
    "request_id": "req_9ba40a67453c36ae4856",
    "code": "OK",
    "message": "Advance renewal scheduled",
    "data": {
        "transaction_id": "SPX260925C44E0A91B7",
        "idempotency_key": "adv-2026-10-482915",
        "operation": "advance_renew",
        "status": "success",
        "channel": "api",
        "customer": {
            "subscriber_id": "482915",
            "name": "Ravi Kumar",
            "mobile": "9876543210"
        },
        "provider": "watcho",
        "provider_transaction_id": "WT3202648084",
        "plan": {
            "id": "171840",
            "name": "Watcho Plus",
            "duration_months": 1
        },
        "amount": 199.0,
        "currency": "INR",
        "expiry": {
            "previous": "2026-10-25 11:02:14",
            "new": "2026-11-25 11:02:14",
            "start_date": "2026-10-26"
        },
        "wallet": {
            "before": 4402.0,
            "after": 4203.0,
            "charged": 199.0,
            "refunded": false,
            "held": false
        },
        "warnings": [],
        "created_at": "2026-09-25 11:02:13",
        "completed_at": "2026-09-25 11:02:14"
    }
}

GET /api/v1/balance

Returns your wallet balance, read-only. Permission: Check Balance. No parameters.

{
    "success": true,
    "request_id": "req_9ba40a67453c36ae4856",
    "code": "OK",
    "message": "Wallet balance",
    "data": {
        "lco_id": 12,
        "wallet_balance": 4203.0,
        "currency": "INR",
        "low_balance": false,
        "as_of": "2026-09-25T11:20:00+05:30",
        "note": "Read-only. Top-ups are made through Specto; the API cannot credit or debit the wallet directly."
    }
}

GET /api/v1/plans

Returns the current plan catalogue with prices. Permission: Get Plans. Cache it for up to 5 minutes. The amount charged is always the catalogue price at the moment of the operation.

{
    "success": true,
    "request_id": "req_9ba40a67453c36ae4856",
    "code": "OK",
    "message": "3 plans",
    "data": {
        "provider": "watcho",
        "currency": "INR",
        "plans": [
            {
                "id": "171839",
                "name": "Watcho Basic",
                "description": "OTT basic pack",
                "price": 99.0,
                "duration": 1,
                "type": "standard"
            },
            {
                "id": "171850",
                "name": "Watcho Quarterly",
                "description": "3 months",
                "price": 499.0,
                "duration": 3,
                "type": "standard"
            },
            {
                "id": "combo_225",
                "name": "Combo 225",
                "description": "Basic + Live",
                "price": 225.0,
                "duration": 1,
                "type": "combo"
            }
        ]
    }
}

duration is in months (30 days each). type: combo plans activate more than one Watcho component.

GET /api/v1/subscription

Returns one of your subscribers. Permission: Check Subscription.

QueryDescription
subscriber_idThe Specto subscriber ID, or
mobileThe 10-digit mobile number
GET /api/v1/subscription?subscriber_id=482915 HTTP/1.1
{
    "success": true,
    "request_id": "req_9ba40a67453c36ae4856",
    "code": "OK",
    "message": "Subscriber found",
    "data": {
        "customer": {
            "subscriber_id": "482915",
            "name": "Ravi Kumar",
            "mobile": "9876543210",
            "email": "ravi@example.com",
            "connection_type": "OTT + Live TV"
        },
        "provider": "watcho",
        "plan": {
            "id": "171850",
            "name": "Watcho Quarterly"
        },
        "status": "active",
        "active": true,
        "expiry_date": "2027-01-23 11:02:14",
        "days_remaining": 120,
        "created_at": "2026-09-25 11:02:14",
        "renewed_at": "2026-09-25 11:10:40",
        "last_provider_transaction": "WT3202648101",
        "pending_operations": []
    }
}

pending_operations lists that subscriber's operations that are still processing or needs_review.

GET /api/v1/transaction

Looks up the result of one of your operations. Permission: Check Transaction. Use it after network errors and to follow PENDING_REVIEW operations.

QueryDescription
transaction_idSPX…, or
idempotency_keyThe key you sent with the operation
GET /api/v1/transaction?idempotency_key=order-100045 HTTP/1.1
{
    "success": true,
    "request_id": "req_9ba40a67453c36ae4856",
    "code": "OK",
    "message": "Transaction found",
    "data": {
        "transaction_id": "SPX260925A1B2C3D4E5",
        "idempotency_key": "order-100045",
        "operation": "activate",
        "status": "success",
        "channel": "api",
        "customer": {
            "subscriber_id": "482915",
            "name": "Ravi Kumar",
            "mobile": "9876543210"
        },
        "provider": "watcho",
        "provider_transaction_id": "WT3202648084",
        "plan": {
            "id": "171839",
            "name": "Watcho Basic",
            "duration_months": 1
        },
        "amount": 99.0,
        "currency": "INR",
        "expiry": {
            "previous": null,
            "new": "2026-10-25 11:02:14",
            "start_date": null
        },
        "wallet": {
            "before": 5000.0,
            "after": 4901.0,
            "charged": 99.0,
            "refunded": false,
            "held": false
        },
        "warnings": [],
        "created_at": "2026-09-25 11:02:13",
        "completed_at": "2026-09-25 11:02:14",
        "result_code": "OK",
        "result_message": "Subscriber activated"
    }
}

result_code is the code the operation returned. If a PENDING_REVIEW operation was settled later, it is the code it would return now.

Error codes

Every error uses the standard envelope with success: false.

Transport & authentication — nothing was processed

HTTPcodeMeaningWhat to do
403HTTPS_REQUIREDThe request used plain HTTP.Use https://.
404NOT_FOUNDUnknown endpoint.Check the path: /api/v1/<endpoint>.
405METHOD_NOT_ALLOWEDWrong HTTP method. The Allow header names the correct one.Use GET or POST as documented.
401MISSING_AUTH_HEADERSX-API-Key, X-Timestamp, X-Nonce or X-Signature is missing.Send all four headers.
401INVALID_API_KEYThe key is unknown or malformed.Use the key exactly as issued by Specto.
401CREDENTIAL_REVOKEDThe key was revoked.Ask Specto for new credentials.
401CREDENTIAL_EXPIREDThe key was rotated and its grace period ended.Deploy the new key and secret.
401TIMESTAMP_EXPIREDX-Timestamp is more than 300 s from server time. data.server_time shows the server clock.Synchronise your clock (NTP); send Unix seconds.
401INVALID_NONCEThe nonce is not 16–64 characters of [A-Za-z0-9_-].Use 32 random hex characters.
401INVALID_SIGNATUREThe HMAC does not match.See Request signing and check against the test vector.
401REPLAY_DETECTEDThe nonce was already used with this key.Use a new nonce for every request, including retries.
413PAYLOAD_TOO_LARGEThe body is larger than 16 KB.—
429TOO_MANY_FAILED_ATTEMPTSToo many failed authentications from your IP in the last minute.Fix the signing bug; wait for Retry-After.
500INTERNAL_ERRORUnexpected server error.Retry with the same Idempotency-Key. If it persists, send the request_id to Specto.
503SERVICE_UNAVAILABLETemporarily unavailable.Retry with backoff.

Authorisation

HTTPcodeMeaningWhat to do
403LCO_NOT_FOUNDThe operator account no longer exists.Contact Specto.
403LCO_INACTIVEThe operator account is suspended or inactive.Contact Specto.
403API_ACCESS_BLOCKEDSpecto has blocked API access for this operator.Contact Specto.
403API_DISABLEDAPI access is switched off for this operator.Ask Specto to enable it.
403IP_NOT_WHITELISTEDYour source IP is not on the whitelist. data.ip shows the address Specto saw.Send that IP to Specto.
403PERMISSION_DENIEDThis endpoint is not enabled for your key.Ask Specto to grant the permission.
429RATE_LIMITEDPer-minute limit exceeded. See the Retry-After and X-RateLimit-* headers.Back off, then retry.

Request validation

HTTPcodeMeaningWhat to do
415UNSUPPORTED_MEDIA_TYPEA POST without Content-Type: application/json.Send JSON.
400INVALID_JSONThe body is not a JSON object.Send {"field": "value", …}.
400MISSING_IDEMPOTENCY_KEYA POST without an Idempotency-Key header.Send one key per business operation.
400INVALID_IDEMPOTENCY_KEYThe key is not 8–100 characters of [A-Za-z0-9_.:-].—
422VALIDATION_ERROROne or more fields are invalid. data.fields gives the error for each field.Fix the fields.
422INVALID_PLANThe plan_id is not in the current catalogue.Use GET /plans.
422IDEMPOTENCY_KEY_REUSEDThe key was already used for a different request body.Use a new key for a new operation.

Business rules — Watcho was not called and nothing was charged

HTTPcodeMeaningWhat to do
402INSUFFICIENT_BALANCEThe wallet is zero or below the plan price. data holds wallet_balance, required_amount and shortfall.Top up through Specto.
409SUBSCRIBER_EXISTSThe mobile number is already registered with Specto.Use /renew for existing customers.
404SUBSCRIBER_NOT_FOUNDNo such subscriber under your account.Check subscriber_id or mobile.
404TRANSACTION_NOT_FOUNDNo such transaction under your account.—
409PLAN_ALREADY_ACTIVEAdvance renewal onto the plan that is currently active.Choose another plan or wait until expiry.
409DUPLICATE_REQUESTThe same subscriber and plan were processed in the last 2 minutes. data.previous_transaction_id identifies the earlier operation.Check that transaction before retrying.
409OPERATION_IN_PROGRESSAnother operation for this customer is running.Retry in a few seconds with the same key.
503PLANS_UNAVAILABLEPrices cannot be verified right now.Retry later.

Operation outcomes

HTTPcodeMeaningWhat to do
200OKCompleted. The wallet was debited and the subscription updated.—
202PENDING_REVIEWSent to Watcho, but the outcome is not confirmed yet. The amount is held.Do not retry with a new key. Poll GET /transaction.
409REQUEST_IN_PROGRESSThe same Idempotency-Key is still being processed.Poll GET /transaction.
502PROVIDER_ERRORWatcho rejected the request. Any reserved amount was refunded.Show data.provider_message; use a new key to try again.

HTTP status codes

StatusMeaningRetry?
200Success—
202Accepted, outcome pending verification (PENDING_REVIEW). The amount is held.No. Poll /transaction.
400Malformed request (JSON, headers)After fixing it
401Authentication failedAfter fixing it (new nonce)
402Insufficient wallet balanceAfter a top-up
403Not allowed (HTTPS, IP, permission, disabled or blocked account)After Specto changes the configuration
404Unknown endpoint, subscriber or transactionNo
405Wrong methodNo
409Conflict (exists, duplicate, in progress, plan already active)Read code. OPERATION_IN_PROGRESS: yes, after a few seconds.
413 / 415 / 422Payload or validation problemAfter fixing it
429Rate limited or throttledAfter Retry-After
500Internal errorYes, with the same Idempotency-Key
502Watcho rejected the request (refunded)With a new key
503Service or plan catalogue temporarily unavailableYes, with backoff

Integration examples

Complete, dependency-free clients that implement signing, idempotent retries and result handling. They were tested against the live API implementation. Download all (.zip).

export SPECTO_API_BASE=https://api.spectotv.com/api/v1
export SPECTO_API_KEY=spk_…
export SPECTO_API_SECRET=sps_…

php specto_client.php                                  # balance + plans
SPECTO_DEMO_MOBILE=9876543210 php specto_client.php    # + activation + lookup
SPECTO_DEMO_SUBSCRIBER=482915 python3 specto_client.py # + renewal + lookup
SPECTO_DEMO_SUBSCRIBER=482915 SPECTO_DEMO_PLAN=171840 node specto_client.js   # + advance renewal
#!/usr/bin/env bash
# Specto LCO API — cURL + OpenSSL example (bash 4+, curl, openssl, xxd not required).
#
#   export SPECTO_API_BASE=https://api.spectotv.com/api/v1
#   export SPECTO_API_KEY=spk_...   SPECTO_API_SECRET=sps_...
#   ./curl.sh GET balance
#   ./curl.sh GET subscription 'subscriber_id=123456'
#   ./curl.sh POST activate '' '{"full_name":"Ravi Kumar","mobile":"9876543210","plan_id":"171839"}' order-1001
#
# Arguments: METHOD ENDPOINT [QUERY(already sorted & encoded)] [JSON_BODY] [IDEMPOTENCY_KEY]
set -euo pipefail
METHOD="${1:?METHOD}"; ENDPOINT="${2:?ENDPOINT}"; QUERY="${3:-}"; BODY="${4:-}"; IDEM="${5:-}"
BASE="${SPECTO_API_BASE%/}"; ORIGIN="${BASE%/api/v1}"
PATH_="/api/v1/${ENDPOINT}"
TS="$(date +%s)"
NONCE="$(openssl rand -hex 16)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.*= //')"
STS="$(printf '%s\n%s\n%s\n%s\n%s\n%s' "$METHOD" "$PATH_" "$QUERY" "$TS" "$NONCE" "$BODY_HASH")"
SIG="$(printf '%s' "$STS" | openssl dgst -sha256 -hmac "$SPECTO_API_SECRET" -hex | sed 's/^.*= //')"

ARGS=(-sS -X "$METHOD" -H "X-API-Key: $SPECTO_API_KEY" -H "X-Timestamp: $TS" -H "X-Nonce: $NONCE" -H "X-Signature: $SIG")
[ -n "$IDEM" ] && ARGS+=(-H "Idempotency-Key: $IDEM")
if [ "$METHOD" = "POST" ]; then ARGS+=(-H "Content-Type: application/json" --data-binary "$BODY"); fi
# optional extra headers separated by '|', e.g. for your own outbound proxy
if [ -n "${SPECTO_EXTRA_HEADERS_CURL:-}" ]; then IFS='|' read -ra EXTRA <<< "$SPECTO_EXTRA_HEADERS_CURL"; for h in "${EXTRA[@]}"; do ARGS+=(-H "$h"); done; fi
URL="${ORIGIN}${PATH_}"; [ -n "$QUERY" ] && URL="${URL}?${QUERY}"
curl "${ARGS[@]}" -w '\nHTTP %{http_code}\n' "$URL"
<?php
/**
 * Specto LCO API — PHP client example (PHP 7.4+, ext-curl).
 *
 *   SPECTO_API_BASE=https://api.spectotv.com/api/v1 SPECTO_API_KEY=spk_... SPECTO_API_SECRET=sps_... php specto_client.php
 *
 * Keep the secret on your server. Never ship it in a mobile app or browser JavaScript.
 */
final class SpectoClient
{
    private string $base; private string $key; private string $secret;

    public function __construct(string $base, string $key, string $secret)
    {
        $this->base = rtrim($base, '/'); $this->key = $key; $this->secret = $secret;
    }

    /** Signature = hex(HMAC-SHA256(secret, METHOD\nPATH\nCANONICAL_QUERY\nTIMESTAMP\nNONCE\nhex(sha256(body)))) */
    public static function sign(string $secret, string $method, string $path, string $query, string $ts, string $nonce, string $body): string
    {
        $canon = '';
        if ($query !== '') {
            $pairs = [];
            foreach (explode('&', $query) as $p) {
                if ($p === '') continue;
                $kv = explode('=', $p, 2);
                $pairs[] = [rawurlencode(rawurldecode(str_replace('+', '%20', $kv[0]))), rawurlencode(rawurldecode(str_replace('+', '%20', $kv[1] ?? '')))];
            }
            usort($pairs, function ($a, $b) { return $a[0] === $b[0] ? strcmp($a[1], $b[1]) : strcmp($a[0], $b[0]); });
            $canon = implode('&', array_map(function ($p) { return $p[0] . '=' . $p[1]; }, $pairs));
        }
        $sts = strtoupper($method) . "\n" . $path . "\n" . $canon . "\n" . $ts . "\n" . $nonce . "\n" . hash('sha256', $body);
        return hash_hmac('sha256', $sts, $secret);
    }

    public function request(string $method, string $endpoint, array $query = [], ?array $body = null, ?string $idempotencyKey = null): array
    {
        $path = '/api/v1/' . ltrim($endpoint, '/');
        $qs = $query ? http_build_query($query, '', '&', PHP_QUERY_RFC3986) : '';
        $json = $body === null ? '' : json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        $ts = (string) time();
        $nonce = bin2hex(random_bytes(16));
        $headers = [
            'X-API-Key: ' . $this->key,
            'X-Timestamp: ' . $ts,
            'X-Nonce: ' . $nonce,
            'X-Signature: ' . self::sign($this->secret, $method, $path, $qs, $ts, $nonce, $json),
            'Accept: application/json',
        ];
        if ($method === 'POST') { $headers[] = 'Content-Type: application/json'; }
        if ($idempotencyKey !== null) { $headers[] = 'Idempotency-Key: ' . $idempotencyKey; }
        // optional extra headers, e.g. when your own outbound proxy needs them (JSON array of "Name: value")
        foreach ((array) json_decode((string) getenv('SPECTO_EXTRA_HEADERS'), true) as $h) { $headers[] = $h; }
        $url = preg_replace('#/api/v1$#', '', $this->base) . $path . ($qs ? '?' . $qs : '');
        $ch = curl_init($url);
        curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers,
            CURLOPT_TIMEOUT => 90, CURLOPT_SSL_VERIFYPEER => true]);
        if ($method === 'POST') { curl_setopt($ch, CURLOPT_POSTFIELDS, $json); }
        $raw = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $err = curl_error($ch);
        curl_close($ch);
        if ($raw === false) { throw new RuntimeException('Network error: ' . $err); }
        return ['http' => $status, 'body' => json_decode((string) $raw, true)];
    }
}

/* ---------------------------------------------------------------------------
   Demo: balance → plans → (optional) activation with a retry-safe idempotency key
         → transaction lookup.  Set SPECTO_DEMO_MOBILE to run the activation.
   --------------------------------------------------------------------------- */
if (PHP_SAPI === 'cli' && realpath($argv[0]) === __FILE__) {
    $api = new SpectoClient(getenv('SPECTO_API_BASE') ?: 'https://api.spectotv.com/api/v1', getenv('SPECTO_API_KEY') ?: '', getenv('SPECTO_API_SECRET') ?: '');

    $bal = $api->request('GET', 'balance');
    echo "balance: HTTP {$bal['http']} ", $bal['body']['code'] ?? '?', ' ', json_encode($bal['body']['data']['wallet_balance'] ?? null), "\n";

    $plans = $api->request('GET', 'plans');
    $list = $plans['body']['data']['plans'] ?? [];
    echo "plans: ", count($list), "\n";

    $mobile = getenv('SPECTO_DEMO_MOBILE');
    if ($mobile && $list) {
        // Create the key BEFORE the first attempt and store it with your order.
        $key = 'order-' . bin2hex(random_bytes(8));
        $r = null;
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try { $r = $api->request('POST', 'activate', [], ['full_name' => 'Demo Customer', 'mobile' => $mobile, 'plan_id' => $list[0]['id']], $key); break; }
            catch (RuntimeException $e) { sleep($attempt); }   // network error: retry with the SAME key
        }
        $code = $r['body']['code'] ?? 'NO_RESPONSE';
        echo "activate: HTTP ", $r['http'] ?? 0, " $code\n";
        if ($code === 'OK') {
            $d = $r['body']['data'];
            echo "  transaction {$d['transaction_id']} · subscriber {$d['customer']['subscriber_id']} · expires {$d['expiry']['new']} · wallet {$d['wallet']['before']} → {$d['wallet']['after']}\n";
        } elseif ($code === 'INSUFFICIENT_BALANCE') {
            echo "  top up needed: short by ", $r['body']['data']['shortfall'], "\n";
        } else {
            echo "  ", $r['body']['message'] ?? '', "\n";
        }
        $t = $api->request('GET', 'transaction', ['idempotency_key' => $key]);
        echo "lookup: ", $t['body']['data']['status'] ?? ($t['body']['code'] ?? '?'), "\n";
    }
}
#!/usr/bin/env node
/**
 * Specto LCO API — Node.js 18+ example client (no dependencies; uses global fetch).
 *
 *   SPECTO_API_BASE=https://api.spectotv.com/api/v1 SPECTO_API_KEY=spk_... SPECTO_API_SECRET=sps_... node specto_client.js
 *
 * Server-side only: never put the API secret in browser or mobile-app code.
 */
'use strict';
const crypto = require('crypto');

const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());

function canonicalQuery(query) {
  if (!query) return '';
  const pairs = query.split('&').filter(Boolean).map((p) => {
    const i = p.indexOf('=');
    const k = decodeURIComponent((i < 0 ? p : p.slice(0, i)).replace(/\+/g, '%20'));
    const v = i < 0 ? '' : decodeURIComponent(p.slice(i + 1).replace(/\+/g, '%20'));
    return [enc(k), enc(v)];
  });
  pairs.sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) : a[0] < b[0] ? -1 : 1));
  return pairs.map((p) => p[0] + '=' + p[1]).join('&');
}

function sign(secret, method, path, query, ts, nonce, body) {
  const sts = [method.toUpperCase(), path, canonicalQuery(query), ts, nonce,
    crypto.createHash('sha256').update(body, 'utf8').digest('hex')].join('\n');
  return crypto.createHmac('sha256', secret).update(sts, 'utf8').digest('hex');
}

class SpectoClient {
  constructor(base, key, secret) {
    this.base = base.replace(/\/+$/, '');
    this.origin = this.base.replace(/\/api\/v1$/, '');
    this.key = key; this.secret = secret;
  }
  async request(method, endpoint, query = {}, body = null, idempotencyKey = null) {
    const path = '/api/v1/' + endpoint.replace(/^\/+/, '');
    const qs = Object.entries(query).map(([k, v]) => enc(k) + '=' + enc(String(v))).join('&');
    const raw = body === null ? '' : JSON.stringify(body);
    const ts = String(Math.floor(Date.now() / 1000));
    const nonce = crypto.randomBytes(16).toString('hex');
    const headers = {
      'X-API-Key': this.key, 'X-Timestamp': ts, 'X-Nonce': nonce,
      'X-Signature': sign(this.secret, method, path, qs, ts, nonce, raw), Accept: 'application/json',
    };
    if (method === 'POST') headers['Content-Type'] = 'application/json';
    if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
    // optional extra headers, e.g. for your own outbound proxy (JSON array of "Name: value")
    for (const h of JSON.parse(process.env.SPECTO_EXTRA_HEADERS || '[]')) { const i = h.indexOf(':'); headers[h.slice(0, i).trim()] = h.slice(i + 1).trim(); }
    const res = await fetch(this.origin + path + (qs ? '?' + qs : ''), { method, headers, body: method === 'POST' ? raw : undefined });
    return { http: res.status, body: await res.json() };
  }
}

module.exports = { SpectoClient, sign, canonicalQuery };

if (require.main === module) {
  // Demo: balance -> subscription lookup -> (optional) advance renewal with a retry-safe key -> transaction lookup.
  (async () => {
    const c = new SpectoClient(process.env.SPECTO_API_BASE || 'https://api.spectotv.com/api/v1', process.env.SPECTO_API_KEY || '', process.env.SPECTO_API_SECRET || '');
    const bal = await c.request('GET', 'balance');
    console.log('balance:', bal.http, bal.body.code, bal.body.data && bal.body.data.wallet_balance);
    const sid = process.env.SPECTO_DEMO_SUBSCRIBER;
    if (!sid) return;
    const sub = await c.request('GET', 'subscription', { subscriber_id: sid });
    console.log('subscription:', sub.http, sub.body.code, sub.body.data && sub.body.data.expiry_date);
    const planId = process.env.SPECTO_DEMO_PLAN;
    if (!planId) return;
    const key = 'adv-' + crypto.randomBytes(8).toString('hex');   // store with your order before sending
    let res;
    for (let attempt = 1; attempt <= 3; attempt++) {
      try { res = await c.request('POST', 'advance-renew', {}, { subscriber_id: sid, plan_id: planId }, key); break; }
      catch (e) { await new Promise((r) => setTimeout(r, 1000 * attempt)); }   // network error: retry with the SAME key
    }
    console.log('advance-renew:', res.http, res.body.code, res.body.message, res.body.data && res.body.data.expiry && res.body.data.expiry.start_date);
    const t = await c.request('GET', 'transaction', { idempotency_key: key });
    console.log('lookup:', t.body.data && t.body.data.status);
  })().catch((e) => { console.error(e); process.exit(1); });
}
#!/usr/bin/env python3
"""
Specto LCO API — Python 3 example client (standard library only).

  SPECTO_API_BASE=https://api.spectotv.com/api/v1 SPECTO_API_KEY=spk_... SPECTO_API_SECRET=sps_... python3 specto_client.py

Keep the secret on your server only.
"""
import hashlib, hmac, json, os, secrets, time, urllib.error, urllib.parse, urllib.request


def canonical_query(query: str) -> str:
    if not query:
        return ""
    pairs = []
    for part in query.split("&"):
        if not part:
            continue
        k, _, v = part.partition("=")
        k = urllib.parse.unquote(k.replace("+", "%20"))
        v = urllib.parse.unquote(v.replace("+", "%20"))
        pairs.append((urllib.parse.quote(k, safe="-_.~"), urllib.parse.quote(v, safe="-_.~")))
    pairs.sort()
    return "&".join(f"{k}={v}" for k, v in pairs)


def sign(secret: str, method: str, path: str, query: str, ts: str, nonce: str, body: bytes) -> str:
    sts = "\n".join([method.upper(), path, canonical_query(query), ts, nonce, hashlib.sha256(body).hexdigest()])
    return hmac.new(secret.encode(), sts.encode(), hashlib.sha256).hexdigest()


class SpectoClient:
    def __init__(self, base: str, key: str, secret: str):
        self.base = base.rstrip("/")
        self.origin = self.base[: -len("/api/v1")] if self.base.endswith("/api/v1") else self.base
        self.key, self.secret = key, secret

    def request(self, method, endpoint, query=None, body=None, idempotency_key=None):
        path = "/api/v1/" + endpoint.lstrip("/")
        qs = urllib.parse.urlencode(query or {}, quote_via=urllib.parse.quote)
        raw = b"" if body is None else json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode()
        ts, nonce = str(int(time.time())), secrets.token_hex(16)
        headers = {
            "X-API-Key": self.key, "X-Timestamp": ts, "X-Nonce": nonce,
            "X-Signature": sign(self.secret, method, path, qs, ts, nonce, raw), "Accept": "application/json",
        }
        if method == "POST":
            headers["Content-Type"] = "application/json"
        if idempotency_key:
            headers["Idempotency-Key"] = idempotency_key
        # optional extra headers, e.g. for your own outbound proxy (JSON array of "Name: value")
        for h in json.loads(os.environ.get("SPECTO_EXTRA_HEADERS", "[]")):
            k, _, v = h.partition(":"); headers[k.strip()] = v.strip()
        url = self.origin + path + ("?" + qs if qs else "")
        req = urllib.request.Request(url, data=raw if method == "POST" else None, headers=headers, method=method)
        try:
            with urllib.request.urlopen(req, timeout=90) as r:
                return r.status, json.loads(r.read())
        except urllib.error.HTTPError as e:   # 4xx/5xx still carry the JSON envelope
            return e.code, json.loads(e.read() or b"{}")


if __name__ == "__main__":
    # Demo: balance -> plans -> (optional) renewal with a retry-safe idempotency key -> lookup.
    c = SpectoClient(os.environ.get("SPECTO_API_BASE", "https://api.spectotv.com/api/v1"),
                     os.environ.get("SPECTO_API_KEY", ""), os.environ.get("SPECTO_API_SECRET", ""))
    status, bal = c.request("GET", "balance")
    print("balance:", status, bal.get("code"), bal.get("data", {}).get("wallet_balance"))
    status, plans = c.request("GET", "plans")
    plan_list = plans.get("data", {}).get("plans", [])
    print("plans:", len(plan_list))

    subscriber = os.environ.get("SPECTO_DEMO_SUBSCRIBER")
    if subscriber and plan_list:
        key = "renew-" + secrets.token_hex(8)          # store with your order before sending
        result = None
        for attempt in range(1, 4):
            try:
                result = c.request("POST", "renew", body={"subscriber_id": subscriber, "plan_id": plan_list[0]["id"]}, idempotency_key=key)
                break
            except urllib.error.URLError:               # network problem: retry with the SAME key
                time.sleep(attempt)
        status, body = result
        print("renew:", status, body.get("code"), body.get("message"))
        if body.get("code") == "OK":
            d = body["data"]
            print("  expiry", d["expiry"]["previous"], "->", d["expiry"]["new"], "| wallet", d["wallet"]["before"], "->", d["wallet"]["after"])
        print("lookup:", c.request("GET", "transaction", {"idempotency_key": key})[1].get("data", {}).get("status"))

Single files: curl.sh · specto_client.php · specto_client.js · specto_client.py

Recommended order-handling logic

on customer payment:
  key = "order-" + order.id            # persist before calling
  loop up to 3 times:
    response = POST /activate (Idempotency-Key: key)   # new nonce/timestamp/signature each attempt
    if network error: wait, continue             # same key → never double-charged
    break
  switch response.code:
    OK                   → save transaction_id, subscriber_id, expiry.new, customer.password
    PENDING_REVIEW       → mark order 'pending'; poll GET /transaction?idempotency_key=key every few minutes
    INSUFFICIENT_BALANCE → alert staff to top up the Specto wallet; retry later (same key allowed)
    SUBSCRIBER_EXISTS    → use /renew with the existing subscriber
    PROVIDER_ERROR       → show data.provider_message; new key for a new attempt
    RATE_LIMITED         → wait Retry-After seconds, retry with same key
    other 4xx            → fix request/configuration; log request_id

Security recommendations

  • Keep the API secret server-side only. Never put it in Android/iOS apps, browser JavaScript, public repositories or log files.
  • Store the key and secret in environment variables or a secret manager, readable only by the service that calls the API.
  • Always use HTTPS with certificate verification switched on. Never disable TLS checks.
  • Use a new cryptographically random nonce for every request (at least 16 bytes).
  • Keep server clocks synchronised with NTP.
  • Call the API from a fixed, whitelisted server address. Ask Specto to remove addresses you no longer use.
  • Ask Specto for only the permissions you need. Read-only integrations should not have Activate or Renew.
  • Log the request_id, transaction_id, code and HTTP status of each call, but never the secret or signature.
  • Rotate credentials periodically, and immediately when staff with access leave. Report suspected leaks to Specto at once so the key can be revoked.
  • Treat customer.password in activation responses as sensitive. Hand it to the customer, then do not keep it in plain text.

Testing & go-live checklist

  • Your signature matches the reference test vector.
  • GET /balance and GET /plans succeed from the production server.
  • A request from a non-whitelisted machine returns IP_NOT_WHITELISTED, which proves the whitelist is enforced.
  • Sending the same POST twice with the same key returns Idempotent-Replayed: true and charges once.
  • Your code handles PENDING_REVIEW, INSUFFICIENT_BALANCE, PROVIDER_ERROR and RATE_LIMITED explicitly.
  • One real low-priced activation has been checked in GET /transaction and against your wallet ledger.

Support & versioning

The API is versioned in the path (/api/v1). Within v1, fields may be added to responses, and new error codes may appear in existing categories. Build your parser to ignore unknown fields. Breaking changes will be released as /api/v2, with advance notice.

For help, contact your Specto account manager and quote the request_id (and the transaction_id, if any). Never send your API secret to anyone, including Specto staff.

Documentation updated 25 September 2026 · OpenAPI 3.0 specification