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
| Scenario | Without idempotency | With idempotency |
|---|---|---|
Your app crashes after POST /pix, but doesn't know if it arrived | Generates 2 charges | Saq returns the existing one |
POST /pix timed out, but the QR was generated | Customer sees 2 different QRs | Saq returns the same transaction |
| Retry job fires the same charge 2x | 2 charges, poor support | 1 charge, customer pays normally |
| Same callback arrives twice (retry after timeout) | Marks order paid 2x | Ignores the duplicate |
Transaction goes through PENDING → COMPLETED → REFUNDED | May ignore the refund | Processes 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
| Pattern | When 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
| Pitfall | Symptom |
|---|---|
Random clientReference on each retry | Duplicate charge, confused customer |
Dedupe using only id (without status) | Refund not settled, "phantom" refund |
| Dedupe TTL too short | Late 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 |
Boas práticas
Padrões testados em produção que separam uma integração que dura uma semana de uma que aguenta produção. Idempotência, multi-tenant, callbacks, paginação, dinheiro, segurança, tratamento de erros e checklist final.
Multi-tenant com virtualAccount
Uma só conta Saq pode atender N tenants (lojas, filiais, marketplaces) usando virtualAccount. Volta em todo callback, permite filtrar listagens e dispensa criar contas filhas.