Skip to content

PIX Cash-Out 按密钥

使用收款方的 PIX 密钥执行 PIX 转账。

端点

POST /api/external/pix/cash-out

请求头

类型必填描述
AuthorizationStringApiKey {client_id}:{client_secret}
Content-TypeStringapplication/json
hmacStringbody 的 HMAC-SHA512 签名(十六进制)
Idempotency-KeyString避免重复处理的唯一键(最大 256 字符)

身份验证

参见 身份验证。HMAC 签名必须按照 HMAC-SHA512 中的说明生成。

Idempotency-Key — 重放行为

提供时,API 会在 24 小时内存储响应(仅 2xx),并为具有相同 (method, path, Idempotency-Key) 组合的任何新 POST 返回缓存的响应。缓存按端点作用域(/cash-out/refund 中的相同键不冲突)。

  • 在重放响应中,API 包含 X-Idempotent-Replay: true 头并回显发送的 Idempotency-Key
  • 超过 256 字符的键返回 400 Bad Request
  • 键是可选的。如果不发送,API 将每个 POST 作为新交易处理(确定性的 end_to_end_id 仍然保证 BACEN/SPI 层的幂等性,但如果第一次尝试已结算,可能会被拒绝并返回 failure_reason: "DUPL")。

必需权限

API Key 必须具有 transfer:write 权限才能发送 PIX。没有该权限,请求返回 403 Forbidden。参见 如何配置权限

请求体

字段类型必填描述
amountInteger金额,以**分(centavos)**为单位。R$ 30,00 = 3000
pix_keyString收款方 PIX 密钥
pix_key_typeString密钥类型:cpfcnpjemailphoneevp。如省略,根据密钥值自动检测。
descriptionString转账描述(最多 140 个字符)
external_idString您系统中的标识符,用于追踪。trim 后最多 128 字符。仅 a-zA-Z0-9._:- 字符。在响应和 webhook 中返回。无效值(不允许的字符、> 128 字符、trim 后为空)被静默丢弃 — 交易继续,external_id: null。如需确保持久化,请在发送前自行验证。
recipient_ispbString目的地机构的 ISPB,用于手动路由(8 位数字)。提供时,将付款定向到指定的 PSP。不要发送 Owem 的 ISPB(37839059 — 机构内请求返回 same_institution 错误(不支持内部 PIX)。
end_to_end_idStringBACEN 格式的 End-to-End ID(E{ISPB}{YYYYMMDDHHmm}{entropy})。建议省略 — 后端每次尝试生成确定性的 E2E(相同的 amount + pix_key + merchant_id → 相同的 E2E)。此确定性即使没有 Idempotency-Key 也能保证 SPI/BACEN 的幂等性。仅在协调的重新处理场景中手动发送。
purposeString转账目的(用于内部使用和合规的自由文本字段)。

货币值

输入值以分为单位(R$ 1,00 = 100)。响应值以基础单位为单位(R$ 1,00 = 10000)。要将响应转换为雷亚尔,请除以 10.000。绝不使用浮点数。

示例

bash
curl -X POST https://api.owem.com.br/api/external/pix/cash-out \
  -H "Authorization: ApiKey $CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -H "hmac: $HMAC" \
  -d '{
    "amount": 3000,
    "pix_key": "12345678901",
    "pix_key_type": "cpf",
    "description": "Pagamento fornecedor",
    "external_id": "order-9876"
  }'

成功响应 -- 200 / 202

json
{
  "worked": true,
  "final": false,
  "transaction_id": "PIXOUT20260309a1b2c3d4e5f6",
  "end_to_end_id": "E37839059202603091530abcdef01",
  "external_id": "order-9876",
  "amount": 300000,
  "fee_amount": 350,
  "net_amount": 300350,
  "status": "accepted",
  "detail": "PIX enviado para processamento"
}

HTTP 200 vs 202

  • HTTP 200:交易已结算(final: true,status: "settled")。
  • HTTP 202:交易已被接受处理(final: false)。通过轮询或 webhook 跟踪状态。status 可能为 "accepted"(正常流程)、"queued"(应用了 rate-limit — 每 3 秒自动重试最多 120 分钟)或 "pending_approval"(等待双重授权工作流批准,启用时)。
