Signed Buyer Handoff

Verify that a buyer arriving at your signup page was really just sent there by Fours.


The problem this solves

When a buyer purchases your product on Google Cloud Marketplace, Fours creates the entitlement and redirects the buyer to your signup URL with the entitlement ID in the query string. Your page reads that ID and binds the new account to the purchase.

By default that ID is the only thing your page has to go on — and it is not a secret. It appears in browser history, support tickets, forwarded confirmation emails and webhook logs. Anyone who obtains one can open your signup page with it and bind their account to someone else’s purchase.

Turning on Signed Buyer Handoff changes the redirect so it carries a short-lived token that only Fours can produce instead of the plain sugerEntitlementId, gcpAccountId and offerType parameters — those are removed from the URL, and their values move inside the token. Your page verifies it before trusting anything, and “knows the entitlement ID” stops being the same thing as “is the buyer”.

Turning it on

Go to Integrations → GCP → Edit and enable Signed Buyer Handoff.

What changes in the URL

Before (default):

https://app.yourcompany.com/gcp-signup
  ?sugerEntitlementId=ent_EXAMPLE1
  &partner=gcp
  &gcpAccountId=016AAA-111111-222222
  &offerType=PRIVATE

After:

https://app.yourcompany.com/gcp-signup
  ?token=eyJhbGciOiJSUzI1NiIsImtpZCI6…
  &partner=gcp

Every field that used to be a query parameter is now a signed claim inside the token. Read them from the verified payload — never by decoding the token without verifying it.

What’s inside the token

ClaimExampleMeaning
isshttps://apiv2.suger.cloudIssuer. Verify this.
audapp.yourcompany.comAudience. Must equal your own domain. Verify this.
iat1727136000Issued at, as a Unix timestamp.
exp1727136300Expires 5 minutes after issue. Verify this.
jti9f1c…Unique token ID (UUID). Useful for your own logging.
scenarioseller_handoffToken type. Fours signs other tokens with the same key, so check that this is seller_handoff.
entent_EXAMPLE1Fours entitlement ID.
buyerbuyer_EXAMPLE1Fours buyer ID.
productprod_EXAMPLE1Fours product ID.
orgorg_EXAMPLE1Fours organization ID.
gcp_account016AAA-111111-222222GCP billing account.
partnergcpMarketplace.
offer_typePRIVATEOffer type.

About aud

The audience is the host Fours actually redirects the buyer to. Normally that is the host of your product’s Fulfillment URL. If you use a BeforeSignupRedirect hook to send buyers somewhere else, the audience follows the hook — so it always matches the page receiving the request.

How to verify

Fetch Fours’ public keys once at startup and cache them:

https://apiv2.suger.cloud/public/handoff/jwks.json

Then on every handoff, check five things:

  1. Signature — proves Fours issued the token.
  2. iss — proves it came from the Fours environment you expect: https://apiv2.suger.cloud in production.
  3. exp — proves it is less than 5 minutes old.
  4. aud — proves it was minted for your domain, not another seller’s.
  5. scenario — proves it is a handoff token at all. Fours signs other tokens with the same key, so require exactly seller_handoff.

If all five pass, every claim is trustworthy. Allow about 30 seconds of clock skew on exp. Each example below checks all five.

Node (jose)

import { jwtVerify, createRemoteJWKSet } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("https://apiv2.suger.cloud/public/handoff/jwks.json")
);

export async function verifyHandoffToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: "https://apiv2.suger.cloud",
    audience: "app.yourcompany.com",
    clockTolerance: "30s",
  });
  if (payload.scenario !== "seller_handoff") {
    throw new Error("Not a seller handoff token");
  }
  return payload; // payload.ent, payload.buyer, payload.product, …
}

Go (jwx)

import (
    "context"
    "time"

    "github.com/lestrrat-go/jwx/v2/jwk"
    "github.com/lestrrat-go/jwx/v2/jwt"
)

const jwksURL = "https://apiv2.suger.cloud/public/handoff/jwks.json"

var jwksCache *jwk.Cache

// Call once at startup.
func initHandoffKeys(ctx context.Context) error {
    jwksCache = jwk.NewCache(ctx)
    return jwksCache.Register(jwksURL)
}

func verifyHandoff(ctx context.Context, token string) (jwt.Token, error) {
    keyset, err := jwksCache.Get(ctx, jwksURL)
    if err != nil {
        return nil, err
    }
    return jwt.Parse([]byte(token),
        jwt.WithKeySet(keyset),
        jwt.WithIssuer("https://apiv2.suger.cloud"),
        jwt.WithAudience("app.yourcompany.com"),
        jwt.WithClaimValue("scenario", "seller_handoff"),
        jwt.WithAcceptableSkew(30*time.Second),
    )
}

