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
| Claim | Example | Meaning |
|---|---|---|
iss | https://apiv2.suger.cloud | Issuer. Verify this. |
aud | app.yourcompany.com | Audience. Must equal your own domain. Verify this. |
iat | 1727136000 | Issued at, as a Unix timestamp. |
exp | 1727136300 | Expires 5 minutes after issue. Verify this. |
jti | 9f1c… | Unique token ID (UUID). Useful for your own logging. |
scenario | seller_handoff | Token type. Fours signs other tokens with the same key, so check that this is seller_handoff. |
ent | ent_EXAMPLE1 | Fours entitlement ID. |
buyer | buyer_EXAMPLE1 | Fours buyer ID. |
product | prod_EXAMPLE1 | Fours product ID. |
org | org_EXAMPLE1 | Fours organization ID. |
gcp_account | 016AAA-111111-222222 | GCP billing account. |
partner | gcp | Marketplace. |
offer_type | PRIVATE | Offer 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:
- Signature — proves Fours issued the token.
iss— proves it came from the Fours environment you expect:https://apiv2.suger.cloudin production.exp— proves it is less than 5 minutes old.aud— proves it was minted for your domain, not another seller’s.scenario— proves it is a handoff token at all. Fours signs other tokens with the same key, so require exactlyseller_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
| Symptom | Cause | Fix |
|---|---|---|
JWTExpired | More than 5 minutes between redirect and verification, or server clock drift | Allow 30s clock tolerance; check NTP sync. |
JWTClaimValidationFailed: aud | The audience in the token is not the string you passed | Match the host Fours redirects to. If you use a BeforeSignupRedirect hook, that is the hook’s host, not your Fulfillment URL’s. |
JWSSignatureVerificationFailed | Wrong JWKS, tampered token, or a stale cache after key rotation | Confirm you are using /public/handoff/jwks.json and not /.well-known/jwks.json. Then refresh the cache. |
No token parameter arrives | The toggle is off, or this redirect fell back to plain parameters | Check the toggle. Fours falls back to unsigned parameters rather than failing a purchase, so handle both while you migrate. |
400 on introspect | Body is missing token or audience | Both fields are required. |
401 on introspect | Signature invalid, wrong issuer, expired, wrong audience, or not a handoff token | Same 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.