Log in

Creating Payments

Forte's Payments API lets you charge your end-users on the payment provider account you connected to Forte. Under the hood we use Stripe Connect direct charges, so money flows from the end-user's card straight into your connected Stripe account. Forte handles the orchestration — tax calculation, lifecycle, triggers — but never touches the funds.

First-time setup

Connect Stripe and complete onboarding from Payments Setup, and finish your Compliance registration before going live.

Open Payments Setup →

Prerequisites

  • Your Forte project owner has completed payments onboarding (Stripe Connect) and 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.
  • The end-user (User) you're charging already exists in your project's users.
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 →

Preview vs. create: which endpoint to call

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.

Both routes are project-scoped:

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

Previewing a payment total with tax

bash
curl -X POST $FORTE_HOST/api/v1/projects/$PROJECT_ID/users/$USER_ID/payments/preview \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "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.

bash
curl -X POST $FORTE_HOST/api/v1/projects/$PROJECT_ID/users/$USER_ID/payments \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "currency": "usd",
    "description": "May 2026 invoice",
    "lineItems": [
      { "description": "Pro plan", "unitAmountCents": 2500, "quantity": 1, "taxCode": "txcd_10000000" }
    ],
    "customerAddress": { "line1": "...", "city": "...", "state": "...", "postalCode": "...", "country": "US" }
  }'

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.

Payment triggers (webhooks to your services)

When a Payment changes state, Forte fires an HTTP POST from the gateway to one of your project's 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 the target service's internal DNS endpoint, so the receiver never needs to be exposed to the public internet.

Trigger events

EventFires when
PAYMENT_COMPLETEDPayment transitions to COMPLETED.
PAYMENT_REFUNDEDPayment transitions to REFUNDED.

Creating a payment trigger

Triggers are project-scoped. Create one with:

bash
curl -X POST $FORTE_HOST/api/v1/projects/$PROJECT_ID/payment-triggers \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "displayName": "Fulfillment service",
    "targetServiceId": "svc_...",
    "targetPath": "/internal/payments/trigger",
    "events": ["PAYMENT_COMPLETED", "PAYMENT_REFUNDED"],
    "enabled": true
  }'

targetServiceId must reference a service in the same project, and targetPath must start with /. Use GET, PUT, and DELETE on /payment-triggers[/{triggerId}] to list, edit, or remove triggers.

Open Services →

Receiving and processing trigger events

For each matching event, Forte sends:

javascript
POST https://<service-internal-dns><targetPath>

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

There is no request signature and no idempotency key. X-Forte-Trusted is meaningful only because the request arrives over your project's internal network — anything reaching your handler from the public internet should be rejected. Treat (paymentId, state) as the natural idempotency key, since Forte will retry if a 2xx response is dropped and your handler must tolerate the same trigger arriving more than once.

Re-fetch on material state changes

For anything that affects revenue, fulfillment, or user entitlements, GET the payment from the API before acting on it. The trigger body is enough to know that something happened, but the API is the source of truth for what the payment looks like right now.

Trigger retry policy

SettingValue
Total attempts5
Delay between attempts60 seconds (fixed)
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 — Forte logs the failure to Sentry but does not retry further, and there is no dead-letter queue. In practice, that means a sustained outage of your receiver (longer than ~5 minutes) will silently drop events; design your handler to be reachable and fast, and reconcile from the payments API on startup if you need stronger guarantees. Triggers that are disabled or deleted between firing and delivery are dropped silently with no failure event.

Best practices for trigger handlers

  • Return 2xx fast — slow handlers risk hitting the 30-second timeout and being retried.
  • Make handling idempotent on (paymentId, state).
  • For material state changes, re-fetch the payment via the API rather than trusting the body alone.

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