Before you begin, make sure you have:

  • A partner account with Sandbox access. Facto creates it; there is no self-signup.
  • A Sandbox app on the merchant_mandate_sandbox capability template, with your storefront origins registered. You create it in the Partner Workspace at partner.facto.to.
  • That app's credential: the app id, the show-once secret, and the credential_id that is your Basic auth username.
  • The hosts your credentials work against. Facto names them at issuance. This page writes them as https://<your-facto-api-host> and https://<your-facto-connect-host>.

Access and credentials covers all four, including the Partner Workspace steps that produce the app and its first credential.

Install the SDK

One package ships both entrypoints. @facto/connect is not on the public npm registry yet: Facto delivers it as a version-pinned tarball together with a SHA-256 digest, and Install the SDK covers the delivery, digest verification, and upgrade flow. Once the npm release lands, this step becomes a plain npm install @facto/connect.

Install from the vendored tarball
npm install ./vendor/facto/facto-connect-0.0.7.tgz
Check both entrypoints resolve
node --input-type=module -e "
  const browser = await import('@facto/connect');
  const server = await import('@facto/connect/server');
  if (typeof browser.createFactoConnect !== 'function') process.exit(1);
  if (typeof server.createFactoConnectServer !== 'function') process.exit(1);
  console.log('browser+server exports ok');
"

Configure server credentials

All five values stay on the server. FACTO_CLIENT_SECRET must never reach a browser bundle, and a credential only works against the host it was issued for.

Server credentials (.env)
FACTO_APP_ID=mapp_...
FACTO_CLIENT_ID=dev_mcid_...
FACTO_CLIENT_SECRET=dev_mcsk_...
FACTO_API_URL=https://<your-facto-api-host>
FACTO_HOSTED_CONNECT_URL=https://<your-facto-connect-host>/connect

Open Hosted Connect from the browser

The browser SDK owns the popup, the state value, the origin check and the return-message validation. It hands your backend a one-time code and nothing else. Serve this page from one of the origins you registered.

Browser
import { createFactoConnect } from '@facto/connect'

const connect = createFactoConnect({
  appId: process.env.NEXT_PUBLIC_FACTO_APP_ID!,
})

const link = await connect.connect({
  fetchLinkSession: () =>
    fetch('/api/facto/link-session', { method: 'POST' }).then((r) => r.json()),
  completeLink: (input) =>
    fetch('/api/facto/link-complete', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(input),
    }).then((r) => r.json()),
})

Both handlers are routes in your own application. See Connect for what each must return, the postMessage contract and origin binding.

Exchange the code on your server

Your backend trades the one-time code for an opaque userLinkRef, the only durable identifier you hold. An expired or reused code fails closed, so restart the browser flow instead of retrying.

Server
import { createFactoConnectServer } from '@facto/connect/server'

export const facto = createFactoConnectServer({
  environment: 'custom',
  appId: process.env.FACTO_APP_ID!,
  clientId: process.env.FACTO_CLIENT_ID!,
  clientSecret: process.env.FACTO_CLIENT_SECRET!,
  apiBaseUrl: process.env.FACTO_API_URL!,
  hostedConnectUrl: process.env.FACTO_HOSTED_CONNECT_URL!,
})

// Inside POST /api/facto/link-complete:
const link = await facto.exchangeUserLinkCode({ code, state, nonce })
// Persist link.userLinkRef against your own customer record.

See SDK reference for the remaining options.

Check payment readiness

Read readiness before you charge. A setup-required answer is a recoverable setup state, not a decline. Resume Hosted Connect and read it again.

Server
const readiness = await facto.getPaymentReadiness({
  userLinkRef: link.userLinkRef,
})

if (!readiness.pipelineReady) {
  // Send the customer back through Hosted Connect setup; do not charge.
}

Get a payment authorization

A merchant-initiated payment runs under an authorization the customer approved, so this step hands off to their browser and back. Create the session, send them to authorizationUrl, then read the session back when they return. externalCustomerRef is your own reference; Facto never matches it against an account.

Server
const session = await facto.createAuthorizationSession({
  externalCustomerRef: 'customer_1001',
  requestedUseCases: ['immediate'],
  defaultLimits: { perTxAmount: '25.00', periodAmount: '100.00', period: 'monthly' },
  returnUrl: 'http://localhost:3000/facto/return',
  state: crypto.randomUUID(),
})

// The customer approves on a Facto-hosted page; send their browser there.
redirect(session.authorizationUrl)

// Back on your returnUrl:
const authorization = await facto.getAuthorizationSession(session.sessionId)
if (authorization.status !== 'authorized' || !authorization.mandateId) {
  throw new Error(`authorization is ${authorization.status}`)
}

See Payments for use cases and limits.

Create the payment

Derive the amount and the merchant reference from trusted server-side order state. idempotencyKey becomes the Idempotency-Key header, not a body field.

Send no funding-route selector. Facto resolves the route from the authorization itself — the payment route the customer selected for your business when they approved it — and a paymentOptionId naming anything else is declined with payment_option_not_pinned. See Payments.

Server: charge, then replay the identical request
const request = {
  mandateId: authorization.mandateId,
  merchantPaymentRef: 'order_1001_capture_1',
  amount: '0.01',
  currency: 'USDC' as const,
  useCase: 'immediate' as const,
  idempotencyKey: 'order_1001-attempt-0001',
}

const created = await facto.createMerchantPayment(request)
const terminal = await facto.onStatus(created.paymentId, (payment) => {
  console.log(payment.paymentId, payment.status)
})

const replay = await facto.createMerchantPayment(request)
// replay.paymentId === created.paymentId, never a second payment.

A correct run leaves one payment, and the replay returns the same paymentId. See Idempotency for the retry contract and Errors for terminal statuses and decline reasons.

Next

  • Connect for popup lifecycle, origin binding and link revocation.
  • Payments for readiness, checkout sessions, refunds and reconciliation.
  • Webhooks to receive terminal payment events instead of polling.
  • Apply for production access. Sandbox apps, credentials and test data never migrate; production is issued fresh.