Creating payments
Forte's Payments API lets you charge your end-users with one-off Payments or recurring Subscriptions. Forte provides payment tracking, tax calculation, and simplified webhooks for you, and manages chargebacks automatically.
Your company is always the merchant of record for Payments on Forte. Fees are calculated on a per-customer basis depending on account size and industry, and range from 0% (no markup over Stripe) to 3% (high risk industries and small accounts).
To enable payments in a production (non-Sandbox) Project, 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 APPROVED. Until then, Forte rejects createPayment and createPaymentPreview with PAYMENTS_REQUIRE_APPROVED_COMPLIANCE. Sandbox projects bypass this gate so you can build against test mode before finishing onboarding.
Payments and payment previews
Forte provides one endpoint for cart previews and a separate endpoint for checkout flows. Preview is read-only. Call it as the user changes items, quantities, addresses, or coupons. Create is the write step. Call it once when the user selects Checkout.
| 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 confirm with Stripe Elements. | Persists a PaymentObject, creates a Stripe PaymentIntent and Tax Calculation on your connected account, writes to the user's audit trail. |
Each endpoint has two API surfaces. Use client-side routes when a signed-in user pays for themselves. Use server-side routes when your backend creates a payment for a user it authenticated another way. See API Surfaces for details.
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
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;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 — for example, txcd_99999999 (general goods) or txcd_10000000 (digital goods). Tax is always applied as EXCLUSIVE (added on top of the unit price).
Use txcd_92010001 for shipping charges. Forte finds every line with this code, sums them, and reports the total as shipping cost so tax calculates correctly — some regions tax shipping differently from goods.
Creating a payment (and Stripe PaymentIntent)
When the user clicks "Pay," call createPayment with the same payload, optionally adding a description and metadata. Payments are scoped to a specific user, so the userId in the URL must match the user you charge.
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;Response:
{
"payment": {
"id": "pay_...",
"state": "DRAFT",
"subtotalCents": 2500,
"taxCents": 219,
"amountCents": 2719,
"stripePaymentIntentId": "pi_...",
"stripeTaxCalculationId": "taxcalc_..."
},
"stripeClientSecret": "pi_..._secret_...",
"stripePublishableKey": "pk_...",
"stripeConnectedAccountId": "acct_..."
}Choosing which payment methods to accept
By default a Payment accepts credit and debit cards only. To offer more, pass supportedPaymentMethods on createPayment — the list of methods the customer may use when confirming:
| Value | Method |
|---|---|
CREDIT_CARD | Cards. Apple Pay / Google Pay also surface automatically in the Payment Element on supported devices — they ride on the card rail. |
ACH_DIRECT_DEBIT | ACH direct debit from a United States bank account. Requires currency: "usd" — any other currency is rejected with PAYMENT_ACH_REQUIRES_USD. |
const result = await forte.projects.createPayment({
projectId,
userId,
createPaymentRequest: {
currency: "usd",
lineItems: [{ description: "Pro plan", unitAmountCents: 2500, quantity: 1 }],
// Offer both a card and a US bank account on this payment:
supportedPaymentMethods: ["CREDIT_CARD", "ACH_DIRECT_DEBIT"],
},
});Omit the field (or pass an empty list) to keep the card-only default. The Payment Element you mount with the returned stripeClientSecret shows exactly the methods you allowed. When you pin a specific saved method with paymentMethodId, supportedPaymentMethods is ignored — the method (and its type) is already chosen.
ACH direct debit settles asynchronously and has its own bank verification, payment states, and saved-method behavior. See ACH direct debit for details.
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.
Charging a saved method off-session (server-initiated)
The preceding flow is on-session — the customer is present and confirms in the browser. To charge a method already on file without the customer present (a server-initiated, one-off charge), pass offSession: true with the saved paymentMethodId. No client secret exists to confirm; Forte charges immediately.
const result = await forte.projects.createPayment({
projectId,
userId,
createPaymentRequest: {
currency: "usd",
lineItems: [{ description: "Usage — May", unitAmountCents: 4200, quantity: 1 }],
paymentMethodId: "pm_...", // a card or bank account the user previously saved
offSession: true,
},
});- Cards complete (or decline) synchronously. A decline returns
PAYMENT_DECLINED. - ACH bank accounts can't fail synchronously — the Payment comes back
PROCESSINGand settles toCOMPLETEDorFAILEDover the following business days. Listen for thePAYMENT_COMPLETED/PAYMENT_FAILEDtriggers to learn the outcome.
offSession: true requires paymentMethodId — without it the call returns PAYMENT_OFF_SESSION_METHOD_REQUIRED. To put a card or bank account on file for later, see saving a payment method.
For recurring billing on a schedule, use Subscriptions instead of calling createPayment yourself on a timer.
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_capture |
PROCESSING | processing, requires_action (for example, an ACH bank account being verified — see ACH direct debit) |
COMPLETED | succeeded |
CANCELLED | canceled |
FAILED | anything else |
Forte updates this automatically through Stripe webhooks. When a Payment hits COMPLETED and had a stripeTaxCalculationId, Forte also finalizes a Stripe Tax Transaction so your tax bookkeeping stays correct.
A Payment the customer never confirms is short-lived. If it stays in DRAFT for 24 hours, Forte automatically moves it to CANCELLED and cancels the underlying PaymentIntent, so it can no longer be confirmed or charged. If the customer comes back later, create a fresh payment rather than reusing the old stripeClientSecret.
Refunding a payment
refundPayment issues a full refund of a COMPLETED payment and transitions it to REFUNDED. Forte returns the entire charged amount, including any tax, to your end-customer.
Refunds can only be initiated from your backend using an API key. No client-side (signed-in user) route exists — 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
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 });
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 — for example, issue licenses, email receipts, write to your own ledger, or notify downstream systems.
Forte delivers triggers over a private network to your Service.
Trigger events
| Event | Fires when |
|---|---|
PAYMENT_COMPLETED | Payment transitions to COMPLETED. |
PAYMENT_FAILED | Payment transitions to FAILED. Most useful for off-session ACH charges, which can bounce (for example, insufficient funds) days after they returned PROCESSING — this is how your backend learns of the failure, since the customer isn't on-session to see it. |
PAYMENT_REFUNDED | Payment transitions to REFUNDED — whether from your own refundPayment call (or the console Refund button), a refund issued directly from the Stripe dashboard, or a lost dispute. |
Creating a payment trigger
Triggers are project-scoped. Create, update, and delete them 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, one of"COMPLETED","FAILED", or"REFUNDED".
Your code must check the X-Forte-Trusted header. Forte automatically strips it from any other request, so its presence guarantees the hook is valid and trusted.
Trigger retry policy
You can view any failed (non-HTTP 2xx) trigger invocations in the web console. Forte retries them 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 |
Forte abandons the trigger after 5 failed attempts. 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 through the payments API just like in production. If the selected user has no COMPLETED (or REFUNDED) payments yet, the CLI offers 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 is a pk_test_.... Switch the project to live (or use a separate live project) to charge real cards.
Audit trail and logging
Forte writes payments and state events to the audit trail for the user they belong to. Payment previews are not logged.