Google Play External Content Links reporting with FastSpring

Report FastSpring transactions to Google Play's External Content Links program to keep your Unity Steer Safe integration compliant.

This page shows how to report FastSpring transactions back to Google Play's External Content Links program (the Android steering program), so an Android steering integration stays compliant. It's an extension of an existing Steer Safe integration, not a standalone build. See What needs to already be in place below.

This page covers a working reference implementation from the FSBuilds GitHub repository, found in the fsbuilds-google-external-linking folder of the FastSpring fsBuilds examples repo.

ℹ️

The reference vehicle is a proof-of-concept Unity/UGS game, not an actively maintained SDK. This specific build does not cover raw Android or Kotlin. It reflects the FastSpring, Google Play, and Unity APIs as of its last update and may not account for later changes to any of the three. Check the linked documentation in Related resources for current behavior.

Scope note: the purpose of this build is to compliantly report transactions to Google. It's a US program specifically. Two things are deliberately out of scope:

  • External Content Links also covers linking to external app downloads. Although part of the same program, that's a different flow with its own enrollment and reporting steps, and isn't covered here.
  • Google's separate External Offers, Alternative Payments, and Payment Choice programs. External Content Links is the program specific to the US, with its own APIs. These programs aren't interchangeable.

What needs to already be in place

This repo assumes an existing FastSpring Steer Safe integration already works. Confirm you have these three pieces in place before going further.

A FastSpring store using Steer Safe

Covered in FastSpring's Steer Safe integration docs. Includes an embedded FastSpring checkout and at least one product configured.

Monetization wired through Unity

Via that Steer Safe integration. The proof of concept uses Unity, but you may use your engine of choice. It must generate and open a signed FastSpring checkout, and grant the entitlement or currency on order.completed. It also needs a native web-store implementation. These elements are assumed already working, not documented here.

A published Android app on Play Billing 8.2.1+

Using Google Play Billing Library 8.2.1 or higher.

Because the app is already published on Play Billing, the required Google Play Developer API is largely set up. The one thing that may still need attention is permissions on the existing service account (see Enrollment and setup, step 4).

⚠️

External Content Links is a US-eligibility, per-user, per-enrollment program. IsBillingProgramAvailableAsync returning BillingUnavailable for a given user is an expected, common outcome, not a bug. Test known ineligible paths.

How it works

This section gives a high-level view of how the existing Steer Safe flow gains two additions: an eligibility check and token up front, and a Google report after purchase. The base flow doesn't change.

  1. Initiate purchase.
  2. Check eligibility.
  3. Generate a single-use token.
  4. Show Google's disclosure.
  5. Tag the token onto the FastSpring order.
  6. Checkout (unchanged Steer Safe flow).
  7. Webhook grants currency and reports to Google in parallel.
  8. Client confirms on return.
