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.
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.
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.
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."
| 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. |
Both routes are project-scoped:
POST /api/v1/projects/{projectId}/users/{userId}/payments/previewPOST /api/v1/projects/{projectId}/users/{userId}/payments
Previewing a payment total with tax
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.
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:
{
"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.
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
| Event | Fires when |
|---|---|
PAYMENT_COMPLETED | Payment transitions to COMPLETED. |
PAYMENT_REFUNDED | Payment transitions to REFUNDED. |
Creating a payment trigger
Triggers are project-scoped. Create one with:
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.
Receiving and processing trigger events
For each matching event, Forte sends:
POST https://<service-internal-dns><targetPath>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".
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.
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
| Setting | Value |
|---|---|
| Total attempts | 5 |
| Delay between attempts | 60 seconds (fixed) |
| 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 — 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
2xxfast — 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.
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.