字段类型描述
workedBooleantrue 表示请求已被接受
finalBoolean当交易达到终止状态(已结算或已拒绝)时为 true。仍在处理中时为 false
transaction_idString交易唯一标识
end_to_end_idStringSPI/BACEN 的 End-to-End 标识(格式 E{ISPB}...
external_idString您的标识符,按发送原样返回。未提供时为 null
amountInteger转账值,以基础单位(÷ 10.000 得到雷亚尔)。300000 = R$ 30,00
fee_amountInteger收取的手续费,以基础单位(÷ 10.000 得到雷亚尔)
net_amountInteger付款账户的总借记值,以基础单位。计算为 amount + fee_amount(总借记包括手续费)。不是收款方收到的值 — 他们只收到 amount。示例:amount=300000 + fee_amount=350net_amount=300350(从您账户借记 R$ 30,035,收款方入账 R$ 30,00)
statusString其中之一:accepted(HTTP 202,正常同步处理)、settled(HTTP 200,立即结算 — 快速通道中罕见)、queued(HTTP 202,进入 DICT rate-limit 自动重试队列 — session 155)、pending_approval(HTTP 202,等待批准)。参见终止状态:按 ID 查询 Cash-Out -- status 字段的值
detailString描述消息

cash-out 中的 net_amount 语义与 cash-in 不同

在 cash-out 中,net_amount = amount + fee_amount(付款账户的总借记)。在 cash-in(QR Code 支付)中,后端将 net_amount 视为扣除手续费后的净入账值。这种不对称性是历史性的 — 始终将 net_amount 视为"该方向上账户的实际变动"。对于会计对账,建议单独操作 amountfee_amount 字段。

拒绝代码

API 可能因输入验证(发送到 SPI 之前)、与 provider / DICT 的集成错误(在同步发送期间)或带自动重试队列的 rate-limit 拒绝 cash-out。通过 PACS.002 RJCT 的 BACEN 拒绝是异步到达的,仅通过 状态查询 或 webhook pix.payout.rejected 出现。

错误响应格式

cash-out 的同步拒绝以两种不同的格式返回 — 根据错误的来源选择正确的 parser:

格式 A — Orchestrator 验证或集成(代码 same_institution_transferinsufficient_balancedict_key_not_founddict_rate_limiteddict_bucket_exhausted 来自同步 OnZ → Orchestrator 路径):HTTP 400 或 422,body {"status": "failed", "errors": [{"code": "<代码>", "params": [...]}]}。由 FallbackController%OutboundPayment.Result{error_stage: :validation | :integration} 生成。

格式 B — Orchestrator 之前的控制器错误(例:Helpers.validate_amount / KeySanitizerinvalid or missing amountambiguous key):HTTP 400,body {"errors": {"bad_request": "消息"}}

通过 data.status === "failed"(格式 A)vs data.errors.bad_request(格式 B)路由。

验证错误 (HTTP 400 / 422)

HTTP格式带代码的字段含义
400Berrors.bad_request: "invalid or missing amount"amount 缺失、零、负数或非整数
400Berrors.bad_request: "ambiguous key"没有 pix_key_type 的 11 位密钥 — 可能是 CPF 或电话。通过 CPF 验证 解决并明确提供 pix_key_type
400Berrors.bad_request: "invalid pix_key"密钥未通过格式规则(无效的 CPF 校验和、格式错误的电子邮件等)
422Aerrors[0].code: "same_institution_transfer"recipient_ispb 是 Owem 自己的 ISPB(37839059)。不支持机构内 PIX — 使用内部 TEF。:此验证返回 HTTP 422(非 400),结构为 {status: "failed", errors: [{code: "same_institution_transfer", params: []}]}
422Aerrors[0].code: "insufficient_balance"可用余额小于 amount + fee_amount。考虑活动的 hold(gotcha min(TB, PG)

same_institution 的结构变化

这些文档的早期版本声称 HTTP 400 带 detail: "same_institution"。实际行为是HTTP 422 带格式 A 结构(errors 作为 {code, params} 数组)。执行 if (status === 400 && body.detail === "same_institution") 的客户端在实践中不会触发 — 使用 if (status === 422 && body.errors?.[0]?.code === "same_institution_transfer")

与 provider / DICT 的集成错误 (HTTP 400)

当 OnZ 同步返回 HTTP 4xx(在 PACS.008 到达 BACEN 之前),后端通过 Fluxiq.UseCases.Pix.ReasonCodes.classify_provider_error/2 分类错误并返回格式 A,HTTP 400error_stage: :integration):

代码(errors[0].code含义建议操作
dict_key_not_foundDICT/BACEN 中未找到 PIX 密钥(OnZ HTTP 404)与付款方核实;密钥可能已被删除或从未注册
dict_key_blocked密钥被封锁(例:疑似欺诈,OnZ HTTP 403)联系密钥持有人
dict_lookup_failed查询 DICT 失败(OnZ body 中消息 "consulta dict")5-30 秒后重试
dict_rate_limitedOnZ 返回 429,消息 "rate limit" 或 "limite de consultas dict"重试前指数退避
dict_bucket_exhaustedOnZ 返回 body 提及 "bucket" / "balde de fichas"60-120 秒后重试;避免突发流量
provider_rejectedOnZ 以未分类的 4xx 通用错误拒绝查看 errors[0].params 获取上下文(OnZ 原始 HTTP);通过 Owem 支持重新打开案例
provider_schema_errorOnZ 返回 HTTP 422 — 格式无效的 PACS.008(内部错误)立即报告 — 不要重试,这是后端 bug
provider_unknown_error进入此路径的 400..499 之外的状态支持处提供完整日志

HTTP 是 400(不是 429)

这些文档的早期版本在同步路径中对 dict_rate_limiteddict_bucket_exhausted 显示 HTTP 429。FallbackController.call/2%OutboundPayment.Result{error_stage: :integration} 映射为 HTTP 400 Bad Request — 从不 429。为 rate-limit 返回 HTTP 202 的唯一路径是自动重试队列(见下文)。

带自动重试的 rate-limit (HTTP 202 queued)

内部适配器(不是 OnZ)在将请求发送到 OnZ 之前检测到超限时,后端将交易加入自动重试队列。此路径由 flag pix_out_retry_queue_enabled 激活(自 session 155 起在 PRD 中为 ON)。

两种场景触发队列:

来源reason_code(webhook pix.payout.queued原因
每 merchant 的 ClientLimiterDICT_CLIENT_RATE_LIMITEDMerchant 超过每分钟的 DICT 查询配额(默认 DICT_CLIENT_MAX_PER_MIN=120,可通过 env 配置)。保护单一客户端不垄断共享的 BACEN bucket。仅适用于 cache-MISS 路径 — 缓存中的重复目的地不计数。
全局的 DictBucket.GuardDICT_BUCKET_EXHAUSTEDOnZ 参与者与 BACEN 的 DICT token 桶已耗尽。经验 refill DICT_BUCKET_REFILL_RATE=18/min(session 155 R Torres 事件),capacity 250 tokens(rating G,§13.1 BACEN DICT Manual)。

队列时的 HTTP 响应(由 FallbackController%Result{status: :queued} 生成):

json
{
  "status": "queued",
  "type": "pix",
  "transaction_id": "PIXOUT20260309a1b2c3d4e5f6",
  "end_to_end_id": "E37839059202603091530abcdef01",
  "outbound_request_id": "0A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D",
  "amount": 300000,
  "message": "Payment rate-limited, enqueued for automatic retry (TTL 120 min)",
  "estimated_retry_seconds": 3,
  "queue_ttl_seconds": 7200
}

重试机制

  • Oban worker Fluxiq.Workers.PixOutRetryWorker3 秒重试(与 BACEN 每个 token ~3,3s refill rate 对齐)。
  • 总 TTL:7200 秒(120 分钟)。过期后,worker 会 void TB pending 并触发 pix.payout.failedreason_code: "DICT_QUEUE_TIMEOUT"
  • 最大尝试次数:50(Oban 的 snooze 不计为尝试)。按 request_id 的唯一约束防止重复作业。
  • 立即 webhook:进入队列时,后端触发 pix.payout.queuedreason_codeDICT_CLIENT_RATE_LIMITEDDICT_BUCKET_EXHAUSTED)和 reason_description。这是 120 分钟内发出的唯一 webhook — 下一个将是 pix.payout.confirmed(成功)或 pix.payout.failed(超时)。

DICT_CLIENT_MAX_PER_MIN ≠ DICT_BUCKET_REFILL_RATE

这是两个独立的限制,原因不同:

  • DICT_CLIENT_MAX_PER_MIN=120每 merchant 配额,60s 滑动窗口,在 DICT 查询之前在 Redis 中计数。达到时 → reason_code DICT_CLIENT_RATE_LIMITED。如果重试队列 flag ON(当前 PRD),响应为 HTTP 202 queued。如果 OFF,请求落入 Result.success(:accepted) 路径,依赖 StaleChecker 最终 void 交易 — 遗留行为。
  • DICT_BUCKET_REFILL_RATE=18(每分钟)+ capacity 250BACEN bucket 全局限制,由整个 OnZ ISPB 共享。每 ~3,3s 重置 token。当桶达到零 → reason_code DICT_BUCKET_EXHAUSTED,HTTP 202 queued 语义相同(如果 flag ON)。

flag pix_out_retry_queue_enabled 自 session 155(2026 年 4 月 20 日)起在生产中为 ON。对于 homologacao 中的客户端,行为可能不同 — 始终将 status === "queued"status === "accepted" 视为非终止状态。

权限与身份验证 (HTTP 401 / 403)

HTTPdetail含义
401Invalid HMAC signatureHMAC 签名不匹配。检查序列化 body 中字段的字母顺序 — 参见 HMAC-SHA512
401Invalid API Keyclient_id:client_secret 不正确
403permission 'transfer:write' requiredAPI Key 没有 PIX 权限
403IP not whitelisted源 IP 不在 API Key 白名单中

代码词汇 — UPPERCASE × lowercase

cash-out 的结构化代码来自两个不同的字典,后端通过 Fluxiq.UseCases.Pix.ReasonCodes 保持一致:

命名空间约定来源示例
BACEN SPIUPPERCASE通过 PACS.002 RJCT 的异步拒绝(在 202 之后到达)— 在 GET /transactions/:id 和 webhook pix.payout.rejected 中可见AC03AB03ED05DUPLAM02FF08BE01
Provider / Adapterlowercase snake_case在 PACS.008 到达 BACEN 之前从 OnZ 的同步拒绝 — 用于此端点中的 errors[0].codedict_key_not_founddict_rate_limitedsame_institution_transferprovider_schema_error
重试队列UPPERCASE(前缀 DICT_当有自动重试时的 webhook pix.payout.queued / pix.payout.failedDICT_CLIENT_RATE_LIMITEDDICT_BUCKET_EXHAUSTEDDICT_QUEUE_TIMEOUT

在程序化错误 switch 时,在您的一侧将其归一化为大写或小写以避免重复分支。不要期望在同步响应中出现 AM02 — BACEN 代码仅在 post-acceptance GET 查询中出现。

对应的 webhook

  • 同步拒绝(上面的格式 A/B)不触发 webhook — 客户端已经在 HTTP 响应中收到错误。
  • 按 rate-limit 入队(HTTP 202 queued立即触发 pix.payout.queued,带 reason_code + reason_description
  • 异步拒绝(202 接受后的 PACS.002 RJCT)触发 pix.payout.rejected,带 BACEN reason_code(AC03、AB03、ED05、DUPL 等)和英文 reason_description
  • 孤儿 void(>30min 无 PACS.002)触发 pix.payout.failed,带 reason_code: "orphan_force_voided"
  • 重试队列过期(120min)触发 pix.payout.failed,带 reason_code: "DICT_QUEUE_TIMEOUT"

PIX 密钥类型

类型格式示例
cpf11 位数字(无标点)12345678901
cnpj14 位数字(无标点)12345678000199
email电子邮件地址nome@empresa.com.br
phone区号 + 号码(11 位数字)11999998888
evpUUID v4a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d

11 位密钥 — CPF vs 电话歧义

恰好 11 位的密钥可能是 CPF,也可能是手机号(DDD + 9xxxx-xxxx)。当密钥有歧义时,API 以 HTTP 400 和 failure_reason: "ambiguous key" 拒绝。

建议解决方案:

  1. 使用 CPF 验证 端点(POST /api/external/cpf/validate)检查 11 位是否形成有效的 CPF
  2. 如果 valid: true → 在 cash-out 中发送 pix_key_type: "cpf"
  3. 如果 valid: false → 是电话,发送 pix_key_type: "phone"(API 自动添加前缀 +55
javascript
// 自动化流程示例
async function resolveKeyType(key) {
  if (key.length !== 11 || /\D/.test(key)) return null; // 无歧义
  
  const { data } = await api.post('/api/external/cpf/validate', { cpf: key });
  return data.valid ? 'cpf' : 'phone';
}

提示:以纯 11 位(DDD + 号码)发送电话。API 自动添加前缀 +55。避免手动发送 +55 — 可能在某些客户端中导致 HMAC 验证失败。

下一步

创建转账后,通过以下方式跟踪状态:

或通过 Webhook 自动接收确认。

Owem Pay Instituição de Pagamento — ISPB 37839059