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
- Embedded iframe checkout (default) —
checkout.psifi.apploads 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 verification —
transaction.updatedevents 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 absorption —
merchant_pays_fees: trueso the customer sees the sticker price
PCI scope: SAQ-A. No card PAN / CVV ever touches your origin.
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
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
git clone https://github.com/<your-org>/psifi-checkout-starter.git
cd psifi-checkout-starter
npm installLog 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 totransaction.updated. Copy the signing secret (starts withep_orwhsec_).
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.
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.
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.
vercel --prodThe vercel.json registers two crons that run automatically on Pro:
*/5 * * * *→/api/cron/psifi-reconcile0 5 * * *→/api/cron/psifi-sync
End-to-end real-money test (there is no sandbox mode):
- Open
https://your-domain.com/, tap the buy button - Fill in real customer info + a real card
- Confirm you land on
checkout.psifi.app - Pay with a real card or Apple Pay
- Confirm the redirect back to your
/checkout/callback - Watch the Vercel function logs for
[psifi/notify] verified— the webhook arrived - The order amount appears in your PsiFi portal balance
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).
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
The component layers redundant signals for reliability:
- postMessage from /embedded-bridge — fastest path. Works in all modern browsers.
- Polling /api/checkout/result every 2s — catches Safari ITP postMessage drops, customer-closes-iframe, network blips, any case where channel #1 misses.
- Iframe onload + same-origin URL inspection — when PsiFi redirects to /embedded-bridge, the iframe is now same-origin;
contentWindow.location.hrefbecomes 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
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
sandboxattribute — sandboxing strips capabilities (forms, scripts, same-origin) that wallet payments require.
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.
Two paths:
- Env flag: set
NEXT_PUBLIC_PSIFI_EMBEDDED=falsein your Vercel env. Falls back to the classic full-page redirect. - Per-session: pass
embedded: falsein the body to/api/checkout/psifi-session. The route honors it.
The redirect-mode code path is preserved end-to-end and still works.
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.
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.
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.
- Configurable: cardpay, banxa, simplex
- Always merchant-pays: transak
- Doesn't apply: helio, onramper, bank (falls back to portal default)
Every API call is production. Guard non-prod environments with an env-flag short-circuit so dev / staging can't accidentally fire real sessions.
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.
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.
The demo has no DB. The reconcile cron + side-effects path are skeletons. To adapt:
- Add an
orderstable with at leastorder_number,payment_status,payment_gateway,gms_ref_id(or rename — it's the PsiFi session id), andshipping_address - In
/api/checkout/psifi-session,INSERTthe order before calling PsiFi (so a gateway 5xx doesn't lose the customer's cart) - In
/api/psifi/notify, look up the order byexternalId, do an atomic conditionalUPDATEto flippayment_statusexactly once - Inside
after(), fire your fulfillment helpers
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.
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.
The demo gates /api/admin/psifi-sync with a static bearer token. Replace with your auth system's admin check.
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 }.
curl https://your-domain.com/api/cron/psifi-reconcile \
-H "Authorization: Bearer $CRON_SECRET"Visit Portal → Developer → Webhooks → Message History. Each delivery shows the URL, HTTP response code, and a Replay button.
| 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 |
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.
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
MIT — see LICENSE.