PAYATHON 2026

如何为网站集成支持 API 的 SMS payment solution?

支付老李

结论

可以集成,但通常没有一套覆盖所有国家和运营商的通用 SMS 支付 API。实际项目一般需要接入当地运营商认可的 Premium SMS(高级资费短信)或 Direct Carrier Billing(运营商代扣)聚合商。

用户可以在网站内输入手机号、确认验证码并解锁内容,但并非所有运营商都支持全程留在网站内。有些流程要求用户发送短信、打开运营商确认页,或完成额外的身份验证。接入前,需要向服务商确认是否提供嵌入式流程、服务端 API、异步回调,以及是否覆盖目标国家的运营商。

推荐的支付流程

对于 1、3、5 或 10 欧元的小额内容,常见的支付方式有两种。

Premium SMS

用户向指定短号码发送关键词,例如:

发送 ACCESS123 到 12345

运营商收取短信费用,支付平台再通过 Webhook 通知网站。网站验证通知后,为用户开放付费内容。

这种方式实现起来比较简单,但用户必须离开浏览器发送短信。不同价格通常还需要使用不同的短号码或关键词。

Direct Carrier Billing

用户在网站输入手机号,服务商向该号码发送一次性验证码。用户在当前页面提交验证码后,费用会计入手机账单,或从预付费余额中扣除。

典型流程如下:

  1. 用户选择价格档位。
  2. 服务器创建支付订单。
  3. 用户输入手机号。
  4. 支付平台发送验证码,或触发运营商确认。
  5. 用户在网站输入验证码。
  6. 支付平台处理扣款。
  7. 支付平台通过 Webhook 返回最终结果。
  8. 服务器确认支付成功并开放内容。

如果希望用户尽量留在网站内,应优先选择支持 API、OTP/PIN 验证和嵌入式结账页面的 Direct Carrier Billing 服务商。

服务端集成示例

各家服务商的接口名称和参数并不统一。下面只展示一个通用结构,其中的 URL、字段名和签名算法都是占位内容,接入时必须替换为服务商官方文档规定的值。

创建订单

不要让浏览器直接决定最终金额。服务器应根据内部商品编号确定价格:

const PRODUCTS = {
  article_1: { amount: 100, currency: "EUR" },
  article_3: { amount: 300, currency: "EUR" },
  article_5: { amount: 500, currency: "EUR" },
  article_10: { amount: 1000, currency: "EUR" }
};

app.post("/api/sms-payments", async (req, res) => {
  const product = PRODUCTS[req.body.productId];

  if (!product) {
    return res.status(400).json({ error: "Unknown product" });
  }

  const order = await db.orders.create({
    userId: req.user.id,
    productId: req.body.productId,
    amount: product.amount,
    currency: product.currency,
    status: "pending"
  });

  const response = await fetch("https://api.provider.example/payments", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.SMS_PAYMENT_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": order.id
    },
    body: JSON.stringify({
      merchantReference: order.id,
      amount: product.amount,
      currency: product.currency,
      phoneNumber: req.body.phoneNumber,
      callbackUrl: "https://example.com/webhooks/sms-payment"
    })
  });

  if (!response.ok) {
    return res.status(502).json({ error: "Payment provider unavailable" });
  }

  const payment = await response.json();

  res.json({
    orderId: order.id,
    paymentId: payment.id,
    nextAction: payment.nextAction
  });
});

金额最好使用最小货币单位。例如,100 表示 1.00 EUR,这样可以避免浮点数计算误差。不过,仍要确认服务商要求的是整数分、十进制字符串,还是预先配置的价格档位。

提交验证码

如果服务商支持通过 API 验证 OTP/PIN,可以在网站中提供验证码输入框:

app.post("/api/sms-payments/:paymentId/confirm", async (req, res) => {
  const response = await fetch(
    `https://api.provider.example/payments/${encodeURIComponent(req.params.paymentId)}/confirm`,
    {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.SMS_PAYMENT_API_KEY}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        code: req.body.code
      })
    }
  );

  const result = await response.json();

  res.status(response.ok ? 200 : 400).json({
    status: result.status
  });
});

支付 API 密钥只能保存在服务器端,不能写入网页 JavaScript、移动端代码或公开仓库。

处理支付回调

