PAYATHON 2026

如何在 firebase 上实现 callback endpoint

支付老李

明确结论

可以用 Cloud Functions for Firebase 的 HTTP 函数实现 callback endpoint。函数部署后会生成一个公开的 HTTPS 地址,把它作为 callbackUrl 传给第三方支付 API 即可。

Firebase Hosting 只能托管静态资源,无法直接处理支付回调。接收请求、校验签名和更新 Realtime Database 等操作,都应放在可信的服务端环境中,例如 Cloud Functions 或 Cloud Run。

也不要让 Android App 携带支付密钥直接调用第三方 API。建议采用下面的流程:

Android App
    ↓ 发起支付
Cloud Function
    ↓ 调用第三方支付 API,并传入 callbackUrl
第三方支付平台
    ↓ POST 支付结果
Callback Cloud Function
    ↓ 校验签名并更新数据
Realtime Database

创建 HTTP callback

先在 Firebase 项目中初始化 Functions:

firebase init functions

下面以 JavaScript 和 Cloud Functions v2 为例。支付通知的字段名、签名请求头和签名算法,需要按照第三方 API 的文档调整。

const { onRequest } = require("firebase-functions/v2/https");
const { defineSecret } = require("firebase-functions/params");
const { initializeApp } = require("firebase-admin/app");
const { getDatabase } = require("firebase-admin/database");
const crypto = require("node:crypto");

initializeApp();

const callbackSecret = defineSecret("PAYMENT_CALLBACK_SECRET");

exports.paymentCallback = onRequest(
  {
    secrets: [callbackSecret],
    region: "asia-east1",
  },
  async (req, res) => {
    if (req.method !== "POST") {
      res.set("Allow", "POST");
      res.status(405).send("Method Not Allowed");
      return;
    }

    try {
      /*
       * 这里只是 HMAC-SHA256 签名校验示例。
       * 仅当支付平台明确采用这种算法时才能直接使用。
       * 请求头名称、原始报文格式和编码方式应以平台文档为准。
       */
      const receivedSignature = req.get("x-signature");

      if (
        !receivedSignature ||
        !verifySignature(
          req.rawBody,
          receivedSignature,
          callbackSecret.value()
        )
      ) {
        res.status(401).send("Invalid signature");
        return;
      }

      const notification = req.body;

      // 以下字段名称仅为示例,应替换成支付平台实际返回的字段。
      const eventId = notification.eventId;
      const orderId = notification.orderId;
      const paymentStatus = notification.status;

      if (
        typeof eventId !== "string" ||
        typeof orderId !== "string" ||
        typeof paymentStatus !== "string"
      ) {
        res.status(400).send("Invalid payload");
        return;
      }

      // Realtime Database 路径不能包含这些字符。
      if (/[.#$\[\]\/]/.test(eventId) || /[.#$\[\]\/]/.test(orderId)) {
        res.status(400).send("Invalid identifier");
        return;
      }

      const paymentRef = getDatabase().ref(`payments/${orderId}`);

      /*
       * 第三方平台可能重复发送同一个通知。
       * 使用 transaction 记录 eventId,可以避免同一事件被重复处理。
       */
      const result = await paymentRef.transaction((current) => {
        const payment = current || {};
        const processedEvents = payment.processedEvents || {};

        if (processedEvents[eventId]) {
          return; // 中止事务,表示已经处理过
        }

        return {
          ...payment,
          status: paymentStatus,
          providerPaymentId:
            notification.paymentId ?? payment.providerPaymentId ?? null,
          processedEvents: {
            ...processedEvents,
            [eventId]: true,
          },
          updatedAt: Date.now(),
        };
      });

      if (!result.committed) {
        // 已处理过的重复通知也返回 2xx,避免平台持续重试。
        res.status(200).send("Already processed");
        return;
      }

      res.status(200).send("OK");
    } catch (error) {
      console.error("Payment callback failed", error);

      // 返回非 2xx 通常会触发支付平台重试,具体行为取决于平台规则。
      res.status(500).send("Internal Server Error");
    }
  }
);

function verifySignature(rawBody, receivedSignature, secret) {
  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  const expected = Buffer.from(expectedSignature, "utf8");
  const received = Buffer.from(receivedSignature, "utf8");

  return (
    expected.length === received.length &&
    crypto.timingSafeEqual(expected, received)
  );
}

配置回调密钥

不要把支付平台密钥写入代码,也不要提交到 Git 仓库。可以通过 Firebase CLI 设置 Secret:

firebase functions:secrets:set PAYMENT_CALLBACK_SECRET

然后部署函数:

firebase deploy --only functions:paymentCallback

部署完成后,CLI 会显示函数的 HTTPS 地址,通常类似下面这样:

https://REGION-PROJECT_ID.cloudfunctions.net/paymentCallback

请以实际部署输出为准,并将完整地址作为第三方 API 所需的 callbackUrl。

使用 Firebase Hosting 域名转发

如果希望使用自己的 Firebase Hosting 域名作为回调地址,例如:

https://example.web.app/api/payment/callback

可以在 firebase.json 中配置 rewrite:

{
  "hosting": {
    "rewrites": [
      {
        "source": "/api/payment/callback",
        "function": {
          "functionId": "paymentCallback",
          "region": "asia-east1"
        }
      }
    ]
  }
}

配置后,部署 Hosting 和 Function:

firebase deploy --only hosting,functions:paymentCallback

这项配置并非必需。多数情况下,直接使用函数生成的 HTTPS 地址更简单。

发起支付时传入 callbackUrl

支付请求也最好通过另一个 Cloud Function 发起,避免第三方 API 密钥暴露在 Android 安装包中:

const callbackUrl =
  "https://REGION-PROJECT_ID.cloudfunctions.net/paymentCallback";

const response = await fetch("https://third-party.example/payments", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": `Bearer ${paymentApiKey}`,
  },
  body: JSON.stringify({
    amount: 1000,
    currency: "CNY",
    callbackUrl,
  }),
});

这里的字段名只是示例,实际参数应以第三方 API 的规定为准。

需要特别注意的安全问题

不能只相信通知正文

callback endpoint 是公开地址,任何人都可以向它发送请求。必须通过支付平台提供的签名、公钥验签、Webhook Secret 或其他认证机制验证请求来源。

不能仅凭请求中出现 "status": "paid",就把订单标记为已支付。

必须处理重复通知

当网络超时或支付平台没有收到成功响应时,同一通知通常会被再次发送。应使用平台提供的唯一事件 ID、交易 ID 或通知 ID 做幂等处理,避免重复发货、充值或记账。

如果平台没有提供事件 ID,需要按照其文档选择稳定的幂等键,例如交易 ID 与事件类型的组合。

不要依赖 Android 客户端确认支付结果

客户端可能断网、退出,也可能被篡改。最终支付状态应以通过签名验证的服务端通知为准。必要时,还可以调用第三方支付平台的订单查询接口进行二次确认。

尽快返回响应

回调函数只需完成必要的校验和数据更新,随后立即返回平台要求的 2xx 响应。耗时较长的业务操作可以交给队列或其他后台任务处理。

CORS 通常与支付回调无关

支付通知属于第三方服务器与 Cloud Function 之间的请求,不受浏览器 CORS 限制。无需为了处理支付回调而无条件开放跨域请求。

备注:内容仅供参考。