Skip to content

tovesblake-max/psifi-checkout-starter

Repository files navigation

PsiFi Hosted Checkout — Reference Implementation

A working, end-to-end PsiFi (api.psifi.app) checkout integration on Next.js 15. Fork this, swap in your catalog + your PsiFi credentials, and you've got a production-grade card rail with daily crypto settlement, no rolling reserve, and zero card-data PCI scope.

Live demo: psifi-demo-checkout.vercel.app


What this gets you

  • Embedded iframe checkout (default)checkout.psifi.app loads inside a modal on your site so the customer never visually leaves; falls back to a full-page redirect via a single env flag
  • Catalog sync — every SKU in your catalog becomes a PsiFi product so the hosted page shows real line items (not a generic placeholder)
  • Multi-product line items — cart items map 1:1 to PsiFi line items
  • Svix webhook verificationtransaction.updated events with HMAC-SHA256 sig check, fail-closed
  • Forensic logging — every inbound webhook attempt (including failed ones) leaves a trail for debugging
  • Exactly-once side effects via after() — Vercel post-response work guaranteed to complete (no dropped emails)
  • Reconciliation cron — sweeps stuck orders every 5 min in case a webhook never arrives
  • Daily catalog sync cron — catalog edits flow to PsiFi automatically
  • Configurable fee absorptionmerchant_pays_fees: true so the customer sees the sticker price

PCI scope: SAQ-A. No card PAN / CVV ever touches your origin.


Architecture

Customer browser
    │  fills name + email + phone + shipping address on your /checkout
    │  (no card fields)
    ▼
your-domain.com/api/checkout/psifi-session
    │  validates input, resolves cart SKUs → PsiFi product ids,
    │  calls processPaymentS2S → PsiFi
    ▼
api.psifi.app/api/v2/checkout-sessions
    │  returns { id: "cs_secure_…", url: "checkout.psifi.app/?sessionId=…" }
    ▼
window.location.href = url  →  customer leaves your site
    │
    ▼
Customer pays on PsiFi hosted page
  (Apple Pay / Google Pay / card / crypto, auto-routed)
    │
    ├── PsiFi POSTs Svix-signed `transaction.updated` webhook
    │       │
    │       ▼
    │   your-domain.com/api/psifi/notify
    │       │  verifies signature, probes session status,
    │       │  fires side effects via after()
    │       ▼
    │   Order completed, fulfillment triggered
    │
    └── PsiFi redirects customer back to your /checkout/callback
            │
            ▼
        Callback page polls /api/checkout/result up to 8× / 12s
            │  (fallback if webhook hasn't arrived yet)
            ▼
        Confirmation screen

File map

src/
├── components/
│   └── PsifiEmbeddedCheckout.tsx           ← iframe modal + postMessage listener + polling fallback
├── lib/
│   ├── catalog.ts                          ← your product catalog
│   ├── psifi.ts                            ← gateway client (createCheckoutSession, getCheckoutSession, verifyWebhookSignature, fetchAllProducts, createProduct, updateProduct)
│   └── psifi-product-map.ts                ← SKU → PsiFi-id resolver (in-memory cache, 10-min TTL, bootstrapped from PsiFi metadata)
├── app/
│   ├── page.tsx                            ← landing
│   ├── checkout/
│   │   ├── page.tsx                        ← checkout form + bump-offer toggle + embedded-modal launcher
│   │   └── callback/page.tsx               ← post-payment confirmation, polls /api/checkout/result
│   ├── embedded-bridge/page.tsx            ← PsiFi's redirect_url target in embedded mode, postMessage-signals parent
│   └── api/
│       ├── admin/
│       │   └── psifi-sync/route.ts         ← manual catalog sync trigger (Bearer DEMO_ADMIN_TOKEN)
│       ├── checkout/
│       │   ├── psifi-session/route.ts      ← creates the session (idempotency-keyed, embedded-aware)
│       │   └── result/route.ts             ← wraps GET /checkout-sessions/:id for the iframe + callback polls
│       ├── psifi/notify/route.ts           ← Svix-verified webhook handler, after() side effects
│       └── cron/
│           ├── psifi-reconcile/route.ts    ← 5-min sweep (skeleton — add your DB logic)
│           └── psifi-sync/route.ts         ← daily catalog refresh
└── vercel.json                             ← cron schedule

Setup

1. Clone + install

git clone https://github.com/<your-org>/psifi-checkout-starter.git
cd psifi-checkout-starter
npm install

2. Get PsiFi credentials

Log in to portal.psifi.app:

  • API key — Developer → API → API Keys → Create New Key. Copy the 48-char hex value.
  • Allowed Domains — Developer → API → Allowed Domains → add your apex + www variant.
  • Webhook endpoint — Developer → Webhooks → Webhook Portal → Add Endpoint. URL: https://your-domain.com/api/psifi/notify. Subscribe to transaction.updated. Copy the signing secret (starts with ep_ or whsec_).

3. Create a placeholder product on PsiFi

The session-create API requires at least one product reference. Even with the catalog sync handling 99% of SKUs, keep a safety-net placeholder for any unsynced ones:

curl -X POST "https://api.psifi.app/api/v2/products" \
  -H "x-api-key: $PSIFI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Your Brand Order",
    "description": "Itemized invoice in your confirmation email.",
    "pricing_context": "contextual",
    "status": "active"
  }'

