Starship Rewards API
Recharges

Submit Recharge API

Place a real-money mobile top-up — request shape, the three possible outcomes, the duplicate guard, and every error the endpoint returns

Submit Recharge API

Places a real recharge. This endpoint debits your wallet and calls the recharge vendor automatically — there is no admin approval step and no inventory to fall back on.

Endpoint

POST /api/v1/recharges

Authentication: API Key + Secret required Idempotency: Idempotency-Key header required

This endpoint moves real money and its effects are irreversible. A successful top-up cannot be recalled — the balance is on someone's phone. Read the three outcomes and the duplicate guard before you wire up any retry logic. A naive retry loop against this endpoint recharges the same number twice.

Request Headers

X-API-Key: sk_7Fq2XmR9vTb4NcZ1yWd6Ke
X-API-Secret: your-secret-here
Content-Type: application/json
Idempotency-Key: rch_a4f9c21e_1740000000
HeaderRequiredDescription
X-API-KeyYesYour API key
X-API-SecretYesYour API secret
Content-TypeYesMust be application/json
Idempotency-KeyYesUnique key, 8–256 characters. Learn more →

A missing or malformed Idempotency-Key is rejected before the request reaches the recharge logic, with its own error shape:

{
  "error": {
    "code": "IDEMPOTENCY_KEY_REQUIRED",
    "message": "Idempotency-Key header is required for this operation",
    "details": "Include a unique Idempotency-Key header (8-256 characters) to prevent duplicate requests. Use a UUID or unique transaction ID from your system. Example: Idempotency-Key: ord_abc123_1705689660"
  }
}

Keys shorter than 8 characters return IDEMPOTENCY_KEY_TOO_SHORT; keys longer than 256 return IDEMPOTENCY_KEY_TOO_LONG.

Request Body

Minimal request

{
  "mobile_number": "9876543210",
  "operator_id": 1,
  "amount": 199
}

Complete request schema

{
  "mobile_number": "9876543210",
  "operator_id": 1,
  "amount": 199,
  "circle_id": 12,
  "is_postpaid": false,
  "is_special": false,
  "country_code": "IN",
  "nickname": "DadsPhone",
  "client_reference": "TOPUP-2026-000412",
  "force": false
}

Field descriptions

FieldTypeRequiredDescription
mobile_numberstringYesDigits only. For India: exactly 10 digits, first digit 6–9.
operator_idnumberYesFrom GET /api/v1/recharge/operators
amountnumber | stringYesFace value in whole rupees, greater than zero
circle_idnumberConditionalTelecom circle. Required only for operators whose vendor mapping demands it — see Circles
is_postpaidbooleanNofalse (default) = prepaid top-up. true = postpaid bill payment.
is_specialbooleanNoSpecial recharge. BSNL / MTNL only. Default false.
country_codestringNoISO 3166-1 alpha-2. Defaults to IN.
nicknamestringNoPassed through to the vendor. Alphanumeric, up to 40 characters.
client_referencestringNoYour own reference. Echoed back on every read.
forcebooleanNoBypass the duplicate guard for a deliberate repeat. Default false. See below.

The mobile number is never reshaped

mobile_number must be digits only. +91 98765 43210, 98765-43210 and (987) 654-3210 are all rejected with 400.

Separators are not stripped, and a country prefix is not detected and removed. Silently reshaping a phone number risks recharging a different number than you meant, so a malformed number is refused rather than repaired. Normalise in your own code before you submit.

For India the number must be exactly 10 digits and begin 6, 7, 8 or 9 — the mobile range of India's national numbering plan. Landlines and short codes are rejected.

Amount must be whole rupees

amount accepts a JSON number or a string and is parsed as an exact decimal. It must be a whole rupee: 199.50 is rejected with 400.

The vendor truncates fractional input, so accepting 199.50 would debit ₹199.50 from your wallet and credit ₹199 to the phone. Rounding it ourselves would be equally wrong — you asked for something that cannot be delivered, so the API says so.

There is no maximum amount. Your pre-funded wallet balance is the only ceiling; the sole amount check is whether the wallet covers payable.

Circles

A telecom circle is the regional licence area of an Indian mobile number. Most operators do not need it — send the request without circle_id and it works.

Some operators do. Where the vendor validates operator and region together, circle_id becomes mandatory and omitting it returns 400 with circle_id is required for this operator.

There is no public endpoint that lists circle IDs. The circle catalog is seeded server-side and is not currently exposed on the client API. If you hit circle_id is required for this operator, ask your Starship contact for the circle IDs covering the operators you sell — you cannot discover them programmatically today.

The three outcomes

A submit that passes the guards always produces one of three results. Every one of them means the wallet has already been debited.

HTTPstatusWhat happenedYour next move
200SUCCESSThe operator credited the numberDone. operator_txn_id is your proof.
200FAILEDThe vendor confirmed it did not happenWallet already credited back. Safe to submit again.
202PROCESSINGNo verdict yetPoll. Do not resubmit.

Starship waits up to 10 seconds for the vendor, so a submit request can legitimately take that long. Past the deadline it stops waiting and returns 202 — the deadline is a limit on our patience, never a verdict on the recharge.

