Android app 与 Web Api 的 Payment Gateway Integration
结论
支付流程应由 Android、后端 Web API 和 Flutterwave 分别承担不同职责:
- Android 启动 Flutterwave 官方 SDK 或托管支付页面,供用户输入银行卡信息、CVV 和 OTP。
- Web API 创建订单,生成唯一的
tx_ref,保管 Secret Key,验证交易结果,并更新用户的 Pro 角色。 - Flutterwave 通过回调和 Webhook 通知后端,后端收到通知后,还要主动调用交易验证接口核实结果。
- 绝对不要保存 CVV,也不要仅凭 Android 返回的“支付成功”更新数据库。
这样既不会把 Secret Key 暴露在 APK 中,也能应对用户断网、关闭 App 或回调丢失等情况。后端可以通过 Webhook 和主动查询恢复交易状态。
推荐的支付流程
可以按以下流程处理支付:
- Android 请求后端创建订单。
- 后端生成不可预测且唯一的
tx_ref,并保存一条状态为Pending的订单。 - 后端使用 Flutterwave Secret Key 创建支付请求,将支付链接或客户端所需的临时参数返回给 Android。
- Android 打开 Flutterwave 官方 SDK 或托管支付页面。
- 用户在 Flutterwave 管理的界面中输入卡号、有效期、CVV 和 OTP。
- Flutterwave 将浏览器或 App 重定向到后端配置的回调地址,同时向后端发送 Webhook。
- 后端根据
transaction_id调用 Flutterwave 的交易验证接口。 - 后端核对交易状态、金额、币种和订单号。全部正确后,将订单标记为
Paid,再授予用户 Pro 角色。 - Android 查询后端的订单状态并刷新界面。
支付回调、Webhook 和 Android 返回的结果都只表示“这笔交易需要核实”,不能直接作为到账凭证。
为什么不能完全放在 Android 端
APK 无法安全保存支付网关的 Secret Key。即使对代码进行混淆,密钥仍可能通过反编译、动态调试或网络请求被提取。Secret Key 一旦泄露,攻击者可能伪造或滥用支付请求。
客户端环境也不可信。攻击者可以修改 App、拦截本地方法,甚至伪造下面这样的数据:
{
"status": "successful",
"amount": 100,
"transaction_id": 12345
}
因此,Android 无权直接决定是否将用户升级为 Pro。它只能把 transaction_id 或 tx_ref 交给后端,再由后端向 Flutterwave 核实。
客户端断网也不应该导致交易记录丢失。只要后端在支付开始前创建了 Pending 订单,之后就可以通过 Webhook 或定时查询继续处理。
银行卡信息应该怎样传输
优先使用 Flutterwave 官方 Android SDK 或托管支付页面,让银行卡信息直接提交给 Flutterwave,不要经过自己的 Web API。
HTTPS 可以加密传输过程,但不能单独构成完整的支付安全方案。如果自己的服务器接收卡号、CVV 等敏感数据,系统会进入更复杂的 PCI DSS 合规范围,还要处理日志脱敏、访问控制、密钥管理、服务器安全和数据保留策略等问题。只给域名安装 SSL 证书远远不够。
Android 通常只需向自己的后端提交订单业务信息,例如:
{
"plan": "pro_monthly"
}
金额和币种最好由后端根据 plan 查询确定,不要采用客户端提交的价格,否则用户可能篡改请求,将正常价格替换成更小的金额。
后端可以向 Android 返回支付链接、订单号或 Flutterwave SDK 所需的公开参数,但其中不能包含 Secret Key。
OTP 应该在哪里处理
OTP、3-D Secure、PIN 和其他银行验证步骤,应交给 Flutterwave SDK 或 Flutterwave 托管页面处理。
如果接入的 Flutterwave 接口采用分步扣款模式,首次请求可能返回“需要进一步验证”的状态。前端应根据接口返回的下一步操作,展示 OTP 页面或跳转到验证页面,再将 OTP 提交给 Flutterwave 指定的验证接口。不要自行判断某张卡是否需要 OTP,因为验证方式取决于发卡行、地区、卡种和风控结果。
使用托管支付页面更简单,风险也更低。Flutterwave 负责收集银行卡信息并完成 OTP 流程,自己的系统只处理订单号和最终验证结果。
Flutterwave 不同产品和 API 版本的字段及验证步骤可能不同。具体的 next_action、验证端点和回调参数,应以当前接入产品的官方文档为准。
后端创建订单的示例
下面的代码仅用于说明架构。Flutterwave 的请求字段需要根据实际使用的 API 版本核对,不能把示例中的地址或字段视为长期不变的接口契约。
public sealed record CreatePaymentRequest(string Plan);
[ApiController]
[Route("api/payments")]
public class PaymentsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
public PaymentsController(
AppDbContext db,
IHttpClientFactory httpClientFactory,
IConfiguration configuration)
{
_db = db;
_httpClientFactory = httpClientFactory;
_configuration = configuration;
}
[HttpPost]
public async Task<IActionResult> CreatePayment(
CreatePaymentRequest request,
CancellationToken cancellationToken)
{
var userId = User.FindFirst("sub")?.Value;
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
// 价格由服务器决定,不能采用客户端传入的金额。
var plan = request.Plan switch
{
"pro_monthly" => new { Amount = 1000m, Currency = "NGN" },
_ => null
};
if (plan is null)
return BadRequest("Unknown plan.");
var txRef = $"pro-{Guid.NewGuid():N}";
var order = new PaymentOrder
{
UserId = userId,
TxRef = txRef,
Amount = plan.Amount,
Currency = plan.Currency,
Status = "Pending",
CreatedAtUtc = DateTime.UtcNow
};
_db.PaymentOrders.Add(order);
await _db.SaveChangesAsync(cancellationToken);
var payload = new
{
tx_ref = txRef,
amount = plan.Amount,
currency = plan.Currency,
redirect_url = _configuration["Payments:RedirectUrl"],
customer = new
{
email = User.FindFirst("email")?.Value
},
customizations = new
{
title = "Pro subscription"
}
};
var client = _httpClientFactory.CreateClient("Flutterwave");
using var response = await client.PostAsJsonAsync(
"payments",
payload,
cancellationToken);
if (!response.IsSuccessStatusCode)
return StatusCode(502, "Unable to initialize payment.");
var gatewayResponse =
await response.Content.ReadFromJsonAsync<FlutterwavePaymentResponse>(
cancellationToken: cancellationToken);
if (string.IsNullOrWhiteSpace(gatewayResponse?.Data?.Link))
return StatusCode(502, "Payment URL was not returned.");
return Ok(new
{
orderId = order.Id,
paymentUrl = gatewayResponse.Data.Link
});
}
}
HttpClient 应在服务器端配置,Secret Key 从密钥管理系统或环境变量中读取:
builder.Services.AddHttpClient("Flutterwave", (services, client) =>
{
var configuration =
services.GetRequiredService<IConfiguration>();
client.BaseAddress =
new Uri("https://api.flutterwave.com/v3/");
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer",
configuration["Flutterwave:SecretKey"]);
});
不要把 Secret Key 写入源代码、Android 资源文件或 Git 仓库,也不能将它返回给客户端。
后端如何确认支付成功
验证交易时,至少要核对以下内容:
- Flutterwave 返回的交易状态是否成功。
tx_ref是否对应本系统中尚未完成的订单。- 实际到账金额是否符合订单金额。
- 币种是否一致。
- 交易是否已经处理。
- 订单所属用户是否正确。
transaction_id是否已经关联到其他订单。
可以把处理逻辑集中到一个服务中,由回调、Webhook 和定时补偿任务共同调用:
public async Task ConfirmPaymentAsync(
long transactionId,
CancellationToken cancellationToken)
{
var verification = await VerifyWithFlutterwaveAsync(
transactionId,
cancellationToken);
if (verification.Status != "successful")
return;
var order = await _db.PaymentOrders
.SingleOrDefaultAsync(
x => x.TxRef == verification.TxRef,
cancellationToken);
if (order is null)
return;
if (order.Status == "Paid")
return; // 幂等处理:重复通知不应重复发放权益。
if (verification.Currency != order.Currency)
throw new InvalidOperationException("Currency mismatch.");
if (verification.Amount < order.Amount)
throw new InvalidOperationException("Amount mismatch.");
var transactionAlreadyUsed = await _db.PaymentOrders.AnyAsync(
x => x.GatewayTransactionId == transactionId &&
x.Id != order.Id,
cancellationToken);
if (transactionAlreadyUsed)
throw new InvalidOperationException(
"Transaction has already been used.");
await using var databaseTransaction =
await _db.Database.BeginTransactionAsync(cancellationToken);
order.Status = "Paid";
order.GatewayTransactionId = transactionId;
order.PaidAtUtc = DateTime.UtcNow;
var user = await _db.Users.SingleAsync(
x => x.Id == order.UserId,
cancellationToken);
user.Role = "Pro";
await _db.SaveChangesAsync(cancellationToken);
await databaseTransaction.CommitAsync(cancellationToken);
}
VerifyWithFlutterwaveAsync 必须调用 Flutterwave 的服务器端交易验证接口,不能只检查 Android 提交的数据。验证接口的具体响应模型应按照当前 API 文档定义。
Webhook 的正确用法
Webhook 可以处理“用户付款后关闭 App”的情况。即使 Android 没有收到支付结果,Flutterwave 仍然可以通知后端。
Webhook 接口需要完成以下工作:
- 读取原始请求体。
- 按照当前 Flutterwave 文档规定的方式验证 Webhook 签名或校验值。
- 尽快返回成功响应,避免网关反复重试。
- 根据通知中的
transaction_id主动查询 Flutterwave。 - 通过统一的幂等服务更新订单和用户权限。
[AllowAnonymous]
[HttpPost("webhook")]
public async Task<IActionResult> Webhook(
CancellationToken cancellationToken)
{
using var reader = new StreamReader(Request.Body);
var rawBody = await reader.ReadToEndAsync(cancellationToken);
if (!VerifyWebhookAccordingToCurrentFlutterwaveDocumentation(
Request.Headers,
rawBody))
{
return Unauthorized();
}
var webhook = JsonSerializer.Deserialize<FlutterwaveWebhook>(
rawBody);
if (webhook?.Data?.Id is null)
return Ok();
await _paymentService.ConfirmPaymentAsync(
webhook.Data.Id.Value,
cancellationToken);
return Ok();
}
不同版本的 Flutterwave Webhook 可能使用不同的请求头和签名算法,所以不能凭经验编写签名验证函数。校验时需要结合当前账户配置和官方文档,并使用原始请求体。
数据库还应为 tx_ref 和 GatewayTransactionId 建立唯一约束,从数据库层面防止重复处理。
Android 不应该等待一次网络请求决定结果
Android 打开支付页面后,即使收到了成功回调,也应该先显示“正在确认支付”,再查询自己的后端:
suspend fun waitForPaymentConfirmation(orderId: String): PaymentStatus {
repeat(10) {
val result = paymentApi.getPaymentStatus(orderId)
when (result.status) {
"Paid" -> return PaymentStatus.PAID
"Failed", "Cancelled" -> return PaymentStatus.FAILED
}
delay(2_000)
}
return PaymentStatus.PENDING
}
轮询超时不代表付款失败,只表示当前还无法确认。用户以后重新打开 App 时,仍应从后端读取订单状态。如有需要,后端可以通过定时任务重新核验长时间停留在 Pending 状态的订单。
CVV 能否保存
不能保存。
CVV 是高度敏感的认证数据。即使经过加密,也不应该在授权完成后写入数据库、缓存、日志、异常信息、分析平台或消息队列。业务通常也没有保存 CVV 的合法需求。
以下内容同样不应记录:
- 完整卡号;
- CVV;
- OTP、PIN;
- Secret Key;
- 未脱敏的支付请求和响应。
如果业务需要展示付款方式,可以保存支付网关提供的 token、交易 ID 和脱敏数据,例如卡品牌、卡号后四位及有效期。具体字段能否保存,仍需符合当地法规、收单机构要求和 PCI DSS 规范。
还需要注意的安全问题
- 所有接口都使用 HTTPS,并正确验证证书,禁止降级到明文 HTTP。
- Secret Key 只保存在服务器端,并支持定期轮换。
- Android 调用创建订单和查询状态接口时,必须通过用户身份认证。
- 金额、币种、套餐和用户身份均由服务器确定。
- 使用唯一的
tx_ref,支付确认逻辑必须支持幂等处理。 - 不要因为重定向 URL 中出现
status=successful就直接升级用户。 - Webhook 必须验证真实性,之后还要调用交易验证接口。
- 支付订单和用户角色的更新应放在同一个数据库事务中。
- 对长期处于
Pending状态的订单建立补偿查询机制。 - 日志只记录排查问题所需的订单号、交易 ID 和脱敏信息。
- 周期性订阅应使用网关提供的 tokenization 或 subscription 能力,不要为了自动续费而保存银行卡信息。
最终原则很明确:敏感支付交互由 Flutterwave 处理,业务状态由后端管理,Android 只负责发起支付和展示结果。
备注:内容仅供参考。