Python (PyJWT)

import jwt
from jwt import PyJWKClient

jwks_client = PyJWKClient("https://apiv2.suger.cloud/public/handoff/jwks.json")

def verify_handoff(token: str) -> dict:
    signing_key = jwks_client.get_signing_key_from_jwt(token).key
    claims = jwt.decode(
        token,
        signing_key,
        algorithms=["RS256"],
        issuer="https://apiv2.suger.cloud",
        audience="app.yourcompany.com",
        leeway=30,  # seconds of clock skew
    )
    if claims.get("scenario") != "seller_handoff":
        raise jwt.InvalidTokenError("Not a seller handoff token")
    return claims

Server-side verification (optional)

If implementing JWT verification is awkward in your stack — Salesforce Apex, for instance — you can POST the token to Fours instead and get the decoded claims back only if the signature, issuer, expiry, audience and scenario all check out.

POST https://apiv2.suger.cloud/public/handoff/introspect
Content-Type: application/json

{
  "token": "eyJ…",
  "audience": "app.yourcompany.com"
}

audience is required, and must be your own domain. Fours checks the token against it for you, so this endpoint gives you the same guarantee as verifying offline. Without it we could only tell you a token is validly signed — not that it was meant for you, which is the part that stops another seller’s token being replayed against your signup page.

Response (200), with exp as a Unix timestamp, the same value the token carries:

{
  "ent": "ent_EXAMPLE1",
  "buyer": "buyer_EXAMPLE1",
  "product": "prod_EXAMPLE1",
  "org": "org_EXAMPLE1",
  "partner": "gcp",
  "offer_type": "PRIVATE",
  "gcp_account": "016AAA-111111-222222",
  "jti": "9f1c…",
  "exp": 1727136300
}

Other responses: 400 (missing token or audience), 401 (invalid signature, wrong issuer, expired, wrong audience, or not a handoff token).

Offline verification is still the recommended path — it is a few lines in any language and costs you no network round-trip on your signup page.

Worked example

A buyer purchases your product. Fours verifies Google’s signed token, creates the entitlement, and redirects:

https://app.yourcompany.com/gcp-signup?token=eyJhbGciOiJSUzI1NiIsImtpZCI6…&partner=gcp

Your handler:

app.get("/gcp-signup", async (req, res) => {
  let payload;
  try {
    payload = await verifyHandoffToken(req.query.token);
  } catch (err) {
    // Invalid signature, wrong issuer, expired, wrong audience, not a handoff
    // token, or tampered.
    return res.status(400).send("This signup link is invalid or has expired.");
  }

  // payload.ent is now trusted.
  const entitlement = await sugerClient.getEntitlement(payload.org, payload.ent);
  renderSignup({ entitlement });
});

Good to know

Tokens last 5 minutes. That is long enough for a redirect and short enough that a URL recovered from browser history the next morning is useless. Verify on first load and establish your own session; do not ask the buyer to re-use the link.

Tokens are not single-use. Within those 5 minutes the same token verifies more than once. If your flow needs strict single-use, dedupe on the jti you receive.

The token proves identity, nothing more. It is not a bearer credential for the Fours API and cannot be used to call us on the buyer’s behalf.

Existing integrations are unaffected. Until you enable the toggle, your signup URL keeps receiving the same plain query parameters it always has.

Troubleshooting

SymptomCauseFix
JWTExpiredMore than 5 minutes between redirect and verification, or server clock driftAllow 30s clock tolerance; check NTP sync.
JWTClaimValidationFailed: audThe audience in the token is not the string you passedMatch the host Fours redirects to. If you use a BeforeSignupRedirect hook, that is the hook’s host, not your Fulfillment URL’s.
JWSSignatureVerificationFailedWrong JWKS, tampered token, or a stale cache after key rotationConfirm you are using /public/handoff/jwks.json and not /.well-known/jwks.json. Then refresh the cache.
No token parameter arrivesThe toggle is off, or this redirect fell back to plain parametersCheck the toggle. Fours falls back to unsigned parameters rather than failing a purchase, so handle both while you migrate.
400 on introspectBody is missing token or audienceBoth fields are required.
401 on introspectSignature invalid, wrong issuer, expired, wrong audience, or not a handoff tokenSame debugging path as the offline errors above.

Testing against dev

Everything above works in the dev environment with the hostname swapped:

https://apiv2.dev.suger.cloud/public/handoff/jwks.json
https://apiv2.dev.suger.cloud/public/handoff/introspect

Tokens issued there carry iss: https://apiv2.dev.suger.cloud, so point your verifier at the matching issuer when testing.

Spotted something wrong or out of date on this page? Tell us and we'll correct it.