202 is the one that catches integrations out. It is not a failure and not a timeout you should retry. A timeout, a vendor 5xx, or an unparseable reply all produce 202 PROCESSING, because none of them tell us the top-up did not reach the phone. Resubmitting on a 202 is how one intent becomes two irreversible recharges. Poll GET /api/v1/recharges/{id} instead — a background job settles the row from the vendor's status API within minutes.

The duplicate guard

Before any money moves, Starship checks whether the same client, the same mobile number and the same amount were submitted inside a recent window (5 minutes by default). If so, the request is refused with 409 RESOURCE_CONFLICT and nothing is debited.

The guard exists because recharge is irreversible. It converts the most common integration bug — a retry storm on a request that already succeeded — from a double top-up into a clean rejection.

Note what it keys on: client, number and amount. Topping up different numbers rapidly is unaffected, so bulk flows do not trip it.

Getting a deliberate repeat through

A genuine second top-up of the same number for the same amount is legitimate. Set force: true to skip the guard:

{
  "mobile_number": "9876543210",
  "operator_id": 1,
  "amount": 199,
  "force": true
}

Never hard-code force: true. It disables the only automatic protection against duplicate recharges, and it is capped only loosely: once the configured number of forced repeats (3 by default) for the same number and amount have landed inside the duplicate window, further forced submits are refused with 409 RESOURCE_CONFLICT until the window passes. Set it only in direct response to a human confirming "yes, charge this number again", and never in a retry path.

Response

Success Response

Status Code: 200 OK (SUCCESS or FAILED) or 202 Accepted (PROCESSING)

{
  "id": 84213,
  "reference_number": "9f2c41ab7de4489bb1c0a6f2e8d35471",
  "status": "SUCCESS",
  "sub_status": "OPERATOR_CONFIRMED",
  "mobile_number": "9876543210",
  "operator_id": 1,
  "face": 199.00,
  "discount": 4.98,
  "payable": 194.02,
  "vendor_recharge_id": "VR-88213904",
  "operator_txn_id": "AIRTEL-772391884",
  "client_reference": "TOPUP-2026-000412",
  "created_at": "2026-08-25T10:30:00Z"
}

Response Fields

FieldTypeDescription
idnumberRecharge identifier. Use it with GET /api/v1/recharges/{id}.
reference_numberstringStarship's reference, 32 alphanumeric characters. Also the vendor's idempotency key. Quote it in support tickets.
statusstringPROCESSING · SUCCESS · FAILED
sub_statusstringFiner detail — diagnostic only, do not branch on it
mobile_numberstringEchoes your request
operator_idnumberEchoes your request
facenumberWhat reaches the phone
discountnumberClient discount applied
payablenumberWhat your wallet was debited
vendor_recharge_idstringThe vendor's own reference. Omitted when the vendor gave none.
operator_txn_idstringThe operator's transaction id. Present on success; omitted otherwise.
failure_reasonstringThe vendor's failure string, verbatim. Present on FAILED; omitted otherwise.
client_referencestringEchoes your request. Omitted when you sent none.
created_atstringISO 8601 UTC timestamp

Fields marked "omitted" are absent from the JSON entirely rather than present and null. Do not assume the key exists.

A 202 response

{
  "id": 84214,
  "reference_number": "3b7d05e9c1a24f6f8e2b9017dcaa4d83",
  "status": "PROCESSING",
  "sub_status": "VENDOR_SUBMITTED",
  "mobile_number": "9876543210",
  "operator_id": 1,
  "face": 199.00,
  "discount": 4.98,
  "payable": 194.02,
  "created_at": "2026-08-25T10:31:12Z"
}

A confirmed failure

{
  "id": 84215,
  "reference_number": "c8e14f2b90a742d3ae51b7c93f60d2aa",
  "status": "FAILED",
  "sub_status": "REVERSED",
  "mobile_number": "9876543210",
  "operator_id": 1,
  "face": 199.00,
  "discount": 4.98,
  "payable": 194.02,
  "failure_reason": "Invalid recharge amount for this plan",
  "created_at": "2026-08-25T10:32:40Z"
}

failure_reason is passed through from the vendor without rewriting. It is useful to display and to log, but it is not a stable enum — never branch on its text.

Errors

Every error below is returned before any money moves, except where noted.

400 — your request

CodeMessageCause
E_VALIDATION_FAILUREinvalid request bodyMalformed JSON or wrong field types
E_VALIDATION_FAILUREoperator_id is requiredoperator_id missing or 0
E_VALIDATION_FAILUREinvalid mobile number: …Empty, non-digit, wrong length, or not starting 6–9
E_VALIDATION_FAILUREamount must be a whole rupee greater than zero: …Zero, negative or fractional amount
E_VALIDATION_FAILUREunknown operatorNo such operator, inactive, or belongs to another country
E_VALIDATION_FAILUREoperator does not support prepaid rechargeis_postpaid: false on a postpaid-only operator
E_VALIDATION_FAILUREoperator does not support postpaid rechargeis_postpaid: true on an operator without postpaid
E_VALIDATION_FAILUREoperator does not support special rechargeis_special: true outside BSNL / MTNL
E_VALIDATION_FAILUREunknown circleNo such circle, inactive, or wrong country
E_VALIDATION_FAILUREcircle_id is required for this operatorThe operator's mapping demands a circle
E_VALIDATION_FAILUREoperator is not mapped to the recharge vendorThe operator has no active vendor mapping
E_VALIDATION_FAILUREinsufficient wallet balanceYour INR wallet cannot cover payable
IDEMPOTENCY_KEY_REQUIRED—Idempotency-Key header missing

