The Steam road, end to end

A Steam subscription is funded from the member’s Steam wallet through a recurring agreement the member authorises once, in Steam. Invo still owns the clock: Invo decides when each period is charged, on the same schedule, retries and events as the card rail. Valve pays you for Steam charges through your own Steamworks account; Invo mints the currency and settles with you afterwards. The clients and raw HTTP helpers the samples use are defined on the overview.

The road in three steps

  1. POST /api/subscriptions/steam/init creates the subscription in pending_steam_authorization and starts the Steam authorisation. Nothing is charged.
  2. The member authorises in the Steam overlay (usersession: "client") or at the checkout URL you open for them (usersession: "web").
  3. POST /api/subscriptions/steam/finalize verifies the authorisation, captures period 1 and settles it exactly as a renewal. This call is yours to make; Steam does not tell Invo the member said yes.

1. Before you start

  • The title must be enabled for Steam in Invo (your Steam app id and publisher Web API key added in the console and verified, a billing method on file). Otherwise 403 STEAM_RAIL_NOT_ENTITLED or 503 STEAM_NOT_CONFIGURED.
  • Use the primary game key or the title’s steam channel key. A key for any other channel is refused 409 STEAM_CHANNEL_REQUIRED.
  • The game must be live (403 GAME_NOT_LIVE otherwise), in sandbox as well as in production.

Use the same item_id as the card version of the membership

The one-live-subscription rule is per game, per player, per item_id, on either rail. A Steam init for an item the member already subscribes to by card is refused 409 ACTIVE_SUBSCRIPTION_EXISTS with the live subscription_id, and that is the intended guard against double-billing one membership over two rails. The subscription’s item_id must also differ from any one-off pack id you sell through Steam or by card. See the item id rule.

Decide the price knowing what it buys

The coins a Steam period is worth depend on the member’s country, because Steam prices include VAT. 9.99 USD is 69 coins for a US member and 58 for a German member. The init response tells you the number in amount_coins; show it to the member before they subscribe. Do not compute it as price times 10; that is the card rail’s arithmetic, not Steam’s.

2. POST /api/subscriptions/steam/init

Creates the subscription in pending_steam_authorization, opens period 1 for the full price, and starts the Steam authorisation. Idempotent on client_request_id.

Request body

The /subscribe body plus the Steam fields, with these differences:

FieldTypeReq.RulesError
steamidstring of digitsyesThe member’s 64-bit Steam id. steam_id is accepted as an alias.400 STEAMID_REQUIRED
usersessionclient or webnoDefault client. client: the member authorises in the Steam overlay inside the game. web: Steam returns a hosted checkout URL you open for the member.400 INVALID_USERSESSION
player_ipstringfor webThe member’s IP as your server sees it.400 MISSING_PLAYER_IP
interval_countintegernoMust be 1. Monthly and yearly only on Steam.400 STEAM_INTERVAL_COUNT_UNSUPPORTED
wallet_onlyRefused. The Steam agreement is the rail.409 WALLET_ONLY_SUBSCRIPTION
player_card_id / card_idRefused.400 CARD_FIELDS_NOT_ALLOWED
trial_days / trial_endRefused. Period 1 must be a real charge to create the agreement.400 TRIAL_NOT_SUPPORTED_ON_STEAM

Everything else (client_request_id, player_email, player_name, player_phone, item_id, item_name, amount_usd, interval, metadata, consent, revenue_share) is validated exactly as on /subscribe.

Refusals specific to Steam, in the order they are checked

