PAYATHON 2026

Twilio Studio 中 Function Widget 的 PCI Compliant <Gather> 问题

支付小周

结论

<Gather action="..."> 调用的是语音 webhook。Twilio 会把这个 webhook 返回的内容当作下一段通话指令处理,不会自动回到原来的 Studio Widget,也不会把内容保存为该 Widget 的输出变量。

响应方式取决于 Function 的调用来源:

  • Studio 的 Run Function Widget 调用 Function 时,返回 application/json,后续 Widget 可以从 parsed 中读取 token。
  • <Gather> 的 action 调用 Function 时,必须返回合法的 Voice TwiML。比如使用 <Redirect> 将呼叫重新交给 Studio,不能直接返回 token 字符串。
  • 代码中的 event.callback(null, response.body) 应改为真正的 callback(...)。event.callback 不是 Function 的完成回调。
  • 处理信用卡信息时,不要使用 GET,也不要把卡号放进 URL。应优先评估 Twilio <Pay>。如果必须自行处理,需要确认整个链路都符合 PCI DSS,而不只是采集页面或某个 Widget。

为什么 token 会进入语音响应

<Gather> 提交完成后,Twilio 会向 action 指定的地址发起请求。该地址需要返回新的 Voice TwiML,告诉 Twilio 接下来是播放内容、重定向、转接还是结束呼叫。

当前代码直接返回了支付网关的响应正文:

event.callback(null, response.body);

这里有三个问题:

  1. <Gather action> 的响应用于控制通话,不是 Studio Function Widget 的数据响应。
  2. 返回的 token 不是合法的 Voice TwiML。
  3. 代码使用了 event.callback,而不是 handler 参数中的 callback。

第二个 Function 还创建了 MessagingResponse。当前处理的是语音呼叫,应使用 Twilio.twiml.VoiceResponse。

方案一:由 Studio 的 Run Function Widget 获取 JSON

如果 Function 由 Studio 的 Run Function Widget 直接调用,可以明确返回 JSON:

const got = require('got');

exports.handler = async function (context, event, callback) {
    try {
        const gatewayResponse = await got.post(context.PAYMENT_GATEWAY_URL, {
            form: {
                cardNumber: event.Digits,
                expDate: event.ExpDate,
                cardType: event.CardType
            },
            responseType: 'json'
        });

        const response = new Twilio.Response();

        response.appendHeader('Content-Type', 'application/json');
        response.setStatusCode(200);
        response.setBody({
            token: gatewayResponse.body.token
        });

        callback(null, response);
    } catch (error) {
        callback(error);
    }
};

假设 Studio 中的 Widget 名为 tokenize_card,后续 Widget 通常可以通过下面的表达式读取结果:

{{widgets.tokenize_card.parsed.token}}

前提是这个 Function 确实由 Studio 的 Run Function Widget 调用。把相同的 JSON 返回给 <Gather action>,不会让 JSON 自动出现在原 Studio Widget 的 parsed 数据中。

方案二:Gather 的 action 返回 TwiML,并把呼叫交回 Studio

如果必须在 Function 中执行 <Gather>,处理 digits 的 Function 在取得 token 后应返回 Voice TwiML。一种常见做法是使用 <Redirect>,将呼叫交给 Studio Webhook Trigger。

const got = require('got');

exports.handler = async function (context, event, callback) {
    const twiml = new Twilio.twiml.VoiceResponse();

    try {
        const gatewayResponse = await got.post(context.PAYMENT_GATEWAY_URL, {
            form: {
                cardNumber: event.Digits,
                expDate: event.ExpDate,
                cardType: event.CardType
            },
            responseType: 'json'
        });

        const token = gatewayResponse.body.token;

        const studioUrl = new URL(context.STUDIO_WEBHOOK_URL);
        studioUrl.searchParams.set('paymentToken', token);

        twiml.redirect(
            { method: 'POST' },
            studioUrl.toString()
        );

        callback(null, twiml);
    } catch (error) {
        console.error('Tokenization failed:', error.message);

        twiml.say('We were unable to process the payment information.');
        callback(null, twiml);
    }
};

