Peer Send, Stage by Stage

One player sends currency to another, addressed by phone number, inside your game or into a different one. Two humans, two sides: the sender initiates and approves in the sending game; the recipient collects in the receiving game, in the Invo app, or on a hosted page with nothing installed. Every call is shown as curl, JavaScript and Python. Base URLs, the status ladder and the actors are on the overview.

The map

SENDER SIDE (sending game)                                RECIPIENT SIDE (receiving game — may be the same game)
──────────────────────────────────────────────────        ─────────────────────────────────────────────────────────
1  POST /api/currency-sends/initiate-send   [game secret]
      -> transaction_id, verification_expires_at            (nothing yet)

2  POST /api/sdk/player-token               [game secret]
      -> the sender's player token (15 min)

3  the sender proves it is them — ONE factor:
      in-app passkey  ->  approve/webauthn/begin, then the assertion
      device grant    ->  begin, show the QR, poll to "approved"
      (guardian gate) ->  202 until the guardian answers, then a factor

4  POST /api/sdk/send/{id}/approve          [player token]      <-- THE CALL THAT MOVES MONEY
      { "device_code": "..." }  or  { "webauthn_assertion": {...} }
      -> { status: "approved", next: "pending_claim" }

5  webhook transfer.claim_pending (direction outbound)      webhook transfer.claim_pending (direction inbound)
   GET /api/currency-sends/{id}/status [game secret]        GET /api/transfers/inbound-pending [game secret]

                                                          6  the recipient collects, with their OWN proof:
                                                               a) GET /api/sdk/transfers/pending [their token]
                                                                  -> kind "receiving_confirm", flow "send"
                                                               b) mint THEIR token -> passkey, or a device grant with
                                                                  flow "send_receipt" ->
                                                                  POST /api/sdk/send/{id}/confirm-receipt
                                                               c) not in your game? the hosted /claim page from the link
                                                                  Invo sent them — email + passkey, nothing to build
                                                               (fallback only, no passkey possible: claim-currency)

   webhook transfer.sent                                     webhook transfer.received

7  not collected in the claim window -> refunded to the sender; transfer.claim_expired + transfer.refunded
   not approved in the approval window -> refunded to the sender; transfer.refunded

Step 4 is the one that moves money. Steps 3 and 4 are different calls. A device grant that polls to “approved” has done nothing to the transaction until your side calls /api/sdk/send/{id}/approve with that device code.

Helpers used by the raw examples

The SDK examples need only the client. The raw HTTP examples share these two helpers; nothing else is assumed.

Node 18+ (raw HTTP helper)
const BASE = process.env.INVO_BASE_URL;            // https://invo.network  |  https://sandbox.invo.network/sandbox
const GAME_SECRET = process.env.INVO_GAME_SECRET;  // server-side only

async function invo(method, path, headers, body) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: { "Content-Type": "application/json", ...headers },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  return { status: res.status, json };
}
const asGame   = { "X-Game-Secret-Key": GAME_SECRET };
const asPlayer = (token) => ({ Authorization: `Bearer ${token}` });
Python 3.9+ (raw HTTP helper)
import os, time, uuid, requests

BASE = os.environ["INVO_BASE_URL"]            # https://invo.network  |  https://sandbox.invo.network/sandbox
GAME_SECRET = os.environ["INVO_GAME_SECRET"]  # server-side only

def invo(method, path, headers=None, body=None):
    r = requests.request(method, BASE + path,
                         headers={"Content-Type": "application/json", **(headers or {})},
                         json=body, timeout=15)
    try:
        return r.status_code, r.json()
    except ValueError:
        return r.status_code, {}

as_game = {"X-Game-Secret-Key": GAME_SECRET}
def as_player(token): return {"Authorization": f"Bearer {token}"}

Stage 1. Initiate the send (your server)

Game secret. Reserves the amount from the sender’s available balance and opens the approval window. The recipient is addressed by phone number.

POST $BASE/api/currency-sends/initiate-send
X-Game-Secret-Key: <game secret>
Content-Type: application/json

{
  "client_request_id": "send-7f3a...",        // your idempotency key, unique per game
  "sender_player_name": "Ada",
  "sender_player_email": "ada@example.com",
  "sender_player_phone": "+15555550100",      // E.164
  "receiver_player_phone": "+15555550111",    // E.164 — the send is addressed to this number
  "receiving_game_id": "155963559928",        // your own game id for a same-game send
  "amount": "50.00",
  "receiver_player_email": "bo@example.com"   // recommended: lets the phone-share consent gate resolve
}
201 Created
{
  "status": "success",
  "transaction_id": "TXN_1757000000_AB12CD",
  "verification_method": "in_app",                          // see the note below
  "verification_expires_at": "2026-09-04T18:10:00+00:00",   // the approval window — read it, never assume
  "order_id": "ORD_1757000000_A1B2C3D4",       // string, not a number
  "new_balance": "950.00",                                  // sender's available balance after the reservation
  "currency_name": "Gold",
  "send_details": {
    "sending_game": "...", "receiving_game": "...", "receiving_game_id": "...",
    "currency": "Gold", "currency_id": 12, "amount_initiated": "50.00",
    "fees_preview": { "...": "..." },
    "receiver_phone": "+15555550111",
    "send_type": "cross_game",                              // or "peer_to_peer" for a same-game send
    "transfer_policy": { "...": "..." }
  },
  "verification_required": { "phone_number_masked": "*********0100", "pin_expires_in_minutes": 10 }
}

verification_method is a hint about the legacy fallback, not a choice you must obey. "in_app" means Invo saw an approval route for this sender and sent no fallback message. "sms" means the legacy PIN fallback was also sent because none was detected yet. Either way, the passkey and device-grant paths below are live and are what you should build. A first-time console sender can see "sms" and still approve by QR seconds later. verification_required.pin_expires_in_minutes is the same window as verification_expires_at, expressed in minutes and named after the legacy path.

202 instead of 201, the guardian gate. A sender on a minor account returns "status": "pending_guardian_approval" with a guardian_approval block (approval_id, state, expires_at, consent_channel, poll_endpoint, resend_endpoint). consent_channel is "email" when the guardian has a verified address, that is the preferred channel. The reservation is made; the send cannot be approved until the guardian answers. Stage 3c covers it.

