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ário | Sem idempotência | Com idempotência |
|---|---|---|
Sua app crasha após POST /pix, mas não sabe se chegou | Gera 2 cobranças | Magen devolve a existente |
POST /pix deu timeout, mas o QR foi gerado | Cliente vê 2 QRs diferentes | Magen devolve a mesma transação |
| Job de retry dispara a mesma cobrança 2x | 2 cobranças, suporte ruim | 1 cobrança, cliente paga normalmente |
| Mesmo callback chega 2 vezes (retry após timeout) | Marca pedido pago 2x | Ignora o duplicado |
Transação passa por PENDING → COMPLETED → REFUNDED | Pode ignorar o estorno | Processa 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 Magen indexa por userId + clientReference e devolve a transação existente se ela já foi criada.
Como gerar
| Padrão | Quando 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 Magen vai criar cobrança duplicada, quebrando exatamente a garantia que você queria ter.
curl -X POST https://api.magen.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/magen"
}'async function createCharge(orderId: string, amount: number) {
const res = await fetch('https://api.magen.processamento.com/v1/pix', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MAGEN_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount,
clientReference: `order-${orderId}`,
callbackUrl: 'https://seusite.com.br/webhooks/magen',
}),
});
return res.json();
}def create_charge(order_id: str, amount: float):
return requests.post(
'https://api.magen.processamento.com/v1/pix',
headers={
'Authorization': f'Bearer {os.environ["MAGEN_TOKEN"]}',
'Content-Type': 'application/json',
},
json={
'amount': amount,
'clientReference': f'order-{order_id}',
'callbackUrl': 'https://seusite.com.br/webhooks/magen',
},
).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/magen",
})
req, _ := http.NewRequest("POST", "https://api.magen.processamento.com/v1/pix", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("MAGEN_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.magen.processamento.com/v1/pix');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('MAGEN_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => $amount,
'clientReference' => 'order-' . $orderId,
'callbackUrl' => 'https://seusite.com.br/webhooks/magen',
]),
]);
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 Magen 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 magenCallback = {
id: string;
type: 'DEPOSIT' | 'WITHDRAW' | 'INTERNAL_TRANSFER';
status: 'PENDING' | 'COMPLETED' | 'CANCELED' | 'WAITING_FOR_REFUND' | 'REFUNDED' | 'EXPIRED' | 'ERROR';
clientReference?: string;
};
async function handleCallback(tx: magenCallback) {
const dedupeKey = `magen:${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"magen:{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 magenCallback) error {
key := fmt.Sprintf("magen:%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
| Armadilha | Sintoma |
|---|---|
clientReference aleatório a cada retry | Cobrança duplicada, cliente confuso |
Dedupe usando só id (sem status) | Estorno não dá baixa, refund "fantasma" |
| TTL do dedupe muito curto | Retry 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 |
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 Magen pode atender N tenants (lojas, filiais, marketplaces) usando virtualAccount. Volta em todo callback, permite filtrar listagens e dispensa criar contas filhas.