Laravel Tutorial: Issue a Google Wallet Pass with the Wallet API

By · Published · Last updated

To issue a Google Wallet pass from Laravel you need four things: a Google Wallet issuer account, a Google Cloud service account with its JSON key, a loyalty class created through the Wallet REST API, and a signed "Add to Google Wallet" link for each customer. This tutorial builds all of it in one small service class using Laravel's HTTP client, google/auth for the access token and firebase/php-jwt for the link. It ends with a PATCH that updates a stamp count on a pass already in a customer's wallet, then shows the shortcut if you would rather not run Google credentials yourself.

Tested against: Laravel 11 and 12, PHP 8.2+. The JWT signing code was run locally against a generated key. The calls to Google need your own issuer account and credentials, so check them in Google's demo mode as described below.

What you will build

  1. A GoogleWalletService that creates a loyalty class and a loyalty object.
  2. A route that issues a pass for a member and redirects to the save link.
  3. A method that changes the stamp count on an installed pass.

Google's model has two levels, described in its docs: a class holds what every pass shares (program name, logo, colour) and an object is one customer's pass, which refers to its class by ID.

Step 1: Set up the issuer and service account

Follow Google's prerequisites:

  1. Create an issuer account in the Google Pay and Wallet Console and note your issuer ID.
  2. In Google Cloud, create a project, enable the Google Wallet API and create a service account. Download its JSON key.
  3. In the Wallet Console, add the service account's email address as a user with developer access, so it can manage your passes.
  4. Until Google approves your classes, your issuer is in demo mode: only test users you list in the console can save passes, and classes must be created with reviewStatus of UNDER_REVIEW. Add your own Google account as a test user.

Keep the JSON key out of version control. Put it outside the web root and point to it from the environment.

Step 2: Install the packages and configure

composer require google/auth firebase/php-jwt

Add to .env:

GOOGLE_WALLET_ISSUER_ID=3388000000012345678
GOOGLE_WALLET_CREDENTIALS=/etc/secrets/google-wallet-service-account.json

Add to config/services.php:

'google_wallet' => [
    'issuer_id' => env('GOOGLE_WALLET_ISSUER_ID'),
    'credentials' => env('GOOGLE_WALLET_CREDENTIALS'),
],

Bind the service in app/Providers/AppServiceProvider.php:

use App\Services\GoogleWalletService;

public function register(): void
{
    $this->app->singleton(GoogleWalletService::class, fn () => new GoogleWalletService(
        config('services.google_wallet.issuer_id'),
        config('services.google_wallet.credentials'),
    ));
}

Step 3: The service class

Create app/Services/GoogleWalletService.php:

<?php

namespace App\Services;

use Firebase\JWT\JWT;
use Google\Auth\Credentials\ServiceAccountCredentials;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;

class GoogleWalletService
{
    private const API = 'https://walletobjects.googleapis.com/walletobjects/v1';
    private const SCOPE = 'https://www.googleapis.com/auth/wallet_object.issuer';

    private array $key;

    public function __construct(private string $issuerId, string $credentialsPath)
    {
        $this->key = json_decode(file_get_contents($credentialsPath), true, 512, JSON_THROW_ON_ERROR);
    }

    public function classId(): string
    {
        return "{$this->issuerId}.coffee_club";
    }

    public function objectId(int $memberId): string
    {
        return "{$this->issuerId}.member_{$memberId}";
    }

    /** Create the loyalty class once. A 409 means it already exists, which is fine. */
    public function ensureClass(): void
    {
        $response = $this->http()->post('/loyaltyClass', [
            'id' => $this->classId(),
            'issuerName' => 'Example Coffee',
            'programName' => 'Coffee Club',
            'programLogo' => [
                'sourceUri' => ['uri' => 'https://example.com/logo.png'],
                'contentDescription' => ['defaultValue' => ['language' => 'en-AU', 'value' => 'Example Coffee logo']],
            ],
            'hexBackgroundColor' => '#4a2c2a',
            'reviewStatus' => 'UNDER_REVIEW',
        ]);

        if ($response->status() !== 409) {
            $response->throw();
        }
    }

    /** Create one customer's pass object. Returns the "Add to Google Wallet" URL. */
    public function issue(int $memberId, string $name, int $points = 0): string
    {
        $response = $this->http()->post('/loyaltyObject', [
            'id' => $this->objectId($memberId),
            'classId' => $this->classId(),
            'state' => 'ACTIVE',
            'accountId' => (string) $memberId,
            'accountName' => $name,
            'loyaltyPoints' => ['label' => 'Stamps', 'balance' => ['int' => $points]],
            'barcode' => ['type' => 'QR_CODE', 'value' => "member-{$memberId}"],
        ]);

        if ($response->status() !== 409) { // 409: object already exists, reuse it
            $response->throw();
        }

        return $this->saveUrl($this->objectId($memberId));
    }

    /** Change the stamp count on a pass that is already in a customer's wallet. */
    public function setPoints(int $memberId, int $points): void
    {
        $this->http()
            ->patch('/loyaltyObject/'.rawurlencode($this->objectId($memberId)), [
                'loyaltyPoints' => ['label' => 'Stamps', 'balance' => ['int' => $points]],
            ])
            ->throw();
    }

    /** Sign a "skinny" JWT that points at an object we already created. */
    public function saveUrl(string $objectId): string
    {
        $claims = [
            'iss' => $this->key['client_email'],
            'aud' => 'google',
            'typ' => 'savetowallet',
            'iat' => time(),
            'origins' => [],
            'payload' => ['loyaltyObjects' => [['id' => $objectId]]],
        ];

        return 'https://pay.google.com/gp/v/save/'.JWT::encode($claims, $this->key['private_key'], 'RS256');
    }

