Appearance
Payment Intents
The Payment Intents API manages the lifecycle of Stripe PaymentIntents for ecommerce checkout.
Payment Intent Flow
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Client │ │ TransactCore│ │ Stripe │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ POST /payment- │ │
│ intents │ │
│──────────────────>│ │
│ │ Create Intent │
│ │──────────────────>│
│ │ client_secret │
│ │<──────────────────│
│ client_secret │ │
│<──────────────────│ │
│ │ │
│ stripe.confirm │ │
│ Payment() │ │
│─────────────────────────────────────>│
│ Payment result │
│<─────────────────────────────────────│
│ │ │
│ GET /payment- │ │
│ intents/{id} │ │
│──────────────────>│ Sync status │
│ │──────────────────>│
│ status: │ │
│ succeeded │ │
│<──────────────────│ │Create Payment Intent
Create a Stripe PaymentIntent for processing a payment.
POST /v1/ecommerce/payment-intentsAuthentication: OAuth Token
Scope: transactcore:payment-intents.create
Rate Limit: 100/min per IP
Request Body
json
{
"organization_id": "org_00000k1L2m3N4o5",
"amount": 3420,
"currency": "usd",
"metadata": {
"order_type": "takeout",
"quote_token_id": "qt_abc123"
}
}Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
organization_id | string | Yes* | Organization hashkey — TransactCore resolves the connected Stripe account for this organization |
connected_account_id | string | Yes* | Connected account hashkey — use this if you already know the specific account |
amount | integer | Yes | Amount in cents (e.g., 3420 = $34.20). Minimum: 50 ($0.50) |
currency | string | No | Three-letter ISO currency code (default: usd) |
metadata | object | No | Arbitrary key-value pairs attached to the Stripe PaymentIntent |
capture_method | string | No | automatic (default), manual (authorize now, capture later), or automatic_async |
description | string | No | Free text, max 1000 characters |
order_id | string | No | Your order identifier. Merged into metadata.orderid — and so onto the Stripe charge — unless metadata already carries orderid or order_id |
billing_details | object | No | name, email, phone. Null and empty values are stripped; the key is omitted entirely if nothing survives |
receipt_email | string | No | Makes Stripe send its own receipt. See the warning below |
source | string | No | Originating channel. Legacy aliases are normalized; whether this is required is deployment-configured |
*One of organization_id or connected_account_id is required.
Pass the shopper's email as billing_details.email
Setting receipt_email makes Stripe send its own receipt on top of yours, and in live mode it does so regardless of the Dashboard's email settings. Use billing_details.email to record the shopper's address without triggering a second receipt.
currency is lowercased before validation, so USD is accepted. Accepted codes are usd, cad, eur, gbp, aud. amount must be between 50 and 99999999 cents.
Response
json
{
"client_secret": "pi_3abc123_secret_xyz789",
"payment_intent_id": "pi_3abc123",
"publishable_key": "pk_live_xxxxx",
"amount": 3420,
"currency": "usd",
"latest_charge_id": null,
"capture_before": null
}Status: 201 Created
Response Fields
| Field | Type | Description |
|---|---|---|
client_secret | string | Stripe client secret — pass this to stripe.confirmPayment() on the frontend |
payment_intent_id | string | Stripe PaymentIntent ID — store this on your backend for order tracking |
publishable_key | string | Stripe publishable key (same as from /config/stripe) |
amount | integer | Confirmed amount in cents |
currency | string | Currency code |
status | string | Initial status (see the status table under Get Payment Intent) |
capture_method | string | automatic, manual, or automatic_async |
application_fee_amount | integer|null | Platform fee in cents, when one applies |
payment_method | string|null | Stripe PaymentMethod ID, when one is already attached |
metadata | object|null | Metadata stored on the intent, including the source and any orderid merged from order_id |
latest_charge_id | string|null | Stripe Charge ID (ch_…) of the intent's latest charge, or null when no charge exists yet |
capture_before | null | Always null on this endpoint — call Get Payment Intent to read the authorization hold deadline |
capture_before is always null on Create
Create does not look up the charge, so it never knows the hold deadline. Call Get Payment Intent once the payment is authorized to read it — see Authorization hold deadline.
Error Responses
A card decline returns HTTP 200
A declined card is a payment outcome, not a transport failure, so it comes back with a 200 status and an error object in the body. Branching on the HTTP status alone will read a decline as a success — check for the error key.
200 — Card declined:
json
{
"payment_intent_id": "pi_3abc123",
"status": "requires_payment_method",
"amount": 3420,
"currency": "usd",
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds."
}
}payment_intent_id is null when Stripe declined before an intent existed. status falls back to requires_payment_method when Stripe returned no intent.
404 — No payment account configured:
json
{
"error": "No payment account configured for this location",
"connected_account_id": null,
"organization_id": "org_00000k1L2m3N4o5"
}The organization does not have a connected Stripe account. The account must be created and onboarded through the admin dashboard before payments can be accepted. Both identifier keys are always present; the one you did not send is null.
400 — Account cannot accept payments:
json
{
"error": "Payment account cannot accept payments",
"details": {
"status": "restricted",
"charges_enabled": false,
"requirements": "incomplete"
}
}The connected Stripe account exists but has incomplete onboarding or is restricted.
422 — Validation error:
json
{
"message": "The amount field must be at least 50.",
"errors": {
"amount": ["The amount field must be at least 50."]
}
}Stripe API error — status mirrors Stripe:
json
{
"error": {
"type": "invalid_request_error",
"code": "resource_missing",
"param": "payment_method",
"message": "No such PaymentMethod: 'pm_missing'",
"request_id": "req_wzHpn6MLeCIXNy"
}
}A non-decline Stripe error (bad customer or payment method, authentication, rate limit) returns Stripe's own HTTP status, falling back to 502 when Stripe reported none — it is not normalized to 500. Note that error is an object here, unlike the string form used by the errors above. Quote request_id when asking Stripe support or searching the Dashboard logs.
500 — Everything else:
json
{
"error": "Failed to create payment intent",
"message": "SQLSTATE[HY000]: General error: 2006 MySQL server has gone away"
}Get Payment Intent
Retrieve the current status of a payment intent. TransactCore syncs with Stripe in real-time for non-terminal statuses.
GET /v1/ecommerce/payment-intents/{payment_intent_id}Authentication: OAuth Token
Scope: transactcore:payment-intents.read
Rate Limit: 100/min per IP
Path Parameters
| Parameter | Type | Description |
|---|---|---|
payment_intent_id | string | The Stripe PaymentIntent ID (e.g., pi_3abc123) |
Response
json
{
"data": {
"payment_intent_id": "pi_3abc123",
"amount": 3420,
"currency": "usd",
"status": "requires_capture",
"capture_method": "manual",
"application_fee_amount": 99,
"payment_method": "pm_1abc123",
"metadata": {
"source": "ecommerce",
"orderid": "ad83f1"
},
"latest_charge_id": "ch_3abc123",
"capture_before": "2026-09-19T18:30:00.000000Z"
}
}Response Fields
| Field | Type | Description |
|---|---|---|
payment_intent_id | string | Stripe PaymentIntent ID |
amount | integer | Amount in cents |
currency | string | Currency code |
status | string | Current status (see table below) |
capture_method | string | Capture method (automatic or manual) |
application_fee_amount | integer|null | Platform fee in cents, when one applies |
payment_method | string|null | Stripe PaymentMethod ID, when one is attached |
metadata | object|null | Metadata stored on the intent |
latest_charge_id | string|null | Stripe Charge ID (ch_…) of the intent's latest charge, or null when no charge exists yet |
capture_before | string|null | When the authorization hold lapses, as an ISO 8601 UTC timestamp (for example 2026-09-19T18:30:00.000000Z). null when there is no open hold — see below |
Cancel Payment Intent and Capture Payment Intent return this same set of fields.
Authorization hold deadline
A manual-capture payment (capture_method: manual) places a hold on the customer's card that expires if it is not captured in time. capture_before tells you when:
- While the hold is open (
status: requires_capture), it is the time by which you must capture. After it passes, the hold lapses and the funds are released to the customer. - Create never returns it. The first Get Payment Intent call after authorization fills it in, and it is stored, so later calls return the same value.
- Once the payment is captured or canceled it returns to
null. On a finished payment,nullmeans there is no hold — not that the deadline is unknown. - Automatic-capture payments never have a hold, so it is always
null.
To find orders at risk of lapsing, look for status: requires_capture with a capture_before close to the current time.
Payment Intent Statuses
| Status | Description |
|---|---|
requires_payment_method | Awaiting payment method from customer |
requires_confirmation | Payment method collected, awaiting confirmation |
requires_action | Customer authentication required (e.g., 3D Secure) |
requires_capture | Payment authorized, awaiting capture (manual capture only) |
processing | Payment is being processed |
succeeded | Payment completed successfully |
canceled | Payment was canceled |
Real-Time Sync
For non-terminal statuses (requires_*, processing), TransactCore syncs with Stripe before returning the response. This handles the race condition between client-side confirmation and webhook delivery — you can poll this endpoint immediately after stripe.confirmPayment() to get the latest status.
Error Response
404 — Payment intent not found:
json
{
"error": "Payment intent not found"
}Cancel Payment Intent
Cancel a payment intent. Use this when order creation fails after payment confirmation, to release the hold on the customer's card.
POST /v1/ecommerce/payment-intents/{payment_intent_id}/cancelAuthentication: OAuth Token
Scope: transactcore:payment-intents.create
Rate Limit: 100/min per IP
Path Parameters
| Parameter | Type | Description |
|---|---|---|
payment_intent_id | string | The Stripe PaymentIntent ID (e.g., pi_3abc123) |
Response
json
{
"data": {
"payment_intent_id": "pi_3abc123",
"amount": 3420,
"currency": "usd",
"status": "canceled",
"capture_method": "automatic",
"application_fee_amount": 99,
"payment_method": "pm_1abc123",
"metadata": {
"source": "ecommerce",
"orderid": "ad83f1"
},
"latest_charge_id": null,
"capture_before": null
}
}The payload is wrapped in data, the same envelope Get Payment Intent uses, and the status is Stripe's American spelling canceled.
Error Responses
404 — Payment intent not found:
json
{
"error": "Payment intent not found"
}400 — Payment intent cannot be canceled:
json
{
"error": "Payment intent cannot be canceled"
}The local state already rules cancellation out — the intent has reached succeeded or canceled. If the payment has already been captured, create a refund instead.
422 — Unable to cancel:
json
{
"error": "Unable to cancel payment intent",
"message": "This PaymentIntent has already succeeded and cannot be canceled."
}The local state allowed the attempt but the gateway refused it — typically a status that changed at Stripe since TransactCore last synced.
Capture Payment Intent
Capture a previously authorized payment intent. Supports partial capture.
POST /v1/ecommerce/payment-intents/{payment_intent_id}/captureAuthentication: OAuth Token
Scope: transactcore:payment-intents.create
Rate Limit: 100/min per IP
Idempotency: send an Idempotency-Key header; it is forwarded to Stripe.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
payment_intent_id | string | The Stripe PaymentIntent ID (e.g., pi_3abc123) |
Request Body
json
{
"amount_to_capture": 2500
}| Field | Type | Required | Description |
|---|---|---|---|
amount_to_capture | integer | No | Amount in cents to capture, minimum 1. Omit to capture the full authorized amount. |
Response
json
{
"data": {
"payment_intent_id": "pi_3abc123",
"amount": 3420,
"currency": "usd",
"status": "succeeded",
"capture_method": "manual",
"application_fee_amount": 99,
"payment_method": "pm_1abc123",
"metadata": {
"source": "ecommerce"
},
"latest_charge_id": "ch_3abc123",
"capture_before": null
}
}Error Responses
404 — Payment intent not found:
json
{
"error": "Payment intent not found"
}400 — Payment intent cannot be captured:
json
{
"error": "Payment intent cannot be captured"
}Only an intent whose capture_method is manual and whose status is requires_capture can be captured.
500 — Capture failed:
json
{
"error": "Failed to capture payment intent",
"message": "Failed to capture payment intent: ..."
}Refund Payment Intent
Refund a payment intent. Supports partial refunds.
POST /v1/ecommerce/payment-intents/{payment_intent_id}/refundAuthentication: OAuth Token
Scope: transactcore:refunds.create
Rate Limit: 100/min per IP
Idempotency: send an Idempotency-Key header; it is forwarded to Stripe, and TransactCore additionally keys the stored refund on Stripe's refund ID so a replay returns the original refund rather than issuing a second one.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
payment_intent_id | string | The Stripe PaymentIntent ID (e.g., pi_3abc123) |
Request Body
json
{
"amount": 1000,
"reason": "requested_by_customer"
}| Field | Type | Required | Description |
|---|---|---|---|
amount | integer | No | Amount in cents to refund, minimum 1. Omit for a full refund. Must not exceed the charge's remaining refundable amount. |
reason | string | No | One of duplicate, fraudulent, requested_by_customer. |
Response
json
{
"data": {
"refund_id": "re_3abc123",
"amount": 1000,
"currency": "usd",
"status": "succeeded",
"payment_intent_id": "pi_3abc123"
}
}Status: 201 Created
Error Responses
404 — Payment intent not found:
json
{
"error": "Payment intent not found"
}404 — No completed transaction:
json
{
"error": "No completed transaction found for this payment intent"
}The intent exists but carries no succeeded charge to refund against.
400 — Transaction cannot be refunded:
json
{
"error": "Transaction cannot be refunded"
}The charge did not succeed, or it has already been refunded in full.
400 — Refund amount exceeds refundable amount:
json
{
"error": "Refund amount exceeds refundable amount"
}422 — Deterministic Stripe error:
json
{
"error": "Refund could not be processed",
"message": "Failed to create refund: Insufficient funds in your Stripe balance to refund this amount.",
"stripe_error": {
"type": "invalid_request_error",
"code": "balance_insufficient",
"decline_code": null,
"param": "amount",
"message": "Insufficient funds in your Stripe balance to refund this amount.",
"request_id": "req_wzHpn6MLeCIXNy"
}
}A Stripe 4xx other than 429 will fail identically on retry, so it is surfaced as a 422 rather than a retryable 5xx.
500 — Retryable failure:
json
{
"error": "Failed to create refund",
"message": "Failed to create refund: Too many requests.",
"stripe_error": {
"type": "rate_limit_error",
"code": "rate_limit",
"decline_code": null,
"param": null,
"message": "Too many requests.",
"request_id": "req_rateLimited99"
}
}Stripe 429, 5xx, and failures that never reached Stripe at all.
The stripe_error object
error and message are always present and always strings. stripe_error is additive — present only when Stripe actually answered — so a caller reading the two original keys is unaffected by it.
| Field | Type | Description |
|---|---|---|
type | string | Stripe's error type (invalid_request_error, card_error, rate_limit_error, …), or api_error when Stripe's body carried none |
code | string|null | Stripe's error code, e.g. balance_insufficient |
decline_code | string|null | Set only for card errors |
param | string|null | The request parameter Stripe objected to |
message | string | Stripe's own message, without the Failed to create refund: prefix that the top-level message carries |
request_id | string|null | Stripe's request ID — quote this to find the request in the Stripe Dashboard logs |
stripe_error is omitted entirely when the failure never reached Stripe — a DNS, TLS, or connection timeout, or a local error inside TransactCore. Its absence means there is no Stripe request to look up, so treat presence of the key as the signal that a Dashboard search will find something.
Best Practices
- Always cancel on failure — If order creation fails after payment confirmation, cancel the payment intent to release the customer's funds.
- Store the payment_intent_id — Save the
payment_intent_idon your backend when creating the intent, before sending theclient_secretto the frontend. This ensures you can always look up or cancel the payment. - Poll for status — After
stripe.confirmPayment(), pollGET /payment-intents/{id}to confirm the final status before creating the order. - Use metadata — Attach order identifiers to the payment intent
metadatafor easier reconciliation. - Handle 3D Secure — Use
redirect: 'if_required'instripe.confirmPayment()to handle SCA/3DS challenges without forcing a redirect.
Changelog
| Date | Change |
|---|---|
| 2026-09-13 | Added latest_charge_id and capture_before to the Create, Get, Cancel and Capture responses, with a section on the authorization hold deadline. Completed Get's field list. |
| 2026-09-12 | Documented the Capture and Refund endpoints, including refund's stripe_error object. |
| 2026-09-12 | Corrected Cancel's response shape, and Create's error responses, request fields and response fields, against the service. |
| 2026-03-19 | Added connected account lookup endpoint and fixed scope format. |
| 2026-03-19 | Initial publication. |