PAYATHON 2026

如何在 iPhone 中无终端集成 Credit Card payment API?

支付老李

结论

iPhone App 可以实现不依赖刷卡终端的信用卡支付,这类交易通常称为 Card-Not-Present(CNP,无卡支付)。可以接入 Stripe、Adyen、Braintree 等支付服务商的 iOS SDK,由服务商处理信用卡信息采集、令牌化、3-D Secure 验证和付款。App 自己的服务器则负责创建订单、确认支付结果并生成收据。

能否通过 Apple 审核,主要取决于用户购买的内容:

  • 实体商品或线下服务,例如外卖、酒店、打车、门票和上门服务,一般可以使用信用卡、Apple Pay 或第三方支付平台。
  • 在 App 内消费的数字内容、会员权益、虚拟币或功能解锁,通常必须使用 Apple 的 In-App Purchase。
  • 不同地区、App 类型以及 Apple 授予的 entitlement 可能有例外。提交审核前,应根据目标市场核对当时有效的 App Review Guidelines,尤其是支付相关条款。

因此,如果只是为了绕过 In-App Purchase,改用信用卡销售 App 内数字内容,通常会有较高的拒审风险。

推荐的实现方式

不要通过自己的界面、日志或服务器直接处理完整卡号、CVV 等敏感信息。较安全的结构如下:

iPhone App
   │
   │ 请求创建订单
   ▼
业务服务器
   │
   │ 调用支付服务商的服务端 API
   ▼
支付服务商
   │
   │ 返回 client secret 或支付会话
   ▼
iPhone App 中的支付 SDK
   │
   │ 用户输入卡片信息并完成 3-D Secure
   ▼
支付服务商
   │
   │ Webhook 通知最终支付结果
   ▼
业务服务器生成订单收据

常见方案包括:

  • Stripe PaymentSheet:提供现成的支付界面,可以处理信用卡、3-D Secure 和多种支付方式。
  • Adyen Drop-in:适用于覆盖多个国家、币种或支付方式的业务。
  • Braintree:提供移动端支付组件和支付方式令牌化能力。
  • Apple Pay:很适合实体商品和线下服务,但仍需要支付处理商或收单机构完成结算。

支付服务商是否支持你的注册地区、经营类别和目标国家,需要分别确认。某些高风险行业可能无法开户,或者需要接受额外审核。

服务端创建支付订单

下面以 Stripe 的 PaymentIntent 模式说明基本流程。真正的密钥只能保存在服务器端,不能写入 iOS App。

示例使用 Node.js 和 Express:

import express from "express";
import Stripe from "stripe";

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.use(express.json());

app.post("/payments/create", async (req, res) => {
  try {
    const { orderId } = req.body;

    // 必须从数据库重新读取订单金额,不能相信客户端传来的金额。
    const order = await loadOrderFromDatabase(orderId);

    if (!order || order.status !== "pending") {
      return res.status(400).json({ error: "Invalid order" });
    }

    const paymentIntent = await stripe.paymentIntents.create(
      {
        amount: order.amountInMinorUnit,
        currency: order.currency,
        metadata: {
          orderId: order.id
        },
        automatic_payment_methods: {
          enabled: true
        }
      },
      {
        idempotencyKey: `payment-${order.id}`
      }
    );

    res.json({
      clientSecret: paymentIntent.client_secret
    });
  } catch (error) {
    res.status(500).json({ error: "Unable to create payment" });
  }
});

app.listen(3000);

金额应使用货币的最小单位。对于支持两位小数的货币,1999 通常表示 19.99。并非所有货币都有两位小数,具体换算规则应以支付服务商的货币文档为准。

最终价格不能由客户端决定。服务器应根据订单重新计算商品价格、优惠、税费和应付金额。

iPhone 端调用支付界面

安装支付服务商的官方 iOS SDK 后,可以调用其预构建支付界面。以下代码展示了 Stripe PaymentSheet 的典型结构。具体初始化参数可能随 SDK 更新而调整,应以当前官方文档为准。

import UIKit
import StripePaymentSheet

final class CheckoutViewController: UIViewController {
    private var paymentSheet: PaymentSheet?

    func preparePayment(orderID: String) {
        var request = URLRequest(
            url: URL(string: "https://api.example.com/payments/create")!
        )
        request.httpMethod = "POST"
        request.setValue(
            "application/json",
            forHTTPHeaderField: "Content-Type"
        )
        request.httpBody = try? JSONSerialization.data(
            withJSONObject: ["orderId": orderID]
        )

        URLSession.shared.dataTask(with: request) { [weak self] data, _, error in
            guard
                error == nil,
                let data,
                let response = try? JSONDecoder().decode(
                    CreatePaymentResponse.self,
                    from: data
                )
            else {
                DispatchQueue.main.async {
                    self?.showError("无法初始化支付")
                }
                return
            }

            var configuration = PaymentSheet.Configuration()
            configuration.merchantDisplayName = "Example Store"

            DispatchQueue.main.async {
                self?.paymentSheet = PaymentSheet(
                    paymentIntentClientSecret: response.clientSecret,
                    configuration: configuration
                )
                self?.presentPaymentSheet()
            }
        }.resume()
    }

