Skip to main content

Partner-Initiated Launch

In this path your backend does not call the Pagamio API. Instead your app redirects the customer's browser or web-view to a Pagamio launch URL carrying a short-lived, signed token. Pagamio verifies the token, opens a widget session for the customer, and redirects into the widget.

Use it when your platform prefers a signed hand-off ("single sign-on") to server-to-server calls. Pagamio enables it per partner during onboarding, where you agree the shared launch secret and the iss value with Pagamio.

The Launch URL​

GET {WIDGET_BASE_URL}/w/{partnerCode}/launch?token=<JWT>&return_url=<URL-encoded https URL>
Query parameterRequiredDescription
tokenYesThe signed JWT described below.
return_urlNoWhere the widget sends the customer when they close it and no host bridge can dismiss it. Must be https, contain no user info, be at most 500 characters, and its host must be on your allowed return_url hosts list. Otherwise it is silently dropped. URL-encode the value.

Outcomes

  • Success: 302 to {WIDGET_BASE_URL}/w/{partnerCode}/#session=<Pagamio session token>. Pagamio's own opaque session token replaces your launch token from this point on.
  • Any failure: 302 to {WIDGET_BASE_URL}/w/{partnerCode}/ with no token. The widget shows "This link is no longer valid". The reason is recorded by Pagamio and never shown to the customer, so contact Pagamio support with the approximate time and your jti when diagnosing.

The response is marked no-store with Referrer-Policy: no-referrer, because the token is in the query string.

The Token​

An HS256 JWT signed with your launch secret.

FieldRequiredDescription
algYesMust be HS256. Any other algorithm (including none) is rejected.
typNoIf present, must be JWT.
kidRecommendedThe secret fingerprint. Lets Pagamio pick the right secret during a rotation.

Claims​

ClaimRequiredDescription
issYesIssuer. Must equal the value agreed at onboarding, exactly.
subYesYour reference for the customer. Currently must be exactly 10 digits (^[0-9]{10}$). Pagamio uses it as both the customer identifier and the wallet identifier, so it is what your wallet API receives as customerId and walletId.
iatYesIssued-at, in seconds since the epoch. Must not be later than now plus the clock skew.
expYesExpiry, in seconds since the epoch. Rejected once now is later than exp plus the clock skew. Set it to iat + 60: exp - iat may not exceed 60 seconds.
jtiYesA unique token id (at most 200 characters). Each jti is accepted once per partner. A replayed token is rejected. Use a random UUID.
nameNoCustomer display name. Trimmed and cut to 60 characters.

The clock skew is 30 seconds. Keep your servers on NTP.

Pagamio rejects a token whose lifetime (exp - iat) is more than 60 seconds, or zero or negative. No clock-skew allowance applies to the lifetime, because both claims come from your clock. Issue a token only when the customer taps the button. Because tokens are single-use, never cache or pre-generate them.

Unlike the server-to-server path, a launch token carries no prefill attributes: only sub and name reach the session, so the customer types names or mobile numbers where a product needs them.

The kid Header​

kid is the first 12 hexadecimal characters (lower-case) of the SHA-256 digest of your launch secret, taking the secret as its UTF-8 text.

kid = hex( SHA-256( utf8(secret) ) )[0:12]

The HMAC key is likewise the UTF-8 bytes of the secret text as issued. Do not base64-decode it, and remove any whitespace picked up when copying it into configuration.

If kid is present and matches none of Pagamio's configured secrets, the token is rejected. If it is absent, each configured secret is tried.

Signing Examples​

Store the secret in your secret manager, never in source code. The examples read it from LAUNCH_SECRET.

Node.js (no dependencies)​

const crypto = require("crypto");

const b64url = (input) => Buffer.from(input).toString("base64url");

function buildLaunchUrl({ baseUrl, partnerCode, customerRef, name, returnUrl }) {
const secret = process.env.LAUNCH_SECRET.trim();
const kid = crypto.createHash("sha256").update(secret, "utf8").digest("hex").slice(0, 12);
const now = Math.floor(Date.now() / 1000);

const header = { alg: "HS256", typ: "JWT", kid };
const payload = {
iss: process.env.LAUNCH_ISSUER, // the value agreed at onboarding
sub: customerRef, // 10-digit customer reference
name,
iat: now,
exp: now + 60,
jti: crypto.randomUUID(),
};

const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const signature = crypto.createHmac("sha256", secret).update(signingInput).digest("base64url");
const token = `${signingInput}.${signature}`;

const url = new URL(`/w/${partnerCode}/launch`, baseUrl);
url.searchParams.set("token", token);
if (returnUrl) url.searchParams.set("return_url", returnUrl);
return url.toString();
}

Java (Nimbus JOSE + JWT)​

import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Date;
import java.util.HexFormat;
import java.util.UUID;

public final class LaunchTokens {

static String fingerprint(String secret) throws Exception {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest).substring(0, 12);
}

public static String sign(String secret, String issuer, String customerRef, String name) throws Exception {
Instant now = Instant.now();
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.HS256)
.type(JOSEObjectType.JWT)
.keyID(fingerprint(secret))
.build();
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer(issuer)
.subject(customerRef)
.claim("name", name)
.issueTime(Date.from(now))
.expirationTime(Date.from(now.plusSeconds(60)))
.jwtID(UUID.randomUUID().toString())
.build();
SignedJWT jwt = new SignedJWT(header, claims);
jwt.sign(new MACSigner(secret.getBytes(StandardCharsets.UTF_8)));
return jwt.serialize();
}
}

MACSigner requires a secret of at least 32 bytes for HS256, and you should use one that long in any language.

Python (PyJWT)​

import hashlib
import os
import time
import uuid
from urllib.parse import urlencode

import jwt # pip install PyJWT


def build_launch_url(base_url, partner_code, customer_ref, name, return_url=None):
secret = os.environ["LAUNCH_SECRET"].strip()
kid = hashlib.sha256(secret.encode("utf-8")).hexdigest()[:12]
now = int(time.time())

token = jwt.encode(
{
"iss": os.environ["LAUNCH_ISSUER"], # the value agreed at onboarding
"sub": customer_ref, # 10-digit customer reference
"name": name,
"iat": now,
"exp": now + 60,
"jti": str(uuid.uuid4()),
},
secret,
algorithm="HS256",
headers={"kid": kid},
)

query = {"token": token}
if return_url:
query["return_url"] = return_url
return f"{base_url}/w/{partner_code}/launch?{urlencode(query)}"

Verification Order And Common Rejections​

Pagamio checks, in order: the partner exists and is enabled with a launch configuration; a token is present; the header is HS256 (and typ, if present, is JWT); kid and the signature match a configured secret; iss; exp and iat within the skew; sub is a 10-digit reference; jti is present and unused.

Symptom (customer sees "This link is no longer valid")Usual cause
Signature or kid mismatchWrong secret, secret with stray whitespace, or the secret was rotated. Recompute your kid and ask Pagamio to confirm the fingerprint of the secret it holds; the fingerprint is safe to share.
iss mismatchThe iss differs from the value agreed at onboarding (case sensitive).
Expired, or issued in the futureToken older than 60 seconds plus skew, or a server clock that has drifted.
sub not a 10-digit referenceWrong customer identifier format.
Token already usedThe customer tapped twice, the browser re-requested the URL, or a token was replayed. Issue a fresh token per tap.

Last updated: September 2026