Log in

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.

First-time 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.
Live payments require approved compliance

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.

Open Compliance →

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."

OperationPurposeSide effects
createPaymentPreviewCompute 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.
createPaymentCreate 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/preview
  • POST /api/v1/{projectId}/users/me/payments

Server-side (API key):

  • POST /api/v1/projects/{projectId}/users/{userId}/payments/preview
  • POST /api/v1/projects/{projectId}/users/{userId}/payments

Previewing a payment total with tax

Client-side (signed-in user):

typescript
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):

typescript
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):

typescript
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):

typescript
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:

json
{
  "payment": {
    "id": "pay_...",
    "state": "DRAFT",
    "subtotalCents": 2500,
    "taxCents": 219,
    "amountCents": 2719,
    "stripePaymentIntentId": "pi_...",
    "stripeTaxCalculationId": "taxcalc_..."
  },
  "stripeClientSecret": "pi_..._secret_...",
  "stripePublishableKey": "pk_...",
  "stripeConnectedAccountId": "acct_..."
}
Open Users →

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.

ts
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: "..." } })
Pass stripeAccount to loadStripe

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 stateStripe status mapped from
DRAFTrequires_payment_method, requires_confirmation, requires_action, requires_capture
PROCESSINGprocessing
COMPLETEDsucceeded
CANCELLEDcanceled
FAILEDanything 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 are a backend-only operation

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

Revoke entitlements first — refunds do not notify your services

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:

  1. Revoke the customer's entitlements / access in your own backend.
  2. 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".

typescript
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

EventFires when
PAYMENT_COMPLETEDPayment transitions to COMPLETED.
PAYMENT_REFUNDEDPayment 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).
Refunds you initiate do not fire a trigger

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:

HeaderValue
Content-Typeapplication/json
X-Forte-Trusted1

Body:

json
{
  "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. GET it 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".
Trust comes from the network, not a signature

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:

SettingValue
Total attempts5
Delay between attempts60 seconds
Per-request timeout30 seconds
Counts as successHTTP 2xx
Counts as failureAny 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 2xx in 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.

bash
forte payment-triggers test

The 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:

KeyAction
enterPOST the webhook to your local URL — press again to re-fire the same payload.
uSearch for a different user.
pPick a different payment for the same user.
tEdit the local URL (defaults to http://localhost:<your service's port><trigger path>).
bOpen the user's page in the Forte console — useful for creating a sample payment if the user doesn't have one yet.
qQuit.

Skip the picker by passing the trigger ID directly:

bash
forte payment-triggers test pmt_trigger_<id>
The user must have a real payment in a fireable state

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.

Open Payments Setup →

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.

Search

Search documentation and console pages