Creating Payments
Forte's Payments API lets you charge your end-users after you complete a Compliance Registration form. Your company goes through a vetting process with Forte and Stripe (our underlying payment gateway), and your company becomes the merchant of record for transactions that are managed and tracked by Forte. We also handle webhooks, tax calculation, and chargebacks for you.
Complete onboarding from Compliance Registration.
Open Payments Setup →Prerequisites
- Your Forte project owner has completed compliance registration.
- Your project is in sandbox mode (charges run against Stripe Test mode) or live (real money). Sandbox routing is automatic — there is no separate flag on the Payment.
For non-sandbox projects, your account's compliance registration must be in the APPROVED state — Forte will reject createPayment and createPaymentPreview with PAYMENTS_REQUIRE_APPROVED_COMPLIANCE until it is. Sandbox projects bypass this gate so you can build against test mode without finishing onboarding.
Payments and Payment Previews
There are two payment endpoints because cart pages and checkout submissions have different needs. Preview is read-only — call it as the user iterates on quantities, addresses, or coupons. Create is the write step — call it once, when the user clicks "Pay."
| Operation | Purpose | Side effects |
|---|---|---|
createPaymentPreview | Compute subtotal, tax, and total without creating a charge. Use this for cart and quote pages where the user iterates. | None — nothing is persisted, no PaymentIntent is created. |
createPayment | Create a Forte Payment (and the underlying Stripe PaymentIntent), ready to be confirmed via Stripe Elements. | Persists a PaymentObject, creates a Stripe PaymentIntent + Tax.Calculation on your connected account, writes to the user's audit trail. |
Each endpoint has two callable surfaces. (See API Surfaces for the full mental model.) Use the client-side routes from your frontend when the signed-in user is paying for themselves; use the server-side routes from your backend when your server is creating the payment on behalf of a user it has authenticated some other way.
Client-side (signed-in user session):
POST /api/v1/{projectId}/users/me/payments/previewPOST /api/v1/{projectId}/users/me/payments
Server-side (API key):
POST /api/v1/projects/{projectId}/users/{userId}/payments/previewPOST /api/v1/projects/{projectId}/users/{userId}/payments
Previewing a payment total with tax
Client-side (signed-in user):
import { ForteClient } from "@forteplatforms/sdk";
const forte = new ForteClient();
const preview = await forte.users.createPaymentPreview({
projectId,
createPaymentPreviewRequest: {
currency: "usd",
lineItems: [
{ description: "Pro plan", unitAmountCents: 2500, quantity: 1, taxCode: "txcd_10000000" },
],
customerAddress: {
line1: "354 Oyster Point Blvd",
city: "South San Francisco",
state: "CA",
postalCode: "94080",
country: "US",
},
},
});
const { subtotalCents, taxCents, amountCents } = preview;Server-side (API key):
import { ForteClient } from "@forteplatforms/sdk";
const forte = new ForteClient({ apiKey: process.env.FORTE_API_KEY });
const preview = await forte.projects.createPaymentPreview({
projectId,
userId,
createPaymentPreviewRequest: {
currency: "usd",
lineItems: [
{ description: "Pro plan", unitAmountCents: 2500, quantity: 1, taxCode: "txcd_10000000" },
],
customerAddress: {
line1: "354 Oyster Point Blvd",
city: "South San Francisco",
state: "CA",
postalCode: "94080",
country: "US",
},
},
});The response includes subtotalCents, taxCents, amountCents, currency, and per-line taxAmountCents. If you omit customerAddress, no tax is computed and amountCents == subtotalCents.
taxCode is Stripe's product tax category — e.g. txcd_99999999 (general goods) or txcd_10000000 (digital goods). Tax is always applied as EXCLUSIVE (added on top of the unit price).
Creating a Payment (and Stripe PaymentIntent)
When the user clicks "Pay," call createPayment with the same payload (optionally augmented with a description and metadata). Payments are scoped to a specific user, so the userId in the URL must match the user being charged.
Client-side (signed-in user):
const result = await forte.users.createPayment({
projectId,
createPaymentRequest: {
currency: "usd",
description: "May 2026 invoice",
lineItems: [
{ description: "Pro plan", unitAmountCents: 2500, quantity: 1, taxCode: "txcd_10000000" },
],
customerAddress: { line1: "...", city: "...", state: "...", postalCode: "...", country: "US" },
},
});
const { payment, stripeClientSecret, stripePublishableKey, stripeConnectedAccountId } = result;Server-side (API key):
const result = await forte.projects.createPayment({
projectId,
userId,
createPaymentRequest: {
currency: "usd",
description: "May 2026 invoice",
lineItems: [
{ description: "Pro plan", unitAmountCents: 2500, quantity: 1, taxCode: "txcd_10000000" },
],
customerAddress: { line1: "...", city: "...", state: "...", postalCode: "...", country: "US" },
},
});
// Pass result.stripeClientSecret / stripePublishableKey / stripeConnectedAccountId
// back to your frontend so Stripe.js can confirm the PaymentIntent.Response:
{
"payment": {
"id": "pay_...",
"state": "DRAFT",
"subtotalCents": 2500,
"taxCents": 219,
"amountCents": 2719,
"stripePaymentIntentId": "pi_...",
"stripeTaxCalculationId": "taxcalc_..."
},
"stripeClientSecret": "pi_..._secret_...",
"stripePublishableKey": "pk_...",
"stripeConnectedAccountId": "acct_..."
}Confirming the payment in the browser with Stripe Elements
The three Stripe identifiers in the response (stripeClientSecret, stripePublishableKey, stripeConnectedAccountId) are everything Stripe.js needs to mount a Payment Element bound to your connected account. Pass them through to your frontend as-is.
import { loadStripe } from "@stripe/stripe-js"
const stripe = await loadStripe(stripePublishableKey, { stripeAccount: stripeConnectedAccountId })
const elements = stripe.elements({ clientSecret: stripeClientSecret })
const paymentElement = elements.create("payment")
paymentElement.mount("#payment-element")
// On submit:
await stripe.confirmPayment({ elements, confirmParams: { return_url: "..." } })The stripeAccount option on loadStripe (or per-call) is required so Stripe.js confirms the PaymentIntent on the right connected account. Direct charges live on the connected account, not on the platform — without this, confirmation fails with a "no such PaymentIntent" error.
Payment lifecycle and state mapping
The state on a Forte Payment reflects the underlying Stripe PaymentIntent's status:
| Forte state | Stripe status mapped from |
|---|---|
DRAFT | requires_payment_method, requires_confirmation, requires_action, requires_capture |
PROCESSING | processing |
COMPLETED | succeeded |
CANCELLED | canceled |
FAILED | anything else |
Forte updates this automatically via Stripe webhooks. When a Payment hits COMPLETED and had a stripeTaxCalculationId, Forte also finalizes a Stripe Tax Transaction so your tax bookkeeping is correct.
Refunding a payment
refundPayment issues a full refund of a COMPLETED payment and transitions it to REFUNDED. The entire charged amount (including any tax) is returned to your end-customer.
Refunds can only be initiated from your backend using an API key. There is no client-side (signed-in user) route — your end-users cannot refund their own payments from the browser. The only route is the server-side one:
POST /api/v1/projects/{projectId}/users/{userId}/payments/{paymentId}/refund
A refund you initiate with refundPayment does not fire a PAYMENT_REFUNDED payment trigger (see Payment triggers below). Because nothing calls back into your system, you are responsible for reversing whatever the payment granted before you refund it. Always do it in this order:
- Revoke the customer's entitlements / access in your own backend.
- Then call Forte
refundPayment.
If you refund first and your revocation step later fails, you can be left with a refunded customer who still has access.
Only COMPLETED payments can be refunded. Refunding a payment in any other state returns PAYMENT_NOT_REFUNDABLE, and refunding one that is already refunded returns PAYMENT_ALREADY_REFUNDED. The call returns the updated Payment with state: "REFUNDED".
import { ForteClient } from "@forteplatforms/sdk";
const forte = new ForteClient({ apiKey: process.env.FORTE_API_KEY });
// 1. Revoke access in your own system first.
await revokeEntitlements(userId, paymentId);
// 2. Then refund through Forte.
const payment = await forte.projects.refundPayment({
projectId,
userId,
paymentId,
});
// payment.state === "REFUNDED"You can also refund a payment from the Forte console: open the user's page, find the payment in the Payments section, and use the Refund action on any completed payment.
Payment triggers (webhooks to your services)
When a Payment changes state, Forte invokes a Payment Trigger (in practice, an HTTP POST call) to one of your Forte Services. Use this to kick off fulfillment work as a side effect of a payment — e.g., issue licenses, email receipts, write to your own ledger, or notify downstream systems.
Triggers are delivered over a private network to your Service.
Trigger events
| Event | Fires when |
|---|---|
PAYMENT_COMPLETED | Payment transitions to COMPLETED. |
PAYMENT_REFUNDED | Payment transitions to REFUNDED as a result of a refund or chargeback Forte detects through Stripe (e.g. a refund issued from the Stripe dashboard, or a lost dispute). |
PAYMENT_REFUNDED does not fire for refunds you initiate through the refundPayment API (or the console Refund button). Since you control when that happens, revoke entitlements in your backend yourself before refunding — see Refunding a payment.
Creating a Payment Trigger
Triggers are project-scoped and can be created, updated, and deleted from the Forte Project's settings page in the web console.
Open Services →Receiving and processing trigger events
For each matching event, Forte sends a POST request to your target Service and URL path with the following headers and body:
Headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Forte-Trusted | 1 |
Body:
{
"userId": "usr_...",
"paymentId": "pay_...",
"paymentTime": "2026-05-10T18:42:11.123Z",
"state": "COMPLETED"
}userId— the Forte user the payment belongs to.paymentId— the Forte payment ID.GETit via the payments API if you need the full object.paymentTime— ISO-8601 UTC timestamp of the state transition.state— uppercase, either"COMPLETED"or"REFUNDED".
Your code must validate the X-Forte-Trusted header. Forte automatically strips this from any other request, so its presence guarantees that the hook is valid and trusted.
Trigger retry policy
Any failed (non-HTTP 2xx response code) trigger invocations can be viewed from the web console and will be retried as follows:
| Setting | Value |
|---|---|
| Total attempts | 5 |
| Delay between attempts | 60 seconds |
| Per-request timeout | 30 seconds |
| Counts as success | HTTP 2xx |
| Counts as failure | Any non-2xx, timeout, or connection error |
After 5 failed attempts the trigger is abandoned. Contact Forte support if you need help replaying dropped events or accessing old data for abandoned triggers.
Best practices for trigger handlers
- Return
2xxin under 30 seconds. - Make handling idempotent on
(paymentId, state)so that retries are safe.
Testing trigger handlers locally
While you're building a handler, you can fire any of your project's triggers directly at your local dev server using the Forte CLI — same JSON body and X-Forte-Trusted: 1 header that production sends, no real payment state change required.
forte payment-triggers testThe command walks you through picking a trigger, searching for a user, and choosing one of that user's recent payments. Once you're on the "Ready to fire" screen:
| Key | Action |
|---|---|
enter | POST the webhook to your local URL — press again to re-fire the same payload. |
u | Search for a different user. |
p | Pick a different payment for the same user. |
t | Edit the local URL (defaults to http://localhost:<your service's port><trigger path>). |
b | Open the user's page in the Forte console — useful for creating a sample payment if the user doesn't have one yet. |
q | Quit. |
Skip the picker by passing the trigger ID directly:
forte payment-triggers test pmt_trigger_<id>The CLI fires using the actual userId and paymentId of a real payment so your handler can look it up via the payments API just like in production. If the selected user has no COMPLETED (or REFUNDED) payments yet, the CLI will offer to open the Forte console where you can create a sample payment in one click (sandbox projects only).
Sandbox mode vs. live payments
If your project has sandbox mode enabled, every Payment runs against your sandbox Stripe Connect account in Stripe Test mode — the publishable key returned will be a pk_test_.... Switch the project to live (or use a separate live project) to charge real cards.
Audit trail and logging
Payments and state events are written to the audit trail for the user they belong to. Payment previews are not logged.