重新进入 Studio 后,可以从 Webhook Trigger 的请求参数中取得 token。具体的 Liquid 路径取决于 Flow 的触发方式和 Studio 版本,通常需要在 Studio 的 Execution/Widget 调试数据中确认,例如:

{{trigger.request.parameters.paymentToken}}

调用 Studio Webhook 通常意味着流程会从 Webhook Trigger 重新进入。不要假定它会恢复已经离开的 Widget 上下文。流程中应单独设计一个”支付完成后继续”的入口。

更安全的传递方式

不建议把支付 token 直接拼进 URL,因为 URL 可能被代理、请求日志或调试记录保存。更稳妥的处理方式是:

  1. Function 取得 token。
  2. 将 token 临时保存在服务端,并以 CallSid 或随机的一次性 ID 为键。
  3. 使用 <Redirect> 返回 Studio 时,只携带一次性 ID。
  4. Studio 再通过 Run Function Widget 查询 token。

例如,重定向时只传递引用:

const studioUrl = new URL(context.STUDIO_WEBHOOK_URL);
studioUrl.searchParams.set('paymentRef', paymentRef);

twiml.redirect(
    { method: 'POST' },
    studioUrl.toString()
);

后续 Studio Widget 使用 paymentRef 获取实际 token。临时数据应设置较短的有效期,并在读取一次后删除,或及时清理。

也可以合并为一个 Function

不必仅仅因为需要调用支付服务就拆成两个 Function。同一个 Function 可以根据是否收到 event.Digits,分别完成信息采集和 tokenization:

const got = require('got');

exports.handler = async function (context, event, callback) {
    const twiml = new Twilio.twiml.VoiceResponse();

    if (!event.Digits) {
        const gather = twiml.gather({
            input: 'dtmf',
            finishOnKey: '#',
            timeout: 10,
            action: context.PAYMENT_FUNCTION_URL,
            method: 'POST'
        });

        gather.say('Enter card information.');
        return callback(null, twiml);
    }

    try {
        const gatewayResponse = await got.post(context.PAYMENT_GATEWAY_URL, {
            form: {
                cardNumber: event.Digits,
                expDate: event.ExpDate,
                cardType: event.CardType
            },
            responseType: 'json'
        });

        const paymentRef = await saveTokenTemporarily(
            event.CallSid,
            gatewayResponse.body.token
        );

        const studioUrl = new URL(context.STUDIO_WEBHOOK_URL);
        studioUrl.searchParams.set('paymentRef', paymentRef);

        twiml.redirect({ method: 'POST' }, studioUrl.toString());
        return callback(null, twiml);
    } catch (error) {
        console.error('Payment request failed:', error.message);
        twiml.say('The payment could not be processed.');
        return callback(null, twiml);
    }
};

saveTokenTemporarily 只是示意代码,需要替换为实际的安全存储实现。

PCI 方面的注意事项

原代码将 event.Digits 拼接到 URL 中,并使用 GET 请求:

const url = 'my payment gateway' + event.Digits;

如果 Digits 是完整卡号,这种写法会明显增加卡号进入访问日志、监控系统和错误记录的风险。至少应改用 POST,将数据放进请求体,并避免记录请求正文:

await got.post(context.PAYMENT_GATEWAY_URL, {
    form: {
        cardNumber: event.Digits,
        expDate: '1220',
        cardType: 'VI'
    }
});

不过,改用 POST 不等于实现了 PCI 合规。只要 Function 接触完整卡号,Function、日志、依赖、支付网关、数据保存方式和运维流程都可能进入 PCI DSS 范围。业务条件允许时,应优先使用专门采集支付信息并执行 tokenization 的 <Pay> 流程,同时根据 Twilio 当前的 PCI 配置要求,核实账户、产品和支付处理器是否满足条件。

备注:内容仅供参考。