React components and Next.js route helpers for SUMIT (formerly OfficeGuy) payments. The companion to
sumit-api.SUMIT is the billing platform. The actual card clearing is performed by partner processors that SUMIT routes to — Upay is one such clearer, and SUMIT can integrate with others. From the perspective of this package, you talk to SUMIT; processor-level error codes (e.g.
Upay_*) only show up inside SUMIT's response bodies.
Ship a working SUMIT checkout flow in a React or Next.js app with two files: a Client Component and a route handler.
| Export | What it does |
|---|---|
<SumitCheckout /> | Client component that loads SUMIT's payments.js, renders the card-input form with the correct field names, and produces a one-time SingleUseToken on submit. |
useSumitCheckout() | Hook for tracking checkout state (idle | submitting | succeeded | failed). |
createSumitChargeRoute() | Next.js App Router (or any Web-Standard) POST handler factory that calls the SUMIT recurring-charge endpoint with your server credentials and returns a normalized event. |
createSumitWebhookRoute() | POST handler factory for SUMIT Triggers (JSON, application/x-www-form-urlencoded, and json=… envelope shapes), with optional shared-secret verification. |
Card data never touches your server. The component renders a form whose card inputs are read by SUMIT's
payments.jsdirectly; only the resultingSingleUseTokenis forwarded to your API route.
- Install
- Render the checkout (Client Component)
- Charge route (server)
- Webhook route (server)
- SUMIT environment
- API surface
- Local development
- Acknowledgements
- License
pnpm add sumit-react sumit-apireact (and optionally next) are peer dependencies of your app. SUMIT's payments.js is loaded from https://app.sumit.co.il/scripts/payments.js at runtime.
"use client";import{SumitCheckout,useSumitCheckout}from"sumit-react/client";exportfunctionCheckout(){constcheckout=useSumitCheckout();asyncfunctionhandleToken(singleUseToken: string){constres=awaitfetch("/api/sumit/charge",{method: "POST",headers: {"content-type": "application/json"},body: JSON.stringify({
singleUseToken,customer: {externalIdentifier: "org_123",name: "Acme Ltd",emailAddress: "billing@example.com"},item: {name: "Pro Plan",description: "Monthly",unitPrice: 19,currency: "USD",durationMonths: 1},}),});if(!res.ok){checkout.handleError(newError(awaitres.text()));return;}checkout.handleSuccess();}return(<SumitCheckoutref={checkout.ref}companyId={Number(process.env.NEXT_PUBLIC_SUMIT_COMPANY_ID)}apiPublicKey={process.env.NEXT_PUBLIC_SUMIT_API_PUBLIC_KEY!}environment="production"language="he-IL"onTokenizationStart={checkout.handleStart}onToken={handleToken}onError={checkout.handleError}><buttontype="submit"disabled={checkout.status==="submitting"}>{checkout.status==="submitting" ? "מעבד..." : "שלם"}</button>{checkout.status==="failed" ? <prole="alert">{checkout.error?.message}</p> : null}</SumitCheckout>);}The component renders the inputs SUMIT expects (og-ccnum, og-expmonth, og-expyear, og-cvv, optional og-citizenid, hidden og-token). You control the surrounding markup and styling via classNames, style, and children (typically a submit button).
// app/api/sumit/charge/route.tsimport{createSumitChargeRoute}from"sumit-react/next";exportconstPOST=createSumitChargeRoute({companyId: Number(process.env.SUMIT_COMPANY_ID),apiKey: process.env.SUMIT_API_KEY!,// mode: "recurring" (default) | "oneOff"onResult: async(event)=>{if(event.ok&&event.eventType==="recurring.charged"){// persist event.customerId, event.recurringItemId, event.paymentId}if(event.ok&&event.eventType==="payment.succeeded"){// one-off charge succeeded — persist event.paymentId, event.documentId}},});mode | Endpoint | Required item fields |
|---|---|---|
"recurring"(default) | POST /billing/recurring/charge/ | name, description, unitPrice, currency, durationMonths |
"oneOff" | POST /billing/payments/charge/ | name, description, unitPrice, currency |
The same <SumitCheckout /> and SingleUseToken work for both — only the route's mode changes.
What the handler does:
| Step | Behaviour |
|---|---|
| Validate | Checks the JSON body shape (singleUseToken, customer, item). |
| Build | Calls buildRecurringChargePayload or buildOneOffChargePayload (per mode) from sumit-api. |
| Send | POSTs to /billing/recurring/charge/ or /billing/payments/charge/ (per mode). |
| Normalize | Calls normalizeChargeResponse. |
| Respond | 200 success, 402 declined, 400 bad input, 502 upstream failure — sensitive fields redacted. |
// app/api/sumit/webhook/route.tsimport{createSumitWebhookRoute,verifySumitSharedSecret}from"sumit-react/next";exportconstPOST=createSumitWebhookRoute({verify: verifySumitSharedSecret(process.env.SUMIT_WEBHOOK_SECRET!),onEvent: async(event)=>{// event is a NormalizedSumitEvent — already redacted, safe to log/persistif(event.eventType==="sumit.trigger.unmapped"){// Store the safe reconciliation fields and decide whether to promote it.}},});Accepts JSON, application/x-www-form-urlencoded, multipart/form-data, and SUMIT's json=<serialized> envelope. Returns 200 on success, 401 when verification fails, 500 (without leaking the original error) when your handler throws.
By default, verifySumitSharedSecret(secret) accepts only the x-sumit-secret header. If an existing SUMIT trigger can only send a URL query secret, opt in explicitly:
verify: verifySumitSharedSecret(process.env.SUMIT_WEBHOOK_SECRET!,{queryParam: "secret"})Header verification is preferred because query strings are commonly stored in access logs.
| Environment | URL loaded by <SumitCheckout /> |
|---|---|
production(default) | https://app.sumit.co.il/scripts/payments.js |
dev | http://dev.sumit.co.il/scripts/payments.js |
companyId and apiPublicKey are safe to expose to the browser. The apiKey (without "Public") is server-only and must never reach the client.
| Concern | How it's handled |
|---|---|
| Card data exposure | SUMIT's payments.js reads card fields directly and returns a SingleUseToken. Card numbers, expiry, and CVV never reach your server or your component state. |
| Server credential leakage | The full apiKey lives only in createSumitChargeRoute; ./client and ./next are separate exports so client bundles cannot transitively pull the server secret. |
| Webhook spoofing | verifySumitSharedSecret checks the x-sumit-secret header by default and hashes both the candidate and the secret to a fixed 32-byte digest before comparing — the comparison is constant-time and length-independent, so response timing leaks neither secret content nor secret length. Query-string secrets are opt-in only because URLs commonly land in logs. |
| Double-submit / token reuse | <SumitCheckout /> uses a synchronous ref guard so two rapid submits cannot both fire CreateToken (single-use tokens are exactly that — single-use). |
| Logging sensitive data | Every event the route helpers return passes through redactSumitPayload from sumit-api. |
// from sumit-react/clientSumitCheckout(props): JSX.Elementprops.companyId,apiPublicKey,environment?,language?
props.requireCvv?,requireCitizenId?
props.onToken,onError?,onTokenizationStart?,onTokenizationEnd?
props.classNames?,style?,labels?
useSumitCheckout(): { ref, status, error, token, submit, reset, handleToken, handleSuccess, handleError, handleStart, clearToken }loadSumitPayments(env?): Promise<SumitPaymentsSdk>createSingleUseToken(settings): Promise<string>// from sumit-react/nextcreateSumitChargeRoute(config): (request: Request)=>Promise<Response>createSumitWebhookRoute(config): (request: Request)=>Promise<Response>verifySumitSharedSecret(secret,options?): SumitWebhookVerifierpnpm install
pnpm typecheck # tsc --noEmit
pnpm test# vitest run
pnpm build # tsc → dist/This package has sumit-api as a peer dependency; the dev dependency installs it from npm. To develop against a local checkout instead, clone sumit-api as a sibling directory and link it:
pnpm link ../sumit-apiThe browser-side API surface (OfficeGuy.Payments.CreateToken and the og-* form fields) was reverse-engineered from the official SUMIT WooCommerce plugin (GPL-2.0+). No code is copied from that plugin; this implementation is independent and MIT-licensed.