    private function http(): PendingRequest
    {
        // Access tokens last an hour; reuse one for 50 minutes.
        $token = Cache::remember('google_wallet_token', 3000, function () {
            $token = (new ServiceAccountCredentials(self::SCOPE, $this->key))->fetchAuthToken();

            return $token['access_token'] ?? throw new RuntimeException('Google did not return an access token');
        });

        return Http::withToken($token)->baseUrl(self::API)->acceptJson();
    }
}

What each part does:

  • http() gets an OAuth access token for the service account with the wallet_object.issuer scope (Google's REST reference) and caches it, so you do not request a new token for every call.
  • ensureClass() creates the shared design. Run it once, for example in a seeder or an Artisan command. Google answers 409 Conflict if the class ID exists, which we treat as success.
  • issue() creates the customer's object through the LoyaltyObject API, then signs a link for it. IDs must be issuerId.suffix, and the suffix may use letters, numbers, dots, underscores and hyphens.
  • saveUrl() builds the JWT described in Google's JWT documentation: iss is the service account email, aud is google, typ is savetowallet, and the payload lists the object to save. It is signed with the service account's private key using RS256. Because the object already exists, the payload only needs its ID.

Step 4: A route to issue a pass

// routes/web.php
use App\Models\Member;
use App\Services\GoogleWalletService;

Route::middleware('auth')->post('/wallet/google', function (GoogleWalletService $wallet) {
    $member = auth()->user();

    return redirect()->away($wallet->issue($member->id, $member->name));
});

Show it as a button: Google publishes button assets and brand guidelines for "Add to Google Wallet". Use their official button image rather than a text link.

Create the class once before the first pass:

php artisan tinker
>>> app(App\Services\GoogleWalletService::class)->ensureClass();

Visit the route as a test user. The link opens Google Wallet's save screen; confirm and the pass appears in the wallet.

Step 5: Update a pass

When a customer earns a stamp, patch the object:

app(GoogleWalletService::class)->setPoints($member->id, $member->stamps);

PATCH changes only the fields you send (Google: patch), and the change syncs to the saved pass without the customer doing anything. To show a message on the pass, use the object's addMessage method. For the whole flow compared with Apple's, see how pass updates and push notifications work.

A small test for the signing code

The link is the part most likely to break silently, so test it. Generate a key in the test, sign, and verify with the public key:

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

it('signs a save link Google can verify', function () {
    $key = openssl_pkey_new(['private_key_bits' => 2048]);
    openssl_pkey_export($key, $private);
    $public = openssl_pkey_get_details($key)['key'];
    $path = tempnam(sys_get_temp_dir(), 'sa');
    file_put_contents($path, json_encode(['client_email' => 'svc@proj.iam.gserviceaccount.com', 'private_key' => $private]));

    $wallet = new App\Services\GoogleWalletService('3388000000012345678', $path);
    $url = $wallet->saveUrl($wallet->objectId(42));

    $jwt = str_replace('https://pay.google.com/gp/v/save/', '', $url);
    $claims = JWT::decode($jwt, new Key($public, 'RS256'));

    expect($claims->aud)->toBe('google')
        ->and($claims->typ)->toBe('savetowallet')
        ->and($claims->payload->loyaltyObjects[0]->id)->toBe('3388000000012345678.member_42');
});

Common errors

  • 401 or 403 from the API: the service account was not added as a user in the Wallet Console, the Wallet API is not enabled in the Google Cloud project, or the key file path is wrong.
  • The save screen refuses the pass: you are in demo mode and the Google account is not a listed test user.
  • 404 on the object: the class or object ID is wrong. IDs are issuerId.suffix, with your numeric issuer ID.
  • Save link opens but fails: the JWT iss must be the service account email, and the key must be the matching private key.
  • Image not showing: logo and hero images must be reachable over HTTPS by Google, not behind a login.

Going to production

  • Submit your class for review in the Wallet Console and then switch reviewStatus as Google describes; demo mode is only for testing.
  • Create objects from a queue if you issue in bulk, and handle Google's rate limits with retries.
  • Store each member's object ID, and make issuing idempotent (our 409 handling does this).
  • Rotate the service account key on a schedule.
  • Do Apple as well. Google is the simpler wallet; Apple needs certificates and an update service (certificates explained).

The shortcut: let an API issue both wallets

Everything above covers Google only. Apple needs a Pass Type ID, a certificate, signing and a web service. If you want one call for both, the Shakewell Wallet REST API issues a pass for the Apple and Google wallets from one template and returns an install link that opens the right wallet for the device:

curl -X POST https://api.shakewellwallet.com/api/public/v1/wallet-passes \
  -H "Authorization: Bearer $SHAKEWELL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"template_id": 38, "person": {"forename": "Ada", "surname": "Lovelace", "emailAddress": "ada@example.com"}}'

The response includes data.pass_id and data.install_urls.universal. Updates are PUT /wallet-passes/{passId} and messages are sent with the notifications endpoint. The trade-offs are plain: you need a template designed in the Shakewell dashboard first, the REST API is on Growth plans and above (pricing), and you rely on a third party rather than owning the integration. Start from the quickstart and the Google Wallet setup page.

All guides

Guides + product

Ready to ship your first wallet card?

Set up takes minutes. No credit card required to start a free trial.