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/rechargesAuthentication: 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| Header | Required | Description |
|---|---|---|
X-API-Key | Yes | Your API key |
X-API-Secret | Yes | Your API secret |
Content-Type | Yes | Must be application/json |
Idempotency-Key | Yes | Unique 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
| Field | Type | Required | Description |
|---|---|---|---|
mobile_number | string | Yes | Digits only. For India: exactly 10 digits, first digit 6–9. |
operator_id | number | Yes | From GET /api/v1/recharge/operators |
amount | number | string | Yes | Face value in whole rupees, greater than zero |
circle_id | number | Conditional | Telecom circle. Required only for operators whose vendor mapping demands it — see Circles |
is_postpaid | boolean | No | false (default) = prepaid top-up. true = postpaid bill payment. |
is_special | boolean | No | Special recharge. BSNL / MTNL only. Default false. |
country_code | string | No | ISO 3166-1 alpha-2. Defaults to IN. |
nickname | string | No | Passed through to the vendor. Alphanumeric, up to 40 characters. |
client_reference | string | No | Your own reference. Echoed back on every read. |
force | boolean | No | Bypass 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.
| HTTP | status | What happened | Your next move |
|---|---|---|---|
200 | SUCCESS | The operator credited the number | Done. operator_txn_id is your proof. |
200 | FAILED | The vendor confirmed it did not happen | Wallet already credited back. Safe to submit again. |
202 | PROCESSING | No verdict yet | Poll. 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
| Field | Type | Description |
|---|---|---|
id | number | Recharge identifier. Use it with GET /api/v1/recharges/{id}. |
reference_number | string | Starship's reference, 32 alphanumeric characters. Also the vendor's idempotency key. Quote it in support tickets. |
status | string | PROCESSING · SUCCESS · FAILED |
sub_status | string | Finer detail — diagnostic only, do not branch on it |
mobile_number | string | Echoes your request |
operator_id | number | Echoes your request |
face | number | What reaches the phone |
discount | number | Client discount applied |
payable | number | What your wallet was debited |
vendor_recharge_id | string | The vendor's own reference. Omitted when the vendor gave none. |
operator_txn_id | string | The operator's transaction id. Present on success; omitted otherwise. |
failure_reason | string | The vendor's failure string, verbatim. Present on FAILED; omitted otherwise. |
client_reference | string | Echoes your request. Omitted when you sent none. |
created_at | string | ISO 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
| Code | Message | Cause |
|---|---|---|
E_VALIDATION_FAILURE | invalid request body | Malformed JSON or wrong field types |
E_VALIDATION_FAILURE | operator_id is required | operator_id missing or 0 |
E_VALIDATION_FAILURE | invalid mobile number: … | Empty, non-digit, wrong length, or not starting 6–9 |
E_VALIDATION_FAILURE | amount must be a whole rupee greater than zero: … | Zero, negative or fractional amount |
E_VALIDATION_FAILURE | unknown operator | No such operator, inactive, or belongs to another country |
E_VALIDATION_FAILURE | operator does not support prepaid recharge | is_postpaid: false on a postpaid-only operator |
E_VALIDATION_FAILURE | operator does not support postpaid recharge | is_postpaid: true on an operator without postpaid |
E_VALIDATION_FAILURE | operator does not support special recharge | is_special: true outside BSNL / MTNL |
E_VALIDATION_FAILURE | unknown circle | No such circle, inactive, or wrong country |
E_VALIDATION_FAILURE | circle_id is required for this operator | The operator's mapping demands a circle |
E_VALIDATION_FAILURE | operator is not mapped to the recharge vendor | The operator has no active vendor mapping |
E_VALIDATION_FAILURE | insufficient wallet balance | Your INR wallet cannot cover payable |
IDEMPOTENCY_KEY_REQUIRED | — | Idempotency-Key header missing |
401 / 403 / 404 / 409 / 503
| Status | Code | Message | Cause | Retry? |
|---|---|---|---|---|
401 | E_UNAUTHORIZED_ACCESS | — | Invalid credentials, failed HMAC, or non-whitelisted IP | No |
403 | FORBIDDEN_ACTION | recharge is not enabled for this client | Your account has never been enabled for recharge | No — contact Starship |
403 | FORBIDDEN_ACTION | recharge over the API is not enabled for this client | Recharge is on, but the API channel is off | No — contact Starship |
403 | FORBIDDEN_ACTION | this recharge type is not enabled for this client | Prepaid or postpaid is disabled for your account | No — contact Starship |
403 | FORBIDDEN_ACTION | this operator is not enabled for this client | This operator is blocked for your account | No — try another operator |
404 | WALLET_NOT_FOUND | no INR wallet found for this client | You have no INR wallet | No — contact Starship |
409 | RESOURCE_CONFLICT | a matching recharge was submitted recently; resend with force=true if the repeat is intended | Duplicate guard | Only with force: true and a new Idempotency-Key, and only if the repeat is genuinely intended |
409 | RESOURCE_CONFLICT | too many forced repeats for this number and amount | Forced-repeat ceiling | After the duplicate window passes |
503 | SERVICE_UNAVAILABLE | recharge is temporarily unavailable | Platform kill-switch, unconfigured, or the vendor is inactive | Yes, with backoff |
503 | SERVICE_UNAVAILABLE | recharge is temporarily paused for this client | Your account is paused for maintenance | Yes, with backoff |
500 | INTERNAL_SERVER_ERROR | Failed to submit recharge | Unexpected server-side failure | With 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 received | Money moved? | Safe to resubmit? |
|---|---|---|
400, 401, 403, 404, 409 | No | Yes, once you have fixed the cause |
503 | No | Yes, with backoff |
200 FAILED | Debited then credited back | Yes |
202 PROCESSING | Debited | No — poll instead |
500 | Possibly | Not 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
- Get Recharge — poll a
PROCESSINGrecharge to its outcome - List Recharges — reconcile submits against your own records
- Idempotency Guide — how the
Idempotency-Keyheader behaves