Affirm payments

Affirm lets a US buyer split a purchase into installments. The buyer approves a plan on Affirm's site or app, you are paid the full amount up front, and Affirm collects the installments. On Flint it is the affirm payment option on the same PaymentIntent, order, Refund, Return, and Dispute resources you use for cards, with the same webhooks.

Two things set it apart from a card payment. The buyer leaves your page to approve the plan, so every Affirm payment needs a clean return URL, and the browser coming back is not proof of anything. And Affirm approves or declines each purchase on its own, so keep card available beside it.

How an Affirm payment flows#

Affirm paymentResponse
BrowserYour backendFlintAffirmcreate the PaymentIntent with payment_return_urlpayment_collection, including Flint's return_urlmount Elements, create a ConfirmationToken with that return_urlctoken_...POST /confirm with the tokenrequires_action with a payment_authentication actionhandleNextAction sends the buyer to Affirmbuyer approves or declines, Affirm returns to Flint's relay303 to your payment_return_url, no query parametersGET the PaymentIntentfinal status, also sent by webhook
  1. Your backend sends create the PaymentIntent with payment_return_url to Flint
  2. Flint returns payment_collection, including Flint's return_url to Your backend
  3. Browser sends mount Elements, create a ConfirmationToken with that return_url to Browser
  4. Browser sends ctoken_... to Your backend
  5. Your backend sends POST /confirm with the token to Flint
  6. Flint returns requires_action with a payment_authentication action to Your backend
  7. Browser sends handleNextAction sends the buyer to Affirm to Affirm
  8. Affirm sends buyer approves or declines, Affirm returns to Flint's relay to Flint
  9. Flint returns 303 to your payment_return_url, no query parameters to Browser
  10. Your backend sends GET the PaymentIntent to Flint
  11. Flint returns final status, also sent by webhook to Your backend

Flint Checkout and Payment Links run this whole sequence for you. A PaymentIntent your own server creates and confirms follows it step by step, building on the collect-and-confirm flow in Server-confirmed payments. A checkout you build on an embedded checkout session follows the same sequence through the order, as described in Your own checkout.

Turn on Affirm#

Affirm is off by default. Three things have to be in place before you can enable it: Affirm pricing on your merchant plan, an active Affirm capability on your payment account, and complete onboarding requirements. One request tells you which of them is still outstanding:

cURL
curl "https://api.withflintpay.com/v1/capabilities?domain=payments&capability=accept_affirm_payments" \
  -H "Authorization: Bearer YOUR_API_KEY"

status: "ready" means you can enable Affirm. While it is pending or blocked, each entry in blocked_reasons names the blocker, who resolves it in resolution_owner, and what to do in next_steps.

Once the capability is ready, add affirm to the checkout payment options in the dashboard or over the API:

cURL
curl -X PATCH https://api.withflintpay.com/v1/settings \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"checkout": {"enabled_payment_options": ["card", "affirm"]}}'
Warning:

Enabling Affirm before the capability is ready fails with PAYMENT_OPTION_NOT_READY (409). The error's details list every blocker, and its next action points back at the capability check. Clear the blockers, confirm status: "ready", then enable it.

Enabling Affirm makes it available to the merchant account as a whole. Each payment is then checked on its own for eligibility.

Eligibility#

Flint checks what it can see on the payment and the ConfirmationToken, and rejects a confirmation that breaks one of these rules:

  • The currency is USD and the amount is within the limits for the surface.
  • Your payment account, the buyer's billing address, and any shipping address are in the US.
  • The payment is one-time and on-session. A token that asks to save the payment method is rejected.
  • One PaymentIntent collects the full balance. Affirm cannot be one leg of a split payment.
  • The surface offers Affirm. Donation, event, and buyer-chosen-amount payment links do not.

Some rules depend on facts Flint never receives, so they stay with you:

Keep card available beside Affirm. Affirm can decline a buyer after every Flint check passes, and Stripe or Affirm can review your account after activation.

Amount limits#

Flint's Affirm minimum is $50.00 on every surface. The ceiling depends on where the payment is collected:

SurfaceMinimumMaximum
Standalone PaymentIntent, hosted checkout, invoice$50.00$30,000.00
Payment link$50.00$25,000.00, or $30,000.00 with a limit raised by Flint

A merchant-configured limit can narrow this range but not widen it. An amount outside the range fails at create with AMOUNT_BELOW_LIMIT or AMOUNT_EXCEEDS_LIMIT on amount_money.amount, and the message says which payment option and surface set the limit. The same check runs again at confirm, so an amount that changed between the two calls is caught there.

Where Affirm is offered#