sequenceDiagram
    participant Player
    participant Client as Unity Client
    participant GPB as Google Play Billing
    participant Backend
    participant FS as FastSpring

    Player->>Client: Tap Buy
    Client->>GPB: Connect, check eligibility
    GPB-->>Client: Eligible
    Client->>GPB: Generate external transaction token
    Client->>GPB: launchExternalLink (shows disclosure dialog)
    Player->>GPB: Acknowledges dialog
    Client->>Backend: POST /encode (product, token)
    Backend-->>Client: Signed checkout URL (securePayload + secureKey)
    Client->>FS: Opens checkout (Custom Tab, WebView, or browser)
    Player->>FS: Completes payment
    FS-->>Player: Redirect to deep link (fastspring://shop-return)
    FS->>Backend: Webhook: order.completed (signed, HMAC)
    par Grant currency
        Backend->>Backend: UGS Economy virtual purchase
    and Report to Google
        Backend->>GPB: POST externalTransactions (real order data)
    end
    Player->>Client: App resumes via deep link
    Client->>Backend: (fallback) POST /reportTransaction
    Client->>Client: Poll balance, show thank-you popup

Currency granting and Google reporting are two independent obligations. Entitlements arrive purely through the FastSpring webhook as part of the standard Steer Safe flow, and aren't detailed further here. Google reporting is a compliance step the program requires. A reporting failure there never blocks or reverses the purchase.

CaseResult
EligibleGoogle's disclosure dialog is shown, a single-use token is generated, checkout opens with the token tagged onto the order, and the sale is reported to Google after purchase.
Not eligible (BillingUnavailable, not enrolled, wrong region)No token, no dialog. Checkout opens normally via the existing Steer Safe flow. Currency is still granted. No Google report is attempted or owed.

Enrollment and setup

Complete these one-time account and permissions steps with Google before any of the code below will work. Verified as of September 2026.

Confirm eligibility

Complete Google's declaration form

Fill out Google's external content links declaration form. Allow up to 7 days for an initial response, and expect possible back and forth before it's fully approved.

Enroll the app in Play Console

Under Play Console > Settings, enable External content links.

Grant the Play Developer API the required permissions

The service account with API access should already exist, but may need updating. In Play Console > Users and permissions, grant the service account:

  • View financial data, orders, and cancellation survey responses
  • Manage orders and subscriptions

Without both, calls to the externaltransactions endpoint below will fail, even though API access already works for everything else.

Client implementation (Unity)

These are the four additions to your existing Unity Steer Safe client, in the order you'd make them. This is scoped to games built in Unity, via Unity IAP's ExternalBillingProgramClient (UnityEngine.Purchasing.GoogleBilling.Models). It's additive to an existing Play Billing integration; the surrounding checkout mechanics (secure payload, deep link return, balance polling) are the same Steer Safe mechanism you already have. GoogleBillingManager.cs (documentation) in this repo is the tested reference implementation.

Create the client once and connect it once

Reuse both for the app's lifetime.

using UnityEngine.Purchasing.GoogleBilling.Models;
using UnityEngine.Purchasing.Models;

private ExternalBillingProgramClient _billingClient;
private bool _isConnected;

void Awake()
{
    _billingClient = new ExternalBillingProgramClient();
}

void OnDestroy()
{
    _billingClient?.EndConnection();
}

private async Task<bool> EnsureConnectedAsync()
{
    if (_isConnected) return true;

    var tcs = new TaskCompletionSource<bool>();
    _billingClient.StartConnection(
        onConnected: () => { _isConnected = true; tcs.TrySetResult(true); },
        onDisconnected: (_) => { _isConnected = false; tcs.TrySetResult(false); }
    );
    return await tcs.Task;
}

StartConnection and EndConnection are meant to be paired once per client lifetime. Calling StartConnection again while a connection is already open is unsupported and can leak service bindings. Call EnsureConnectedAsync() at the start of a purchase attempt; it's a no-op after the first successful connect.

Check eligibility first, every time

When the player taps Buy:

var connected = await EnsureConnectedAsync();
if (!connected) return; // fall back to your normal Play Billing purchase flow

var availability = await _billingClient.IsBillingProgramAvailableAsync();
if (availability != GoogleBillingResponseCode.Ok)
{
    // Not eligible, or a transient failure (see the response code table
    // below). Either way, fall back to your normal Play Billing purchase
    // flow for this purchase.
    return;
}

Eligibility isn't something to check on app load and cache for the session. A player's country or enrollment status can change between app launch and a purchase later that session. Call this at the start of every purchase attempt, immediately before the next step.

Generate a fresh token once eligible

var reportingDetails = await _billingClient.CreateBillingProgramReportingDetailsAsync();
if (reportingDetails.responseCode != GoogleBillingResponseCode.Ok) return;

var externalTransactionToken = reportingDetails.externalTransactionToken;
// Send this to your backend now, tagged onto the order about to be
// created (see Backend integration below). Don't hold onto it for a
// later purchase.

Show Google's disclosure, then open the checkout URL yourself

var launchResponse = await _billingClient.LaunchExternalLink(
    checkoutUrl,
    LinkType.LINK_TO_DIGITAL_CONTENT_OFFER,
    LaunchMode.CALLER_WILL_LAUNCH_LINK
);

if (launchResponse != GoogleBillingResponseCode.Ok) return;

// Google's disclosure was shown and accepted. Now open checkoutUrl
// yourself through your existing Steer Safe checkout provider.

CALLER_WILL_LAUNCH_LINK means Play shows the disclosure and hands control back to your app, which opens the URL.

Handle every response code this flow can return:

CodeMeaning
GoogleBillingResponseCode.BillingUnavailableNot eligible (wrong country) or not enrolled. Check enrollment status in Play Console before assuming it's a bug.
GoogleBillingResponseCode.FeatureNotSupportedNot supported on this device.
GoogleBillingResponseCode.UserCanceledPlayer backed out. Don't retry automatically.
GoogleBillingResponseCode.DeveloperErrorSomething's wrong with the request. Check the debug message.
GoogleBillingResponseCode.NetworkError / ServiceDisconnected / ServiceUnavailableTransient. Retry with normal backoff.
GoogleBillingResponseCode.FatalErrorUnity IAP's name for Google's generic internal error code (raw Android calls this ERROR). Don't proceed; retry the whole flow on the next attempt.
ℹ️

Test with license tester accounts. Google won't invoice transactions those accounts initiate.

Reporting compliance: the reporting-relevant deep-link handling is threading the token back. The token is tagged onto the FastSpring order at /encode time (so the webhook can report immediately), and also appended to the checkout URL, so the client can ask the backend to confirm or retry reporting (/reportTransaction) if the webhook hasn't landed yet. That's the only Google-specific addition to your existing return-deep-link handling; everything else about the return trip (re-authenticating, showing a thank-you popup, polling the balance) is standard Steer Safe and isn't detailed further here.

Source: Play Billing, external content links integration guide

Backend integration

These are the four backend additions, used together to tag, report, authenticate, and track each transaction exactly once. Use a load-bearing backend to hold real order data between the webhook firing and the report going out, since a request can arrive before its webhook does, or vice versa.

Tag the token onto the FastSpring order at checkout

Wherever your existing /encode payload is built, add the token from the client's token-generation step as an order tag:

// Existing /encode handler: add googleExternalTransactionToken to tags
const payload = {
  items: [{ product: productId }],
  tags: {
    playerId,
    googleExternalTransactionToken: externalTransactionToken // from the client
  }
};

FastSpring echoes tags back on the order.completed webhook. This is the only thing that connects "player tapped Buy" to "FastSpring says the order completed" later.

Build and send the report in your order.completed webhook handler

Add this alongside wherever the webhook already grants the entitlement, not in place of it. The two run independently; a reporting failure must never block or delay the actual purchase grant.

async function reportToGoogle(externalTransactionToken: string, order: FastSpringOrderCompletedEvent) {
  const accessToken = await getGoogleAccessToken(); // next step, below
  const externalTransactionId = order.id; // FastSpring's own order id, see gotchas below

  const response = await fetch(
    `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${PACKAGE_NAME}/externalTransactions` +
    `?externalTransactionId=${encodeURIComponent(externalTransactionId)}`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${accessToken}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        // priceMicros is millionths of the currency unit: $4.99 becomes "4990000"
        originalPreTaxAmount: { priceMicros: toPriceMicros(order.subtotal), currency: order.currency },
        originalTaxAmount: { priceMicros: toPriceMicros(order.tax), currency: order.currency },
        transactionTime: new Date(order.changed).toISOString(),
        oneTimeTransaction: { externalTransactionToken },
        userTaxAddress: { regionCode: 'US' }, // this program is US-only, nothing to derive per order
        externalContentLinkDetails: { linkType: 'LINK_TO_DIGITAL_CONTENT_OFFER' } // this program specifically, not externalOfferDetails
      })
    }
  );

  if (!response.ok) {
    // Log it and leave unreported. A webhook redelivery or a scheduled
    // sweep gets another shot at it. Don't throw and fail the whole
    // webhook handler over a Google-side rejection.
    console.error('[reportToGoogle] rejected:', response.status, await response.text());
    return;
  }

  await markReported(externalTransactionToken); // see the tracking step, below
}

