PIX Cash-Out 按密钥
使用收款方的 PIX 密钥执行 PIX 转账。
端点
POST /api/external/pix/cash-out请求头
| 头 | 类型 | 必填 | 描述 |
|---|---|---|---|
Authorization | String | 是 | ApiKey {client_id}:{client_secret} |
Content-Type | String | 是 | application/json |
hmac | String | 是 | body 的 HMAC-SHA512 签名(十六进制) |
Idempotency-Key | String | 否 | 避免重复处理的唯一键(最大 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。参见 如何配置权限。
请求体
| 字段 | 类型 | 必填 | 描述 |
|---|---|---|---|
amount | Integer | 是 | 金额,以**分(centavos)**为单位。R$ 30,00 = 3000 |
pix_key | String | 是 | 收款方 PIX 密钥 |
pix_key_type | String | 否 | 密钥类型:cpf、cnpj、email、phone、evp。如省略,根据密钥值自动检测。 |
description | String | 否 | 转账描述(最多 140 个字符) |
external_id | String | 否 | 您系统中的标识符,用于追踪。trim 后最多 128 字符。仅 a-zA-Z0-9._:- 字符。在响应和 webhook 中返回。无效值(不允许的字符、> 128 字符、trim 后为空)被静默丢弃 — 交易继续,external_id: null。如需确保持久化,请在发送前自行验证。 |
recipient_ispb | String | 否 | 目的地机构的 ISPB,用于手动路由(8 位数字)。提供时,将付款定向到指定的 PSP。不要发送 Owem 的 ISPB(37839059) — 机构内请求返回 same_institution 错误(不支持内部 PIX)。 |
end_to_end_id | String | 否 | BACEN 格式的 End-to-End ID(E{ISPB}{YYYYMMDDHHmm}{entropy})。建议省略 — 后端每次尝试生成确定性的 E2E(相同的 amount + pix_key + merchant_id → 相同的 E2E)。此确定性即使没有 Idempotency-Key 也能保证 SPI/BACEN 的幂等性。仅在协调的重新处理场景中手动发送。 |
purpose | String | 否 | 转账目的(用于内部使用和合规的自由文本字段)。 |
货币值
输入值以分为单位(R$ 1,00 = 100)。响应值以基础单位为单位(R$ 1,00 = 10000)。要将响应转换为雷亚尔,请除以 10.000。绝不使用浮点数。
示例
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
{
"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"(等待双重授权工作流批准,启用时)。
| 字段 | 类型 | 描述 |
|---|---|---|
worked | Boolean | true 表示请求已被接受 |
final | Boolean | 当交易达到终止状态(已结算或已拒绝)时为 true。仍在处理中时为 false |
transaction_id | String | 交易唯一标识 |
end_to_end_id | String | SPI/BACEN 的 End-to-End 标识(格式 E{ISPB}...) |
external_id | String | 您的标识符,按发送原样返回。未提供时为 null |
amount | Integer | 转账值,以基础单位(÷ 10.000 得到雷亚尔)。300000 = R$ 30,00 |
fee_amount | Integer | 收取的手续费,以基础单位(÷ 10.000 得到雷亚尔) |
net_amount | Integer | 付款账户的总借记值,以基础单位。计算为 amount + fee_amount(总借记包括手续费)。不是收款方收到的值 — 他们只收到 amount。示例:amount=300000 + fee_amount=350 → net_amount=300350(从您账户借记 R$ 30,035,收款方入账 R$ 30,00) |
status | String | 其中之一:accepted(HTTP 202,正常同步处理)、settled(HTTP 200,立即结算 — 快速通道中罕见)、queued(HTTP 202,进入 DICT rate-limit 自动重试队列 — session 155)、pending_approval(HTTP 202,等待批准)。参见终止状态:按 ID 查询 Cash-Out -- status 字段的值 |
detail | String | 描述消息 |
cash-out 中的 net_amount 语义与 cash-in 不同
在 cash-out 中,net_amount = amount + fee_amount(付款账户的总借记)。在 cash-in(QR Code 支付)中,后端将 net_amount 视为扣除手续费后的净入账值。这种不对称性是历史性的 — 始终将 net_amount 视为"该方向上账户的实际变动"。对于会计对账,建议单独操作 amount 和 fee_amount 字段。
拒绝代码
API 可能因输入验证(发送到 SPI 之前)、与 provider / DICT 的集成错误(在同步发送期间)或带自动重试队列的 rate-limit 拒绝 cash-out。通过 PACS.002 RJCT 的 BACEN 拒绝是异步到达的,仅通过 状态查询 或 webhook pix.payout.rejected 出现。
错误响应格式
cash-out 的同步拒绝以两种不同的格式返回 — 根据错误的来源选择正确的 parser:
格式 A — Orchestrator 验证或集成(代码 same_institution_transfer、insufficient_balance、dict_key_not_found、dict_rate_limited、dict_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 / KeySanitizer 的 invalid or missing amount、ambiguous key):HTTP 400,body {"errors": {"bad_request": "消息"}}。
通过 data.status === "failed"(格式 A)vs data.errors.bad_request(格式 B)路由。
验证错误 (HTTP 400 / 422)
| HTTP | 格式 | 带代码的字段 | 含义 |
|---|---|---|---|
| 400 | B | errors.bad_request: "invalid or missing amount" | amount 缺失、零、负数或非整数 |
| 400 | B | errors.bad_request: "ambiguous key" | 没有 pix_key_type 的 11 位密钥 — 可能是 CPF 或电话。通过 CPF 验证 解决并明确提供 pix_key_type |
| 400 | B | errors.bad_request: "invalid pix_key" | 密钥未通过格式规则(无效的 CPF 校验和、格式错误的电子邮件等) |
| 422 | A | errors[0].code: "same_institution_transfer" | recipient_ispb 是 Owem 自己的 ISPB(37839059)。不支持机构内 PIX — 使用内部 TEF。注:此验证返回 HTTP 422(非 400),结构为 {status: "failed", errors: [{code: "same_institution_transfer", params: []}]} |
| 422 | A | errors[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 400(error_stage: :integration):
代码(errors[0].code) | 含义 | 建议操作 |
|---|---|---|
dict_key_not_found | DICT/BACEN 中未找到 PIX 密钥(OnZ HTTP 404) | 与付款方核实;密钥可能已被删除或从未注册 |
dict_key_blocked | 密钥被封锁(例:疑似欺诈,OnZ HTTP 403) | 联系密钥持有人 |
dict_lookup_failed | 查询 DICT 失败(OnZ body 中消息 "consulta dict") | 5-30 秒后重试 |
dict_rate_limited | OnZ 返回 429,消息 "rate limit" 或 "limite de consultas dict" | 重试前指数退避 |
dict_bucket_exhausted | OnZ 返回 body 提及 "bucket" / "balde de fichas" | 60-120 秒后重试;避免突发流量 |
provider_rejected | OnZ 以未分类的 4xx 通用错误拒绝 | 查看 errors[0].params 获取上下文(OnZ 原始 HTTP);通过 Owem 支持重新打开案例 |
provider_schema_error | OnZ 返回 HTTP 422 — 格式无效的 PACS.008(内部错误) | 立即报告 — 不要重试,这是后端 bug |
provider_unknown_error | 进入此路径的 400..499 之外的状态 | 支持处提供完整日志 |
HTTP 是 400(不是 429)
这些文档的早期版本在同步路径中对 dict_rate_limited 和 dict_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 的 ClientLimiter | DICT_CLIENT_RATE_LIMITED | Merchant 超过每分钟的 DICT 查询配额(默认 DICT_CLIENT_MAX_PER_MIN=120,可通过 env 配置)。保护单一客户端不垄断共享的 BACEN bucket。仅适用于 cache-MISS 路径 — 缓存中的重复目的地不计数。 |
全局的 DictBucket.Guard | DICT_BUCKET_EXHAUSTED | OnZ 参与者与 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} 生成):
{
"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.PixOutRetryWorker每 3 秒重试(与 BACEN 每个 token ~3,3s refill rate 对齐)。 - 总 TTL:7200 秒(120 分钟)。过期后,worker 会 void TB pending 并触发
pix.payout.failed带reason_code: "DICT_QUEUE_TIMEOUT"。 - 最大尝试次数:50(Oban 的 snooze 不计为尝试)。按
request_id的唯一约束防止重复作业。 - 立即 webhook:进入队列时,后端触发
pix.payout.queued带reason_code(DICT_CLIENT_RATE_LIMITED或DICT_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_codeDICT_CLIENT_RATE_LIMITED。如果重试队列 flag ON(当前 PRD),响应为 HTTP 202queued。如果 OFF,请求落入Result.success(:accepted)路径,依赖 StaleChecker 最终 void 交易 — 遗留行为。DICT_BUCKET_REFILL_RATE=18(每分钟)+ capacity 250:BACEN bucket 全局限制,由整个 OnZ ISPB 共享。每 ~3,3s 重置 token。当桶达到零 → reason_codeDICT_BUCKET_EXHAUSTED,HTTP 202queued语义相同(如果 flag ON)。
flag pix_out_retry_queue_enabled 自 session 155(2026 年 4 月 20 日)起在生产中为 ON。对于 homologacao 中的客户端,行为可能不同 — 始终将 status === "queued" 和 status === "accepted" 视为非终止状态。
权限与身份验证 (HTTP 401 / 403)
| HTTP | detail | 含义 |
|---|---|---|
| 401 | Invalid HMAC signature | HMAC 签名不匹配。检查序列化 body 中字段的字母顺序 — 参见 HMAC-SHA512 |
| 401 | Invalid API Key | client_id:client_secret 不正确 |
| 403 | permission 'transfer:write' required | API Key 没有 PIX 权限 |
| 403 | IP not whitelisted | 源 IP 不在 API Key 白名单中 |
代码词汇 — UPPERCASE × lowercase
cash-out 的结构化代码来自两个不同的字典,后端通过 Fluxiq.UseCases.Pix.ReasonCodes 保持一致:
| 命名空间 | 约定 | 来源 | 示例 |
|---|---|---|---|
| BACEN SPI | UPPERCASE | 通过 PACS.002 RJCT 的异步拒绝(在 202 之后到达)— 在 GET /transactions/:id 和 webhook pix.payout.rejected 中可见 | AC03、AB03、ED05、DUPL、AM02、FF08、BE01 |
| Provider / Adapter | lowercase snake_case | 在 PACS.008 到达 BACEN 之前从 OnZ 的同步拒绝 — 用于此端点中的 errors[0].code | dict_key_not_found、dict_rate_limited、same_institution_transfer、provider_schema_error |
| 重试队列 | UPPERCASE(前缀 DICT_) | 当有自动重试时的 webhook pix.payout.queued / pix.payout.failed | DICT_CLIENT_RATE_LIMITED、DICT_BUCKET_EXHAUSTED、DICT_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,带 BACENreason_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 密钥类型
| 类型 | 格式 | 示例 |
|---|---|---|
cpf | 11 位数字(无标点) | 12345678901 |
cnpj | 14 位数字(无标点) | 12345678000199 |
email | 电子邮件地址 | nome@empresa.com.br |
phone | 区号 + 号码(11 位数字) | 11999998888 |
evp | UUID v4 | a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d |
11 位密钥 — CPF vs 电话歧义
恰好 11 位的密钥可能是 CPF,也可能是手机号(DDD + 9xxxx-xxxx)。当密钥有歧义时,API 以 HTTP 400 和 failure_reason: "ambiguous key" 拒绝。
建议解决方案:
- 使用 CPF 验证 端点(
POST /api/external/cpf/validate)检查 11 位是否形成有效的 CPF - 如果
valid: true→ 在 cash-out 中发送pix_key_type: "cpf" - 如果
valid: false→ 是电话,发送pix_key_type: "phone"(API 自动添加前缀+55)
// 自动化流程示例
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 验证失败。
下一步
创建转账后,通过以下方式跟踪状态:
- 按 ID 查询
- 按 E2E ID 查询
- 按 Tag 查询
- 按 External ID 查询 --
GET /api/external/transactions/ref/{external_id}
或通过 Webhook 自动接收确认。