Affirm is available on hosted and embedded checkout sessions, fixed-price payment links, standalone PaymentIntents, one-time invoices collected through the hosted invoice page or an invoice checkout session, and Return balance collection. It is not available for subscriptions or invoice autopay, off-session or saved-credential charges, virtual terminal, or any split payment. A request that asks for Affirm where it is not offered fails with PAYMENT_OPTION_NOT_ALLOWED, and the error's reason says why, for example surface_not_supported, amount_out_of_range, or split_payment_not_supported.

Accept an Affirm payment#

  1. Create the PaymentIntent with a return URL#

    Whenever payment_options contains affirm, the request must also carry payment_return_url: the page on your site the buyer lands on after Affirm. It must be an absolute HTTPS URL without credentials, a query string, or a fragment. In test mode, http://localhost and other loopback hosts are allowed so you can develop locally.

    cURL
    curl -X POST https://api.withflintpay.com/v1/payment-intents \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: affirm-order-1042" \
      -d '{
        "amount_money": {"amount": 9900, "currency": "USD"},
        "payment_options": ["card", "affirm"],
        "payment_return_url": "https://shop.example.com/orders/1042/payment-return"
      }'
    

    The response's payment_collection.stripe carries everything the browser needs: the publishable key and connected account_id, the Elements options, and a return_url that points at Flint, not at your site. Flint holds your payment_return_url and never passes it to Stripe or Affirm.

    Response
    {
      "data": {
        "payment_intent": {
          "payment_intent_id": "pi_1kmn0aExample",
          "status": "requires_payment_method",
          "amount_money": {"amount": 9900, "currency": "USD"},
          "payment_options": ["card", "affirm"]
        },
        "payment_collection": {
          "stripe": {
            "account_id": "acct_Example",
            "publishable_key": "pk_test_Example",
            "return_url": "https://api.withflintpay.com/payment-returns/prtn_1kmn0aExample",
            "elements": {
              "next_step": "create_confirmation_token",
              "submit_to": "confirm_payment_intent",
              "mode": "payment",
              "amount_money": {"amount": 9900, "currency": "USD"},
              "payment_method_types": ["card", "affirm"],
              "payment_method_creation": "manual"
            }
          }
        }
      }
    }
    

    Omitting the URL returns PAYMENT_RETURN_URL_REQUIRED. A URL that breaks one of the rules returns PAYMENT_RETURN_URL_INVALID. Leave out transaction_purpose: it belongs to ACH debit, and sending it without ach_debit in the option list returns TRANSACTION_PURPOSE_NOT_APPLICABLE.

  2. Mount Elements from the collection bootstrap#

    Initialize Stripe.js for the returned connected account and build Elements from payment_collection.stripe.elements as returned. Use this one Stripe instance for the Payment Element, the ConfirmationToken, and the redirect in step 4.

    JavaScript
    const response = await fetch("/api/payment-intents/affirm", {method: "POST"});
    if (!response.ok) throw new Error("Could not create the payment.");
    
    const {data} = await response.json();
    const collection = data.payment_collection.stripe;
    const stripe = Stripe(collection.publishable_key, {
      stripeAccount: collection.account_id,
    });
    
    const elements = stripe.elements({
      mode: collection.elements.mode,
      amount: collection.elements.amount_money.amount,
      currency: collection.elements.amount_money.currency.toLowerCase(),
      paymentMethodCreation: collection.elements.payment_method_creation,
      paymentMethodTypes: collection.elements.payment_method_types,
    });
    
    elements
      .create("payment", {layout: "tabs", paymentMethodOrder: ["card", "affirm"]})
      .mount("#payment-element");
    
  3. Create the ConfirmationToken with Flint's return URL#

    After elements.submit(), create the token with return_url set to the exact collection.return_url from step 1. Flint rejects a token whose return URL is missing (CONFIRMATION_RETURN_URL_REQUIRED), malformed (CONFIRMATION_RETURN_URL_INVALID), or anything other than this payment's relay (CONFIRMATION_RETURN_URL_NOT_ALLOWED).

    JavaScript
    const {error: submitError} = await elements.submit();
    if (submitError) throw submitError;
    
    const {error, confirmationToken} = await stripe.createConfirmationToken({
      elements,
      params: {
        return_url: collection.return_url,
        payment_method_data: {
          billing_details: {
            name: buyer.name,
            email: buyer.email,
            address: buyer.billingAddress,
          },
        },
        ...(order.shipping ? {shipping: order.shipping} : {}),
      },
    });
    if (error) throw error;
    
    await sendTokenToYourBackend(confirmationToken.id);
    

    The billing address must include a US country. Send shipping only when the purchase ships to a real recipient. When the PaymentIntent belongs to an order with a delivery destination, shipping is required and must match that destination, or the confirmation fails with PAYMENT_OPTION_NOT_ALLOWED. Do not copy the billing address into a made-up shipping address.

  4. Confirm from your backend#

    cURL
    curl -X POST https://api.withflintpay.com/v1/payment-intents/pi_1kmn0aExample/confirm \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: affirm-confirm-1042" \
      -d '{"confirmation_token": "ctoken_Example"}'
    

    The response is requires_action with a payment_authentication action. Initialize Stripe.js from the action's own publishable_key and account_id, then call stripe.handleNextAction with its client_secret. That sends the buyer to Affirm, on desktop to Affirm's site, on mobile to the Affirm app when it is installed.

    Response
    {
      "data": {
        "payment_intent_id": "pi_1kmn0aExample",
        "status": "requires_action",
        "current_payment_action": {
          "pending_action_id": "pendact_1kmn0aExample",
          "action_type": "payment_authentication",
          "client_action": {
            "stripe": {
              "account_id": "acct_Example",
              "publishable_key": "pk_test_Example",
              "payment_intent": {
                "stripe_js_call": "handle_next_action",
                "client_secret": "pi_Example_secret_Example"
              }
            }
          }
        }
      }
    }
    

    A PaymentIntent that belongs to a checkout session has a deadline. Affirm needs at least 15 minutes left in the session's payment window, and a confirmation inside those last 15 minutes fails with PAYMENT_ACTION_WINDOW_TOO_SHORT (409) before the buyer is sent anywhere. Start a new payment attempt or offer card. A standalone PaymentIntent has no such window.

  5. Handle the return#

    Affirm sends the buyer back to Flint's relay, not to you. The relay drops every query parameter Affirm attached and answers with a 303 to your stored payment_return_url, exactly as you gave it. Your return page therefore receives nothing about the outcome in the URL. Look the payment up by the order or PaymentIntent ID in your return path, then read the PaymentIntent from your backend:

    cURL
    curl https://api.withflintpay.com/v1/payment-intents/pi_1kmn0aExample \
      -H "Authorization: Bearer YOUR_API_KEY"
    