Refusals to expect here

  • 400, missing or invalid field, or insufficient balance
  • 403 SOURCE_GAME_NOT_LIVE / TARGET_GAME_NOT_LIVE, both games must be live
  • 403 CURRENCY_SEND_POLICY_VIOLATION, the destination is not permitted by your transfer policy
  • 403 PASSKEY_RECOVERY_COOLDOWN, the sender recovered a passkey recently; nothing is reserved (Stage 8)
  • 403 GUARDIAN_REQUIRED, a minor account with no usable guardian on file
  • 409, duplicate client_request_id; PHONE_SHARE_APPROVAL_REQUIRED; STEAM_VALUE_NON_TRANSFERABLE; NON_STEAM_VALUE_INTO_STEAM_BLOCKED
  • 429, velocity or lockout, with retry_after where applicable; 503 flow_paused during a maintenance pause
curl
curl -sS -X POST "$BASE/api/currency-sends/initiate-send" \
  -H "X-Game-Secret-Key: $GAME_SECRET" -H "Content-Type: application/json" \
  -d '{
    "client_request_id": "send-'"$(uuidgen)"'",
    "sender_player_name": "Ada",
    "sender_player_email": "ada@example.com",
    "sender_player_phone": "+15555550100",
    "receiver_player_phone": "+15555550111",
    "receiver_player_email": "bo@example.com",
    "receiving_game_id": "155963559928",
    "amount": "50.00"
  }'
Node, @invonetwork/web-sdk/server
import { InvoServer } from "@invonetwork/web-sdk/server";
import { randomUUID } from "node:crypto";

const invoSdk = new InvoServer({ gameSecret: process.env.INVO_GAME_SECRET!, baseUrl: process.env.INVO_BASE_URL! });

const send = await invoSdk.initiateSend({
  clientRequestId: randomUUID(),
  senderPlayerName: "Ada", senderPlayerEmail: "ada@example.com", senderPlayerPhone: "+15555550100",
  receiverPlayerPhone: "+15555550111", receiverPlayerEmail: "bo@example.com",
  receivingGameId: 155963559928,
  amount: "50.00",
});
// send.transactionId · send.verificationExpiresAt
// send.guardianApproval is present on the 202 branch — see Stage 3c
Python, invonetwork
import os, uuid
from invonetwork import InvoServer

invo_sdk = InvoServer(game_secret=os.environ["INVO_GAME_SECRET"], base_url=os.environ["INVO_BASE_URL"])

send = invo_sdk.initiate_send(
    client_request_id=str(uuid.uuid4()),
    sender_player_name="Ada", sender_player_email="ada@example.com", sender_player_phone="+15555550100",
    receiver_player_phone="+15555550111", receiver_player_email="bo@example.com",
    receiving_game_id=155963559928,
    amount="50.00",
)
# send.transaction_id · send.verification_expires_at

Stage 2. Mint the sender’s player token (your server)

