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.
https://api.spectotv.com/api/v1Your Specto administrator confirms the URL when issuing credentials.JSON · UTF-8 · HTTPS onlyEvery response uses the same envelope.API key + HMAC-SHA256Timestamp, nonce and IP whitelist on every call.How it works
→
→
| Responsibility | Your software | Specto |
|---|---|---|
| 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
- 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. - Send Specto the public IP address of the server that will call the API. Requests from other addresses are rejected.
- Check your signing against the reference test vector.
- 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 plansExpected 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
| Step | Who | Details |
|---|---|---|
| 1. Request API access | LCO | Contact your Specto account manager. Say which operations you need: activate, renew, advance renew, and/or the read-only lookups. |
| 2. Whitelist | LCO → Specto | Public IPv4/IPv6 address(es) of your API client server. Ranges from /24 (IPv4) or /48 (IPv6) are accepted. |
| 3. Credentials | Specto | Specto issues the API key and secret, and enables the permissions. |
| 4. Build & test | LCO developer | Implement signing, idempotency and error handling. Test with read-only endpoints first. |
| 5. Go live | LCO | Run 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.
| Header | Required | Value |
|---|---|---|
X-API-Key | always | Your API key: spk_ followed by 32 hex characters. |
X-Timestamp | always | Current Unix time in seconds (UTC). |
X-Nonce | always | A new random string for every request: 16–64 characters from [A-Za-z0-9_-]. |
X-Signature | always | Lower-case hex HMAC-SHA256. See Request signing. |
Content-Type | POST | application/json |
Idempotency-Key | POST | 8–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| Line | Rule | Example |
|---|---|---|
| METHOD | Upper-case HTTP method. | POST |
| PATH | Always starts at /api/v1/. No host and no query string. | /api/v1/activate |
| CANONICAL_QUERY | Empty for POST or when there is no query (the line is still present). See below. | mobile=&subscriber_id=482915 |
| TIMESTAMP | The exact X-Timestamp value. | 1790000000 |
| NONCE | The exact X-Nonce value. | 3b1f0c9e8a7d6c5b4a39281706f5e4d3 |
| BODY_SHA256 | Lower-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
- Split the raw query on
&, then split each part on the first=. - URL-decode the name and the value (
+means space). - Re-encode both with RFC 3986: unreserved characters
A–Z a–z 0–9 - _ . ~stay as they are, everything else becomes%XXwith upper-case hex, and a space becomes%20. - Sort by name, then by value (byte order), and join as
name=valuepairs with&.
Example: b=2&a=hello+world&a=%41&c=it%27s becomes a=A&a=hello%20world&b=2&c=it%27s.
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.
| secret | sps_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef |
| method / path | POST · /api/v1/activate |
| query | (empty) |
| timestamp / nonce | 1767225600 · n0nce-0000000000001 |
| body | {"full_name":"Ravi Kumar","mobile":"9876543210","plan_id":"171839"} |
| signature | 51ed2ebc09930c91b24ad1950a111f0c7f502a70f3ab899b3e5e4baa271eb60b |
$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-Timestampmust be within ±300 seconds of Specto's clock. Otherwise the response is401 TIMESTAMP_EXPIRED, and itsdata.server_timeshows 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 sameIdempotency-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.meon 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, anddata.ipshows 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-Limit | Your limit per minute |
X-RateLimit-Remaining | Requests left in the current window |
X-RateLimit-Reset | Unix time when the window resets |
Retry-After | Seconds 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"
}
}success | true only when the request fully succeeded. |
request_id | A unique ID for this request, also sent as the X-Request-Id header. Log it and quote it to Specto support. |
code | A machine-readable result. Branch your code on code and the HTTP status, never on message. |
message | Human-readable text. It may change. |
data | The 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.
- Create one key per business action, such as your order ID (
order-100045). Store it before the first attempt. - 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: trueheader. - Using the same key with a different body is rejected with
422 IDEMPOTENCY_KEY_REUSED. - 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. - A
PROVIDER_ERROR(Watcho rejected the request and you were refunded) is stored. To try again, use a new key. - After
PENDING_REVIEW, never start a new operation for that customer. PollGET /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 timeDuplicate protection
Several independent safeguards prevent double activations and double charges:
| Safeguard | Result |
|---|---|
Same Idempotency-Key and same body | The original result is replayed; nothing is charged again. |
| Activation of a mobile number that already exists in Specto | 409 SUBSCRIBER_EXISTS (use /renew) |
| Same subscriber and same plan renewed within 2 minutes, even with a new key | 409 DUPLICATE_REQUEST with data.previous_transaction_id |
| Two requests for the same customer at the same moment | One runs; the other gets 409 OPERATION_IN_PROGRESS |
| Advance renewal onto the currently active plan | 409 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
| Outcome | Wallet | Ledger |
|---|---|---|
200 OK | Debited by the plan price | One debit entry in your Specto wallet ledger (description ends with [API]) |
502 PROVIDER_ERROR | Not charged. A reserved amount is returned at once (wallet.refunded: true). | No entry |
202 PENDING_REVIEW | Amount held (wallet.held: true) while Specto verifies the result with Watcho | An entry is written only if the activation is confirmed; otherwise the hold is refunded. |
| Any 4xx before processing | Untouched | No entry |
Wallet, ledger, expiry and MSO commission follow exactly the same rules as operations in the Specto LCO panel.
Activation & renewal flow
- Authenticate: HTTPS, key, timestamp, signature, nonce, account status, IP whitelist, rate limit, permission.
- Idempotency: a known key returns the stored result immediately.
- Validate the request and look up the plan price in the Specto catalogue.
- Business checks: subscriber exists or belongs to you, duplicates, and the plan rule for advance renewals.
- Balance check: zero or insufficient →
402. Watcho is never called. - Reserve: Specto atomically debits the plan price and records the operation as processing.
- Specto calls Watcho and verifies the response strictly.
- 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.
- Success: subscriber record, expiry, ledger entry and MSO commission are written together →
Expiry rules
| Activation | The new expiry is 30 days from the moment of activation. |
| Renewal | The current expiry (or now, if already expired) + plan duration × 30 days. |
| Advance renewal | The 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"
}| Field | Description |
|---|---|
transaction_id | The Specto transaction ID (SPX…). Store it with your order. |
status | One of:
|
customer.subscriber_id | The Specto subscriber ID. Use it for renewals. |
customer.password | Activation only. The customer's app password, returned once when you did not supply one. It is not included in replays or lookups. |
provider_transaction_id | Watcho's transaction number, when available. |
plan | Plan ID, name and duration in months. |
amount | The catalogue price charged, or that would have been charged, for this operation. |
expiry.previous / new / start_date | The expiry before the operation, the new expiry (on success), and the start date (advance renewal). |
wallet.before / after | The wallet balance immediately before and after this operation's debit. |
wallet.charged / refunded / held | What happened to the money. |
warnings | COMBO_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
| Field | Type | Required | Rules |
|---|---|---|---|
full_name | string | yes | 1–100 characters |
mobile | string | yes | 10 digits. It must not already exist in Specto. |
plan_id | string | yes | From GET /plans |
email | string | no | A valid email, up to 150 characters |
password | string | no | Customer app password, up to 64 characters. If omitted, one is generated and returned once. |
connection_type | string | no | Default OTT + Live TV |
address | string | no | Up to 500 characters |
subscriber_id | string | no | Preferred 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
| Field | Type | Required | Rules |
|---|---|---|---|
subscriber_id | string | yes, or mobile | Your subscriber's Specto ID |
mobile | string | alternative | Used only when subscriber_id is empty |
plan_id | string | yes | May 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.
| Query | Description |
|---|---|
subscriber_id | The Specto subscriber ID, or |
mobile | The 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.
| Query | Description |
|---|---|
transaction_id | SPX…, or |
idempotency_key | The 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
| HTTP | code | Meaning | What to do |
|---|---|---|---|
| 403 | HTTPS_REQUIRED | The request used plain HTTP. | Use https://. |
| 404 | NOT_FOUND | Unknown endpoint. | Check the path: /api/v1/<endpoint>. |
| 405 | METHOD_NOT_ALLOWED | Wrong HTTP method. The Allow header names the correct one. | Use GET or POST as documented. |
| 401 | MISSING_AUTH_HEADERS | X-API-Key, X-Timestamp, X-Nonce or X-Signature is missing. | Send all four headers. |
| 401 | INVALID_API_KEY | The key is unknown or malformed. | Use the key exactly as issued by Specto. |
| 401 | CREDENTIAL_REVOKED | The key was revoked. | Ask Specto for new credentials. |
| 401 | CREDENTIAL_EXPIRED | The key was rotated and its grace period ended. | Deploy the new key and secret. |
| 401 | TIMESTAMP_EXPIRED | X-Timestamp is more than 300 s from server time. data.server_time shows the server clock. | Synchronise your clock (NTP); send Unix seconds. |
| 401 | INVALID_NONCE | The nonce is not 16–64 characters of [A-Za-z0-9_-]. | Use 32 random hex characters. |
| 401 | INVALID_SIGNATURE | The HMAC does not match. | See Request signing and check against the test vector. |
| 401 | REPLAY_DETECTED | The nonce was already used with this key. | Use a new nonce for every request, including retries. |
| 413 | PAYLOAD_TOO_LARGE | The body is larger than 16 KB. | — |
| 429 | TOO_MANY_FAILED_ATTEMPTS | Too many failed authentications from your IP in the last minute. | Fix the signing bug; wait for Retry-After. |
| 500 | INTERNAL_ERROR | Unexpected server error. | Retry with the same Idempotency-Key. If it persists, send the request_id to Specto. |
| 503 | SERVICE_UNAVAILABLE | Temporarily unavailable. | Retry with backoff. |
Authorisation
| HTTP | code | Meaning | What to do |
|---|---|---|---|
| 403 | LCO_NOT_FOUND | The operator account no longer exists. | Contact Specto. |
| 403 | LCO_INACTIVE | The operator account is suspended or inactive. | Contact Specto. |
| 403 | API_ACCESS_BLOCKED | Specto has blocked API access for this operator. | Contact Specto. |
| 403 | API_DISABLED | API access is switched off for this operator. | Ask Specto to enable it. |
| 403 | IP_NOT_WHITELISTED | Your source IP is not on the whitelist. data.ip shows the address Specto saw. | Send that IP to Specto. |
| 403 | PERMISSION_DENIED | This endpoint is not enabled for your key. | Ask Specto to grant the permission. |
| 429 | RATE_LIMITED | Per-minute limit exceeded. See the Retry-After and X-RateLimit-* headers. | Back off, then retry. |
Request validation
| HTTP | code | Meaning | What to do |
|---|---|---|---|
| 415 | UNSUPPORTED_MEDIA_TYPE | A POST without Content-Type: application/json. | Send JSON. |
| 400 | INVALID_JSON | The body is not a JSON object. | Send {"field": "value", …}. |
| 400 | MISSING_IDEMPOTENCY_KEY | A POST without an Idempotency-Key header. | Send one key per business operation. |
| 400 | INVALID_IDEMPOTENCY_KEY | The key is not 8–100 characters of [A-Za-z0-9_.:-]. | — |
| 422 | VALIDATION_ERROR | One or more fields are invalid. data.fields gives the error for each field. | Fix the fields. |
| 422 | INVALID_PLAN | The plan_id is not in the current catalogue. | Use GET /plans. |
| 422 | IDEMPOTENCY_KEY_REUSED | The 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
| HTTP | code | Meaning | What to do |
|---|---|---|---|
| 402 | INSUFFICIENT_BALANCE | The wallet is zero or below the plan price. data holds wallet_balance, required_amount and shortfall. | Top up through Specto. |
| 409 | SUBSCRIBER_EXISTS | The mobile number is already registered with Specto. | Use /renew for existing customers. |
| 404 | SUBSCRIBER_NOT_FOUND | No such subscriber under your account. | Check subscriber_id or mobile. |
| 404 | TRANSACTION_NOT_FOUND | No such transaction under your account. | — |
| 409 | PLAN_ALREADY_ACTIVE | Advance renewal onto the plan that is currently active. | Choose another plan or wait until expiry. |
| 409 | DUPLICATE_REQUEST | The same subscriber and plan were processed in the last 2 minutes. data.previous_transaction_id identifies the earlier operation. | Check that transaction before retrying. |
| 409 | OPERATION_IN_PROGRESS | Another operation for this customer is running. | Retry in a few seconds with the same key. |
| 503 | PLANS_UNAVAILABLE | Prices cannot be verified right now. | Retry later. |
Operation outcomes
| HTTP | code | Meaning | What to do |
|---|---|---|---|
| 200 | OK | Completed. The wallet was debited and the subscription updated. | — |
| 202 | PENDING_REVIEW | Sent to Watcho, but the outcome is not confirmed yet. The amount is held. | Do not retry with a new key. Poll GET /transaction. |
| 409 | REQUEST_IN_PROGRESS | The same Idempotency-Key is still being processed. | Poll GET /transaction. |
| 502 | PROVIDER_ERROR | Watcho rejected the request. Any reserved amount was refunded. | Show data.provider_message; use a new key to try again. |
HTTP status codes
| Status | Meaning | Retry? |
|---|---|---|
| 200 | Success | — |
| 202 | Accepted, outcome pending verification (PENDING_REVIEW). The amount is held. | No. Poll /transaction. |
| 400 | Malformed request (JSON, headers) | After fixing it |
| 401 | Authentication failed | After fixing it (new nonce) |
| 402 | Insufficient wallet balance | After a top-up |
| 403 | Not allowed (HTTPS, IP, permission, disabled or blocked account) | After Specto changes the configuration |
| 404 | Unknown endpoint, subscriber or transaction | No |
| 405 | Wrong method | No |
| 409 | Conflict (exists, duplicate, in progress, plan already active) | Read code. OPERATION_IN_PROGRESS: yes, after a few seconds. |
| 413 / 415 / 422 | Payload or validation problem | After fixing it |
| 429 | Rate limited or throttled | After Retry-After |
| 500 | Internal error | Yes, with the same Idempotency-Key |
| 502 | Watcho rejected the request (refunded) | With a new key |
| 503 | Service or plan catalogue temporarily unavailable | Yes, 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_idSecurity 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.passwordin 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 /balanceandGET /planssucceed 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: trueand charges once. - Your code handles
PENDING_REVIEW,INSUFFICIENT_BALANCE,PROVIDER_ERRORandRATE_LIMITEDexplicitly. - One real low-priced activation has been checked in
GET /transactionand 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