Save the returned id as PSIFI_PLACEHOLDER_PRODUCT_ID.

4. Environment variables

Copy .env.example to .env.local and fill in:

cp .env.example .env.local
Var Required Notes
PSIFI_API_KEY Step 2 output
PSIFI_WEBHOOK_SECRET Step 2 output
PSIFI_PLACEHOLDER_PRODUCT_ID Step 3 output
DEMO_ADMIN_TOKEN Random 32+ char string. python -c 'import secrets;print(secrets.token_urlsafe(32))'
CRON_SECRET Same generation. Used by Vercel cron auth.
PSIFI_WEBHOOK_URL optional Set if you've registered a specific endpoint URL in PsiFi portal
HEARTBEAT_URL_PSIFI_* optional Uptime monitoring pings

For Vercel deploys, set all of the above with vercel env add.

5. Run the initial catalog sync

Edit src/lib/catalog.ts to your real product list. SKU + name + description + price per item. Then trigger the sync:

curl -X POST https://your-domain.com/api/admin/psifi-sync \
  -H "Authorization: Bearer $DEMO_ADMIN_TOKEN"

Response includes a mapping object showing every SKU → PsiFi product id. The route is idempotent — safe to re-run.

6. Deploy

vercel --prod

The vercel.json registers two crons that run automatically on Pro:

  • */5 * * * */api/cron/psifi-reconcile
  • 0 5 * * */api/cron/psifi-sync

7. Smoke test

End-to-end real-money test (there is no sandbox mode):

  1. Open https://your-domain.com/, tap the buy button
  2. Fill in real customer info + a real card
  3. Confirm you land on checkout.psifi.app
  4. Pay with a real card or Apple Pay
  5. Confirm the redirect back to your /checkout/callback
  6. Watch the Vercel function logs for [psifi/notify] verified — the webhook arrived
  7. The order amount appears in your PsiFi portal balance

Embedded checkout (iframe mode)

Default mode. PsiFi's hosted checkout loads inside a modal on your site so the customer never visually leaves. Disable by setting NEXT_PUBLIC_PSIFI_EMBEDDED=false (falls back to a full-page redirect).

How it works

1. Customer submits the checkout form
2. /api/checkout/psifi-session creates a session with redirect_url
   pointed at /embedded-bridge (instead of /checkout/callback)
3. Server returns { url: "checkout.psifi.app/?sessionId=…" }
4. Client mounts <PsifiEmbeddedCheckout> — opens the URL in a modal iframe
5. Customer pays inside the iframe (Apple Pay / Google Pay / card / crypto)
6. PsiFi redirects the IFRAME to /embedded-bridge?session_id=…
7. /embedded-bridge runs window.parent.postMessage({type:"psifi-checkout-complete", …})
8. <PsifiEmbeddedCheckout> receives, dismisses the modal, navigates parent to /checkout/callback

Why three completion-detection channels

The component layers redundant signals for reliability:

  1. postMessage from /embedded-bridge — fastest path. Works in all modern browsers.
  2. Polling /api/checkout/result every 2s — catches Safari ITP postMessage drops, customer-closes-iframe, network blips, any case where channel #1 misses.
  3. Iframe onload + same-origin URL inspection — when PsiFi redirects to /embedded-bridge, the iframe is now same-origin; contentWindow.location.href becomes readable. Belt-and-braces in case both #1 and #2 fail.

If any channel reports completion, the modal dismisses and the parent navigates to the canonical callback. Idempotent — multiple signals won't fire navigation multiple times (guarded by a completedRef).

Iframe permissions

<iframe
  src="https://checkout.psifi.app/?sessionId=…"
  allow="payment; publickey-credentials-get; clipboard-write"
/>
  • allow="payment" is required for Apple Pay / Google Pay sheets to render inside the iframe. Without it the wallet button is greyed out.
  • No sandbox attribute — sandboxing strips capabilities (forms, scripts, same-origin) that wallet payments require.

PsiFi-side requirements

PsiFi's checkout.psifi.app sends no X-Frame-Options header and no Content-Security-Policy: frame-ancestors at time of writing — meaning the page can be iframed from any origin without coordination. No infra change needed on PsiFi's side.

If you need to disable embedded mode

Two paths:

  • Env flag: set NEXT_PUBLIC_PSIFI_EMBEDDED=false in your Vercel env. Falls back to the classic full-page redirect.
  • Per-session: pass embedded: false in the body to /api/checkout/psifi-session. The route honors it.

The redirect-mode code path is preserved end-to-end and still works.


Watch out for these (the things that cost hours)

Svix secret format

PsiFi's secret format is ep_<rawstring>NOT the Svix-standard whsec_<base64>. The default Svix Webhook constructor base64-decodes the suffix and throws on non-base64 chars. Use:

new Webhook(secret.slice(3), { format: "raw" });

