SaqSaq Docs
Best practices

Idempotency

Idempotency is the guarantee that calling the same operation multiple times has the same effect as calling it once. Without it, retries turn into duplicate charges, double settlements, and lost refunds.

Scenarios that require idempotency

ScenarioWithout idempotencyWith idempotency
Your app crashes after POST /pix, but doesn't know if it arrivedGenerates 2 chargesSaq returns the existing one
POST /pix timed out, but the QR was generatedCustomer sees 2 different QRsSaq returns the same transaction
Retry job fires the same charge 2x2 charges, poor support1 charge, customer pays normally
Same callback arrives twice (retry after timeout)Marks order paid 2xIgnores the duplicate
Transaction goes through PENDING → COMPLETED → REFUNDEDMay ignore the refundProcesses each transition only once

clientReference on creation

clientReference is the idempotent external identifier that you define when creating a charge, payout, or transfer. Saq indexes by userId + clientReference and returns the existing transaction if it was already created.

How to generate

PatternWhen to use
order-{orderId}1 charge per order. Recommended.
payout-{payoutId}1 payout per request.
subscription-{subId}-{period}Recurring charges (1 per cycle).
retry-{orderId}-{attempt}When you need to force a new charge after a definitive failure.
transfer-{from}-{to}-{date}Internal transfers idempotent per day.

Never use Date.now(), uuid(), or any other random value as clientReference. The retry will generate a different value and Saq will create a duplicate charge, breaking exactly the guarantee you wanted to have.

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://yoursite.com/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://yoursite.com/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://yoursite.com/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://yoursite.com/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://yoursite.com/webhooks/saq',
        ]),
    ]);
    return json_decode(curl_exec($ch), true);
}

Callback dedupe by id + status

The same callback can arrive more than once:

  • Retry after timeout, you responded in 5.1s, Saq resends.
  • Successive changes, PENDING → COMPLETED → REFUNDED, each one triggers a callback.
  • Manual reprocessing via POST /user/callbacks/resend.

The dedupe key must be id + status, not just id. If you use only id, you will ignore the REFUNDED callback because you already saw COMPLETED before, and the refund won't be settled.

Implementation

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const TTL_30_DAYS = 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_DAYS, 'NX');
  if (!isFirstTime) return;

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

def handle_callback(tx: dict):
    key = f"saq:{tx['id']}:{tx['status']}"
    is_first = r.set(key, '1', ex=TTL_30_DAYS, 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)
}

Common pitfalls

PitfallSymptom
Random clientReference on each retryDuplicate charge, confused customer
Dedupe using only id (without status)Refund not settled, "phantom" refund
Dedupe TTL too shortLate retry recreates processing
In-memory dedupe (local Map)After restart, processes everything again
Recreating clientReference with Date.now() thinking it "changes"Doesn't trigger idempotency, generates new charge

On this page