This guide walks through the complete payment lifecycle in LatticeStripe — from creating a customer to confirming a payment to issuing refunds. For an overview of Stripe's payment model, see the Stripe Payments documentation. For recurring billing see Subscriptions; for marketplace or platform charges see Connect.

Creating a Customer

Customers let you associate payments, subscriptions, and payment methods with a person or business. Creating a customer before charging is recommended — it enables features like saving payment methods and listing past charges.

{:ok, customer} = LatticeStripe.Customer.create(client, %{
  "email" => "alice@example.com",
  "name" => "Alice Johnson",
  "phone" => "+1-555-123-4567",
  "metadata" => %{
    "user_id" => "usr_123",
    "plan" => "pro"
  }
})

IO.puts("Created customer: #{customer.id}")
# Created customer: cus_OtVFqSomeStripeId

metadata is a hash of up to 50 key/value string pairs. Use it to link Stripe objects back to your own data model — it shows up in the Stripe Dashboard and is returned on every fetch.

Retrieving and Updating Customers

# Retrieve a customer by ID
{:ok, customer} = LatticeStripe.Customer.retrieve(client, "cus_OtVFqSomeStripeId")

# Update the customer's name and metadata
{:ok, updated} = LatticeStripe.Customer.update(client, customer.id, %{
  "name" => "Alice Smith",
  "metadata" => %{"plan" => "enterprise"}
})

Creating a PaymentIntent

A PaymentIntent represents your intent to collect payment from a customer. It tracks the lifecycle of the payment and handles retries, 3D Secure authentication, and more.

{:ok, intent} = LatticeStripe.PaymentIntent.create(client, %{
  "amount" => 4999,
  "currency" => "usd",
  "customer" => customer.id,
  "description" => "Pro plan subscription",
  "metadata" => %{"order_id" => "ord_456"}
})

IO.puts("PaymentIntent #{intent.id} — status: #{intent.status}")
# PaymentIntent pi_3OzqKZ2eZvKYlo2C1FRzQc8s — status: requires_payment_method

Amount is always in the smallest currency unit. For USD, that's cents: 4999 = $49.99. For JPY (zero-decimal currency), 4999 = ¥4,999.

Automatic vs. Manual Confirmation

confirmation_method selects who is allowed to call confirm; it does not itself attempt a payment. The default "automatic" flow has your client-side code call Stripe.js to confirm. For a server-owned confirmation flow (for example, backend-only flows or Stripe Connect), create the intent with confirmation_method: "manual", then call confirm/3:

{:ok, intent} = LatticeStripe.PaymentIntent.create(client, %{
  "amount" => 4999,
  "currency" => "usd",
  "confirmation_method" => "manual",
  "payment_method" => "pm_card_visa"
})

Confirming a PaymentIntent

For a manually confirmed PaymentIntent, confirm/3 attempts the payment. If it returns :requires_action, branch on the action type: Stripe.js handles use_stripe_sdk with the client secret, while redirect actions provide a URL. Do not assume every SCA flow redirects.

case LatticeStripe.PaymentIntent.confirm(client, intent.id, %{
  "payment_method" => "pm_card_visa"
}) do
  {:ok, confirmed} ->
    case confirmed.status do
      :succeeded ->
        IO.puts("Payment succeeded!")

      :requires_action ->
        case confirmed.next_action do
          %{"type" => "use_stripe_sdk"} ->
            # Return confirmed.client_secret to your authenticated client and let
            # Stripe.js handle the required next action there.
            IO.puts("3D Secure required — handle the next action with Stripe.js")

          %{"type" => "redirect_to_url", "redirect_to_url" => %{"url" => url}} ->
            IO.puts("Customer action required — redirect to: #{url}")

          _ ->
            IO.puts("Customer action required — inspect next_action before continuing")
        end

      other ->
        IO.puts("Unexpected status: #{other}")
    end

  {:error, %LatticeStripe.Error{type: :card_error} = err} ->
    IO.puts("Card declined: #{err.message}")
    IO.puts("Decline code: #{err.decline_code}")
end

Status values: LatticeStripe atomizes known PaymentIntent statuses on %PaymentIntent{} (e.g. :succeeded, :requires_action). Stripe's API reference, Dashboard, webhooks, and search queries use the wire string names below.

The PaymentIntent status machine:

  • requires_payment_method → attach a payment method
  • requires_confirmation → call confirm/3
  • requires_action → customer must complete authentication (e.g., 3D Secure)
  • processing → payment is being processed (async)
  • succeeded → payment successful
  • canceled → terminal state

Capturing a PaymentIntent (Manual Capture)

If you need to authorize a payment now but capture funds later — for example, when fulfillment happens after checkout — create the PaymentIntent with capture_method: "manual":