401 / 403 / 404 / 409 / 503

StatusCodeMessageCauseRetry?
401E_UNAUTHORIZED_ACCESS—Invalid credentials, failed HMAC, or non-whitelisted IPNo
403FORBIDDEN_ACTIONrecharge is not enabled for this clientYour account has never been enabled for rechargeNo — contact Starship
403FORBIDDEN_ACTIONrecharge over the API is not enabled for this clientRecharge is on, but the API channel is offNo — contact Starship
403FORBIDDEN_ACTIONthis recharge type is not enabled for this clientPrepaid or postpaid is disabled for your accountNo — contact Starship
403FORBIDDEN_ACTIONthis operator is not enabled for this clientThis operator is blocked for your accountNo — try another operator
404WALLET_NOT_FOUNDno INR wallet found for this clientYou have no INR walletNo — contact Starship
409RESOURCE_CONFLICTa matching recharge was submitted recently; resend with force=true if the repeat is intendedDuplicate guardOnly with force: true and a new Idempotency-Key, and only if the repeat is genuinely intended
409RESOURCE_CONFLICTtoo many forced repeats for this number and amountForced-repeat ceilingAfter the duplicate window passes
503SERVICE_UNAVAILABLErecharge is temporarily unavailablePlatform kill-switch, unconfigured, or the vendor is inactiveYes, with backoff
503SERVICE_UNAVAILABLErecharge is temporarily paused for this clientYour account is paused for maintenanceYes, with backoff
500INTERNAL_SERVER_ERRORFailed to submit rechargeUnexpected server-side failureWith backoff — but check the status first

The two 403 enablement errors are the ones you are most likely to see on day one: recharge is off by default for every client, and the API channel is a second, separate toggle.

Retrying safely

You receivedMoney moved?Safe to resubmit?
400, 401, 403, 404, 409NoYes, once you have fixed the cause
503NoYes, with backoff
200 FAILEDDebited then credited backYes
202 PROCESSINGDebitedNo — poll instead
500PossiblyNot blindly — see below

Every resubmit after a 400, 404, 409 or 503 must use a new Idempotency-Key. The original key replays the stored error for 24 hours, and reusing it with a changed body (for example, adding force: true) returns 422 E_IDEMPOTENCY_KEY_REUSED. A concurrent request with the same key returns 409 E_IDEMPOTENCY_IN_FLIGHT.

A 500 is the ambiguous case: the wallet debit may have committed before the failure. Do not resubmit on reflex. Call GET /api/v1/recharges?search=<mobile_number>&from_date=<today> and look for your client_reference in the results to check whether a row already exists for this attempt. Resubmit only if none does.

Passing a distinct client_reference on every submit makes this check trivial, and is the single most useful habit for operating this endpoint.

Examples

# Prepaid top-up
curl -X POST "{{host}}/api/v1/recharges" \
  -H "X-API-Key: sk_7Fq2XmR9vTb4NcZ1yWd6Ke" \
  -H "X-API-Secret: your-secret-here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: rch_a4f9c21e_1740000000" \
  -d '{
    "mobile_number": "9876543210",
    "operator_id": 1,
    "amount": 199,
    "client_reference": "TOPUP-2026-000412"
  }'

# Postpaid bill payment
curl -X POST "{{host}}/api/v1/recharges" \
  -H "X-API-Key: sk_7Fq2XmR9vTb4NcZ1yWd6Ke" \
  -H "X-API-Secret: your-secret-here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: rch_b71d02fa_1740000120" \
  -d '{
    "mobile_number": "9876543210",
    "operator_id": 1,
    "amount": 649,
    "is_postpaid": true,
    "client_reference": "BILL-2026-000087"
  }'

# Operator that requires a circle
curl -X POST "{{host}}/api/v1/recharges" \
  -H "X-API-Key: sk_7Fq2XmR9vTb4NcZ1yWd6Ke" \
  -H "X-API-Secret: your-secret-here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: rch_c93e5518_1740000240" \
  -d '{
    "mobile_number": "9876543210",
    "operator_id": 7,
    "amount": 99,
    "circle_id": 12
  }'

# Deliberate repeat — only after a human confirms it
curl -X POST "{{host}}/api/v1/recharges" \
  -H "X-API-Key: sk_7Fq2XmR9vTb4NcZ1yWd6Ke" \
  -H "X-API-Secret: your-secret-here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: rch_d04a6629_1740000360" \
  -d '{
    "mobile_number": "9876543210",
    "operator_id": 1,
    "amount": 199,
    "force": true
  }'

Next steps