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

POST

/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_here

Never 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

FieldTypeReq.Notes
client_request_idstringyesYour idempotency key. ≤ 255 chars, unique per subscription and per member. Must not begin with sub_ — that prefix is reserved (see section 6).
player_emailstringyesThe member being subscribed. ≤ 255 chars, lower-cased on receipt.
player_namestringyes≤ 255 chars. Used only if the player has to be created.
item_idstringyesYour entitlement handle — opaque to Invo, echoed on every read and event. ≤ 255 chars. At most one live subscription per (game, player, item).
amount_usddecimalyesThe 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_namestringno≤ 255 chars. Human-readable label for the membership.
player_phonestringno≤ 30 chars. See the phone-share note in section 4.
intervalstringno"month" (default) or "year".
interval_countintnoHow many intervals per cycle. Default 1, range 1–36. An explicit 0 is rejected, not silently corrected.
trial_daysintno1–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_endstringnoISO 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_onlyboolnoDefault 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_idintnoA 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.
metadataobjectnoYour 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_shareobjectno{ 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.
consentobjectnoEvidence 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.

FieldTypeReq.Notes
consent_atstringnoISO 8601. Cannot be in the future.
consent_ipstringnoThe member's IP, captured by you at signup. ≤ 45 chars (IPv6).
consent_user_agentstringnoThe member's user agent. Truncated at 500 chars rather than rejected.
disclosed_amount_usddecimalnoWhat the member was actually shown. Also the baseline for the 2× price-increase cap, so sending it makes that guardrail meaningfully tighter.
disclosed_intervalstringno"month" or "year".
terms_versionstringno≤ 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.

HTTPCodeMeaning
400INVALID_BODYThe body is not a JSON object.
400CLIENT_REQUEST_ID_REQUIRED / CLIENT_REQUEST_ID_INVALID / CLIENT_REQUEST_ID_TOO_LONGMissing, non-scalar, or over 255 chars.
400CLIENT_REQUEST_ID_RESERVEDThe key begins with sub_ — see section 6.
400PLAYER_EMAIL_INVALID / PLAYER_EMAIL_TOO_LONGMissing or malformed email, or over 255 chars.
400PLAYER_NAME_REQUIRED / PLAYER_NAME_TOO_LONG / PLAYER_PHONE_TOO_LONGMember identity fields.
400ITEM_ID_REQUIRED / ITEM_ID_TOO_LONG / ITEM_NAME_TOO_LONGEntitlement handle problems.
400AMOUNT_REQUIRED / AMOUNT_INVALID / AMOUNT_TOO_SMALL / AMOUNT_TOO_LARGEMissing, non-finite, below 0.01, or above 999999.99.
400INTERVAL_INVALIDNot month or year.
400INTERVAL_COUNT_INVALID / INTERVAL_COUNT_OUT_OF_RANGENot an integer, or outside 1–36.
400TRIAL_DAYS_INVALID / TRIAL_DAYS_OUT_OF_RANGENot an integer, or outside 1–365.
400TRIAL_END_INVALID / TRIAL_END_IN_PAST / TRIAL_END_TOO_FARUnparseable, not in the future, or more than 365 days out.
400METADATA_INVALID / METADATA_TOO_LARGENot an object, not serializable, contains NUL, or over 8192 bytes.
400CONSENT_AT_INVALID / CONSENT_INVALID / CONSENT_DISCLOSED_AMOUNT_INVALID / CONSENT_DISCLOSED_INTERVAL_INVALIDConsent field problems.
400REVENUE_SHARE_INVALID / REVENUE_SHARE_PERCENT_OUT_OF_RANGE / REVENUE_SHARE_RECIPIENT_REQUIRED / REVENUE_SHARE_RECIPIENT_NOT_FOUND / REVENUE_SHARE_RECIPIENT_IS_SUBSCRIBERAttribution block problems. The recipient must already be a player in this game and cannot be the subscriber.
400PAYMENT_METHOD_INVALID / PAYMENT_METHOD_EXPIREDCard id is not an integer, or the card has already expired.
400CURRENCY_NOT_CONFIGUREDThe game has no currency set up. See Virtual Currency Setup.
400DATA_CONFLICT / INVALID_FIELD_VALUEBackstops for a value the database refused.
401Missing or invalid X-Game-Secret-Key.
403GAME_NOT_LIVEOnly a live game may open a recurring agreement.
404PAYMENT_METHOD_NOT_FOUNDNo saved card with that id for this member in this game. Deliberately the same answer as "not their card".
409ACTIVE_SUBSCRIPTION_EXISTSThis member already has a live subscription to this item_id. The response includes its subscription_id.
409CLIENT_REQUEST_ID_CONFLICTThat key already belongs to a different member or item. Nothing about the other row is disclosed.
409IDEMPOTENT_REPLAY_MISMATCHSame key, different terms — see section 5. Carries mismatched_fields.
409CONCURRENT_REQUESTTwo creates for a brand-new member raced. Retry with the same key.
409WALLET_ONLY_SUBSCRIPTIONplayer_card_id was sent alongside wallet_only: true.
409REVENUE_SHARE_EXISTSA share is already recorded for this subscription.
409PHONE_SHARE_APPROVAL_REQUIREDThe phone belongs to another account — approve, then retry with the same key.
429rate_limit_exceededRate limited. Honour the Retry-After header and the retry_after field.
503flow_pausedSubscription changes are temporarily paused for maintenance. Retry shortly.
500INTERNAL_ERRORNothing 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 sendYou get
The same key, same member, same item, same terms200 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 item409 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_only409 IDEMPOTENT_REPLAY_MISMATCH with mismatched_fields.
Two identical creates at the same instantOne 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

Create a $9.99/month membership
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

createSubscription() — stable key, replay-aware
// 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)

This must run on your secure server, not in the shipped client
// 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.renewed is 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 on current_period_end. The second one exists from the moment of creation and says nothing about whether money moved.
  • Surface confirmation_url when subscription.authentication_required arrives. It is not a failure, and a member who never sees the link eventually expires.
  • Manage the agreement afterwards with /cancel, /amount, /payment-method and /refund — see Manage a Subscription, and Subscription Reporting if you record a revenue_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.