# Step 1: Authorize (hold funds on the card, don't capture yet)
{:ok, intent} = LatticeStripe.PaymentIntent.create(client, %{
  "amount" => 4999,
  "currency" => "usd",
  "payment_method" => "pm_card_visa",
  "capture_method" => "manual",
  "confirm" => true
})

IO.puts("Authorized: #{intent.status}")
# Authorized: requires_capture

# (Later, once the order ships or service is fulfilled)

# Step 2: Capture the authorized funds
{:ok, captured} = LatticeStripe.PaymentIntent.capture(client, intent.id)
IO.puts("Captured: #{captured.status}")
# Captured: succeeded

You can also capture a partial amount:

{:ok, captured} = LatticeStripe.PaymentIntent.capture(client, intent.id, %{
  "amount_to_capture" => 2500  # Capture only $25.00 instead of $49.99
})

Uncaptured authorizations automatically expire after 7 days (or 2 days for some card networks). See Stripe's capture docs.

Canceling a PaymentIntent

Cancel a PaymentIntent that hasn't succeeded or been captured yet:

{:ok, canceled} = LatticeStripe.PaymentIntent.cancel(client, intent.id, %{
  "cancellation_reason" => "abandoned"
})

IO.puts("Status: #{canceled.status}")
# Status: canceled

Valid cancellation reasons: "duplicate", "fraudulent", "requested_by_customer", "abandoned". The canceled status is terminal — you cannot revive a canceled PaymentIntent.

Listing and Searching

Listing with Filters

# List recent PaymentIntents for a specific customer
{:ok, resp} = LatticeStripe.PaymentIntent.list(client, %{
  "customer" => customer.id,
  "limit" => 10
})

intents = resp.data.data
IO.puts("Found #{length(intents)} PaymentIntents")

Auto-Pagination with Streams

Use list/2 when one bounded page is the result you need. For a large collection, or one whose size is unknown, prefer stream!/2: it auto-paginates lazily instead of loading every item at once.

# Process all succeeded PaymentIntents in the last 30 days
client
|> LatticeStripe.PaymentIntent.stream!(%{"created" => %{"gte" => thirty_days_ago}})
|> Stream.filter(fn intent -> intent.status == :succeeded end)
|> Stream.map(fn intent -> intent.amount end)
|> Enum.sum()
|> then(fn total -> IO.puts("Total revenue: $#{total / 100}") end)

stream!/2 fetches a page only when enumeration needs more items. Lazy transforms such as Stream.filter/2 preserve that memory advantage; a terminal operation such as Enum.to_list/1 materializes the entire collection.

A stream is not a transaction. If a later page fails, stream!/2 raises after any earlier items have already been yielded. Make per-item side effects idempotent and checkpoint progress, or stage the complete result before committing when the application requires all-or-nothing behavior.

Use search/3 for full-text search across PaymentIntents:

{:ok, resp} =
  LatticeStripe.PaymentIntent.search(client, "metadata['order_id']:'ord_456'")

results = resp.data.data

Note: Stripe's Search API has eventual consistency. Newly created objects may not appear in search results immediately. For real-time lookups, use list/3 with filters or retrieve/3 by ID. See Stripe Search docs.

Charge reconciliation

When a PaymentIntent succeeds, Stripe creates a Charge — the settled payment result record. New integrations should use LatticeStripe.PaymentIntent to accept payments; use LatticeStripe.Charge to read and reconcile those result records after the fact. LatticeStripe.Charge.create at arity 3 will not be added: Charge is a read/reconciliation resource, not a payment-initiation API. For direct server-side payment initiation, use LatticeStripe.PaymentIntent.create/3:

{:ok, intent} =
  LatticeStripe.PaymentIntent.create(client, %{
    "amount" => 4_999,
    "currency" => "usd",
    "payment_method" => "pm_card_visa",
    "confirm" => true
  })

A successful PaymentIntent creates the resulting Charge for reconciliation. Here "confirm" => true asks Stripe to make the initial confirmation on the server; do not then call Stripe.js to confirm that same PaymentIntent. If it returns :requires_action, send the client secret to the authenticated client and use Stripe.js to handle the returned next_action (or redirect only when the action explicitly supplies a redirect URL). Customer action or SCA may still be required.

GoalFunctionNotes
Fetch one charge by idretrieve/3Expand balance_transaction for fee details
Filter chargeslist/3Real-time; use filters for fresh lookups
Auto-paginate large setsstream!/3Lazy pagination over list/3
Full-text lookupsearch/3Query string syntax; eventually consistent
Post-hoc metadataupdate/4Metadata/description only — not payment state
Capture uncaptured legacy chargecapture/4Legacy direct charges only — see Capturing a PaymentIntent for PI flows

Retrieve a charge

{:ok, charge} =
  LatticeStripe.Charge.retrieve(client, "ch_3OoLqrJ...",
    expand: ["balance_transaction"]
  )

