Skip to content

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-intents

Authentication: 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 ​

FieldTypeRequiredDescription
organization_idstringYes*Organization hashkey — TransactCore resolves the connected Stripe account for this organization
connected_account_idstringYes*Connected account hashkey — use this if you already know the specific account
amountintegerYesAmount in cents (e.g., 3420 = $34.20). Minimum: 50 ($0.50)
currencystringNoThree-letter ISO currency code (default: usd)
metadataobjectNoArbitrary key-value pairs attached to the Stripe PaymentIntent
capture_methodstringNoautomatic (default), manual (authorize now, capture later), or automatic_async
descriptionstringNoFree text, max 1000 characters
order_idstringNoYour order identifier. Merged into metadata.orderid — and so onto the Stripe charge — unless metadata already carries orderid or order_id
billing_detailsobjectNoname, email, phone. Null and empty values are stripped; the key is omitted entirely if nothing survives
receipt_emailstringNoMakes Stripe send its own receipt. See the warning below
sourcestringNoOriginating 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 ​

FieldTypeDescription
client_secretstringStripe client secret — pass this to stripe.confirmPayment() on the frontend
payment_intent_idstringStripe PaymentIntent ID — store this on your backend for order tracking
publishable_keystringStripe publishable key (same as from /config/stripe)
amountintegerConfirmed amount in cents
currencystringCurrency code
statusstringInitial status (see the status table under Get Payment Intent)
capture_methodstringautomatic, manual, or automatic_async
application_fee_amountinteger|nullPlatform fee in cents, when one applies
payment_methodstring|nullStripe PaymentMethod ID, when one is already attached
metadataobject|nullMetadata stored on the intent, including the source and any orderid merged from order_id
latest_charge_idstring|nullStripe Charge ID (ch_…) of the intent's latest charge, or null when no charge exists yet
capture_beforenullAlways 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 ​

ParameterTypeDescription
payment_intent_idstringThe 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 ​

FieldTypeDescription
payment_intent_idstringStripe PaymentIntent ID
amountintegerAmount in cents
currencystringCurrency code
statusstringCurrent status (see table below)
capture_methodstringCapture method (automatic or manual)
application_fee_amountinteger|nullPlatform fee in cents, when one applies
payment_methodstring|nullStripe PaymentMethod ID, when one is attached
metadataobject|nullMetadata stored on the intent
latest_charge_idstring|nullStripe Charge ID (ch_…) of the intent's latest charge, or null when no charge exists yet
capture_beforestring|nullWhen 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, null means 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 ​

StatusDescription
requires_payment_methodAwaiting payment method from customer
requires_confirmationPayment method collected, awaiting confirmation
requires_actionCustomer authentication required (e.g., 3D Secure)
requires_capturePayment authorized, awaiting capture (manual capture only)
processingPayment is being processed
succeededPayment completed successfully
canceledPayment 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}/cancel

Authentication: OAuth Token

Scope: transactcore:payment-intents.create

Rate Limit: 100/min per IP

Path Parameters ​

ParameterTypeDescription
payment_intent_idstringThe 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}/capture

Authentication: 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 ​

ParameterTypeDescription
payment_intent_idstringThe Stripe PaymentIntent ID (e.g., pi_3abc123)

Request Body ​

json
{
  "amount_to_capture": 2500
}
FieldTypeRequiredDescription
amount_to_captureintegerNoAmount 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}/refund

Authentication: 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 ​

ParameterTypeDescription
payment_intent_idstringThe Stripe PaymentIntent ID (e.g., pi_3abc123)

Request Body ​

json
{
  "amount": 1000,
  "reason": "requested_by_customer"
}
FieldTypeRequiredDescription
amountintegerNoAmount in cents to refund, minimum 1. Omit for a full refund. Must not exceed the charge's remaining refundable amount.
reasonstringNoOne 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.

FieldTypeDescription
typestringStripe's error type (invalid_request_error, card_error, rate_limit_error, …), or api_error when Stripe's body carried none
codestring|nullStripe's error code, e.g. balance_insufficient
decline_codestring|nullSet only for card errors
paramstring|nullThe request parameter Stripe objected to
messagestringStripe's own message, without the Failed to create refund: prefix that the top-level message carries
request_idstring|nullStripe'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 ​

  1. Always cancel on failure — If order creation fails after payment confirmation, cancel the payment intent to release the customer's funds.
  2. Store the payment_intent_id — Save the payment_intent_id on your backend when creating the intent, before sending the client_secret to the frontend. This ensures you can always look up or cancel the payment.
  3. Poll for status — After stripe.confirmPayment(), poll GET /payment-intents/{id} to confirm the final status before creating the order.
  4. Use metadata — Attach order identifiers to the payment intent metadata for easier reconciliation.
  5. Handle 3D Secure — Use redirect: 'if_required' in stripe.confirmPayment() to handle SCA/3DS challenges without forcing a redirect.

Changelog
DateChange
2026-09-13Added 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-12Documented the Capture and Refund endpoints, including refund's stripe_error object.
2026-09-12Corrected Cancel's response shape, and Create's error responses, request fields and response fields, against the service.
2026-03-19Added connected account lookup endpoint and fixed scope format.
2026-03-19Initial publication.

ShopHero CommerceCore Platform