    private func presentPaymentSheet() {
        paymentSheet?.present(from: self) { [weak self] result in
            switch result {
            case .completed:
                // 这里只表示客户端支付流程完成。
                // 订单是否真正支付成功,仍应由服务器验证。
                self?.showMessage("支付处理中,请稍候")

            case .canceled:
                self?.showMessage("已取消支付")

            case .failed(let error):
                self?.showError(error.localizedDescription)
            }
        }
    }

    private func showMessage(_ message: String) {
        // 使用项目自己的提示组件
    }

    private func showError(_ message: String) {
        // 使用项目自己的错误提示组件
    }
}

private struct CreatePaymentResponse: Decodable {
    let clientSecret: String
}

不要仅凭 .completed 就把订单标记为已支付。支付可能包含异步验证、银行授权或延迟处理,最终状态应由服务器通过支付服务商 API 或 Webhook 确认。

使用 Webhook 确认付款

支付服务商通常会向业务服务器发送 Webhook。服务器需要先验证 Webhook 签名,再更新订单。

app.post(
  "/payments/webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers["stripe-signature"],
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch {
      return res.status(400).send("Invalid webhook signature");
    }

    if (event.type === "payment_intent.succeeded") {
      const paymentIntent = event.data.object;
      const orderId = paymentIntent.metadata.orderId;

      await markOrderAsPaidOnce({
        orderId,
        providerPaymentId: paymentIntent.id,
        paidAmount: paymentIntent.amount_received,
        currency: paymentIntent.currency
      });

      await createAndSendReceipt(orderId);
    }

    if (event.type === "payment_intent.payment_failed") {
      const paymentIntent = event.data.object;
      await recordPaymentFailure(
        paymentIntent.metadata.orderId,
        paymentIntent.id
      );
    }

    res.sendStatus(200);
  }
);

实际项目还需要处理以下情况:

  • Webhook 重复投递;
  • 事件乱序;
  • 签名验证失败;
  • 退款和部分退款;
  • 拒付与争议;
  • 支付金额或币种不一致;
  • 已取消订单收到迟到的成功通知。

订单更新必须具有幂等性。同一个支付成功事件即使被接收多次,也只能发货或开通一次服务。

收据如何提供

收据应根据可信的服务端数据生成,不能依赖客户端返回值。通常有两种做法:

  1. 使用支付服务商提供的付款凭证或托管收据页面;
  2. 由业务服务器生成订单收据,通过邮件发送,同时在 App 的订单详情页展示。

收据至少应包含:

  • 商户名称和联系方式;
  • 订单号;
  • 支付日期;
  • 商品或服务明细;
  • 金额、税费和币种;
  • 支付状态;
  • 支付方式的脱敏信息,例如 Visa •••• 4242;
  • 退款规则或客服联系入口。

不要在收据、日志或数据库中保存 CVV,也不要显示完整卡号。税务发票和普通支付收据不是同一个概念。如果业务需要开具正式发票,还要遵守经营所在地的税务规定。

Apple 审核时最容易出问题的地方

数字内容使用外部信用卡支付

如果用户通过信用卡购买 App 内会员、虚拟货币、数字课程、电子书内容、云功能或功能解锁,通常应接入 StoreKit 和 In-App Purchase。把支付页面改成网页、WebView 或第三方 SDK,并不会自动规避这一要求。

某些地区或特定类别可能允许外部购买链接或替代支付,但适用条件、界面措辞、entitlement 和费用政策可能变化,不能直接套用到所有 App。

实体商品或线下服务说明不清

如果 App 销售实体商品或线下服务,应在审核备注中说明:

  • 用户购买的具体内容;
  • 商品或服务的交付地点;
  • 为什么该交易不属于 App 内数字内容;
  • 测试账户和完整的支付测试路径;
  • 测试环境是否会产生真实扣款。

清楚描述商品和交付方式,可以帮助审核人员判断支付场景。

在 App 中直接处理原始卡片数据

自行设计输入框收集卡号,再将数据上传到业务服务器,会显著扩大 PCI DSS 合规范围,也更容易造成敏感数据泄露。应使用支付服务商提供的原生支付组件、托管字段或令牌化 SDK。

安全与合规注意事项

  • 所有通信都必须使用 HTTPS。
  • 服务端密钥不得放入 App、前端代码或公开仓库。
  • 不要记录卡号、CVV、支付 clientSecret 或未经处理的支付请求。
  • 确实需要长期保存的本地凭据,应存入系统 Keychain。
  • 使用支付服务商提供的测试卡和沙盒环境,不要用真实卡测试开发版本。
  • 创建支付、退款和更新订单时,都应采用幂等机制。
  • 服务端必须校验订单金额、币种、用户身份和订单状态。
  • 根据业务覆盖地区处理 3-D Secure、SCA、隐私政策和数据删除请求。
  • 隐私说明和 App Store 隐私申报应与实际收集的数据一致。
  • 如果需要保存支付方式供以后使用,必须取得用户明确同意,并保存服务商返回的支付方式令牌,不能保存原始卡片信息。

较稳妥的方案是,实体商品或线下服务使用 Stripe PaymentSheet、Adyen Drop-in、Braintree 或 Apple Pay,App 内数字商品使用 StoreKit。支付是否成功以服务端 Webhook 为准,客户端只负责展示支付流程和查询订单结果。

备注:内容仅供参考。