SaqSaq Docs
最佳实践

幂等性

幂等性是指多次调用同一操作与只调用一次具有相同效果的保证。没有它,重试会变成重复扣款、重复入账和丢失退款。

需要幂等性的场景

场景无幂等性有幂等性
应用在 POST /pix 后崩溃,但不知道是否送达生成 2 笔扣款Saq 返回已存在的那笔
POST /pix 超时,但 QR 已生成客户看到 2 个不同的 QRSaq 返回同一笔交易
重试 job 触发同一笔扣款 2 次2 笔扣款,客服困扰1 笔扣款,客户正常支付
同一 callback 到达 2 次(超时后重试)订单标记已付款 2 次忽略重复的那次
交易经历 PENDING → COMPLETED → REFUNDED可能忽略退款每次状态转换只处理一次

创建时的 clientReference

clientReference在创建扣款、提现或转账时定义的外部幂等标识符。Saq 按 userId + clientReference 索引,如果交易已创建,则返回已存在的那笔。

如何生成

模式何时使用
order-{orderId}每个订单 1 笔扣款。推荐。
payout-{payoutId}每次申请 1 笔提现。
subscription-{subId}-{period}周期性扣款(每个周期 1 笔)。
retry-{orderId}-{attempt}当您需要在彻底失败后强制发起新扣款时。
transfer-{from}-{to}-{date}按天幂等的内部转账。

绝对不要使用 Date.now()uuid() 或其他随机值作为 clientReference。重试会生成不同的值,Saq 会创建重复扣款,这正好破坏了您想要的那个保证。

curl -X POST https://api.saq.processamento.com/v1/pix \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 99.90,
    "clientReference": "order-1234",
    "callbackUrl": "https://seusite.com.br/webhooks/saq"
  }'
async function createCharge(orderId: string, amount: number) {
  const res = await fetch('https://api.saq.processamento.com/v1/pix', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.SAQ_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      amount,
      clientReference: `order-${orderId}`,
      callbackUrl: 'https://seusite.com.br/webhooks/saq',
    }),
  });
  return res.json();
}
def create_charge(order_id: str, amount: float):
    return requests.post(
        'https://api.saq.processamento.com/v1/pix',
        headers={
            'Authorization': f'Bearer {os.environ["SAQ_TOKEN"]}',
            'Content-Type': 'application/json',
        },
        json={
            'amount': amount,
            'clientReference': f'order-{order_id}',
            'callbackUrl': 'https://seusite.com.br/webhooks/saq',
        },
    ).json()
func createCharge(orderID string, amount float64) (map[string]any, error) {
    payload, _ := json.Marshal(map[string]any{
        "amount":          amount,
        "clientReference": "order-" + orderID,
        "callbackUrl":     "https://seusite.com.br/webhooks/saq",
    })
    req, _ := http.NewRequest("POST", "https://api.saq.processamento.com/v1/pix", bytes.NewReader(payload))
    req.Header.Set("Authorization", "Bearer "+os.Getenv("SAQ_TOKEN"))
    req.Header.Set("Content-Type", "application/json")
    res, err := http.DefaultClient.Do(req)
    if err != nil { return nil, err }
    defer res.Body.Close()
    var out map[string]any
    return out, json.NewDecoder(res.Body).Decode(&out)
}
function createCharge(string $orderId, float $amount): array {
    $ch = curl_init('https://api.saq.processamento.com/v1/pix');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('SAQ_TOKEN'),
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'amount' => $amount,
            'clientReference' => 'order-' . $orderId,
            'callbackUrl' => 'https://seusite.com.br/webhooks/saq',
        ]),
    ]);
    return json_decode(curl_exec($ch), true);
}

通过 id + status 对 callback 去重

同一 callback 可能多次到达:

  • 超时后重试:您在 5.1 秒响应,Saq 重新发送。
  • 连续状态变化:PENDING → COMPLETED → REFUNDED,每次都会产生 callback。
  • 手动重新处理:通过 POST /user/callbacks/resend

去重键必须是 id + status,而不仅仅是 id。如果只使用 id,您会忽略 REFUNDED 的 callback,因为之前已经看过 COMPLETED,这样退款就不会入账。

实现

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const TTL_30_DIAS = 30 * 86400;

type saqCallback = {
  id: string;
  type: 'DEPOSIT' | 'WITHDRAW' | 'INTERNAL_TRANSFER';
  status: 'PENDING' | 'COMPLETED' | 'CANCELED' | 'WAITING_FOR_REFUND' | 'REFUNDED' | 'EXPIRED' | 'ERROR';
  clientReference?: string;
};

async function handleCallback(tx: saqCallback) {
  const dedupeKey = `saq:${tx.id}:${tx.status}`;
  const isFirstTime = await redis.set(dedupeKey, '1', 'EX', TTL_30_DIAS, 'NX');
  if (!isFirstTime) return;

  await processTransaction(tx);
}
import redis
r = redis.from_url(os.environ['REDIS_URL'])
TTL_30_DIAS = 30 * 86400

def handle_callback(tx: dict):
    key = f"saq:{tx['id']}:{tx['status']}"
    is_first = r.set(key, '1', ex=TTL_30_DIAS, nx=True)
    if not is_first:
        return
    process_transaction(tx)
func HandleCallback(ctx context.Context, tx saqCallback) error {
    key := fmt.Sprintf("saq:%s:%s", tx.ID, tx.Status)
    ok, err := rdb.SetNX(ctx, key, "1", 30*24*time.Hour).Result()
    if err != nil { return err }
    if !ok { return nil }
    return processTransaction(ctx, tx)
}

常见陷阱

陷阱症状
每次重试使用随机的 clientReference扣款重复,客户困惑
仅用 id 去重(不带 status)退款不入账,出现"幽灵"退款
去重 TTL 过短延迟重试导致重新处理
内存中去重(本地 Map)重启后全部重新处理
因认为"会变化"而用 Date.now() 重新生成 clientReference不触发幂等性,产生新扣款

On this page