HTTPerror_code or bodyMeaningWhat to do
503{"message": "Steam subscriptions are not enabled."}The Steam rail is off platform-wide.Contact Invo.
503body error: "flow_paused"Currency purchases or subscriptions are paused.Retry later, same key.
403STEAM_RAIL_NOT_ENTITLEDThe title is not enabled for Steam.Complete Steam setup in the console and ask Invo to enable it.
403RAIL_NOT_AVAILABLE_TO_PLATFORMThis rail serves game currency only and your tenant is not a game.Not available to your tenant type.
409STEAM_CHANNEL_REQUIREDThe key belongs to a non-Steam channel.Use the primary key or the steam channel key.
503STEAM_NOT_CONFIGUREDThe title has no verified Steam configuration.Add the Steam app id and publisher key in the console.
409ACTIVE_SUBSCRIPTION_EXISTSThe player already has a live subscription to this item, on either rail. Body carries subscription_id.Use it.
409STEAM_AUTHORIZATION_PENDINGA Steam subscription for this player and item is already waiting for authorisation, and this request carries different terms. Body carries the pending subscription_id. (With the same terms you get the pending row back instead; see “Pending reuse” below.)Finalize or wait out the pending row, or send the same terms.
409STEAM_ACCOUNT_LOCKEDThe Steam account cannot make purchases.The member must resolve it with Steam Support.
400AMOUNT_TOO_SMALL_FOR_STEAMThe price buys no currency through Steam in the member’s country (below about 0.15 USD in the US).Raise amount_usd.
409PARTNER_BILLING_NOT_SET_UP, PARTNER_RAIL_SUSPENDED, PARTNER_CREDIT_UNAVAILABLEInvo cannot issue currency for your title right now. The message is safe to show the member.Complete or repair your billing setup in the console; for PARTNER_CREDIT_UNAVAILABLE retry later or contact Invo.
429body {"error": "spending_limit_exceeded", "message": "..."}The member’s spending limit (1000 USD per hour, 2000 per day, 10000 per month per game) would be exceeded by this price.Retry later.
409STEAM_CHARGE_UNPLANNABLEThe price cannot be charged through Steam in this region. The subscription was abandoned.Choose a different price; retry with a new key.
503STEAM_UNAVAILABLESteam is temporarily unavailable. The subscription was abandoned.Retry with a new key.
400STEAM_REQUEST_INVALIDThis subscription cannot be represented as a Steam agreement.Contact Invo.
502STEAM_ERRORSteam could not start the subscription. The subscription was abandoned.Retry with a new key.
500CLAIM_FAILED, ORDER_FAILEDInvo could not open the first period.Retry with a new key.

When a Steam-side failure abandons the row after creation, the body also carries subscription_id of the abandoned (now expired) row.

The call

curl
curl -sS -X POST "$BASE/api/subscriptions/steam/init" \
  -H "X-Game-Secret-Key: $GAME_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "client_request_id": "guild-42-member-7-steam",
    "player_email": "member@example.com",
    "player_name": "Member Seven",
    "item_id": "guild-42-membership",
    "item_name": "Guild 42 membership",
    "amount_usd": "9.99",
    "interval": "month",
    "steamid": "76561198000000000",
    "usersession": "client",
    "metadata": {"guild_id": "42"}
  }'

# web session: add "usersession": "web" and "player_ip": "<the member's IP>";
# the response then carries steam_checkout_url
Node, @invonetwork/web-sdk
const init = await invo.subscriptions.steamInit({
  clientRequestId: "guild-42-member-7-steam",
  playerEmail: "member@example.com",
  playerName: "Member Seven",
  itemId: "guild-42-membership",
  itemName: "Guild 42 membership",
  amountUsd: "9.99",
  interval: "month",
  steamid: "76561198000000000",
  usersession: "client",              // or "web" with playerIp: memberIp
  metadata: { guild_id: "42" },
});

// init.subscriptionId      SUB_...
// init.amountCoins         "69" for a US member: SHOW THIS to the member
// init.steamOrderId        the value the Steam client callback's order_id echoes: store it,
//                          it is how you find this subscription when the overlay closes
// init.steamTransid        Steam's transaction reference for period 1 (support tooling only)
// init.steamCheckoutUrl    present only for usersession "web": open it top-level
// init.pendingReuse        true when an identical pending row was returned instead of a new one
// init.firstCharge.status  "pending_authorization": nothing charged yet
await db.saveSteamOrder(init.steamOrderId, init.subscriptionId);
Node, raw HTTP
const body = {
  client_request_id: "guild-42-member-7-steam",
  player_email: "member@example.com",
  player_name: "Member Seven",
  item_id: "guild-42-membership",
  item_name: "Guild 42 membership",
  amount_usd: "9.99",
  interval: "month",
  steamid: "76561198000000000",
  usersession: "client",              // or "web" with player_ip: memberIp
  metadata: { guild_id: "42" },
};