function toPriceMicros(amount: number): string {
  return String(Math.round(amount * 1_000_000));
}

Get an access token for the permissioned service account

Use the service account permissioned in the Enrollment and setup section, step 4.

import { google } from 'googleapis';

// Module-level, not constructed per call. google-auth-library caches and
// refreshes the resolved access token internally once this has been used.
const auth = new google.auth.GoogleAuth({
  credentials: JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_JSON),
  scopes: ['https://www.googleapis.com/auth/androidpublisher']
});

async function getGoogleAccessToken(): Promise<string> {
  const client = await auth.getClient();
  const { token } = await client.getAccessToken();
  if (!token) {
    throw new Error('Failed to obtain Google access token');
  }
  return token;
}

Track what's already been reported

Check this before the reporting step runs:

// { token TEXT PRIMARY KEY, reported_at TIMESTAMPTZ }
async function alreadyReported(token: string): Promise<boolean> {
  const row = await db.get('SELECT reported_at FROM google_reports WHERE token = $1', [token]);
  return !!row?.reported_at;
}

async function markReported(token: string) {
  await db.query('UPDATE google_reports SET reported_at = now() WHERE token = $1', [token]);
}

// Wherever the webhook currently grants the entitlement, add:
if (!(await alreadyReported(externalTransactionToken))) {
  await reportToGoogle(externalTransactionToken, order);
}

This table is what makes redelivery safe. Check it first, and skip straight past an order that's already been reported instead of calling Google again.

A few gotchas worth knowing, that don't fit neatly into a step above:

Use FastSpring's own order id as externalTransactionId

Use FastSpring's own order id as externalTransactionId, not a freshly generated one per attempt. Google treats it as the idempotency key for this call specifically, on top of the alreadyReported check above. These are two layers of idempotency, not redundant: the tracking check stops a second call from happening at all, and a stable externalTransactionId stops Google-side duplication even if a second call somehow does happen.

Renewals and recurring charges don't carry a token at all

Renewals and recurring charges don't carry a token at all. Send a fresh externalTransactionId per charge, plus initialExternalTransactionId referencing the very first transaction in the series. This isn't applicable to a one-time digital-item purchase; this repo only ever sends oneTimeTransaction.

