如何在 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 重复投递;
- 事件乱序;
- 签名验证失败;
- 退款和部分退款;
- 拒付与争议;
- 支付金额或币种不一致;
- 已取消订单收到迟到的成功通知。
订单更新必须具有幂等性。同一个支付成功事件即使被接收多次,也只能发货或开通一次服务。
收据如何提供
收据应根据可信的服务端数据生成,不能依赖客户端返回值。通常有两种做法:
- 使用支付服务商提供的付款凭证或托管收据页面;
- 由业务服务器生成订单收据,通过邮件发送,同时在 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 为准,客户端只负责展示支付流程和查询订单结果。
备注:内容仅供参考。