This repo handles it inside verifyWebhookSignature in src/lib/psifi.ts. Don't try to verify signatures with a hand-rolled HMAC.

payment_method_unavailable 400 on session create

Transak and CardPay are sometimes provisioned with checkout_kind: nft on a PsiFi merchant account, which makes them reject explicit payment_method requests with this error. Either omit payment_method (auto-route) or contact PsiFi support to switch the MID kind to onramp.

Webhook URL must be pre-registered

If you set webhook_url on a session, PsiFi requires it to exactly match an active endpoint in your Webhook Portal. Otherwise session create returns invalid_webhook_url. Either register the URL first or leave webhook_url unset to fan out to all your registered endpoints.

merchant_pays_fees honor varies by rail

  • Configurable: cardpay, banxa, simplex
  • Always merchant-pays: transak
  • Doesn't apply: helio, onramper, bank (falls back to portal default)

No sandbox mode

Every API call is production. Guard non-prod environments with an env-flag short-circuit so dev / staging can't accidentally fire real sessions.

Fire-and-forget side effects get killed

Vercel kills function instances after the response returns. Unawaited promises (e.g. sendEmail().catch(...)) often don't complete. Use Next.js 16's after() from next/server:

import { after } from "next/server";

after(async () => {
  await sendEmail();
  await pushToFulfillment();
});

return NextResponse.json({ received: true });

runSideEffects inside src/app/api/psifi/notify/route.ts shows the pattern.

Webhooks sometimes don't arrive

We've seen PsiFi-delivered webhooks fail to reach the endpoint with no obvious cause. The reconcile cron at /api/cron/psifi-reconcile is the safety net — it sweeps every 5 minutes, probes PsiFi for each open session, and fires a synthetic webhook to your own handler if PsiFi reports paid but your DB says unpaid. Don't run the integration in production without it.


Adapting this to your stack

Database

The demo has no DB. The reconcile cron + side-effects path are skeletons. To adapt:

  1. Add an orders table with at least order_number, payment_status, payment_gateway, gms_ref_id (or rename — it's the PsiFi session id), and shipping_address
  2. In /api/checkout/psifi-session, INSERT the order before calling PsiFi (so a gateway 5xx doesn't lose the customer's cart)
  3. In /api/psifi/notify, look up the order by externalId, do an atomic conditional UPDATE to flip payment_status exactly once
  4. Inside after(), fire your fulfillment helpers

Catalog

Replace src/lib/catalog.ts with your real product source — Shopify, WooCommerce, your own DB, etc. The sync route reads CATALOG and pushes it to PsiFi. Whatever shape you read from, just produce a { sku, name, description, priceCents } array.

Mapping persistence

The demo holds the SKU → PsiFi-id map in-memory (10-min TTL, re-bootstrapped from PsiFi metadata on cold start). For production, persist the mapping to your own KV / Postgres so lookups are one DB read instead of paginating the PsiFi /products endpoint. The pattern stays the same.

Auth on admin routes

The demo gates /api/admin/psifi-sync with a static bearer token. Replace with your auth system's admin check.


Operations

Manual catalog sync

curl -X POST https://your-domain.com/api/admin/psifi-sync \
  -H "Authorization: Bearer $DEMO_ADMIN_TOKEN"

Response: { ok, scanned, created, updated, unchanged, errors, mapping }.

Manual reconcile (e.g. during an incident)

curl https://your-domain.com/api/cron/psifi-reconcile \
  -H "Authorization: Bearer $CRON_SECRET"

Verify webhook delivery

Visit Portal → Developer → Webhooks → Message History. Each delivery shows the URL, HTTP response code, and a Replay button.

Common failure modes

Symptom Likely cause Fix
Invalid signature 401 from notify endpoint Wrong secret OR wrong Svix format handling Verify env var; ensure { format: "raw" } for ep_ prefix
Customer paid, our DB says unpaid Webhook lost / 401'd Reconcile cron auto-recovers in ≤5 min. Manually trigger if urgent.
Confirmation email never sent Fire-and-forget killed Verify side effects are inside after()
Session create 400 with payment_method_unavailable Transak/CardPay configured as NFT Omit payment_method or contact PsiFi support
Session create 400 with invalid_webhook_url URL not in Webhook Portal Either register it OR omit webhook_url

Rate limits

Default merchant tier:

  • 10 req/min general API (session creates, products CRUD)
  • 30 req/min checkout-session lookups (the callback poll path)

For ad-spike traffic, request a limit increase via the portal before going live as primary rail. The reconcile cron uses ~1 req/min steady-state so it doesn't push you over the limit.


Acknowledgements

Built atop the architecture lessons from running PsiFi as a primary card rail for a real e-commerce site. Patterns that survived production incidents:

  • Forensic webhook logging caught delivery / signature debugging that nothing else would have
  • after() for exactly-once side effects after one too many dropped emails
  • The reconcile cron caught a $220 real-customer order whose webhook never arrived
  • The catalog sync replaced a single placeholder line item with real per-SKU display

License

MIT — see LICENSE.

About

Working PsiFi hosted-checkout integration on Next.js 15 — catalog sync, Svix webhooks, exactly-once fulfillment, reconcile cron.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages