Web SDK · v1.0.0-beta01

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
Note: The browser half has no way to reach MatchPass directly, so a leaked bundle leaks nothing. That is why the paywall calls /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.

Note: Requires Node 20.9+. React 18.2+ or 19+ for the UI components.

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.

ContentTypeUse caseCache TTLPass label
"match"Live sport, single event5 minGame Pass
"channel"Live channel, time-limited60 sChannel Pass
"season"Full TV season, rewatch24 hSeason Pass
"movie"Movie, own permanently30 daysOwn it
"event"Ticketed one-off — comedy, concert5 minEvent Pass

Server Configuration

Everything except apiKey and sessionSecret has a working default. You choose a backend with environment — you never type a URL.

OptionDefaultPurpose
apiKeyrequiredOperator API key. Server-side only — never expose it.
sessionSecretrequiredSigns 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.
sessionMaxAge90 daysSession lifetime, in seconds.
secureCookietrueSet Secure on the cookie. False when NODE_ENV is development.
demoOtpfalseEcho the OTP in the response. Testing only.
debugfalseLog upstream requests to stderr. Never in production.
Note: 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

ExportReturnsDescription
createMatchPassHandlers(config)MatchPassHandlersGET/POST/DELETE handlers for the catch-all route.
getVerifiedPhone(req, config)string | nullThe OTP-verified phone for this request, from the signed cookie.
setVerifiedPhone(req, phone, config)stringIssue 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 | nullRead 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)MatchPassSessionDecode the session cookie yourself.
MatchPassApiClientclassDirect access to the MatchPass API, if you need something bespoke.

@matchpass/web-sdk/react

ExportKindDescription
MatchPassProvidercomponentWraps your app. Supplies basePath, colors and the session.
PaywallcomponentFull purchase flow — phone, OTP, M-Pesa, pass issuing and restore.
LogincomponentStandalone OTP sign-in, for apps that gate browsing behind login.
useMatchPass()hookTransport, colors and basePath from the provider.
useMatchPassSession()hookThe current phone, plus signIn and signOut.
useAccess({ contentId, contentType })hookAccess state for one item — drives lock icons and play buttons.
useMyPasses()hookEverything this viewer owns. Use for a library screen.
usePaywall({ content, onAccessGranted })hookThe paywall state machine, if you want your own UI.
resolveColors / DEFAULT_COLORShelperTheme 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.

MethodPathPurpose
GET/api/matchpass/sessionThe verified phone for this browser, or null.
GET/api/matchpass/otp/channelsWhich OTP channels your operator has enabled.
POST/api/matchpass/otp/requestSend an OTP to a phone number.
POST/api/matchpass/otp/verifyVerify the code and issue the session cookie.
GET/api/matchpass/accessAccess state for one content id.
GET/api/matchpass/passesEverything this viewer owns.
POST/api/matchpass/payments/initiateStart an M-Pesa STK push.
GET/api/matchpass/payments/statusPoll a payment until the pass is issued.
DELETE/api/matchpass/sessionSign 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
Note: +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.

Note: Keep 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 →