After the return#

The status says what happened at Affirm:

statusWhat happenedWhat to do
succeededAffirm approved the plan and the payment settled.Fulfill once, from the webhook or this read.
requires_captureAffirm approved a manual-capture hold.Capture in full before authorization_expires_at.
requires_actionThe buyer has not finished at Affirm, or closed the tab.Show the pending state. Re-run handleNextAction from the same action if they want to continue.
requires_payment_methodAffirm declined, or the buyer abandoned or timed out.Read last_payment_error.code and start a new submission.

Never create a second PaymentIntent or a second ConfirmationToken just because the browser returned. The buyer may still be inside Affirm's flow, and a lost redirect is recovered by reading the existing payment. Server-confirmed payments has the full recovery order.

Your own checkout#

A checkout you build on an embedded checkout session offers Affirm on the order, the same way it offers card. The differences from a standalone PaymentIntent:

  • The return page is the session's redirects.success_redirect_url, not payment_return_url. An embedded session that offers Affirm without it fails with EMBEDDED_PAYMENT_RETURN_URL_REQUIRED. The same URL rules apply: HTTPS, no credentials, query string, or fragment, and loopback HTTP in test mode.
  • Read payment_collection from the order with the checkout headers. Its stripe.return_url is Flint's relay, and the ConfirmationToken must carry that exact URL.
  • Pay with POST /v1/orders/{order_id}/pay. The attempt pauses on a pending action that sends the buyer to Affirm, and on return you read the order and the attempt instead of a PaymentIntent.
  • Fulfill from order.paid.

This works for an order checkout, an invoice checkout session, and a Return balance launched with "surface": "embedded". Build your own checkout walks through it.

Failures and retries#

A failed Affirm attempt lands the PaymentIntent in requires_payment_method with a normalized last_payment_error.code:

CodeMeaning
payment_method_declinedAffirm declined the buyer for this purchase.
payment_not_completedThe buyer left Affirm without approving a plan.
payment_action_expiredThe redirect was not completed in time.
payment_method_temporarily_unavailableAffirm could not take the payment right now.
payment_method_unavailableAffirm is not available for this payment.

The prior attempt is finished. Create a new ConfirmationToken and confirm again, presenting card first. Flint Checkout removes Affirm from the immediate retry after payment_method_declined or payment_method_temporarily_unavailable, and leaves it selectable after an abandoned or expired redirect. A standalone integration decides this itself: Flint does not rewrite payment_options on a PaymentIntent after a failure.