Google rejects the call outright for ineligible users or regions

Google rejects the call outright for a user or region not eligible under the program. This isn't only a client-side gate; the backend enforces it too, so a report can fail even after the client-side eligibility check passed if something changed in between.

Rate limits are per Google's standard API quotas

Rate limits are per Google's standard API quotas (the ExternalTransactions group is its own quota bucket). This isn't a concern at this integration's scale; check the Google Play Developer API quotas page for current numbers if this ever needs to report in bulk.

Refunds go through a separate endpoint

Refunds go through a separate endpoint, externaltransactions.refundexternaltransaction, referenced by the original externalTransactionId.

Source: Play Billing, integrating your backend outside GPB

Repository layout

This section maps out where the Unity client, backend, and build workflow live in the repo.

.
├── Unity-UGS/                  The reference build: Unity game + Node backend + web checkout page
│   ├── Unity/                  Unity 6000.0.59f2 project (Eggblast Arena)
│   ├── Backend/                Express/TypeScript server deployed to Railway (also serves WebCode/)
│   └── ...
└── .github/workflows/          Manual "Generate Build" workflow for Unity targets

The backend also exposes a handful of gameplay-economy routes (/spend, /earn, /earnEggs, /checkLevelUp) and a separate native-purchase-validation route (/validateGooglePurchase) that aren't part of the Google reporting flow described above. See the per-folder READMEs if you need those.

Environment variables

This table covers only the Google-reporting-specific variables. The base Steer Safe integration needs its own set (UGS project and service account, FastSpring RSA private key, and so on); see the Unity-UGS/Backend README for the full list.

Set these in your secure backend, or a local .env.

VariablePurpose
GOOGLE_SERVICE_ACCOUNT_JSONFull JSON of a Google Cloud service account with Play Developer API access, permissioned per Enrollment and setup, step 4
DATABASE_URLPostgres connection string. Stores real FastSpring order data for Google reporting, plus replay-protection state (see Security).
FASTSPRING_WEBHOOK_SECRETThe HMAC SHA256 secret set on the FastSpring webhook. /webhook verifies X-FS-Signature against this and fails closed; every delivery is rejected with a 500 until this is set to match.
cd Unity-UGS/Backend && npm install && npm run build && npm start

Building

Build through this script, or the Google steering path gets silently skipped.

Editor/BuildScript.cs sets the scripting define for a variant and builds; the define chooses which store-opening provider is used. Building via the Unity Editor's normal Build Settings window instead (rather than one of BuildScript.cs's -executeMethod entry points) sets none of these defines, so the Google steering path is silently skipped, not broken. Always build through BuildScript.cs (or the CI workflow) if you want to test steering. See the Unity-UGS/Unity README for the full target list.

Testing

Walk through these scenarios to confirm both the eligible and ineligible paths behave correctly, plus safe webhook redelivery.

Use FastSpring's sandbox test card for purchases (4242 4242 4242 4242, any future expiry; the CVV is unique to your account and found in the FastSpring dashboard).

The eligible path (Android, US-eligible test account). Confirms the disclosure dialog, token generation, and the post-purchase Google report all fire.

In the Unity Editor, press D while GameplayScene or MainScene is running to simulate the return deep link without a real device or checkout.

Backend: npx tsc --noEmit from Unity-UGS/Backend type-checks the whole server with no build step. There's no automated test suite for either the backend or the Unity client; verification so far has been manual, against a live sandbox order.

Security

These are the security practices specific to the Google reporting layer, on top of your existing Steer Safe security.

Generate the Google token fresh every time

Generate the Google token fresh every time, never cache. This is Google's own guidance, and this repo follows it (see the client's token-generation step above).

Keep the Google service account key server-side only

Keep the Google service account key server-side only. GOOGLE_SERVICE_ACCOUNT_JSON is only ever read by the backend, never shipped in client code.

Don't let reporting block or delay the purchase grant

Don't let reporting block or delay the actual purchase grant. The webhook runs currency-granting and Google reporting independently (see How it works).

Webhook signature verification

Webhook signature verification: /webhook verifies FastSpring's X-FS-Signature (HMAC-SHA256, constant-time comparison) against FASTSPRING_WEBHOOK_SECRET, and fails closed if unconfigured. Without this, anyone who could reach the endpoint could forge an order.completed body and mint currency for free. See FastSpring's webhook security guidance.

Replay protection

Replay protection: a redelivered webhook won't re-grant currency or re-report an already-reported order to Google, both keyed by FastSpring's own identifiers, using a claim/grant/fail state machine so a genuinely failed attempt can still be retried.

Related resources

Start with the base Steer Safe integration this repo extends, then reference these for the Google-specific pieces.


Did this page help you?