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
POST /api/subscriptions/steam/initcreates the subscription inpending_steam_authorizationand starts the Steam authorisation. Nothing is charged.- The member authorises in the Steam overlay (
usersession: "client") or at the checkout URL you open for them (usersession: "web"). POST /api/subscriptions/steam/finalizeverifies 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_ENTITLEDor503 STEAM_NOT_CONFIGURED. - Use the primary game key or the title’s
steamchannel key. A key for any other channel is refused409 STEAM_CHANNEL_REQUIRED. - The game must be
live(403 GAME_NOT_LIVEotherwise), 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:
| Field | Type | Req. | Rules | Error |
|---|---|---|---|---|
steamid | string of digits | yes | The member’s 64-bit Steam id. steam_id is accepted as an alias. | 400 STEAMID_REQUIRED |
usersession | client or web | no | Default 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_ip | string | for web | The member’s IP as your server sees it. | 400 MISSING_PLAYER_IP |
interval_count | integer | no | Must be 1. Monthly and yearly only on Steam. | 400 STEAM_INTERVAL_COUNT_UNSUPPORTED |
wallet_only | Refused. The Steam agreement is the rail. | 409 WALLET_ONLY_SUBSCRIPTION | ||
player_card_id / card_id | Refused. | 400 CARD_FIELDS_NOT_ALLOWED | ||
trial_days / trial_end | Refused. 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
| HTTP | error_code or body | Meaning | What to do |
|---|---|---|---|
| 503 | {"message": "Steam subscriptions are not enabled."} | The Steam rail is off platform-wide. | Contact Invo. |
| 503 | body error: "flow_paused" | Currency purchases or subscriptions are paused. | Retry later, same key. |
| 403 | STEAM_RAIL_NOT_ENTITLED | The title is not enabled for Steam. | Complete Steam setup in the console and ask Invo to enable it. |
| 403 | RAIL_NOT_AVAILABLE_TO_PLATFORM | This rail serves game currency only and your tenant is not a game. | Not available to your tenant type. |
| 409 | STEAM_CHANNEL_REQUIRED | The key belongs to a non-Steam channel. | Use the primary key or the steam channel key. |
| 503 | STEAM_NOT_CONFIGURED | The title has no verified Steam configuration. | Add the Steam app id and publisher key in the console. |
| 409 | ACTIVE_SUBSCRIPTION_EXISTS | The player already has a live subscription to this item, on either rail. Body carries subscription_id. | Use it. |
| 409 | STEAM_AUTHORIZATION_PENDING | A 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. |
| 409 | STEAM_ACCOUNT_LOCKED | The Steam account cannot make purchases. | The member must resolve it with Steam Support. |
| 400 | AMOUNT_TOO_SMALL_FOR_STEAM | The price buys no currency through Steam in the member’s country (below about 0.15 USD in the US). | Raise amount_usd. |
| 409 | PARTNER_BILLING_NOT_SET_UP, PARTNER_RAIL_SUSPENDED, PARTNER_CREDIT_UNAVAILABLE | Invo 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. |
| 429 | body {"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. |
| 409 | STEAM_CHARGE_UNPLANNABLE | The price cannot be charged through Steam in this region. The subscription was abandoned. | Choose a different price; retry with a new key. |
| 503 | STEAM_UNAVAILABLE | Steam is temporarily unavailable. The subscription was abandoned. | Retry with a new key. |
| 400 | STEAM_REQUEST_INVALID | This subscription cannot be represented as a Steam agreement. | Contact Invo. |
| 502 | STEAM_ERROR | Steam could not start the subscription. The subscription was abandoned. | Retry with a new key. |
| 500 | CLAIM_FAILED, ORDER_FAILED | Invo 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 -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_urlconst 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);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;
}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)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_coinsis what one period is worth for this member, price-derived for their country. Show it.steam_charge_usdis what period 1 will charge (the full price);recurring_amount_usdis 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’sorder_idechoes when the overlay closes. Store it against thesubscription_idbefore you return; it is how your game matches the callback to the subscription it must finalize.steam_transidis Steam’s transaction reference for period 1, for your own support tooling. Do not match the callback on it; match onsteam_order_id.steam_checkout_urlis present only forusersession: "web".pending_reuseistruewhen the row returned is an existing pending authorisation rather than a new one (see “Pending reuse” below).
The client step: client versus web
usersession | Where the member authorises | What 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. |
web | A 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 samesteam_transid,steam_order_idandsteam_checkout_url, and no new Steam agreement is opened. Present the same authorisation again and finalize as usual. This holds even with a newclient_request_id. - Different terms:
409 STEAM_AUTHORIZATION_PENDINGwith the pendingsubscription_idin 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.
| HTTP | Body | Meaning | What to do |
|---|---|---|---|
| 200 | The /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. |
| 200 | Same, idempotent_replay: true, already_processed: true | Already finalised (or a concurrent finalize won). | Treat as success. |
| 200 | Same plus warning | Steam 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. |
| 409 | SUBSCRIPTION_TERMINAL | The subscription is canceled or expired. | Start a new one. |
| 409 | NOT_A_STEAM_SUBSCRIPTION | Not a Steam subscription. | Nothing to finalize. |
| 409 | NO_STEAM_AUTHORIZATION | No open Steam authorisation to finalize. | Start a new subscription. |
| 409 | STEAM_APP_CHANGED | The 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. |
| 400 | SUBSCRIPTION_ID_REQUIRED | Missing body field. | Fix. |
| 404 | SUBSCRIPTION_NOT_FOUND | Unknown 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 -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"}'// 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)
}
}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
}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)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 finalize4. 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, not9.86); - wallet partly funded: Steam is charged the smallest amount that buys the missing coins, never above the price, never below the
1.00USD 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
spliton 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
| Period | Wallet before | Steam charged | Coins minted | Coins spent | Wallet after |
|---|---|---|---|---|---|
| 1 (always full price) | 20 coins | 9.99 USD | 69 | 69 | 20 |
| 2, empty wallet | 0 | 9.99 USD | 69 | 69 | 0 |
| 2, partial wallet | 20 | 7.00 USD (smallest charge that buys the missing 49) | 49 | 69 | 0 |
| 2, full wallet | 80 | nothing; no Steam call | 0 | 69 | 11 |
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_count1 (400 STEAM_INTERVAL_COUNT_UNSUPPORTED). - No price increases: the member consented to a ceiling.
/amountwith a higher price is409 STEAM_REAUTHORIZATION_REQUIREDwithcurrent_amount_usdandrequested_amount_usd; start a new Steam subscription at the new price and cancel this one. Decreases stage normally. - No card:
/payment-methodis409 NOT_A_CARD_SUBSCRIPTION. - No
wallet_only(409 WALLET_ONLY_SUBSCRIPTION). - Refunds of Steam-charged periods are not supported yet:
/refundon a period that was charged through Steam answers409 STEAM_REFUND_NOT_SUPPORTEDbefore 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
- Finalize is your call. Steam does not tell Invo the member authorised; your game must call
/steam/finalizeafter the overlay or checkout closes, and again if it returnednot_authorized. - 24 hours to authorise. An unfinalised subscription expires and must be re-created with a new key.
websessions needplayer_ipand the checkout URL must open top-level; it cannot be framed.- Only the primary key or the
steamchannel key may start a Steam subscription. - No trials, no
interval_countother than 1, nowallet_only, no card fields. - The price is a ceiling. Increases are refused; start a new Steam subscription. Decreases stage.
/payment-methodis refused on a Steam subscription.- Coins per period depend on the member’s country and are fixed at init. Show
amount_coinsfrom the init response, not price times 10. - 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. - A member can cancel from Steam. You learn it from
subscription.canceledwithcanceled_by: "steam"; access runs toaccess_until. - 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. - Replaying
/steam/initreturns the row’s current status, which may already beactive,canceledorexpired. - A second init while one is pending reuses it. Same terms: the pending row comes back with
pending_reuse: trueand the samesteam_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. - Match the Steam callback on
steam_order_id, never onsteam_transid. Store it before you return from init. - Refunds of Steam-charged periods are not supported yet.
/refundanswers409 STEAM_REFUND_NOT_SUPPORTEDon them; wallet-covered periods refund in coins. - Same
item_idon both rails, different from any one-off pack.409 ACTIVE_SUBSCRIPTION_EXISTSacross rails is the guard, not a bug. - Sandbox does not exercise Valve.
sandbox_auto_approved: truemeans the charge was simulated; do one real Steam sandbox authorisation before launch. See the Steam sandbox recipe. split.total_usdon Steam is not the price. It is what the coins are worth after Valve’s share and VAT; the price is the top-levelamount_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.