let { status, json } = await invo("POST", "/api/subscriptions/steam/init", body);
if (status >= 500 && !json.error_code) {
  ({ status, json } = await invo("POST", "/api/subscriptions/steam/init", body));   // replay, same key
}

if (status === 201 || (status === 200 && json.idempotent_replay)) {
  // json.status is the row's CURRENT status on a replay: may already be "success"/"active"
  if (json.subscription_status === "pending_steam_authorization") {
    await db.saveSteamOrder(json.steam_order_id, json.subscription_id);   // callback order_id -> subscription
    showCoins(json.amount_coins);                       // "69"
    if (json.steam_checkout_url) openTopLevel(json.steam_checkout_url);
    // json.pending_reuse === true: an identical pending row was returned, no new agreement opened
    // then, after the member authorises: finalize (next section)
  }
} else if (["STEAM_UNAVAILABLE", "STEAM_ERROR", "STEAM_CHARGE_UNPLANNABLE",
            "CLAIM_FAILED", "ORDER_FAILED"].includes(json.error_code)) {
  // the row was abandoned (json.subscription_id names it): retry with a NEW key
} else if (json.error_code === "ACTIVE_SUBSCRIPTION_EXISTS") {
  const existing = json.subscription_id;
}
Python, invonetwork
init = invo.subscriptions.steam_init(
    client_request_id="guild-42-member-7-steam",
    player_email="member@example.com",
    player_name="Member Seven",
    item_id="guild-42-membership",
    item_name="Guild 42 membership",
    amount_usd="9.99",
    interval="month",
    steamid="76561198000000000",
    usersession="client",               # or "web" with player_ip=member_ip
    metadata={"guild_id": "42"},
)

# init.subscription_id       SUB_...
# init.amount_coins          "69" for a US member: SHOW THIS to the member
# init.steam_order_id        the value the Steam client callback's order_id echoes: store it,
#                            it is how you find this subscription when the overlay closes
# init.steam_transid         Steam's transaction reference for period 1 (support tooling only)
# init.steam_checkout_url    present only for usersession "web": open it top-level
# init.pending_reuse         True when an identical pending row was returned instead of a new one
# init.first_charge.status   "pending_authorization": nothing charged yet
db.save_steam_order(init.steam_order_id, init.subscription_id)
Python, raw HTTP
body = {
    "client_request_id": "guild-42-member-7-steam",
    "player_email": "member@example.com",
    "player_name": "Member Seven",
    "item_id": "guild-42-membership",
    "item_name": "Guild 42 membership",
    "amount_usd": "9.99",
    "interval": "month",
    "steamid": "76561198000000000",
    "usersession": "client",            # or "web" with "player_ip": member_ip
    "metadata": {"guild_id": "42"},
}

status, res = invo("POST", "/api/subscriptions/steam/init", body)
if status >= 500 and not res.get("error_code"):
    status, res = invo("POST", "/api/subscriptions/steam/init", body)   # replay, same key

if status == 201 or (status == 200 and res.get("idempotent_replay")):
    if res["subscription_status"] == "pending_steam_authorization":
        db.save_steam_order(res["steam_order_id"], res["subscription_id"])   # callback order_id -> subscription
        show_coins(res["amount_coins"])                    # "69"
        if res.get("steam_checkout_url"):
            open_top_level(res["steam_checkout_url"])
        # res.get("pending_reuse"): an identical pending row was returned, no new agreement opened
        # then, after the member authorises: finalize (next section)