Game secret in, player token out. The token identifies this player in this game and is what every /api/sdk/* call authenticates with. It lives 15 minutes and there is no refresh, so mint it right before Stage 3 rather than at login. It can only be minted for a player who already exists in your game, initiate created or matched the sender, so mint after Stage 1.

POST $BASE/api/sdk/player-token
X-Game-Secret-Key: <game secret>

{ "player_email": "ada@example.com" }

200 OK
{ "token": "eyJ...", "expires_at": "2026-09-04T18:15:00+00:00", "identity_id": "9f2c..." }

401 INVALID_GAME_SECRET
403 TENANT_NOT_MIGRATED   — in-app verification is not switched on for your game yet; ask Invo
404 player_not_found
422 identity_unavailable
503 sdk_verification_disabled

Where the token lives depends on the platform. On a console or native desktop build your server holds it and runs Stages 3 and 4 on the player’s behalf. On the web the browser SDK holds it. It is not a secret in the way the game secret is, one player, fifteen minutes, but it does let its holder approve that player’s transactions, so keep it out of logs and URLs.

curl
PLAYER_TOKEN=$(curl -sS -X POST "$BASE/api/sdk/player-token" \
  -H "X-Game-Secret-Key: $GAME_SECRET" -H "Content-Type: application/json" \
  -d '{"player_email":"ada@example.com"}' | jq -r .token)
Node, @invonetwork/web-sdk/server
const { token: senderToken } = await invoSdk.mintPlayerToken({ playerEmail: "ada@example.com" });
Python, invonetwork
sender_token = invo_sdk.mint_player_token(player_email="ada@example.com").token

Stage 3. The sender proves it is them (one factor)

Two paths, chosen by what the client can do, not by what the game is. Each ends with something you pass to the approve call in Stage 4; neither moves money on its own.

ClientFactorWhat you send to approve
Desktop web, mobile web, mobile appPasskey in-client (3b)webauthn_assertion
Consoles, TVs, native Steam and desktop buildsDevice approval grant. QR (3a)device_code

3a. Device grant, consoles, TVs, native Steam and desktop clients

The client cannot invoke the platform authenticator, so the ceremony moves to the player’s phone on Invo’s own page. Four steps, all driven from your server with the sender’s token: begin → show → poll → approve. The behaviour of the hosted page itself (first-scan enrolment, the on-screen match code, recovery) is on Device Approval; this is the integrator’s sequence.

Step 1: begin

POST $BASE/api/sdk/approvals/device/begin
Authorization: Bearer <sender's player token>

{ "transaction_id": "TXN_1757000000_AB12CD", "flow": "send", "channel": "qr" }
   // flow MUST be "send" for a peer send's SENDER. channel is optional; "qr" is the default.

200 OK
{
  "device_code": "b1s4...",                    // secret; your server keeps it, never shows it
  "user_code": "K7QP-3MRD",                    // short code the player can read off the screen
  "verification_uri": "https://invo.network/device",
  "verification_uri_complete": "https://invo.network/device?user_code=K7QP-3MRD",   // render as the QR
  "expires_in": 600,                           // seconds — honour this, not a local timer
  "interval": 5,                               // minimum seconds between polls
  "channel": "qr"
}

Beginning again for the same transaction. If a grant for this transaction and this flow is still pending, a second begin supersedes it: the old code is closed and you get a fresh one. That is the right answer when your screen was lost, the player never finished on their phone, or the client crashed. If the grant is already approved and still inside its window, begin answers 409 DEVICE_APPROVAL_ALREADY_PENDING with expires_at, the approval already exists, so settle with the device code you hold rather than minting a second one. Once that window passes you may begin again.

The sender’s grant (flow: "send") and the recipient’s grant (flow: "send_receipt") share a transaction id and do not collide, they are tracked separately.

Step 2: show it

Render verification_uri_complete as a QR code and print user_code underneath for a player who would rather type it at verification_uri. Never display device_code, it is your server’s secret.

Step 3: poll

POST $BASE/api/sdk/approvals/device/poll
Authorization: Bearer <sender's player token>

{ "device_code": "b1s4..." }

200 OK   { "status": "approved", "transaction_id": "TXN_...", "flow": "send", "approved_at": "..." }
400      { "error": "authorization_pending", "interval": 5 }     keep polling
400      { "error": "slow_down", "interval": 5 }                 you polled too fast; back off
400      { "error": "expired_token" }                            the grant timed out; begin again
400      { "error": "access_denied" }                            the player declined; stop
400      { "error": "invalid_grant" }                            unknown code, or not this player's

The first time a player scans with a phone that has no Invo passkey, the phone asks your screen to vouch for it. While that is pending, the authorization_pending body carries an enrollment object with state: "awaiting_screen", a device_label and a match_code. Show “Set up Invo on this phone: <label>, code <match_code>?”, and send the answer to POST /api/sdk/approvals/device/confirm-enrollment with { "device_code", "decision": "approve" | "deny" } and the same player token. The console screen is the proof, so nothing is messaged to the player at all. The key is absent entirely for an already-enrolled phone.

Step 4: approve. Stage 4 below, and it is not optional.

status: "approved" means the player finished the ceremony. It does not mean the send has progressed. Go straight to Stage 4 with the same device_code.

curl: begin, poll, approve
# begin
BEGIN=$(curl -sS -X POST "$BASE/api/sdk/approvals/device/begin" \
  -H "Authorization: Bearer $PLAYER_TOKEN" -H "Content-Type: application/json" \
  -d "{\"transaction_id\":\"$TXN\",\"flow\":\"send\",\"channel\":\"qr\"}")
DEVICE_CODE=$(echo "$BEGIN" | jq -r .device_code)
INTERVAL=$(echo "$BEGIN" | jq -r .interval)
echo "$BEGIN" | jq -r '.verification_uri_complete, .user_code'   # render the first as a QR

# poll
while :; do
  P=$(curl -sS -X POST "$BASE/api/sdk/approvals/device/poll" \
    -H "Authorization: Bearer $PLAYER_TOKEN" -H "Content-Type: application/json" \
    -d "{\"device_code\":\"$DEVICE_CODE\"}")
  case "$(echo "$P" | jq -r '.status // .error')" in
    approved)              break ;;
    authorization_pending) sleep "$INTERVAL" ;;
    slow_down)             INTERVAL=$((INTERVAL + 5)); sleep "$INTERVAL" ;;
    *)                     echo "stopped: $P"; exit 1 ;;
  esac
done

# APPROVE — the step that moves money. Do not stop at "approved" above.
curl -sS -X POST "$BASE/api/sdk/send/$TXN/approve" \
  -H "Authorization: Bearer $PLAYER_TOKEN" -H "Content-Type: application/json" \
  -d "{\"device_code\":\"$DEVICE_CODE\"}"
Node, @invonetwork/web-sdk/server 3.7.0
import { InvoServer } from "@invonetwork/web-sdk/server";

const invoSdk = new InvoServer({ gameSecret: process.env.INVO_GAME_SECRET!, baseUrl: process.env.INVO_BASE_URL! });

// 1. BEGIN is always yours: your game draws the QR between begin and the first poll.
const grant = await invoSdk.beginDeviceApproval({
  playerToken: senderToken,
  transactionId: txn,
  flow: "send",
  channel: "qr",
});
showQr(grant.verificationUriComplete, grant.userCode);

// 2 + 3. completeDeviceApproval polls AND calls approveWithDeviceCode when the poll
//        says approved. One call, so stopping at the poll is not possible. It blocks
//        for as long as the player takes: run it from a worker or a job, not from a
//        request handler with a short timeout.
//        RECORD THE ATTEMPT FIRST. If the process dies between the settle call and its
//        response, that record plus getSendStatus is the only way to
//        learn whether the money moved.
await recordAttempt(txn, grant.deviceCode);

const outcome = await invoSdk.completeDeviceApproval({
  playerToken: senderToken,
  transactionId: txn,
  flow: "send",
  deviceCode: grant.deviceCode,
  interval: grant.interval,
  onEnrollment: (enrollment, respond) => {
    if (enrollment?.state === "awaiting_screen") {
      showPrompt(
        "Set up Invo on " + enrollment.deviceLabel + "? Code " + enrollment.matchCode,
        (ok) => respond(ok ? "approve" : "deny"),
      );
    } else {
      hidePrompt();
    }
  },
});

// outcome.status: "approved" | "denied" | "expired" | "aborted"
if (outcome.status !== "approved") return handleDeclineOrExpiry(outcome);

const settlement = outcome.settlement!;   // the money step has already run
// settlement.status "approved" + settlement.next "pending_claim" on success.
// settlement.status "not_pending": the backend answered TRANSACTION_NOT_PENDING.
//   settlement.alreadySettled true  -> an earlier attempt already landed. Safe.
//   settlement.alreadySettled false -> DOES NOT mean "start again". Read
//   settlement.currentStatus, or fetch the transaction, before telling the player anything.
// settlement.holdReason: a 202 hold (GUARDIAN_APPROVAL_PENDING, RISK_HOLD, ...). The
//   money is held, not refused.
Node, driving the poll yourself (3.7.0)
// Use the three calls directly when your server cannot block. Keep deviceCode in your
// own store and poll from a scheduler.
const poll = await invoSdk.pollDeviceApproval({ playerToken: senderToken, deviceCode });
// poll.status: "approved" | "authorization_pending" | "slow_down" | "expired_token" | "access_denied"
//   invalid_grant is THROWN as an InvoError, not returned.

if (poll.status === "authorization_pending" && poll.enrollment?.state === "awaiting_screen") {
  const ok = await askOnScreen(
    "Set up Invo on " + poll.enrollment.deviceLabel + "? Code " + poll.enrollment.matchCode);
  await invoSdk.confirmDeviceEnrollment({
    playerToken: senderToken, deviceCode, decision: ok ? "approve" : "deny",
  });
}

if (poll.status === "approved") {
  // THE MONEY STEP. Polling to "approved" and stopping here moves nothing.
  const settlement = await invoSdk.approveWithDeviceCode({
    playerToken: senderToken, transactionId: txn, flow: "send", deviceCode,
  });
}
Node, raw HTTP (works today, any SDK version)
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));

// begin
const { json: grant } = await invo("POST", "/api/sdk/approvals/device/begin", asPlayer(senderToken), {
  transaction_id: txn, flow: "send", channel: "qr",
});
showQr(grant.verification_uri_complete, grant.user_code);

// poll
let interval = grant.interval;
for (;;) {
  const { json: p } = await invo("POST", "/api/sdk/approvals/device/poll", asPlayer(senderToken), {
    device_code: grant.device_code,
  });
  if (p.status === "approved") break;
  if (p.error === "authorization_pending") {
    if (p.enrollment?.state === "awaiting_screen") {
      const ok = await askOnScreen(`Set up Invo on ${p.enrollment.device_label}? Code ${p.enrollment.match_code}`);
      await invo("POST", "/api/sdk/approvals/device/confirm-enrollment", asPlayer(senderToken), {
        device_code: grant.device_code, decision: ok ? "approve" : "deny",
      });
    }
    await sleep(interval);
    continue;
  }
  if (p.error === "slow_down") { interval += 5; await sleep(interval); continue; }
  throw new Error(`device approval stopped: ${p.error}`);   // expired_token | access_denied | invalid_grant
}

// APPROVE — the poll proved WHO. This is what moves the money.
const { status, json: approved } = await invo("POST", `/api/sdk/send/${txn}/approve`,
  asPlayer(senderToken), { device_code: grant.device_code });
// 200 -> { status: "approved", next: "pending_claim", transaction_id }
Python, invonetwork 3.6.0
import os
from invonetwork import InvoServer

invo_sdk = InvoServer(game_secret=os.environ["INVO_GAME_SECRET"], base_url=os.environ["INVO_BASE_URL"])

# 1. BEGIN is always yours: your game draws the QR between begin and the first poll.
grant = invo_sdk.begin_device_approval(
    player_token=sender_token,
    transaction_id=txn,
    flow="send",
    channel="qr",
)
show_qr(grant.verification_uri_complete, grant.user_code)

# 2 + 3. complete_device_approval polls AND calls approve_with_device_code when the poll
#        says approved. One call, so stopping at the poll is not possible. It blocks for
#        as long as the player takes: run it from a worker, not a request handler.
#        RECORD THE ATTEMPT FIRST. If the process dies between the settle call and its
#        response, that record plus get_send_status is the only way to learn
#        whether the money moved.
record_attempt(txn, grant.device_code)

def on_enrollment(enrollment, respond):
    if enrollment and enrollment.state == "awaiting_screen":
        ok = ask_on_screen(
            f"Set up Invo on {enrollment.device_label}? Code {enrollment.match_code}")
        respond("approve" if ok else "deny")

outcome = invo_sdk.complete_device_approval(
    player_token=sender_token,
    transaction_id=txn,
    flow="send",
    device_code=grant.device_code,
    interval=grant.interval,
    on_enrollment=on_enrollment,
)

# outcome.status: "approved" | "denied" | "expired" | "aborted"
if outcome.status != "approved":
    return handle_decline_or_expiry(outcome)

settlement = outcome.settlement   # the money step has already run
# settlement.status "approved" + settlement.next "pending_claim" on success.
# settlement.status "not_pending": the backend answered TRANSACTION_NOT_PENDING.
#   settlement.already_settled true  -> an earlier attempt already landed. Safe.
#   settlement.already_settled false -> DOES NOT mean "start again". Read
#   settlement.current_status, or fetch the transaction, before telling the player anything.
# settlement.hold_reason: a 202 hold (GUARDIAN_APPROVAL_PENDING, RISK_HOLD, ...). The
#   money is held, not refused.
Python, driving the poll yourself (3.6.0)
# Use the three calls directly when your server cannot block.
poll = invo_sdk.poll_device_approval(player_token=sender_token, device_code=device_code)
# poll.status: "approved" | "authorization_pending" | "slow_down" | "expired_token" | "access_denied"
#   invalid_grant is RAISED as an InvoError, not returned.

if poll.status == "authorization_pending" and poll.enrollment and poll.enrollment.state == "awaiting_screen":
    ok = ask_on_screen(
        f"Set up Invo on {poll.enrollment.device_label}? Code {poll.enrollment.match_code}")
    invo_sdk.confirm_device_enrollment(
        player_token=sender_token, device_code=device_code,
        decision="approve" if ok else "deny",
    )

if poll.status == "approved":
    # THE MONEY STEP. Polling to "approved" and stopping here moves nothing.
    settlement = invo_sdk.approve_with_device_code(
        player_token=sender_token, transaction_id=txn, flow="send",
        device_code=device_code,
    )
Python, raw HTTP (works today, any SDK version)
# begin
_, grant = invo("POST", "/api/sdk/approvals/device/begin", as_player(sender_token),
                {"transaction_id": txn, "flow": "send", "channel": "qr"})
show_qr(grant["verification_uri_complete"], grant["user_code"])

# poll
interval = grant["interval"]
while True:
    _, p = invo("POST", "/api/sdk/approvals/device/poll", as_player(sender_token),
                {"device_code": grant["device_code"]})
    if p.get("status") == "approved":
        break
    err = p.get("error")
    if err == "authorization_pending":
        enrol = p.get("enrollment")
        if enrol and enrol.get("state") == "awaiting_screen":
            ok = ask_on_screen(f"Set up Invo on {enrol['device_label']}? Code {enrol['match_code']}")
            invo("POST", "/api/sdk/approvals/device/confirm-enrollment", as_player(sender_token),
                 {"device_code": grant["device_code"], "decision": "approve" if ok else "deny"})
        time.sleep(interval)
        continue
    if err == "slow_down":
        interval += 5
        time.sleep(interval)
        continue
    raise RuntimeError(f"device approval stopped: {err}")   # expired_token | access_denied | invalid_grant

# APPROVE — the poll proved WHO. This is what moves the money.
status, approved = invo("POST", f"/api/sdk/send/{txn}/approve", as_player(sender_token),
                        {"device_code": grant["device_code"]})
# 200 -> {"status": "approved", "next": "pending_claim", "transaction_id": ...}

3b. Passkey in-client, desktop web, mobile web, mobile app

Where the client can invoke the platform authenticator, run the ceremony there. Two calls: fetch a transaction-bound challenge, then send the assertion straight to approve. Both use the sender’s player token, and the passkey must have been enrolled under your game’s verified domain. See Platform Step-Up.

Check this before you build the in-client path. The in-client ceremony runs on your own verified domain, and new partner domains are no longer accepted. For most titles the normal answer is 403 WEBAUTHN_NOT_ENABLED_FOR_TENANT with reason: "no_verified_domain" and hosted_flow.partner_rp_frozen: true. That is not a setup gap you can close, and it is not a malfunction: the body points you at the device approval grant, which runs the same passkey ceremony on Invo's own domain and settles with device_code. Unless you already hold a verified domain, build the device grant.

POST $BASE/api/sdk/send/{transaction_id}/approve/webauthn/begin
Authorization: Bearer <sender's player token>

200 OK -> PublicKeyCredentialRequestOptions, bound to this transaction

400 WEBAUTHN_NO_CREDENTIAL          this player has not enrolled a passkey yet. Enrol one, or use 3a
403 WEBAUTHN_NOT_ENABLED_FOR_TENANT your game has no verified passkey domain. This is the NORMAL answer
                                    for a new title (partner domains are frozen). Use 3a instead.
403 WEBAUTHN_TENANT_MISMATCH        the token's game is not the sending game

# then, with the assertion the browser produced:
POST $BASE/api/sdk/send/{transaction_id}/approve
Authorization: Bearer <sender's player token>
{ "webauthn_assertion": { "id": "...", "response": { ... } } }
Browser, @invonetwork/web-sdk
import { InvoClient } from "@invonetwork/web-sdk";

const client = new InvoClient({ token: senderToken, baseUrl });

// runs the challenge, the passkey ceremony AND the approve call in one step
const approved = await client.approveSend(transactionId);
// approved.status === "approved", approved.next === "pending_claim"

In a browser with no passkey of its own? The web SDK can run the whole device grant from the page with the player token it already holds: approveHosted({ transactionId, flow }) opens the Invo approval page in a popup, shows the match-code prompt, and calls the settle endpoint for you. It is the browser equivalent of everything in this section, and it ends at the same money call.

Call it from a click handler, because the popup is opened synchronously before any network round trip. The page’s postMessage is a wake-up that triggers an immediate poll, never the result.

Browser, @invonetwork/web-sdk, approveHosted()
// FROM A CLICK HANDLER. The popup must open synchronously.
const result = await client.approveHosted({
  transactionId: txn,
  flow: "send",
  // renderPrompt: false + onEnrollment if you want to draw the match-code prompt yourself
  // popupBlocked: "resolve" (default) resolves "blocked" so you can retry from a button;
  //   "redirect" navigates THIS tab instead, and your page unloads.
});

// result.status: "approved" | "denied" | "expired" | "blocked" | "redirected" | "aborted"
// On "approved", result carries the settle response: the money call already ran.
// On "redirected", your page is gone: the device_approval.approved webhook is your signal.
Browser, raw HTTP + WebAuthn
// 1. transaction-bound challenge
const optsRes = await fetch(`${BASE}/api/sdk/send/${txn}/approve/webauthn/begin`, {
  method: "POST", headers: { Authorization: `Bearer ${senderToken}` },
});
const options = await optsRes.json();

// 2. the ceremony (decode the base64url fields per the WebAuthn spec first)
const credential = await navigator.credentials.get({ publicKey: decodeOptions(options) });

// 3. THE CALL THAT MOVES MONEY
const res = await fetch(`${BASE}/api/sdk/send/${txn}/approve`, {
  method: "POST",
  headers: { Authorization: `Bearer ${senderToken}`, "Content-Type": "application/json" },
  body: JSON.stringify({ webauthn_assertion: encodeCredential(credential) }),
});
Browser, the base64url helpers the samples above use
// The challenge and credential ids travel as base64url strings; the WebAuthn API wants
// ArrayBuffers, and hands them back as ArrayBuffers. These two are the whole conversion.
const b64uToBuf = (s) =>
  Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)).buffer;

const bufToB64u = (b) =>
  btoa(String.fromCharCode(...new Uint8Array(b)))
    .replace(/+/g, "-").replace(///g, "_").replace(/=+$/, "");

function decodeOptions(options) {
  return {
    ...options,
    challenge: b64uToBuf(options.challenge),
    allowCredentials: (options.allowCredentials ?? []).map((c) => ({
      ...c, id: b64uToBuf(c.id),
    })),
  };
}

function encodeCredential(cred) {
  return {
    id: cred.id,
    rawId: bufToB64u(cred.rawId),
    type: cred.type,
    response: {
      clientDataJSON:    bufToB64u(cred.response.clientDataJSON),
      authenticatorData: bufToB64u(cred.response.authenticatorData),
      signature:         bufToB64u(cred.response.signature),
      userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null,
    },
  };
}

There is no Python example for this factor: the ceremony happens in the browser or the mobile client, never on your server. Your Python backend’s job is Stage 1 and Stage 2, and reading status in Stage 5.

3c. Guardian approval, the 202 branch

A minor account needs a guardian to agree before any factor counts. Initiate answers 202, and until the guardian answers, approve keeps answering 202 GUARDIAN_APPROVAL_PENDING. The approval goes to the guardian by email when they have a verified address; a text is a fallback you request explicitly through resend_endpoint.

# poll (game secret)
GET $BASE/api/transactions/{transaction_id}/approval-status
X-Game-Secret-Key: <game secret>
-> { "status": "ok", "approval": { "state": "pending" | "approved" | "rejected" | "expired", ... } }

# once state is "approved", produce a factor (3a or 3b) and call Stage 4 as normal.

# approve's answers while the gate is closed:
202 GUARDIAN_APPROVAL_PENDING              still waiting — poll, do not retry in a loop
410 GUARDIAN_APPROVAL_REJECTED             the guardian said no; stop
410 GUARDIAN_APPROVAL_EXPIRED              nobody answered in time; stop
503 GUARDIAN_APPROVAL_CHECK_UNAVAILABLE    transient; retry shortly

See Guardian Approval for the resend endpoint and the full state machine.

Stage 4. Approve. The call that moves money.

A factor is not a settlement. Whatever Stage 3 produced, an approved device grant, a verified passkey assertion, proves who. It does not advance the transaction. This call does, and only this call. It re-checks the guardian gate, the recovery hold and the transaction’s own state before it moves anything, which is exactly why the factor cannot be allowed to settle on its own.

POST $BASE/api/sdk/send/{transaction_id}/approve
Authorization: Bearer <the SENDER's player token>       <-- never the game secret
Content-Type: application/json

{ "device_code": "b1s4..." }                            // the grant you just polled to "approved"
// or { "webauthn_assertion": { ... } }                 // the in-client passkey
// or { "device_fingerprint": "...", "device_signal": { ... }, "biometric_verified": true }

200 OK
{ "status": "approved", "next": "pending_claim", "transaction_id": "TXN_..." }

After a 200 the reservation is committed, the transaction is pending_claim, the claim window is open, and transfer.claim_pending has been queued to both games. Nothing more is required of the sender.

What each refusal means

Status & codeMeaningDo this
400 TRANSACTION_NOT_PENDINGNot a failure. The transaction already moved on, almost always because your first approve succeeded and this is a retry. The body carries current_status.Read current_status and continue from there. Do not refund, alert, or start again.
400 DEVICE_APPROVAL_NOT_APPROVEDThe device code is unknown, belongs to another identity, is for another transaction, is for another flow, or was never approved. One answer covers all of them deliberately.Check flow was "send" and the transaction id matches. Otherwise begin a fresh grant.
400 INVALID_INPUTNo factor in the body, or a malformed one.Send exactly one of the three factor shapes.
401 SDK_TOKEN_MALFORMEDThe token carries no identity, or is not a player token.Re-mint the sender’s token. A 401 after 15 minutes is expected, tokens do not refresh.
403 not_send_senderThe token’s player is not this send’s sender.Mint the token for the sender’s email, in the sending game.
403 PASSKEY_RECOVERY_COOLDOWNThe sender recovered a passkey recently; money out is held. retry_after is an absolute time, retry_after_seconds the countdown.Show the time and stop. Do not retry inside the hold. See Stage 8.
202 / 410 guardian codesThe guardian gate is open, closed or lapsed. See 3c.202: poll and retry after approval. 410: stop.
400 SEND_APPROVE_FAILEDOur fault, and ambiguous. It does not mean nothing moved: the money can already have moved before the error was built. Carries an error_ref.Never retry, never refund, never re-initiate. Read the transaction status, or wait for the claim-pending webhook, to find out what actually happened. Then quote the error_ref to Invo support.
503 sdk_verification_disabledIn-app verification is not switched on for the environment.Contact Invo.
Node, handling the repeat call properly
const { status, json } = await invo("POST", `/api/sdk/send/${txn}/approve`,
  asPlayer(senderToken), { device_code: deviceCode });

// Some refusals carry "code", others "error_code", a few only "error".
// Read all three or you fall through on every guardian, risk and phone-share hold.
const code = json.code ?? json.error_code ?? json.error;

if (status === 200) {
  markApproved(txn);                       // json.next === "pending_claim"
} else if (code === "TRANSACTION_NOT_PENDING") {
  // NOT an error. Someone (probably you) already approved it.
  reconcile(txn, json.current_status);     // e.g. "pending_claim" or "completed"
} else if (code === "SEND_APPROVE_FAILED") {
  // AMBIGUOUS: the money may already have moved. Never retry, never refund,
  // never re-initiate. Read the status first, then raise the error_ref.
  reconcileThenReport(txn, json.error_ref);
} else {
  handleRefusal(status, json);
}
Python, handling the repeat call properly
status, body = invo("POST", f"/api/sdk/send/{txn}/approve",
                    as_player(sender_token), {"device_code": device_code})

# Some refusals carry "code", others "error_code", a few only "error".
# Read all three or you fall through on every guardian, risk and phone-share hold.
code = body.get("code") or body.get("error_code") or body.get("error")

if status == 200:
    mark_approved(txn)                          # body["next"] == "pending_claim"
elif code == "TRANSACTION_NOT_PENDING":
    # NOT an error. Already approved: read the status and move on.
    reconcile(txn, body["current_status"])
elif code == "SEND_APPROVE_FAILED":
    # AMBIGUOUS: the money may already have moved. Never retry, never refund,
    # never re-initiate. Read the status first, then raise the error_ref.
    reconcile_then_report(txn, error_ref=body.get("error_ref"))
else:
    handle_refusal(status, body)

Stage 5. Knowing it moved

Two signals, and you should wire both: webhooks for your ledger, a status read for “what is this one doing right now”.

GET $BASE/api/currency-sends/{transaction_id}/status
X-Game-Secret-Key: <game secret of either game>

200 OK
{
  "status": "success",
  "transaction_id": "TXN_...",
  "transaction_type": "currency_send",
  "transaction_status": "pending_claim",
  "verification_state": "approved",          // awaiting | approved | completed | expired | failed
  "amount": "50.00", "net_amount": "45.00", "fee_amount": "5.00",
  "sending_game_id": "...", "sending_game_name": "...",
  "receiving_game_id": "...", "receiving_game_name": "...",
  "currency_id": 12, "currency_name": "Gold",
  "order_id": "ORD_1757000000_A1B2C3D4",     // string, not a number
  "claim_info": { "claim_code_expires_at": "2026-09-05T18:10:00+00:00" }
}

The receiving game gets two extra fields on this response. to_phone and to_identity_id, so it can attribute the inbound send to one of its own players. to_identity_id is null when the phone matches more than one of your players.

EventYou receive it asMeans
device_approval.approvedThe game that began the grantA device grant settled. Call approve now, this is a faster cue than the next poll, not a substitute for the approve call.
transfer.claim_pendingSending game (direction: outbound) and receiving game (inbound)Stage 4 succeeded. The receiving game should now surface “you have something to collect”.
transfer.sentSending gameCollected. The sending side of completion.
transfer.receivedReceiving gameCredited. On the send rail: amount_received, currency_name, new_balance, flow: "currency_send", and confirmed_via when collected through an Invo surface. No gross_amount and no fee_breakdown on this rail, those are transfer-rail only.
curl
curl -sS "$BASE/api/currency-sends/$TXN/status" \
  -H "X-Game-Secret-Key: $GAME_SECRET" | jq '{transaction_status, verification_state, claim_info}'
Node, raw HTTP
const { json: s } = await invo("GET", `/api/currency-sends/${txn}/status`, asGame);
// s.verification_state: "awaiting" | "approved" | "completed" | "expired" | "failed"
Python, raw HTTP
_, s = invo("GET", f"/api/currency-sends/{txn}/status", as_game)
# s["verification_state"]: "awaiting" | "approved" | "completed" | "expired" | "failed"

Stage 6. The recipient collects

Receiving is exactly as strong as sending

The recipient proves it is her, on a device that is hers, with a passkey, the same class of proof the sender gave. A code in a message is bearer value: it belongs to whoever reads the message first. That is why typing a code is no longer how you collect in game, and why the primary route below is the recipient’s own factor.

6a. Show the recipient what is waiting for them

The recipient’s client asks, with the recipient’s player token, what needs their action. This is the list to build your “you have something to collect” screen from.

GET $BASE/api/sdk/transfers/pending
Authorization: Bearer <the RECIPIENT's player token, minted by the RECEIVING game>

200 OK
{
  "pending": [
    {
      "transfer_id": "TXN_1757000000_AB12CD",
      "kind": "receiving_confirm",       // something to COLLECT  ("identity_gate" = something to APPROVE)
      "flow": "send",                    // "send" -> /api/sdk/send/{id}/confirm-receipt
                                         // "transfer" -> /api/sdk/transfers/{id}/confirm-receipt
      "amount": "45.00",                 // what the recipient receives, net
      "currency": "Gold",
      "counterparty_game": "Ship Busters",
      "expires_at": "2026-09-05T18:10:00+00:00",
      "step_up_required": false,
      "held": false,
      "hold_reason": null
    }
  ]
}

Branch on flow, not on kind. receiving_confirm covers both rails: a peer send arrives with flow: "send" and a cross-game transfer with flow: "transfer". An integrator who read receiving_confirm as “transfers only” shipped a collect screen with no button for sends. Use flow to pick the endpoint and nothing else.

Two server-side signals complement it, for the receiving game rather than the player:

# the webhook — you are told, you do not ask
transfer.claim_pending   direction: "inbound"   with to_phone and to_identity_id

# enumerate what is waiting for one of your players, on demand (game secret)
GET $BASE/api/transfers/inbound-pending?player_phone=%2B15555550111
X-Game-Secret-Key: <RECEIVING game secret>
-> { "inbound_pending": [ { "transaction_id", "flow": "send", "amount", "net_amount",
                            "source_game_id", "source_game", "to_phone", "to_identity_id",
                            "created_at", "claim_code_expires_at" } ] }
   # player_email works too. The claim code is never returned here.

6b. Collect in your game: confirm-receipt

The primary route. The recipient is signed into the receiving game, so they prove who they are directly and the value lands. Mint their token with the receiving game’s secret, produce a factor, and settle. Same two-step shape as the sender’s side, one flow value along: a device grant for this side uses flow: "send_receipt".

POST $BASE/api/sdk/send/{transaction_id}/confirm-receipt
Authorization: Bearer <the RECIPIENT's player token, minted by the RECEIVING game>

{ "device_code": "..." }                       // from a grant begun with flow "send_receipt"
// or { "webauthn_assertion": { ... } }        // challenge first from
//    POST /api/sdk/send/{id}/confirm-receipt/webauthn/begin

200 OK
{ "status": "completed", "transaction_id": "TXN_...", "amount_received": "45.00" }
RefusalMeaningDo this
409 receiver_not_enrolled_use_claim_codeThis person has no account in your game yet, so there is nobody to credit.Use the hosted claim page (6c), or the claim-code fallback below, both create the account as part of collecting.
403 not_intended_receiverThe token’s player is not the addressed recipient, or their phone does not match the number the send was addressed to.Check you minted the token in the receiving game, for the addressed player.
409 PHONE_SHARE_APPROVAL_REQUIREDThis phone number is already tied to a different Invo identity; that person must consent before value lands here.Follow the consent flow the body describes, then retry. Consent is requested by email where one is verified.
400 TRANSACTION_NOT_PENDINGAlready collected, or the claim window closed. current_status says which.Read the status. If completed, you are done.
400 CONFIRM_RECEIPT_FAILEDOur fault, and ambiguous. It does not mean nothing moved: the money can already have moved before the error was built. Carries an error_ref.Never retry, never refund. Read the status first, then quote the error_ref to Invo support.
curl, recipient side, device grant
# the RECEIVING game mints the RECIPIENT's token
RECIPIENT_TOKEN=$(curl -sS -X POST "$BASE/api/sdk/player-token" \
  -H "X-Game-Secret-Key: $RECEIVING_GAME_SECRET" -H "Content-Type: application/json" \
  -d '{"player_email":"bo@example.com"}' | jq -r .token)

# begin a grant on the RECEIPT flow
curl -sS -X POST "$BASE/api/sdk/approvals/device/begin" \
  -H "Authorization: Bearer $RECIPIENT_TOKEN" -H "Content-Type: application/json" \
  -d "{\"transaction_id\":\"$TXN\",\"flow\":\"send_receipt\"}"

# ... show the QR, poll to "approved" exactly as in Stage 3a ...

# THE CALL THAT COLLECTS
curl -sS -X POST "$BASE/api/sdk/send/$TXN/confirm-receipt" \
  -H "Authorization: Bearer $RECIPIENT_TOKEN" -H "Content-Type: application/json" \
  -d "{\"device_code\":\"$DEVICE_CODE\"}"
Node, the receiving game's server (raw HTTP)
const receiving = { "X-Game-Secret-Key": process.env.RECEIVING_GAME_SECRET };

const { json: tok } = await invo("POST", "/api/sdk/player-token", receiving, { player_email: "bo@example.com" });

const { json: grant } = await invo("POST", "/api/sdk/approvals/device/begin", asPlayer(tok.token), {
  transaction_id: txn, flow: "send_receipt",          // note the flow
});
// ... show QR, poll to approved (identical loop to Stage 3a) ...

const { status, json } = await invo("POST", `/api/sdk/send/${txn}/confirm-receipt`,
  asPlayer(tok.token), { device_code: grant.device_code });
// 200 -> { status: "completed", amount_received: "45.00" }
// 409 receiver_not_enrolled_use_claim_code -> hosted claim page (6c), or the claim-code fallback
Python, the receiving game's server (raw HTTP)
receiving = {"X-Game-Secret-Key": os.environ["RECEIVING_GAME_SECRET"]}

_, tok = invo("POST", "/api/sdk/player-token", receiving, {"player_email": "bo@example.com"})

_, grant = invo("POST", "/api/sdk/approvals/device/begin", as_player(tok["token"]),
                {"transaction_id": txn, "flow": "send_receipt"})   # note the flow
# ... show QR, poll to approved (identical loop to Stage 3a) ...

status, body = invo("POST", f"/api/sdk/send/{txn}/confirm-receipt",
                    as_player(tok["token"]), {"device_code": grant["device_code"]})
# 200 -> {"status": "completed", "amount_received": "45.00"}
# 409 receiver_not_enrolled_use_claim_code -> hosted claim page (6c), or the claim-code fallback
Browser, @invonetwork/web-sdk (recipient signed in on the web)
const client = new InvoClient({ token: recipientToken, baseUrl });
await client.confirmReceiptSend(transactionId);   // challenge + passkey + confirm-receipt in one

6c. For a recipient who is not in your game: the hosted claim page

A peer send is addressed to a phone number, and the person behind that number may not have your game at all. For them, Invo sends a notification with a link to a hosted page at https://invo.network/claim. The page takes their email and mints them a passkey, then collects, it is the same class of proof as 6b, not a code box. You build nothing for this.

This is the one place a text message is still sent, and it is deliberate. A phone number is all the sender gave us: there is no address to email and no app to notify. If the recipient is reachable in-app (the receiving game is on the Invo rail and the recipient has an Invo wallet), the text is suppressed and they collect through 6b instead. Everywhere else in these flows, notification is in-app or by email.

Fallback only: collecting with the claim code

This is not how a recipient collects in your game. In-game claim-code entry was removed deliberately: a code sitting in a message is bearer value, and the person who reads the message first is not necessarily the person the money was meant for. Do not build a “enter your code” screen.

The endpoint remains for the one case the stronger routes cannot serve: a recipient who has no Invo passkey and cannot get one, which is also what 409 receiver_not_enrolled_use_claim_code is pointing you at, because this route creates the receiving player as part of collecting.

POST $BASE/api/currency-sends/claim-currency
X-Game-Secret-Key: <RECEIVING game secret>

{
  "claim_code": "KJMRS-47281",
  "receiver_player_name": "Bo",
  "receiver_player_email": "bo@example.com",
  "receiver_player_phone": "+15555550111"      // must match the number the send was addressed to
}

200 OK -> { "status": "success", "transaction_id", "new_balance", "currency_name",
            "send_details": { "amount_received", ... }, "completion_time", "order_id" }

400 WRONG_CLAIM_ENDPOINT   this code belongs to a transfer — use /api/transfers/claim-transfer
Node, @invonetwork/web-sdk/server
const receivingSdk = new InvoServer({ gameSecret: process.env.RECEIVING_GAME_SECRET!, baseUrl });
await receivingSdk.claimCurrency({
  claimCode: "KJMRS-47281",
  receiverPlayerName: "Bo", receiverPlayerEmail: "bo@example.com", receiverPlayerPhone: "+15555550111",
});
Python, invonetwork
receiving_sdk = InvoServer(game_secret=os.environ["RECEIVING_GAME_SECRET"], base_url=BASE)
receiving_sdk.claim_currency(
    claim_code="KJMRS-47281",
    receiver_player_name="Bo", receiver_player_email="bo@example.com",
    receiver_player_phone="+15555550111",
)

The sender’s resend

“They say they never got it.” The sender can re-deliver the same still-valid claim code to the recipient. Nothing new is minted, the identical message goes out again, so support can read the two side by side. Authenticated with the sender’s player token.

POST $BASE/api/sdk/send/{transaction_id}/resend-claim
Authorization: Bearer <the SENDER's player token>       (no body)

200 { "status": "resent", "transaction_id": "TXN_...", "retry_after": 30 }

429 RESEND_COOLDOWN          too soon; retry_after seconds
400 NOT_CLAIMABLE            this send is not awaiting a claim (current status in the message)
400 CLAIM_EXPIRED            the claim window closed; start a new send
400 NO_RECEIVER_PHONE        nothing to deliver to
409 CLAIM_SMS_SUPPRESSED     this recipient collects in-app; no message is sent for this send
403 not_send_sender          the token is not the sender's
curl
curl -sS -X POST "$BASE/api/sdk/send/$TXN/resend-claim" -H "Authorization: Bearer $PLAYER_TOKEN"
Node, raw HTTP
const { status, json } = await invo("POST", `/api/sdk/send/${txn}/resend-claim`, asPlayer(senderToken));
if (status === 429) waitThen(json.retry_after);
Python, raw HTTP
status, body = invo("POST", f"/api/sdk/send/{txn}/resend-claim", as_player(sender_token))
if status == 429:
    wait_then(body["retry_after"])

Stage 7. Expiry and refunds

Nothing strands. A send that is not approved, or is approved and not collected, is swept back to the sender by Invo. You do not call anything to make that happen, you listen for it.

Which window lapsedEnds up asEvents you receive
The approval window (verification_expires_at), nobody approvedexpired_pin_verification, reservation released to the sendertransfer.refunded with reason: "pin_expired", to the sending game only. refunded_amount is the gross the sender committed, which is the whole reservation: fees are only ever taken at collect, so an expiry returns the full amount and nothing is kept.
The claim window (claim_code_expires_at), approved but never collectedexpired_claim, funds returned to the sendertransfer.claim_expired to both games, plus transfer.refunded with reason: "claim_expired" to the sending game

The sweep runs on a short cycle rather than to the second, so expect the refund shortly after the deadline. Do not compute expiry yourself and mark a transaction dead: it is not dead until transfer.refunded arrives or the status endpoint says so. A send that expires is finished, start a new one from Stage 1 rather than trying to revive it.

Stage 8. The recovery hold

If a player self-recovers a lost passkey, money leaving their account is paused for 24 hours. This is protection against someone who has taken over the phone number, so it refuses every factor, not just the one that was recovered.

403 Forbidden        (from BOTH /initiate-send and /api/sdk/send/{id}/approve)
{
  "status": "error",
  "error": "This account recently recovered a passkey; money transfers are paused for 24 hours.",
  "code": "PASSKEY_RECOVERY_COOLDOWN",
  "retry_after": "2026-09-05T18:04:00+00:00",     // absolute — show this
  "retry_after_seconds": 82800                    // the same thing, for a backoff
}

At initiate, nothing is reserved. At approve, the reservation stands and will be swept back when the approval window lapses. Collecting is unaffected, a held player can still receive and claim. Show the time from retry_after and stop; retrying inside the hold just repeats the 403.

Legacy fallback: the SMS PIN

Before passkeys and device grants, a send was approved by texting the sender a numeric PIN and having them type it back. That path still exists for partners who have not moved yet: POST /api/currency-sends/verify-sms with the game secret, transaction_id and sms_pin. It reaches the same pending_claim state.

It is the legacy path and it is being retired. Do not build a new integration on it. Text messaging is the most expensive and least secure channel available to us, and every other part of these flows has already moved off it, notifications go in-app, consent goes by email, and the device page proves a new phone with a code shown on your own screen rather than a message. The only message still sent as a matter of course is the claim link to a recipient we can reach no other way (Stage 6c).

If verification_method came back as "sms", that tells you a fallback PIN was sent, it does not tell you to use it. Show the passkey or QR path anyway; both work on that transaction. See Approve a Send for the legacy endpoint’s full contract.

Common mistakes on a send

  1. Polling to “approved” and stopping. The most expensive mistake there is, because it looks like success. Stage 3 ends at a factor; Stage 4 is a separate call.
  2. Using the game secret on an /api/sdk/* call. Only player-token takes it. Everything else takes that player’s bearer token.
  3. Approving with the wrong player’s token. The sender’s token for approve; the recipient’s token, minted by the receiving game, for confirm-receipt.
  4. Beginning the grant with the wrong flow. send for the sender, send_receipt for the recipient. A mismatch surfaces at approve as DEVICE_APPROVAL_NOT_APPROVED, which reads like the player never approved at all.
  5. Treating TRANSACTION_NOT_PENDING as a failure. It means already done. Read current_status.
  6. Expiring the grant on a local timer. Honour expires_in and interval from begin, and the transaction windows from the responses that carry them.
  7. Retrying SEND_APPROVE_FAILED. It is never the caller’s fault and never gets better on a retry. Capture error_ref and send it to us.