不能只根据用户提交验证码后的页面响应开放内容。网络超时、延迟扣款和重复通知都很常见,最终结果应以通过签名验证的 Webhook 为准:

import crypto from "node:crypto";

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

  if (
    typeof signature !== "string" ||
    signature.length !== expected.length
  ) {
    return false;
  }

  return crypto.timingSafeEqual(
    Buffer.from(signature, "utf8"),
    Buffer.from(expected, "utf8")
  );
}

app.post(
  "/webhooks/sms-payment",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.get("X-Payment-Signature");

    if (
      !verifySignature(
        req.body,
        signature,
        process.env.SMS_PAYMENT_WEBHOOK_SECRET
      )
    ) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(req.body.toString("utf8"));

    if (event.type === "payment.succeeded") {
      const order = await db.orders.findById(event.merchantReference);

      if (
        order &&
        order.status === "pending" &&
        order.amount === event.amount &&
        order.currency === event.currency
      ) {
        await db.transaction(async (tx) => {
          await tx.orders.markPaid(order.id, event.paymentId);
          await tx.entitlements.grant(order.userId, order.productId);
        });
      }
    }

    res.sendStatus(204);
  }
);

实际签名方式可能涉及原始请求体、时间戳、证书或其他编码格式,必须严格按照服务商的签名规范实现。还要保存事件 ID,并让回调处理保持幂等,避免重复通知导致多次授权。

选择服务商时要确认什么

不能只看服务商有没有 API 文档,还要逐项确认:

  • 是否覆盖用户所在国家及其移动运营商;
  • 是否支持一次性付款,而不只是周期订阅;
  • 能否提供 1、3、5、10 欧元这些价格档位;
  • 用户是否必须跳转到运营商页面;
  • 是否支持网页内 OTP/PIN 验证;
  • 是否提供服务端 API、Webhook、签名验证和沙箱环境;
  • 手机号由谁收集、保存和处理;
  • 用户账单中显示什么商户或服务名称;
  • 退款、拒付、投诉和未成年人消费如何处理;
  • 分成比例、最低结算额及结算周期;
  • API 返回的是扣款成功,还是仅表示请求已受理。

运营商支付的实际到账金额通常会明显低于用户支付的金额。除了聚合商手续费,还可能扣除运营商分成、税费和退款准备金。因此,标价 1 EUR 不代表商户最终能够收到 1 EUR。

合规和体验方面的限制

SMS 支付受国家、运营商和内容类型影响很大。服务商可能要求提交公司注册资料并接受内容审核,还可能要求页面提供清楚的价格说明、退款入口、客服联系方式和指定的确认文案。成人内容、博彩、数字货币等类别通常会受到更多限制,也可能直接无法接入。

页面至少应清楚说明:

  • 单次收费还是自动续费;
  • 含税总价;
  • 费用将计入手机账单或从余额扣除;
  • 内容有效期;
  • 退款和客服渠道;
  • 用户需要执行的确认操作。

手机号不能作为可靠的登录身份,因为号码可能被回收、转移,也可能由多人共用。内容授权应绑定网站账户和内部订单,手机号只用于支付流程中的身份标识。

系统还应限制验证码尝试次数、订单创建频率和同一手机号的请求频率,并记录订单状态变化,以防止暴力尝试、重复扣款和伪造回调。

可行的落地方案

如果业务只面向一个国家,可以优先寻找当地运营商认可的聚合商。先确认价格档位和嵌入式支付能力,再开始开发。如果业务覆盖多个欧洲国家,则可能需要按国家分别签约,并分别配置短号码或支付流程。

如果没有服务商能够提供完全在网站内完成的流程,可以采用以下方案:

  • 在网站内完成商品选择和订单创建;
  • 通过弹窗、嵌入式组件或短暂打开的运营商确认页完成授权;
  • 用户返回后显示”正在确认支付”;
  • 服务器等待 Webhook;
  • 收到最终成功通知后自动开放内容。

API 集成本身是可行的,但能否完全避免跳转,不只取决于技术实现,还要看运营商和支付服务商允许使用哪种认证方式。确定目标国家、运营商覆盖范围和内容类型后,再根据结算比例、合规要求和用户流程选择聚合商,比单纯寻找一个”SMS API”更实际。

备注:内容仅供参考。