Create a Subscription
One server-to-server call opens a recurring billing agreement: Invo takes over the schedule, the charge, the retries, and the recurring currency grant from that moment on. The call is idempotent, so a retry can never open a second agreement against the same member.
New to this API? Read Subscriptions first — it covers the lifecycle, the status values, the balance-first funding model, and the events you have to handle. This page is the endpoint reference.
1. Endpoint
/api/subscriptions/subscribe
Production: https://invo.network/api/subscriptions/subscribe. Sandbox: https://sandbox.invo.network/sandbox/api/subscriptions/subscribe.
Authentication
Call this from your server with your secret key:
X-Game-Secret-Key: your_secret_key_hereNever ship the secret key in a browser or game client — a subscription is standing authority to charge a consumer, so this endpoint is server-only. See Authentication.
Your game must be live. A game still in testing gets 403 GAME_NOT_LIVE here, though it can still read and cancel existing subscriptions.
2. Request body
| Field | Type | Req. | Notes |
|---|---|---|---|
client_request_id | string | yes | Your idempotency key. ≤ 255 chars, unique per subscription and per member. Must not begin with sub_ — that prefix is reserved (see section 6). |
player_email | string | yes | The member being subscribed. ≤ 255 chars, lower-cased on receipt. |
player_name | string | yes | ≤ 255 chars. Used only if the player has to be created. |
item_id | string | yes | Your entitlement handle — opaque to Invo, echoed on every read and event. ≤ 255 chars. At most one live subscription per (game, player, item). |
amount_usd | decimal | yes | The recurring price in USD. 0.01 – 999999.99, rounded to 2 dp. There is deliberately no currency-denominated alias: a coin figure in this field would silently create the wrong agreement for its whole life. |
item_name | string | no | ≤ 255 chars. Human-readable label for the membership. |
player_phone | string | no | ≤ 30 chars. See the phone-share note in section 4. |
interval | string | no | "month" (default) or "year". |
interval_count | int | no | How many intervals per cycle. Default 1, range 1–36. An explicit 0 is rejected, not silently corrected. |
trial_days | int | no | 1–365. The subscription is created trialing; the trial window is the first period and is never charged for. A small random offset is added to the derived end so a signup cohort does not all convert on the same instant. |
trial_end | string | no | ISO 8601 timestamp instead of trial_days, and it wins if both are sent. Must be in the future and within 365 days. Honoured to the second — you picked it, so no offset is applied. |
wallet_only | bool | no | Default false. true opts out of the card backstop entirely: renewals succeed only from the member's balance. Cannot be combined with player_card_id (409 WALLET_ONLY_SUBSCRIPTION). |
player_card_id | int | no | A saved-card id for this member (see Payment Methods). Verified against this player and this game, and refused if expired. You can also attach one later. Integers only — a fractional value is rejected rather than truncated. |
metadata | object | no | Your correlation data, echoed verbatim on every read and every event. JSON object, ≤ 8192 bytes encoded, no NUL characters. Keys under _invo are reserved for Invo's own annotations. |
revenue_share | object | no | { recipient_player_email | recipient_player_id, percent }. percent is 0–100. Attribution only — Invo records and reports it and settles nothing. One recipient per subscription; the recipient must already be a player in this game and cannot be the subscriber. |
consent | object | no | Evidence the member authorized the recurring charge — see below. |
The consent object
Optional and unenforced today, and worth sending anyway: Invo is merchant of record, so a dispute over a recurring charge is decided on our evidence. Everything here is stored verbatim and returned on every read.
| Field | Type | Req. | Notes |
|---|---|---|---|
consent_at | string | no | ISO 8601. Cannot be in the future. |
consent_ip | string | no | The member's IP, captured by you at signup. ≤ 45 chars (IPv6). |
consent_user_agent | string | no | The member's user agent. Truncated at 500 chars rather than rejected. |
disclosed_amount_usd | decimal | no | What the member was actually shown. Also the baseline for the 2× price-increase cap, so sending it makes that guardrail meaningfully tighter. |
disclosed_interval | string | no | "month" or "year". |
terms_version | string | no | ≤ 50 chars. |
Send the member's IP and user agent, not your server's. This call is server-to-server, so the request we see comes from your infrastructure and is worthless as evidence that a consumer agreed to anything.
3. Responses
Created (201)
{
"status": "success",
"idempotent_replay": false,
"subscription": {
"subscription_id": "SUB_1786000000_A1B2C3D4",
"game_id": "1234567890",
"player_id": 4821,
"client_request_id": "aninat-guild-gold-4821-v1",
"status": "active", // or "trialing"
"amount_usd": "9.99",
"pending_amount_usd": null,
"interval": "month",
"interval_count": 1,
"item_id": "guild_gold",
"item_name": "Gold Guild Membership",
"current_period_start": "2026-08-13T14:02:11.904-04:00",
"current_period_end": "2026-09-13T14:41:53.221-04:00",
"period_seq": 1,
"next_charge_at": "2026-08-13T14:02:11.904-04:00",
"cancel_at_period_end": false,
"trial_end": null,
"canceled_at": null,
"ended_at": null,
"wallet_only": false,
"has_payment_method": true,
"metadata": { "tier": "gold" },
"consent": {
"consent_at": "2026-08-13T14:01:58-04:00",
"consent_ip": "203.0.113.44",
"consent_user_agent": "Mozilla/5.0 ...",
"disclosed_amount_usd": "9.99",
"disclosed_interval": "month",
"terms_version": "2026-06-01"
},
"created_at": "2026-08-13T14:02:11.904-04:00",
"updated_at": "2026-08-13T14:02:11.904-04:00",
"revenue_share": null,
"paid_through": null,
"amount_coins_estimate": "99.90"
},
"card": {
"id": 91,
"last_four": "4242",
"brand": "visa",
"exp_month": 11,
"exp_year": 2029,
"created_at": "2026-08-01T10:22:00+00:00"
}
}Notes worth reading once: paid_through is null because nothing has been charged yet — it is the entitlement clock and it stays null until the first successful renewal, whereas current_period_end already has a value because it is only a billing projection. next_charge_at is now because subscriptions bill in advance: the first sweep charges for the period the member is already in (or, on a trial, at the end of the trial window). amount_coins_estimate is an advisory display figure only. card is null when no card was attached, and never carries a processor identifier — only whether a card is attached (has_payment_method) and its display details.
Idempotent replay (200)
The same body, byte-identical in shape, with idempotent_replay: true and the original subscription. Nothing is created. The two paths deliberately return one shape so your retry parses exactly like your first call.
{
"status": "success",
"idempotent_replay": true,
"subscription": { "subscription_id": "SUB_1786000000_A1B2C3D4", "...": "..." },
"card": null
}Approval required for a shared phone (409)
If player_email is new on the network and the player_phone you sent is already on file under a different email, the create is refused and an SMS one-time code is sent to the existing phone owner. Resolve the approval, then re-issue the same request with the same client_request_id.
{
"status": "error",
"error_code": "PHONE_SHARE_APPROVAL_REQUIRED",
"message": "...",
"phone": "+15551234567",
"requesting_email": "member@example.com",
"existing_account_hints": ["m***r@example.com"],
"approval_id": "...",
"expires_at": "2026-08-13T14:12:11-04:00",
"code_sent": true,
"already_approved": false,
"next_endpoint": "/api/wallet/phone-share/initiate"
}Created, but the response could not be rendered (201)
Rare, and deliberately not an error: the subscription was created and reporting a failure would push you into a retry with a fresh key, which then collides with the one-live-subscription-per-item guard. Fetch the full object with GET /api/subscriptions/{subscription_id}.
{
"status": "success",
"idempotent_replay": false,
"subscription": { "subscription_id": "SUB_...", "status": "active" },
"warning": "Subscription was created; full details unavailable. Fetch GET /api/subscriptions/{subscription_id}."
}4. Error codes
Every failure carries a message and, where one applies, a machine-readable error_code. Branch on the code, not the message.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | INVALID_BODY | The body is not a JSON object. |
| 400 | CLIENT_REQUEST_ID_REQUIRED / CLIENT_REQUEST_ID_INVALID / CLIENT_REQUEST_ID_TOO_LONG | Missing, non-scalar, or over 255 chars. |
| 400 | CLIENT_REQUEST_ID_RESERVED | The key begins with sub_ — see section 6. |
| 400 | PLAYER_EMAIL_INVALID / PLAYER_EMAIL_TOO_LONG | Missing or malformed email, or over 255 chars. |
| 400 | PLAYER_NAME_REQUIRED / PLAYER_NAME_TOO_LONG / PLAYER_PHONE_TOO_LONG | Member identity fields. |
| 400 | ITEM_ID_REQUIRED / ITEM_ID_TOO_LONG / ITEM_NAME_TOO_LONG | Entitlement handle problems. |
| 400 | AMOUNT_REQUIRED / AMOUNT_INVALID / AMOUNT_TOO_SMALL / AMOUNT_TOO_LARGE | Missing, non-finite, below 0.01, or above 999999.99. |
| 400 | INTERVAL_INVALID | Not month or year. |
| 400 | INTERVAL_COUNT_INVALID / INTERVAL_COUNT_OUT_OF_RANGE | Not an integer, or outside 1–36. |
| 400 | TRIAL_DAYS_INVALID / TRIAL_DAYS_OUT_OF_RANGE | Not an integer, or outside 1–365. |
| 400 | TRIAL_END_INVALID / TRIAL_END_IN_PAST / TRIAL_END_TOO_FAR | Unparseable, not in the future, or more than 365 days out. |
| 400 | METADATA_INVALID / METADATA_TOO_LARGE | Not an object, not serializable, contains NUL, or over 8192 bytes. |
| 400 | CONSENT_AT_INVALID / CONSENT_INVALID / CONSENT_DISCLOSED_AMOUNT_INVALID / CONSENT_DISCLOSED_INTERVAL_INVALID | Consent field problems. |
| 400 | REVENUE_SHARE_INVALID / REVENUE_SHARE_PERCENT_OUT_OF_RANGE / REVENUE_SHARE_RECIPIENT_REQUIRED / REVENUE_SHARE_RECIPIENT_NOT_FOUND / REVENUE_SHARE_RECIPIENT_IS_SUBSCRIBER | Attribution block problems. The recipient must already be a player in this game and cannot be the subscriber. |
| 400 | PAYMENT_METHOD_INVALID / PAYMENT_METHOD_EXPIRED | Card id is not an integer, or the card has already expired. |
| 400 | CURRENCY_NOT_CONFIGURED | The game has no currency set up. See Virtual Currency Setup. |
| 400 | DATA_CONFLICT / INVALID_FIELD_VALUE | Backstops for a value the database refused. |
| 401 | — | Missing or invalid X-Game-Secret-Key. |
| 403 | GAME_NOT_LIVE | Only a live game may open a recurring agreement. |
| 404 | PAYMENT_METHOD_NOT_FOUND | No saved card with that id for this member in this game. Deliberately the same answer as "not their card". |
| 409 | ACTIVE_SUBSCRIPTION_EXISTS | This member already has a live subscription to this item_id. The response includes its subscription_id. |
| 409 | CLIENT_REQUEST_ID_CONFLICT | That key already belongs to a different member or item. Nothing about the other row is disclosed. |
| 409 | IDEMPOTENT_REPLAY_MISMATCH | Same key, different terms — see section 5. Carries mismatched_fields. |
| 409 | CONCURRENT_REQUEST | Two creates for a brand-new member raced. Retry with the same key. |
| 409 | WALLET_ONLY_SUBSCRIPTION | player_card_id was sent alongside wallet_only: true. |
| 409 | REVENUE_SHARE_EXISTS | A share is already recorded for this subscription. |
| 409 | PHONE_SHARE_APPROVAL_REQUIRED | The phone belongs to another account — approve, then retry with the same key. |
| 429 | rate_limit_exceeded | Rate limited. Honour the Retry-After header and the retry_after field. |
| 503 | flow_paused | Subscription changes are temporarily paused for maintenance. Retry shortly. |
| 500 | INTERNAL_ERROR | Nothing was created. Retry with the same key. |
Cancellation is deliberately not subject to the maintenance pause — a pause must never trap a member in a subscription they are trying to leave. Creating, repricing and re-pointing a card all are.
5. Idempotency and safe retries
Generate the key once, outside your retry loop
Derive it from something stable and member-scoped — for example subscribe:{player_id}:{item_id}:v1. The single most common integration bug in recurring billing is regenerating the key inside the retry, which turns one timeout into two agreements. Here it does not, because ACTIVE_SUBSCRIPTION_EXISTS catches it — but that is a backstop, not a plan. Do not key on something like {item_id}:{today}, which collides across your whole member base.
| You send | You get |
|---|---|
| The same key, same member, same item, same terms | 200 with idempotent_replay: true. Nothing is created, and no player, no card attachment, and no SMS is triggered as a side effect of a retry. |
| The same key, a different member or item | 409 CLIENT_REQUEST_ID_CONFLICT. Ownership is asserted before anything is returned, so a colliding key can never hand you someone else's subscription. |
The same key, changed amount_usd, interval, interval_count or wallet_only | 409 IDEMPOTENT_REPLAY_MISMATCH with mismatched_fields. |
| Two identical creates at the same instant | One wins; the other resolves through the same ownership check and replays it, or returns 409 CONCURRENT_REQUEST asking you to retry with the same key. |
A changed price on a replay is an error, on purpose
Most idempotent endpoints silently ignore a changed body on replay. This one does not, because on a recurring agreement the consequence is not one wrong response — it is quietly billing the old price for the life of the subscription while you believe you repriced it. To change an existing subscription use POST /api/subscriptions/{subscription_id}/amount or /payment-method (see Manage a Subscription); to create a genuinely new one, use a new key.
6. The reserved sub_ prefix
client_request_id may not begin with sub_
Values beginning with sub_ (in any case) are rejected with 400 CLIENT_REQUEST_ID_RESERVED, on every purchase endpoint, not just this one. Invo's own renewal records are written into the same idempotency-key space using that prefix. Because subscription_id is returned on every read, a partner could otherwise construct the exact key a future renewal will use and either stall that member's billing or have a recovery path resolve to the wrong record. Nothing in a normal integration uses this prefix; pick any other.
7. Code samples
All three run on your server. Never call this endpoint from a game client, a browser, or anything else that ships to a user.
curlCommand line
curl -X POST https://invo.network/api/subscriptions/subscribe \
-H "X-Game-Secret-Key: your_secret_key_here" \
-H "Content-Type: application/json" \
-d '{
"client_request_id": "subscribe:4821:guild_gold:v1",
"player_email": "member@example.com",
"player_name": "Ada Lovelace",
"item_id": "guild_gold",
"item_name": "Gold Guild Membership",
"amount_usd": "9.99",
"interval": "month",
"interval_count": 1,
"player_card_id": 91,
"metadata": { "tier": "gold", "guild": "aurora" },
"consent": {
"consent_at": "2026-08-13T14:01:58-04:00",
"consent_ip": "203.0.113.44",
"consent_user_agent": "Mozilla/5.0 (Macintosh) Safari/605.1.15",
"disclosed_amount_usd": "9.99",
"disclosed_interval": "month",
"terms_version": "2026-06-01"
}
}'Node.jsServer-side
// SERVER ONLY. The secret key must never reach a browser or game client.
const INVO_BASE = 'https://invo.network';
async function createSubscription(player, item, priceUsd, savedCardId, consent) {
// Generate the idempotency key ONCE, outside the retry loop.
const clientRequestId = 'subscribe:' + player.id + ':' + item.id + ':v1';
const res = await fetch(INVO_BASE + '/api/subscriptions/subscribe', {
method: 'POST',
headers: {
'X-Game-Secret-Key': process.env.INVO_GAME_SECRET_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_request_id: clientRequestId,
player_email: player.email,
player_name: player.name,
item_id: item.id,
item_name: item.name,
amount_usd: priceUsd, // string or number, USD
interval: 'month',
interval_count: 1,
player_card_id: savedCardId, // omit for a card-less start
metadata: { tier: item.tier },
consent: consent // the MEMBER's ip + user agent
})
});
const body = await res.json();
if (res.status === 201 || res.status === 200) {
// 200 means this was a replay of a call that already succeeded.
if (body.idempotent_replay) {
console.log('replay — no second agreement was created');
}
return body.subscription;
}
if (res.status === 409 && body.error_code === 'ACTIVE_SUBSCRIPTION_EXISTS') {
// Already a member. body.subscription_id is the live one.
return { subscription_id: body.subscription_id, already_subscribed: true };
}
if (res.status === 409 && body.error_code === 'PHONE_SHARE_APPROVAL_REQUIRED') {
// Run the phone-share approval, then re-issue with the SAME key.
throw new PhoneShareRequired(body);
}
if (res.status === 429 || res.status === 503 || res.status >= 500) {
// Safe to retry — with the SAME client_request_id.
throw new RetryableInvoError(body, res.headers.get('Retry-After'));
}
throw new Error('subscribe failed: ' + (body.error_code || res.status));
}UnityUnity C# (server-side logic)
// SERVER-SIDE ONLY. Shipping the secret key in a Unity build hands an attacker
// the ability to open billing agreements against your members.
public IEnumerator CreateSubscription(
string playerEmail, string playerName, string itemId, string itemName,
string amountUsd, int savedCardId,
Action<string> onSuccess, Action<string> onError)
{
string url = "https://invo.network/api/subscriptions/subscribe";
// Stable, member-scoped, generated ONCE — reuse it verbatim on every retry.
string clientRequestId = "subscribe:" + playerEmail + ":" + itemId + ":v1";
string jsonBody =
"{" +
"\"client_request_id\":\"" + clientRequestId + "\"," +
"\"player_email\":\"" + playerEmail + "\"," +
"\"player_name\":\"" + playerName + "\"," +
"\"item_id\":\"" + itemId + "\"," +
"\"item_name\":\"" + itemName + "\"," +
"\"amount_usd\":\"" + amountUsd + "\"," +
"\"interval\":\"month\"," +
"\"interval_count\":1," +
"\"player_card_id\":" + savedCardId +
"}";
using (UnityWebRequest request = new UnityWebRequest(url, "POST"))
{
byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(jsonBody);
request.uploadHandler = new UploadHandlerRaw(bodyRaw);
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("X-Game-Secret-Key", "your_game_secret_key_here");
request.SetRequestHeader("Content-Type", "application/json");
yield return request.SendWebRequest();
// 201 = created, 200 = idempotent replay. Both are success.
if (request.responseCode == 201 || request.responseCode == 200) {
onSuccess?.Invoke(request.downloadHandler.text);
} else {
// 409 ACTIVE_SUBSCRIPTION_EXISTS means they are already a member.
onError?.Invoke(request.downloadHandler.text);
}
}
}8. After the subscription exists
Creating the agreement is the easy half. The behaviour you actually have to build against starts at the first renewal:
- Wire the seven lifecycle events — payloads in Subscription Webhooks, delivery and signature verification in Receiving Webhooks and Webhook Management.
subscription.renewedis what grants the next cycle of access. - Understand billing day itself — Renewals & Dunning covers balance-first funding, the retry schedule, and step-up authentication.
- Gate entitlement on
paid_through, never oncurrent_period_end. The second one exists from the moment of creation and says nothing about whether money moved. - Surface
confirmation_urlwhensubscription.authentication_requiredarrives. It is not a failure, and a member who never sees the link eventually expires. - Manage the agreement afterwards with
/cancel,/amount,/payment-methodand/refund— see Manage a Subscription, and Subscription Reporting if you record arevenue_share.
Test the whole cycle in sandbox before you go live — Sandbox Testing covers the separate base URL, the /sandbox path prefix, and the separate keys.