Idempotência
Como o externalId evita cobranças e assinaturas duplicadas em retentativas e chamadas concorrentes.
A Allya Payments aceita uma única criação para cada externalId dentro de um ambiente: tanto para pagamentos quanto para assinaturas. Retentativas, jobs duplicados ou concorrência no seu backend não criam recursos duplicados se você enviar o mesmo externalId em cada chamada.
externalId é obrigatório em POST /v1/payments e POST /v1/subscriptions. Chamadas sem o campo respondem 400 invalid_input. A obrigatoriedade existe porque idempotência sem chave externa é uma armadilha clássica de gateway: um timeout de rede leva o caller a fazer retry, e sem externalId cada retry vira uma cobrança nova no gateway.
Esta página cobre o mecanismo geral. Detalhes específicos:
- Idempotência em pagamentos:
POST /v1/payments. - Idempotência em assinaturas:
POST /v1/subscriptions. - Chamadas concorrentes: race-safety.
- Limitações: o que a idempotência não cobre.
Como funciona
A chave de idempotência é (environmentId, externalId). O environmentId vem implícito da API key (sk_test_… = sandbox, sk_live_… = production).
A unicidade é garantida no banco por @@unique([environmentId, externalId]) no Prisma, tanto para Payment quanto para Subscription.
Pagamentos
Quando você chama POST /v1/payments:
- Se não existe pagamento com aquele
externalIdno ambiente → a Allya cria a cobrança, persiste o pagamento e devolve o objeto normalizado. - Se já existe pagamento com aquele
externalIdno ambiente → a Allya não cria uma nova cobrança no gateway. Devolve o pagamento existente, do jeito que estiver naquele momento (statuspending,paid, etc.).
A resposta é a mesma em estrutura: a única diferença é que o pagamento devolvido pode ter qualquer status, não necessariamente pending.
Assinaturas
POST /v1/subscriptions segue exatamente a mesma regra com (environmentId, externalId). Reenviar a mesma requisição com o mesmo externalId retorna a assinatura existente, sem criar uma nova no gateway.
Recomendado para assinaturas: use um externalId que identifique unicamente o cliente + oferta, não a fatura/ciclo. Exemplo: assinatura_cliente_42_plano_pro_mensal. Cada ciclo individual aparece como um Payment vinculado por subscriptionId: esses sim têm externalId próprio se você quiser.
Quando usar
Use externalId sempre que o caller pode disparar a mesma operação mais de uma vez:
- Retentativa por timeout de rede no seu próprio backend.
- Reprocessamento de fila/cron que pode rodar duas vezes para o mesmo pedido.
- Worker que cai logo após o
POST /v1/paymentse reprocessa o job na próxima execução.
O externalId natural costuma ser o identificador do pedido/assinatura no seu sistema:
externalId: `pedido_${order.id}`
externalId: `assinatura_${subscription.id}_${cicloAtual}`
externalId: `fatura_${invoice.id}`Garanta que o valor é único por cobrança real. Se você cobrar a mesma assinatura todo mês, inclua o ciclo/competência: caso contrário, da segunda cobrança em diante a Allya devolve a primeira em vez de criar uma nova.
Chamadas concorrentes
Duas requisições idênticas chegando ao mesmo tempo são race-safe. O Prisma + Postgres garantem unicidade por (environmentId, externalId); uma das duas vence o INSERT e a outra recebe o recurso já criado pela vencedora: não dá erro 500, nem cria duplicata. Vale para Payment e Subscription.
Limitações
- Idempotência é por ambiente. O mesmo
externalIdem sandbox e production cria duas cobranças separadas: uma em cada lado. Faz sentido: chaves diferentes, dados diferentes. - Payload diferente, mesmo externalId. Se você reenviar com
amountoucustomerdiferentes mantendo oexternalId, a Allya devolve o primeiro pagamento criado: o segundo payload é silenciosamente ignorado. Não há comparação semântica. - Sem expiração. A chave de idempotência fica registrada para sempre (não há janela de 24h como em alguns provedores). Se você quer "esquecer" um
externalId, gere outro. - Sem
Idempotency-Keyem header. A Allya usa o campoexternalIddo body, não header HTTP separado.
Verificando depois
Para conferir se um externalId já gerou cobrança sem disparar POST de novo, use:
GET /v1/payments?externalId=pedido_123
Authorization: Bearer sk_test_...Resposta 200 com o pagamento se existe; 404 not_found se não. Veja Consultar pagamento para detalhes.
Exemplo de retentativa segura
async function createPaymentWithRetry(order: Order) {
const externalId = `pedido_${order.id}`;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await fetch("https://payments-api.allyasolutions.com/v1/payments", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ALLYA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: order.amount,
currency: "BRL",
method: "pix",
externalId,
customer: order.customer,
}),
});
if (res.ok || res.status === 201) {
return res.json();
}
// Erro do cliente (4xx que não seja 5xx/timeout): não retentar.
if (res.status >= 400 && res.status < 500) {
throw new Error(`allya error: ${await res.text()}`);
}
// 5xx ou timeout cairá aqui: segura para tentar de novo.
} catch (err) {
if (attempt === 3) throw err;
await new Promise((r) => setTimeout(r, 500 * attempt));
}
}
}Mesmo que a primeira tentativa tenha chegado no servidor e criado o pagamento (mas a resposta nunca voltou), a segunda tentativa devolve o pagamento existente. Sem duplicidade.
Veja também
- Criar pagamento: onde o
externalIdé descrito no payload. - Criar assinatura: idempotência em recorrência.
- Rate limiting: outro caso que se beneficia de retries idempotentes.
- Troubleshooting: quando algo dá errado em retries.