elif res.get("error_code") in ("STEAM_UNAVAILABLE", "STEAM_ERROR", "STEAM_CHARGE_UNPLANNABLE",
                               "CLAIM_FAILED", "ORDER_FAILED"):
    pass   # the row was abandoned (res["subscription_id"]): retry with a NEW key
elif res.get("error_code") == "ACTIVE_SUBSCRIPTION_EXISTS":
    existing = res["subscription_id"]

201 response

{
  "status": "pending_steam_authorization",
  "subscription_status": "pending_steam_authorization",
  "idempotent_replay": false,
  "subscription": { "... the subscription object: status pending_steam_authorization,
                     funding_rail steam, steam_agreement_status pending,
                     next_charge_at null, paid_through null ..." },
  "card": null,
  "subscription_id": "SUB_1757155200_A1B2C3D4",
  "order_id": "ORD_...",
  "steam_order_id": "9876543210987654321",
  "steam_transid": "1234567890123456789",
  "steam_checkout_url": null,
  "pending_reuse": false,
  "amount_usd": "9.99",
  "amount_coins": "69",
  "steam_charge_usd": "9.99",
  "recurring_amount_usd": "9.99",
  "first_charge": {
    "status": "pending_authorization",
    "amount_usd": "9.99",
    "paid_through": null,
    "failure_code": null,
    "next_retry_at": null,
    "confirmation_url": null,
    "expires_at": null,
    "message": "Nothing has been charged yet. Have the member authorise the agreement in Steam, then call POST /api/subscriptions/steam/finalize."
  },
  "message": "Steam agreement created. Have the member authorise it in the Steam client, then call POST /api/subscriptions/steam/finalize."
}
  • amount_coins is what one period is worth for this member, price-derived for their country. Show it.
  • steam_charge_usd is what period 1 will charge (the full price); recurring_amount_usd is the ceiling the member consents to, which is the price.
  • steam_order_id (a string holding an unsigned 64-bit integer) is the value the Steam client callback’s order_id echoes when the overlay closes. Store it against the subscription_id before you return; it is how your game matches the callback to the subscription it must finalize.
  • steam_transid is Steam’s transaction reference for period 1, for your own support tooling. Do not match the callback on it; match on steam_order_id.
  • steam_checkout_url is present only for usersession: "web".
  • pending_reuse is true when the row returned is an existing pending authorisation rather than a new one (see “Pending reuse” below).

The client step: client versus web

usersessionWhere the member authorisesWhat you do
client (default)The Steam overlay presents the authorisation to the member inside the game.Nothing to open. When the overlay closes, call finalize.
webA Steam-hosted checkout page.Send player_ip on init. Open steam_checkout_url in the member’s browser as a top-level window or tab. It cannot be framed. When the member returns, call finalize.

Matching the Steam client callback

When the overlay closes, the Steam client callback your game receives carries an order_id. It is the steam_order_id from the init response. Look up the subscription you stored under that value, then call finalize for it. Do not try to match on steam_transid.

The member has 24 hours. A subscription still unauthorised after that expires (subscription.expired with failure_code: "steam_authorization_abandoned"and final_period_end: null) and must be re-created with a new key. While it is pending, a second init for the same member and item is handled by the pending-reuse rule below.

Pending reuse: a second init while one is waiting

If a member abandons the overlay and your game calls /steam/init again for the same player and item while the first row is still pending_steam_authorization:

  • Same terms (price, interval, and the rest of the material fields): you get the existing pending row back with pending_reuse: true, the same steam_transid, steam_order_id and steam_checkout_url, and no new Steam agreement is opened. Present the same authorisation again and finalize as usual. This holds even with a new client_request_id.
  • Different terms: 409 STEAM_AUTHORIZATION_PENDING with the pending subscription_id in the body. Either finalize or cancel that row, or wait for it to expire, before starting one on new terms.

Replay

A repeated client_request_id returns HTTP 200 with "idempotent_replay": true and the row’s current status in status / subscription_status (success and active once finalised, canceled, expired, and so on), never a stale pending_steam_authorization. The replay carries the same steam_order_id. A Steam key replayed against a card subscription, or the reverse, is 409 IDEMPOTENT_REPLAY_MISMATCH with funding_rail in mismatched_fields.

3. POST /api/subscriptions/steam/finalize

Body: {"subscription_id": "SUB_..."}. Call it after the member has authorised. Invo verifies the authorisation with Steam, captures period 1, records the agreement and settles period 1 exactly as a renewal. Idempotent.

HTTPBodyMeaningWhat to do
200The /subscribe 201 body with first_charge.status: "paid" and first_charge.paid_period_seq: 1, plus already_processed: false and steam_agreement_status: "active"Period 1 charged and settled. subscription.period_seq is now 2: the next period.Grant access until paid_through, keyed on the paid period (paid_period_seq, which is 1). Expect subscription.renewed with period_seq: 1.
200Same, idempotent_replay: true, already_processed: trueAlready finalised (or a concurrent finalize won).Treat as success.
200Same plus warningSteam charged, but period 1 could not settle in one step; the currency was credited and Invo completes the period on a later run of the renewal engine. first_charge.status reads failed from the rows.Treat the money as taken; do not retry the charge. Watch for subscription.renewed.
409{"status": "not_authorized", "message": "...", "steam_status": "..."}The member has not completed authorisation yet.Retry after they have.
409SUBSCRIPTION_TERMINALThe subscription is canceled or expired.Start a new one.
409NOT_A_STEAM_SUBSCRIPTIONNot a Steam subscription.Nothing to finalize.
409NO_STEAM_AUTHORIZATIONNo open Steam authorisation to finalize.Start a new subscription.
409STEAM_APP_CHANGEDThe subscription was started against a different Steam application.Contact Invo.
503{"status": "service_unavailable"}Steam unavailable.Retry.
502{"status": "steam_error"}Could not verify the authorisation.Retry.
400SUBSCRIPTION_ID_REQUIREDMissing body field.Fix.
404SUBSCRIPTION_NOT_FOUNDUnknown id for this game.Check the id.
503{"message": "Steam subscriptions are not enabled."}Rail off.Contact Invo.

Two counters, one meaning. After finalize, subscription.period_seq reads 2 because the subscription now points at the next window, while the subscription.renewed event that follows reports the period just paid as period_seq: 1, matching first_charge.paid_period_seq. Key your entitlement records on the paid period (paid_period_seq on the response, period_seq on the renewed event), never on subscription.period_seq. The card road behaves identically.

steam_agreement_status on the 200 is normally active. It can read pending if Steam captured the charge but the agreement could not be located yet; Invo keeps looking and repairs it without your involvement. If a later renewal finds the wallet short and no active agreement, that renewal fails as insufficient_funds and goes into dunning like any other failure.

curl
curl -sS -X POST "$BASE/api/subscriptions/steam/finalize" \
  -H "X-Game-Secret-Key: $GAME_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"subscription_id": "SUB_1757155200_A1B2C3D4"}'
Node, @invonetwork/web-sdk
// after the overlay closed / the member came back from the checkout URL
try {
  const done = await invo.subscriptions.steamFinalize(init.subscriptionId);
  // done.firstCharge.status === "paid", done.firstCharge.paidPeriodSeq === 1,
  // done.steamAgreementStatus === "active"; done.subscription.periodSeq is already 2 (the NEXT period)
  grantAccess(done.subscription.subscriptionId, done.firstCharge.paidPeriodSeq, done.subscription.paidThrough);
  if (done.warning) {
    // Steam charged but period 1 settles on a later run; money is taken, do not retry the charge
  }
} catch (err) {
  if (err.status === 409 && err.body?.status === "not_authorized") {
    // the member has not finished yet: ask again, then retry finalize
  } else if (err.code === "SUBSCRIPTION_TERMINAL" || err.code === "NO_STEAM_AUTHORIZATION") {
    // start a new subscription with a new key
  } else if (err.status === 502 || err.status === 503) {
    // Steam hiccup: retry finalize (it is idempotent)
  }
}
Node, raw HTTP
const { status, json } = await invo("POST", "/api/subscriptions/steam/finalize", {
  subscription_id: subscriptionId,
});

if (status === 200) {
  // json.first_charge.status "paid", json.first_charge.paid_period_seq 1; json.already_processed true on a repeat
  grantAccess(json.subscription.subscription_id, json.first_charge.paid_period_seq, json.subscription.paid_through);
  if (json.warning) { /* money taken; period 1 settles on a later run; do not retry */ }
} else if (status === 409 && json.status === "not_authorized") {
  // ask the member to finish in Steam, then call finalize again
} else if (status === 409) {
  // SUBSCRIPTION_TERMINAL | NOT_A_STEAM_SUBSCRIPTION | NO_STEAM_AUTHORIZATION | STEAM_APP_CHANGED
} else if (status === 502 || status === 503) {
  // retry finalize
}
Python, invonetwork
from invonetwork import InvoError

try:
    done = invo.subscriptions.steam_finalize(init.subscription_id)
    # done.first_charge.status == "paid", done.first_charge.paid_period_seq == 1,
    # done.steam_agreement_status == "active"; done.subscription.period_seq is already 2 (the NEXT period)
    grant_access(done.subscription.subscription_id, done.first_charge.paid_period_seq, done.subscription.paid_through)
    if done.warning:
        pass   # Steam charged; period 1 settles on a later run; do not retry the charge
except InvoError as err:
    if err.status == 409 and err.body.get("status") == "not_authorized":
        pass   # the member has not finished yet: ask again, then retry finalize
    elif err.code in ("SUBSCRIPTION_TERMINAL", "NO_STEAM_AUTHORIZATION"):
        pass   # start a new subscription with a new key
    elif err.status in (502, 503):
        pass   # Steam hiccup: retry finalize (idempotent)
Python, raw HTTP
status, res = invo("POST", "/api/subscriptions/steam/finalize",
                   {"subscription_id": subscription_id})

if status == 200:
    grant_access(res["subscription"]["subscription_id"], res["first_charge"]["paid_period_seq"],
                 res["subscription"]["paid_through"])
    if res.get("warning"):
        pass   # money taken; period 1 settles on a later run; do not retry
elif status == 409 and res.get("status") == "not_authorized":
    pass   # ask the member to finish in Steam, then call finalize again
elif status == 409:
    pass   # SUBSCRIPTION_TERMINAL | NOT_A_STEAM_SUBSCRIPTION | NO_STEAM_AUTHORIZATION | STEAM_APP_CHANGED
elif status in (502, 503):
    pass   # retry finalize

4. Funding rules on Steam

  • Period 1 always charges the full price through Steam (that is what creates the agreement) and mints exactly what the price buys for the member’s country.
  • Later periods are wallet-first:
    • wallet covers the period: no Steam charge at all that period;
    • wallet empty: Steam is charged the full advertised price (the member’s statement says 9.99, not 9.86);
    • wallet partly funded: Steam is charged the smallest amount that buys the missing coins, never above the price, never below the 1.00 USD minimum top-up; any coins above the shortfall stay in the wallet.
  • The country used for the coin arithmetic is the one recorded at init, so every period of a subscription is worth the same coins.
  • The split on a Steam renewal is computed on what the coins are worth (basis: "steam_net"), not on the price. See the Steam renewal example.

Worked example: 9.99 USD per month, US member, 69 coins per period

PeriodWallet beforeSteam chargedCoins mintedCoins spentWallet after
1 (always full price)20 coins9.99 USD696920
2, empty wallet09.99 USD69690
2, partial wallet207.00 USD (smallest charge that buys the missing 49)49690
2, full wallet80nothing; no Steam call06911

For a German member the same price is worth 58 coins and the partial top-up above would be 6.46 USD. The country is fixed at init.

5. What is not available on Steam

  • No trials (400 TRIAL_NOT_SUPPORTED_ON_STEAM).
  • Monthly and yearly only, interval_count 1 (400 STEAM_INTERVAL_COUNT_UNSUPPORTED).
  • No price increases: the member consented to a ceiling. /amount with a higher price is 409 STEAM_REAUTHORIZATION_REQUIRED with current_amount_usd and requested_amount_usd; start a new Steam subscription at the new price and cancel this one. Decreases stage normally.
  • No card: /payment-method is 409 NOT_A_CARD_SUBSCRIPTION.
  • No wallet_only (409 WALLET_ONLY_SUBSCRIPTION).
  • Refunds of Steam-charged periods are not supported yet: /refund on a period that was charged through Steam answers 409 STEAM_REFUND_NOT_SUPPORTED before anything moves. A period of a Steam subscription that the wallet covered entirely is refundable in coins. See Refunds.

6. The member can cancel from Steam

A member can end the agreement from their Steam account without touching your game. You learn it from subscription.canceled with canceled_by: "steam", cancel_at_period_end: false, effective_at now, and access running to access_until (the last paid boundary). No further charge is ever made. When you cancel a Steam subscription through /cancel, the response carries steam_agreement_status and steam_agreement_canceled; if the agreement could not be ended at Steam in that call, Invo retries on its own and, either way, never charges it again.

7. Steam traps

  1. Finalize is your call. Steam does not tell Invo the member authorised; your game must call /steam/finalize after the overlay or checkout closes, and again if it returned not_authorized.
  2. 24 hours to authorise. An unfinalised subscription expires and must be re-created with a new key.
  3. web sessions need player_ip and the checkout URL must open top-level; it cannot be framed.
  4. Only the primary key or the steam channel key may start a Steam subscription.
  5. No trials, no interval_count other than 1, no wallet_only, no card fields.
  6. The price is a ceiling. Increases are refused; start a new Steam subscription. Decreases stage.
  7. /payment-method is refused on a Steam subscription.
  8. Coins per period depend on the member’s country and are fixed at init. Show amount_coins from the init response, not price times 10.
  9. An empty wallet pays the full price; a partial wallet pays only for the missing coins. Both are correct and both appear as funding.steam_charged_usd.
  10. A member can cancel from Steam. You learn it from subscription.canceled with canceled_by: "steam"; access runs to access_until.
  11. Some Steam renewals are deferred for reasons on your side (billing setup, credit, Steam configuration) with no event. Keep your Invo billing setup healthy and watch next_charge_at. See deferrals.
  12. Replaying /steam/init returns the row’s current status, which may already be active, canceled or expired.
  13. A second init while one is pending reuses it. Same terms: the pending row comes back with pending_reuse: true and the same steam_order_id; no new agreement is opened. Different terms: 409 STEAM_AUTHORIZATION_PENDING. A pending row never occupies the one-live slot, so a card subscription to the same item is still refused only once something is live.
  14. Match the Steam callback on steam_order_id, never on steam_transid. Store it before you return from init.
  15. Refunds of Steam-charged periods are not supported yet. /refund answers 409 STEAM_REFUND_NOT_SUPPORTED on them; wallet-covered periods refund in coins.
  16. Same item_id on both rails, different from any one-off pack. 409 ACTIVE_SUBSCRIPTION_EXISTS across rails is the guard, not a bug.
  17. Sandbox does not exercise Valve. sandbox_auto_approved: true means the charge was simulated; do one real Steam sandbox authorisation before launch. See the Steam sandbox recipe.
  18. split.total_usd on Steam is not the price. It is what the coins are worth after Valve’s share and VAT; the price is the top-level amount_usd.

Before launch, run the Steam sandbox recipe end to end, then do at least one real Steam sandbox authorisation (a real Steam account approving in the overlay or at the web checkout URL, against your title’s Steam sandbox interface). Only that exercises Valve’s side.