SaqSaq Docs
Boas práticas

Idempotência

Idempotência é a garantia de que chamar a mesma operação várias vezes tem o mesmo efeito que chamar uma vez só. Sem isso, retries viram cobranças duplicadas, baixas em dobro e estornos perdidos.

Cenários que exigem idempotência

CenárioSem idempotênciaCom idempotência
Sua app crasha após POST /pix, mas não sabe se chegouGera 2 cobrançasSaq devolve a existente
POST /pix deu timeout, mas o QR foi geradoCliente vê 2 QRs diferentesSaq devolve a mesma transação
Job de retry dispara a mesma cobrança 2x2 cobranças, suporte ruim1 cobrança, cliente paga normalmente
Mesmo callback chega 2 vezes (retry após timeout)Marca pedido pago 2xIgnora o duplicado
Transação passa por PENDING → COMPLETED → REFUNDEDPode ignorar o estornoProcessa cada transição uma única vez

clientReference na criação

clientReference é o identificador externo idempotente que você define ao criar uma cobrança, saque ou transferência. A Saq indexa por userId + clientReference e devolve a transação existente se ela já foi criada.

Como gerar

PadrãoQuando usar
order-{orderId}1 cobrança por pedido. Recomendado.
payout-{payoutId}1 saque por solicitação.
subscription-{subId}-{period}Cobranças recorrentes (1 por ciclo).
retry-{orderId}-{attempt}Quando você precisa forçar uma nova cobrança após falha definitiva.
transfer-{from}-{to}-{date}Transferências internas idempotentes por dia.

Nunca use Date.now(), uuid() ou outro valor aleatório como clientReference. O retry vai gerar valor diferente e a Saq vai criar cobrança duplicada, quebrando exatamente a garantia que você queria ter.

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);
}

Dedupe de callbacks por id + status

O mesmo callback pode chegar mais de uma vez:

  • Retry após timeout, você respondeu em 5,1s, a Saq reenvia.
  • Mudanças sucessivas, PENDING → COMPLETED → REFUNDED, cada uma gera callback.
  • Reprocessamento manual via POST /user/callbacks/resend.

A chave de dedupe deve ser id + status, não só id. Se você usar só id, vai ignorar o callback de REFUNDED porque já viu COMPLETED antes, e estorno não dá baixa.

Implementação

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)
}

Armadilhas comuns

ArmadilhaSintoma
clientReference aleatório a cada retryCobrança duplicada, cliente confuso
Dedupe usando só id (sem status)Estorno não dá baixa, refund "fantasma"
TTL do dedupe muito curtoRetry tardio recria processamento
Dedupe em memória (Map local)Após restart, processa tudo de novo
Recriar clientReference com Date.now() por achar que "muda"Não dispara idempotência, gera nova cobrança

On this page