PAYATHON 2026

Cybersource Flex API v2 请求 /flex/v2/tokens 返回 415

排障赵师傅

结论

调用 POST /flex/v2/tokens 时应使用以下请求头:

Content-Type: application/jwt
Accept: application/json

请求体直接传入 JWE Compact Serialization 字符串,例如:

xxxxx.yyyyy.zzzzz.aaaaa.bbbbb

不要使用 application/jose+json,也不要将 JWE 包装成 { "jwe": "..." }、{ "jweToken": "..." } 或表单参数。

Cybersource 官方文档中的请求 payload 是一段未经过 JSON 包装的 compact JWE,但当前文档没有明确标注 Content-Type。Cybersource Flex API v2 文档 一个公共接入案例确认该端点接受 application/jwt。相关问题及验证结果

正确的请求方式

JWE 生成正确后,可以这样发送请求:

const https = require('https');

const body = jwe;

const options = {
  hostname: 'apitest.cybersource.com',
  path: '/flex/v2/tokens',
  method: 'POST',
  headers: {
    'Content-Type': 'application/jwt',
    'Accept': 'application/json',
    'Content-Length': Buffer.byteLength(body, 'utf8')
  }
};

const req = https.request(options, (res) => {
  let responseBody = '';

  res.setEncoding('utf8');

  res.on('data', (chunk) => {
    responseBody += chunk;
  });

  res.on('end', () => {
    console.log('Status:', res.statusCode);
    console.log('Response:', responseBody);
  });
});

req.on('error', (error) => {
  console.error(error);
});

req.write(body);
req.end();

对应的 curl 请求如下:

curl -X POST 'https://apitest.cybersource.com/flex/v2/tokens' \
  -H 'Content-Type: application/jwt' \
  -H 'Accept: application/json' \
  --data-binary "$JWE"

$JWE 必须是完整的 compact JWE,其中不能包含引号、JSON 字段名、换行或多余空格。

检查 JWE 的生成方式

将 Content-Type 改为 application/jwt 后,如果接口返回 400、406 或 Invalid JWE format,一般表示媒体类型检查已经通过,需要继续排查 JWE 本身和加密前的 payload。

Cybersource 要求加密前的数据采用以下结构:

const payloadToEncrypt = {
  data: {
    paymentInformation: {
      card: {
        number: card.number,
        expirationMonth: card.expirationMonth,
        expirationYear: card.expirationYear,
        securityCode: card.securityCode
      }
    }
  },
  context: captureContext,
  index: 0
};

各字段要求如下:

  • context 是 /flex/v2/sessions 返回的完整 capture context 字符串。
  • index 必须是数字 0。
  • data 中的字段必须符合生成 capture context 时声明的字段范围。
  • 加密必须使用 capture context 中 flx.jwk 提供的一次性公钥。

官方文档给出的加密前结构同样由 data、context 和 index 三部分组成。Securing Payment Information

使用 node-jose 的完整示例

问题中的调用方式符合 node-jose 的用法。这个库通过 fields 设置 JWE protected header,而不是使用 header。node-jose 文档

const jose = require('node-jose');
const https = require('https');

// decodedCaptureContext 是解析后的 capture context。
// 此处只读取内容,不代表可以跳过对 capture context 签名的验证。
const encryptionJwk = decodedCaptureContext.payload.flx.jwk;

const oneTimePublicKey = await jose.JWK.asKey({
  kty: encryptionJwk.kty,
  n: encryptionJwk.n,
  e: encryptionJwk.e,
  kid: encryptionJwk.kid,
  use: encryptionJwk.use
});

const payloadToEncrypt = {
  data: {
    paymentInformation: {
      card: {
        number: card.number,
        expirationMonth: card.expirationMonth,
        expirationYear: card.expirationYear,
        securityCode: card.securityCode
      }
    }
  },
  context: captureContext,
  index: 0
};

const jwe = await jose.JWE.createEncrypt(
  {
    format: 'compact',
    fields: {
      alg: 'RSA-OAEP',
      enc: 'A256GCM',
      kid: encryptionJwk.kid
    }
  },
  oneTimePublicKey
)
  .update(Buffer.from(JSON.stringify(payloadToEncrypt), 'utf8'))
  .final();

const options = {
  hostname: 'apitest.cybersource.com',
  path: '/flex/v2/tokens',
  method: 'POST',
  headers: {
    'Content-Type': 'application/jwt',
    'Accept': 'application/json',
    'Content-Length': Buffer.byteLength(jwe, 'utf8')
  }
};

const req = https.request(options, (res) => {
  let responseBody = '';

  res.setEncoding('utf8');
  res.on('data', (chunk) => {
    responseBody += chunk;
  });

  res.on('end', () => {
    console.log(res.statusCode, responseBody);
  });
});

req.on('error', console.error);
req.write(jwe);
req.end();

容易混淆的 kid

capture context 中可能有多个 kid。生成 JWE 时,应使用 flx.jwk 中的一次性加密密钥 ID:

decodedCaptureContext.payload.flx.jwk.kid

capture context 自身的 JOSE header 中也有一个用于签名验证的 kid,不要将它用于 JWE 加密。JWE protected header 中的 kid 必须与实际用于加密的 JWK 对应,否则可能出现 Invalid JWE format、密钥无法识别或其他请求校验错误。

生成后的 JWE 至少应满足以下两个条件:

console.log(typeof jwe);          // string
console.log(jwe.split('.').length); // 5

解码第一段后,还应能看到预期的 header:

const protectedHeader = JSON.parse(
  Buffer.from(jwe.split('.')[0], 'base64url').toString('utf8')
);

console.log(protectedHeader);
// 应包含 alg、enc 和正确的 kid

生产日志中不要记录完整的 JWE、capture context 或银行卡数据。

如何理解之前的状态码

415 Unsupported Media Type 表示服务器不接受当前请求的 Content-Type。这里的 application/jose+json 指 JOSE JSON Serialization,与接口示例使用的 compact JWE 字符串格式不匹配。

改用 application/jwt 后,如果接口返回其他状态码,通常说明请求已经进入下一步处理,需要检查:

  • JWE 是否包含五个由 . 分隔的部分。
  • 是否使用了 format: 'compact'。
  • alg、enc 和 kid 是否写入 protected header。
  • kid 是否来自 flx.jwk.kid。
  • 是否使用了 capture context 中的一次性加密公钥。
  • 明文 payload 是否包含 data、context 和 index: 0。
  • context 是否完整、未过期并且属于当前环境。
  • capture context 中声明的字段是否与实际提交的字段一致。
  • 测试环境的 capture context 是否发送到了 apitest.cybersource.com。

/flex/v2/tokens 是一个无需单独进行 API 身份验证的 POST 端点,但 capture context 和一次性密钥仍受有效期和环境限制。Cybersource 也明确说明,Flex API 不适合直接在浏览器中使用。需要在浏览器中收集支付信息时,应采用 Microform Integration。

备注:内容仅供参考。