Webhooks#

Affirm adds no event types. It uses the PaymentIntent events, and payment_intent.requires_action fires when the buyer is sent to Affirm:

  1. What happens: You confirm with an Affirm ConfirmationToken
  2. Flint sends: payment_intent.requires_action
    The buyer has been sent to Affirm.
  3. What happens: Affirm approves
  4. Flint sends: payment_intent.succeeded
    Automatic capture. The payment settled.
  5. Flint sends: payment_intent.requires_capture
    Manual capture. The hold is in place.
  6. Flint sends: order.paid
    Checkout sessions, hosted or embedded, and payment links. The order is fully paid.
  7. What happens: Affirm declines, or the buyer abandons
  8. Flint sends: payment_intent.payment_failed
    Carries last_payment_error.

Fulfill from payment_intent.succeeded, or from order.paid for an order, never from the browser return. Refunds and disputes fire the usual refund.* and dispute.* events.

Manual capture#

Affirm supports "capture_method": "manual" with exactly one full capture. Capture with an empty body. Sending amount_money for anything other than the whole hold returns PARTIAL_CAPTURE_NOT_SUPPORTED, and the hold cannot be raised, lowered, or reauthorized. The hold lasts seven days, and authorization_expires_at carries the deadline. Manual capture covers the hold lifecycle.

Refunds#

Refund an Affirm payment through the Refunds API as you would a card. Full and partial refunds are both allowed. A refund does not return the original processing fee.

Affirm applies the refund to the buyer's loan. It cancels remaining scheduled payments and returns what the buyer has already paid, minus any interest they paid. Two limits come from the provider:

  • Submit the refund within 120 days of the payment settling. After that, POST /v1/refunds returns REFUND_SUBMISSION_DEADLINE_EXPIRED, and you reimburse the buyer another way.
  • The refund is asynchronous and can take up to two days. Wait for refund.updated with succeeded, or refund.failed. An Affirm refund cannot be canceled once submitted.

If a refund on an Affirm payment reaches failed, the money is back in your balance and the buyer has not been paid. Reimburse them outside Flint. Any further refund on that payment returns AFFIRM_REFUND_RETRY_NOT_ALLOWED.

Disputes#

An Affirm dispute arrives as the same Dispute resource as a card chargeback, with payment_option set to affirm. A buyer authenticates every Affirm payment by signing in to Affirm, and Affirm covers losses from buyer fraud. Stripe may ask you on Affirm's behalf to pause a shipment before a loss occurs. Comply promptly. Handling disputes covers evidence and deadlines.

Reading an Affirm payment#

Once the buyer picks Affirm, the PaymentIntent identifies it in two fields, and support_reference becomes the Affirm transaction reference:

Response
{
  "payment_intent_id": "pi_1kmn0aExample",
  "status": "succeeded",
  "selected_payment_option": "affirm",
  "payment_source": {"type": "affirm"},
  "support_reference": "N7XBTQJ4KMEXAMPLE"
}

Give support_reference to a buyer who contacts Affirm support. It is the value Affirm asks for. Before Affirm has issued a reference it holds the PaymentIntent ID, so treat it as an opaque string and do not parse it.

Flint does not risk-assess Affirm payments, so the risk object is absent from an Affirm PaymentIntent. Affirm's own approval decision and your operational controls still apply.

Show Affirm plans before checkout#

Stripe's Payment Method Messaging Element shows the plans a buyer may qualify for on product, cart, and payment pages. Create it on the same connected-account Stripe instance and bind it to the amount you will charge:

JavaScript
stripe
  .elements()
  .create("paymentMethodMessaging", {
    amount: order.total_money.amount,
    currency: "USD",
    countryCode: "US",
    paymentMethodTypes: ["affirm"],
  })
  .mount("#affirm-message");

The element shows plans that may be available, not an approval. Do not write your own installment amount, APR, or approval claim next to it; Affirm's marketing compliance guides govern that copy. Stripe uses cookies and IP addresses to record which Elements a buyer saw, and you are responsible for disclosing that and obtaining any consent your page needs.

Test in a sandbox#

With a test API key, the same create and confirm calls work, and payment_return_url may be an http://localhost URL. When the buyer picks Affirm and submits, Stripe shows a test page instead of Affirm where you approve or decline the payment. Approve to exercise payment_intent.succeeded, decline to exercise payment_intent.payment_failed and the retry path. There are no Affirm-specific test amounts; any amount within the limits works.

Errors#

Was this helpful?