Quickstart
From Sandbox credentials to one idempotent test payment.
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_sandboxcapability 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_idthat 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>andhttps://<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.
npm install ./vendor/facto/facto-connect-0.0.7.tgznode --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.
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>/connectOpen 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.
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.
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.
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.
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.
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.