PAYATHON 2026

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 和主动查询恢复交易状态。

推荐的支付流程

可以按以下流程处理支付:

  1. Android 请求后端创建订单。
  2. 后端生成不可预测且唯一的 tx_ref,并保存一条状态为 Pending 的订单。
  3. 后端使用 Flutterwave Secret Key 创建支付请求,将支付链接或客户端所需的临时参数返回给 Android。
  4. Android 打开 Flutterwave 官方 SDK 或托管支付页面。
  5. 用户在 Flutterwave 管理的界面中输入卡号、有效期、CVV 和 OTP。
  6. Flutterwave 将浏览器或 App 重定向到后端配置的回调地址,同时向后端发送 Webhook。
  7. 后端根据 transaction_id 调用 Flutterwave 的交易验证接口。
  8. 后端核对交易状态、金额、币种和订单号。全部正确后,将订单标记为 Paid,再授予用户 Pro 角色。
  9. 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 接口需要完成以下工作:

  1. 读取原始请求体。
  2. 按照当前 Flutterwave 文档规定的方式验证 Webhook 签名或校验值。
  3. 尽快返回成功响应,避免网关反复重试。
  4. 根据通知中的 transaction_id 主动查询 Flutterwave。
  5. 通过统一的幂等服务更新订单和用户权限。
[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 只负责发起支付和展示结果。

备注:内容仅供参考。