MatchPass Web SDK
The same M-Pesa pay-per-view passes, for the web. Mount one route handler, drop in one component. Your operator key and the viewer's pass tokens stay on your server — the browser never sees either.
How it works
The SDK comes in two halves that never mix. @matchpass/web-sdk/server holds your API key and talks to MatchPass. @matchpass/web-sdk/react runs in the browser and talks only to your own origin. Between them sits a signed, httpOnly session cookie that your server issues after OTP verification.
Browser (@matchpass/web-sdk/react)
│ fetch same-origin only
▼
Your server /api/matchpass/* ← createMatchPassHandlers()
│ holds apiKey + signs the session cookie
▼
MatchPass API (staging | production)
│
├─ OTP request / verify ──────► signed httpOnly cookie set
├─ Access check ──────────────► pass restored if already owned
└─ M-Pesa STK push ───────────► pass issued on payment
Playback stays server-side:
resolveGrant(request, { contentId }, config) → token → your stream API/api/matchpass/* on your own domain rather than the MatchPass API.Installation
npm install @matchpass/web-sdk # React is only needed for the UI components npm install react react-dom
React is an optional peer dependency — you only need it if you use the/react entry point. The server half works in any runtime that speaks the Web Request and Response API.
Quick Start
1. Set your environment variables
Get your API key from staging.b2b.matchpass.africa. Never give either of these a NEXT_PUBLIC_ prefix — that would publish them in the browser bundle.
# .env.local — server-only, never NEXT_PUBLIC_ MATCHPASS_API_KEY=your-operator-key MATCHPASS_SESSION_SECRET=$(openssl rand -base64 32)
2. Mount the route handlers
One catch-all route serves every endpoint the browser half calls. It defaults to/api/matchpass; change it with basePath on both the config and the provider.
// app/api/matchpass/[...matchpass]/route.ts
import { createMatchPassHandlers } from "@matchpass/web-sdk/server";
const handlers = createMatchPassHandlers({
apiKey: process.env.MATCHPASS_API_KEY!,
sessionSecret: process.env.MATCHPASS_SESSION_SECRET!,
environment: "staging", // "production" is the default
});
export const GET = handlers.GET;
export const POST = handlers.POST;
export const DELETE = handlers.DELETE;3. Wrap your app in the provider
Pass initialPhone from a session you already read on the server so the first paint isn't a flash of the signed-out header.
"use client";
import { MatchPassProvider } from "@matchpass/web-sdk/react";
export function Providers({
children,
phone,
}: {
children: React.ReactNode;
phone: string | null;
}) {
return (
<MatchPassProvider initialPhone={phone}>
{children}
</MatchPassProvider>
);
}
// In a server component, read the phone for the first paint:
// const phone = getVerifiedPhone(request, config);4. Show the paywall
"use client";
import { Paywall } from "@matchpass/web-sdk/react";
const [showPaywall, setShowPaywall] = useState(false);
{showPaywall && (
<Paywall
content={{
id: "arsenalmancity01jul26",
title: "Arsenal vs Manchester City",
price: "299",
currency: "KSh",
durationHours: 4,
contentType: "match",
}}
onAccessGranted={() => {
setShowPaywall(false);
startPlayback(); // no token here — resolve it server-side
}}
onDismiss={() => setShowPaywall(false)}
/>
)}5. Gate the stream on your server
onAccessGranted deliberately carries no token. Resolve it server-side, where the browser can't tamper with the answer, and return a playback URL only.
// app/api/watch/[id]/route.ts
import { getVerifiedPhone, resolveGrant } from "@matchpass/web-sdk/server";
export async function POST(request: Request) {
const phone = getVerifiedPhone(request, config);
if (!phone) {
return Response.json({ reason: "signin_required" }, { status: 401 });
}
const grant = await resolveGrant(
request,
{ contentId: "arsenalmancity01jul26", contentType: "match" },
config,
);
if (grant.result.status !== "granted") {
return Response.json({ reason: grant.result.status }, { status: 402 });
}
// grant.token proves the purchase to your streaming backend.
// grant.setCookie persists a pass that was just restored — pass it on.
const headers = new Headers();
if (grant.setCookie) headers.append("Set-Cookie", grant.setCookie);
return Response.json({ url: await playbackUrlFor(grant.token) }, { headers });
}Content Types
Content types are lowercase strings on the web, and select the same validation frequency and pass label as their Android counterparts.
| ContentType | Use case | Cache TTL | Pass label |
|---|---|---|---|
"match" | Live sport, single event | 5 min | Game Pass |
"channel" | Live channel, time-limited | 60 s | Channel Pass |
"season" | Full TV season, rewatch | 24 h | Season Pass |
"movie" | Movie, own permanently | 30 days | Own it |
"event" | Ticketed one-off — comedy, concert | 5 min | Event Pass |
Server Configuration
Everything except apiKey and sessionSecret has a working default. You choose a backend with environment — you never type a URL.
| Option | Default | Purpose |
|---|---|---|
apiKey | required | Operator API key. Server-side only — never expose it. |
sessionSecret | required | Signs the session cookie. 32+ characters, stable across deploys. |
environment | "production" | "staging" while integrating. Selects the base URL for you. |
basePath | "/api/matchpass" | Where the route handlers are mounted. |
cookieName | "mp_session" | Session cookie name. |
sessionMaxAge | 90 days | Session lifetime, in seconds. |
secureCookie | true | Set Secure on the cookie. False when NODE_ENV is development. |
demoOtp | false | Echo the OTP in the response. Testing only. |
debug | false | Log upstream requests to stderr. Never in production. |
sessionSecret must be at least 32 characters — the SDK refuses to start otherwise, because a short secret makes the cookie signature forgeable and would let anyone mint a session for any phone number. Keep it stable across deploys or every viewer is signed out on each restart.API Reference
@matchpass/web-sdk/server
| Export | Returns | Description |
|---|---|---|
createMatchPassHandlers(config) | MatchPassHandlers | GET/POST/DELETE handlers for the catch-all route. |
getVerifiedPhone(req, config) | string | null | The OTP-verified phone for this request, from the signed cookie. |
setVerifiedPhone(req, phone, config) | string | Issue a session from your own auth — skips MatchPass OTP. Returns a Set-Cookie value. |
resolveGrant(req, params, config) | Promise<ResolvedGrant> | Full access check with server restore. Gives token and setCookie. |
getPassToken(req, contentId, config) | string | null | Read a token already in the session. No network call, no restore. |
checkAccess(params) | Promise<AccessCheckOutcome> | Lower-level access check used by resolveGrant. |
readSession(req, config) | MatchPassSession | Decode the session cookie yourself. |
MatchPassApiClient | class | Direct access to the MatchPass API, if you need something bespoke. |
@matchpass/web-sdk/react
| Export | Kind | Description |
|---|---|---|
MatchPassProvider | component | Wraps your app. Supplies basePath, colors and the session. |
Paywall | component | Full purchase flow — phone, OTP, M-Pesa, pass issuing and restore. |
Login | component | Standalone OTP sign-in, for apps that gate browsing behind login. |
useMatchPass() | hook | Transport, colors and basePath from the provider. |
useMatchPassSession() | hook | The current phone, plus signIn and signOut. |
useAccess({ contentId, contentType }) | hook | Access state for one item — drives lock icons and play buttons. |
useMyPasses() | hook | Everything this viewer owns. Use for a library screen. |
usePaywall({ content, onAccessGranted }) | hook | The paywall state machine, if you want your own UI. |
resolveColors / DEFAULT_COLORS | helper | Theme the paywall and login screens. |
MatchPassContent
const content: MatchPassContent = {
id: "arsenalmancity01jul26", // your stable content identifier
title: "Arsenal vs Man City",
price: "299", // decimal string, sent to payments as-is
currency: "KSh", // optional, defaults to "KSh"
durationHours: 4, // optional, defaults to 4
thumbnailUrl: undefined, // optional background image
contentType: "match", // optional, defaults to "match"
policy: undefined, // optional override of cache TTL / copy
};Building a library screen
"use client";
import { useMyPasses } from "@matchpass/web-sdk/react";
export function Library() {
const { passes, isLoading, error } = useMyPasses();
if (isLoading) return <Spinner />;
if (error) return <p>{error}</p>;
return (
<ul>
{passes.map(pass => (
<li key={pass.contentId}>{pass.title}</li>
))}
</ul>
);
}Mounted Endpoints
These are served by the catch-all handler on your own origin. You never call them directly — the browser half does — but they are useful when debugging a network tab.
| Method | Path | Purpose |
|---|---|---|
GET | /api/matchpass/session | The verified phone for this browser, or null. |
GET | /api/matchpass/otp/channels | Which OTP channels your operator has enabled. |
POST | /api/matchpass/otp/request | Send an OTP to a phone number. |
POST | /api/matchpass/otp/verify | Verify the code and issue the session cookie. |
GET | /api/matchpass/access | Access state for one content id. |
GET | /api/matchpass/passes | Everything this viewer owns. |
POST | /api/matchpass/payments/initiate | Start an M-Pesa STK push. |
GET | /api/matchpass/payments/status | Poll a payment until the pass is issued. |
DELETE | /api/matchpass/session | Sign out. Does NOT revoke passes. |
Phone Number Format
A viewer's identity is their phone number, matched as an exact string. The SDK keeps two normalisations and they are not interchangeable:
import { normalizePhone, normalizePhoneForMpesa } from "@matchpass/web-sdk";
normalizePhone("0714688019") // "+254714688019" identity — pass lookups
normalizePhoneForMpesa("0714688019") // "254714688019" Daraja STK push only+254… is the canonical identity form and is what the Android SDK writes too, so a viewer who bought on their phone sees the same passes on the web. The bare form exists only because Daraja rejects a leading +.Debug & Local Dev
createMatchPassHandlers({
apiKey: process.env.MATCHPASS_API_KEY!,
sessionSecret: process.env.MATCHPASS_SESSION_SECRET!,
environment: "staging",
demoOtp: true, // echo the OTP in the response — local dev only
debug: true, // log upstream requests to stderr
});demoOtp asks the backend to echo the code in its response, so you can sign in without waiting for a message. debug logs every upstream request to stderr.
demoOtp off anywhere reachable from the internet. Echoing the code lets anyone who can load the page sign in as any phone number, including numbers that already hold passes.Ready to integrate?
Register as an operator to get your API key and start issuing passes.
Create operator account →