List and stream charges

{:ok, resp} = LatticeStripe.Charge.list(client, %{"customer" => customer.id, "limit" => 10})

client
|> LatticeStripe.Charge.stream!()
|> Stream.take(100)
|> Enum.each(&process_charge/1)

Search charges

{:ok, resp} =
  LatticeStripe.Charge.search(client, "status:'succeeded' AND customer:'cus_123'")

Note: Like PaymentIntent search, Charge search is eventually consistent. Do not use search/3 to confirm a payment that just succeeded — use retrieve/3 or follow the PaymentIntent state instead.

Update metadata on a settled charge

{:ok, charge} =
  LatticeStripe.Charge.update(client, "ch_3OoLqrJ...", %{
    "metadata" => %{"support_ticket" => "TKT-789"},
    "description" => "Order #456 — support update"
  })

Capture a legacy direct charge

For uncaptured legacy direct charges (not PaymentIntent-initiated), use capture/4:

{:ok, charge} = LatticeStripe.Charge.capture(client, "ch_3OoLqrJ...")

For PaymentIntent manual capture, use PaymentIntent.capture/4 instead.

Connect platforms reconciling application fees should walk balance_transaction.fee_details — see Connect Money Movement for the full fee walkthrough.

Operator guides: Production Checklist §Support and audit lookups; Event Debugging §charge.* events.

Refunding a Payment

To return funds to a customer, create a Refund referencing the original PaymentIntent:

Full Refund

{:ok, refund} = LatticeStripe.Refund.create(client, %{
  "payment_intent" => intent.id,
  "reason" => "requested_by_customer"
})

IO.puts("Refund #{refund.id} — status: #{refund.status}")
# Refund re_3OzqKZ2eZvKYlo2C1FRzQc8s — status: succeeded

Partial Refund

Specify an amount to refund only part of the original charge:

# Refund $10.00 of a $49.99 payment
{:ok, refund} = LatticeStripe.Refund.create(client, %{
  "payment_intent" => intent.id,
  "amount" => 1000
})

Refund Reasons

Valid reasons: "duplicate", "fraudulent", "requested_by_customer". The reason affects how the refund appears in the Stripe Dashboard and any reporting. Omitting the reason is also valid.

Listing Refunds

{:ok, resp} = LatticeStripe.Refund.list(client, %{
  "payment_intent" => intent.id
})

refunds = resp.data.data

Working with Idempotency Keys

LatticeStripe generates an idempotency key for every POST and reuses it for automatic retries inside that one library call. For durable work that may cross a process crash, job restart, or message redelivery, pass an explicit key derived from the business operation:

{:ok, intent} = LatticeStripe.PaymentIntent.create(client, %{
  "amount" => 4999,
  "currency" => "usd",
  "customer" => customer.id
},
  idempotency_key: "payment_intent:create:order:#{order.id}:v1"
)

Reuse that key only for another attempt at the same operation with the same parameters. Do not create a fresh random key on each job attempt or delegate key generation to a magic callback: business identity belongs with the operation. When a timed-out request has an indeterminate outcome, reconcile through a webhook or retrieve before deciding what to do next. See Client Configuration for the complete policy.

Common Pitfalls

Amount is in the smallest currency unit (cents for USD). 4999 means $49.99, not $4,999. Always think in cents when working with Stripe. This is the single most common mistake when integrating Stripe for the first time.

PaymentIntent status machine — transitions only go one direction. You can't capture a canceled PaymentIntent. You can't confirm an already-succeeded one. Always check intent.status before performing an action, and handle the case where the intent is in an unexpected state.

Idempotency keys must be unique per distinct request. If you want to create two different payments for the same customer on the same order, use different keys (for example, include the operation kind or line-item identity). Reusing a key with different parameters produces an idempotency error, not a new payment.

Automatic confirmation vs. manual confirmation. With "automatic" confirmation, your frontend calls Stripe.js to confirm the payment. With confirmation_method: "manual", your backend must call confirm/3; setting that field alone does not confirm anything. In either flow, a :requires_action response may require Stripe.js to handle a use_stripe_sdk next action or a browser redirect when Stripe supplies redirect_to_url.

Search API has eventual consistency. Newly created objects may not appear in search results for up to a few seconds. Don't use search for real-time workflows — use retrieve/3 or list/3 with filters instead. See Stripe's search documentation for consistency guarantees.

See also

  • Checkout — Stripe-hosted payment pages for the same flows
  • Subscriptions — recurring billing on top of the payment primitives
  • Tax — standalone Tax.Calculation → Tax.Transaction flow when you own the cart and tax logic
  • Client Configuration — durable idempotency and request overrides
  • Error Handling — card errors, retries, and idempotency
  • Webhooks — confirm payment completion via payment_intent.succeeded