Reconciliation diária
Match entre transações Zhex e seu banco de dados via cursor pagination. Detecta webhooks perdidos, divergências de valor, fraude, refunds órfãos. Cron diário com retry-safe.
Cenário: webhooks são at-least-once mas não são at-least-once garantido para sempre. Em janelas raras (incidente da Zhex, falha do seu lado, DLQ esgotada), um evento crítico pode não chegar. Sem reconciliation, você descobre semanas depois que liberou produto sem cobrar — ou cobrou e não liberou. Fontes financeiras (DRE, fechamento mensal) precisam de uma fonte de verdade que bate com o extrato.
A solução é simples: um cron diário que itera todas as transações Zhex desde o último cursor, compara com sua tabela local, e flagra divergências. Webhook é o fast path; reconcile é o safety net.
Quando rodar
- Diariamente (madrugada, ~3am quando volume é baixo) — captura tudo do dia anterior.
- Sob demanda quando algo está estranho (cliente reclama, finance vê discrepância).
- Após restart longo do sistema — se o webhook handler ficou off por 1h, eventos podem ter gone DEAD_LETTER.
Modelo de dados
Você precisa de duas tabelas no seu banco:
CREATE TABLE reconcile_cursor (
resource text PRIMARY KEY, -- 'payment_intents', 'refunds', 'events'
last_id text, -- último id processado (para starting_after)
last_run_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE local_transactions (
zhex_id text PRIMARY KEY, -- pi_*, re_*
zhex_status text NOT NULL, -- succeeded / canceled / processing
amount int NOT NULL, -- centavos
currency text NOT NULL,
customer_id text,
internal_order text, -- seu id de pedido (FK para sua tabela orders)
granted_access boolean DEFAULT false,
webhook_event text, -- evt_* que liberou (nullable se reconcile)
reconciled_at timestamptz,
divergence text, -- nullable; flag pra revisão humana
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);Job de reconciliation
import Zhex from '@zhexio/node';
import { db } from './db';
const zhex = new Zhex(process.env.ZHEX_SECRET_KEY);
export async function reconcilePaymentIntents() {
const cursor = await db.reconcileCursor.findUnique({
where: { resource: 'payment_intents' },
});
let starting_after = cursor?.last_id;
let processed = 0;
let lastSeen = starting_after;
for await (const intent of zhex.paymentIntents
.list({
...(starting_after ? { starting_after } : {}),
limit: 100,
})
.autoPagingEach()) {
await reconcileIntent(intent);
processed += 1;
lastSeen = intent.id;
}
if (lastSeen) {
await db.reconcileCursor.upsert({
where: { resource: 'payment_intents' },
update: { last_id: lastSeen, last_run_at: new Date() },
create: { resource: 'payment_intents', last_id: lastSeen },
});
}
console.log(`[reconcile] processed ${processed} payment_intents (cursor at ${lastSeen})`);
}
async function reconcileIntent(intent) {
const local = await db.localTransactions.findUnique({
where: { zhex_id: intent.id },
});
// CASO 1: existe na Zhex mas não no seu BD = webhook perdido.
if (!local) {
await db.localTransactions.create({
data: {
zhex_id: intent.id,
zhex_status: intent.status,
amount: intent.amount,
currency: intent.currency,
customer_id: intent.customer,
webhook_event: null, // nunca chegou
reconciled_at: new Date(),
divergence: 'webhook_missing', // flag para revisão
},
});
if (intent.status === 'succeeded') {
// Ação corretiva: liberar acesso retroativamente
await grantAccessRetroactive(intent.customer, intent.id);
}
return;
}
// CASO 2: status divergente (Zhex diz succeeded mas seu BD ainda tá processing).
if (local.zhex_status !== intent.status) {
await db.localTransactions.update({
where: { zhex_id: intent.id },
data: {
zhex_status: intent.status,
reconciled_at: new Date(),
divergence: `status_drift_${local.zhex_status}_to_${intent.status}`,
},
});
return;
}
// CASO 3: valor divergente (improvável mas crítico se acontecer).
if (local.amount !== intent.amount) {
await db.localTransactions.update({
where: { zhex_id: intent.id },
data: {
amount: intent.amount,
reconciled_at: new Date(),
divergence: `amount_drift_${local.amount}_vs_${intent.amount}`,
},
});
return;
}
// CASO 4: tudo bate. Marca apenas o reconcile timestamp.
await db.localTransactions.update({
where: { zhex_id: intent.id },
data: { reconciled_at: new Date() },
});
}
async function grantAccessRetroactive(customerId, intentId) {
// Mesma lógica do webhook handler. Em SaaS: liberar acesso. Em
// e-commerce: marcar pedido como pago + disparar fulfillment.
console.warn(`[reconcile] granting retroactive access for ${intentId} (webhook never arrived)`);
// ...
}Auto-paginação com cursor
O @zhexio/node SDK abstrai cursor pagination com autoPagingEach():
for await (const intent of zhex.paymentIntents.list({ limit: 100 }).autoPagingEach()) {
// SDK busca página a página automaticamente. Lazy: para se você der break.
}Sem SDK (fetch puro):
async function* paginate(path, query = {}) {
let starting_after;
while (true) {
const url = new URL(`https://prometheus.zhex.io${path}`);
url.searchParams.set('limit', '100');
if (starting_after) url.searchParams.set('starting_after', starting_after);
Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, String(v)));
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.ZHEX_SECRET_KEY}` },
});
const page = await res.json();
for (const item of page.data) yield item;
if (!page.has_more) return;
starting_after = page.data[page.data.length - 1].id;
}
}Veja Auto-paginação para o helper completo.
Reconciliando refunds
Mesmo padrão. Cursor separado em reconcile_cursor.refunds:
export async function reconcileRefunds() {
const cursor = await db.reconcileCursor.findUnique({ where: { resource: 'refunds' } });
let lastSeen = cursor?.last_id;
for await (const refund of zhex.refunds
.list({
...(cursor?.last_id ? { starting_after: cursor.last_id } : {}),
limit: 100,
})
.autoPagingEach()) {
const local = await db.localRefunds.findUnique({ where: { zhex_id: refund.id } });
if (!local) {
// Refund executado na Zhex mas não registrado localmente.
// Em SaaS: marcar pedido como reembolsado, revogar acesso, baixa contábil.
await registerRefundLocally(refund);
}
lastSeen = refund.id;
}
if (lastSeen) {
await db.reconcileCursor.upsert({
where: { resource: 'refunds' },
update: { last_id: lastSeen, last_run_at: new Date() },
create: { resource: 'refunds', last_id: lastSeen },
});
}
}Reconciliando events (audit log completo)
Para uma reconciliação paranoica (nada escapa), itere /v1/events em vez de cada recurso:
for await (const event of zhex.events
.list({ starting_after: cursor?.last_id, limit: 100 })
.autoPagingEach()) {
const inserted = await db.processedEvents
.insert({ id: event.id, type: event.type, source: 'reconcile' })
.onConflict('id').ignore();
if (inserted) {
// Event não foi processado pelo webhook handler — processa agora.
await dispatchEventHandler(event);
}
}Trade-off: events são per-merchant podendo ter alto volume. Em volume baixo (< 1k events/dia), reconciliando events é mais simples e captura tudo. Em volume alto, reconciliar payment_intents + refunds separados é mais barato.
Cron diário
Node + GitHub Actions
.github/workflows/reconcile.yml:
name: Reconcile diário
on:
schedule:
- cron: '0 6 * * *' # 6 UTC = 3am BRT
workflow_dispatch: # botão manual no GitHub
jobs:
reconcile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: node scripts/reconcile.js
env:
ZHEX_SECRET_KEY: ${{ secrets.ZHEX_SECRET_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}Node + worker dedicado
import cron from 'node-cron';
cron.schedule('0 3 * * *', async () => {
console.log('[reconcile] starting');
await reconcilePaymentIntents();
await reconcileRefunds();
console.log('[reconcile] done');
}, { timezone: 'America/Sao_Paulo' });Cloud (AWS EventBridge / GCP Cloud Scheduler / Vercel Cron)
Mesmo handler exposto como endpoint protegido por API token (/api/cron/reconcile), agendado no provider.
Detectando divergências
Após o cron, query simples:
SELECT zhex_id, divergence, reconciled_at
FROM local_transactions
WHERE divergence IS NOT NULL
AND divergence != 'reconciled'
AND reconciled_at > now() - interval '24 hours'
ORDER BY reconciled_at DESC;Tipos comuns:
divergence | Significado | Ação |
|---|---|---|
webhook_missing | Existia na Zhex mas não no seu BD | Investigar webhook delivery; processar retroativo |
status_drift_processing_to_succeeded | Cliente pagou mas seu BD ainda mostra "processing" | Liberar produto agora |
status_drift_succeeded_to_canceled | Você liberou e a Zhex cancelou (chargeback?) | Revogar acesso |
amount_drift_4990_vs_4900 | Valor divergente | Auditar, possível ataque ou bug seu |
Configure alerta no Sentry/Datadog quando contagem dessa query > 0.
Reconciliando com finance
Para fechamento mensal, gere um CSV consolidado:
const since = new Date('2026-04-01');
const until = new Date('2026-05-01');
const succeeded = [];
for await (const intent of zhex.paymentIntents.list({ limit: 100 }).autoPagingEach()) {
const created = new Date(intent.created * 1000);
if (created < since) break; // já passou da janela
if (created >= until) continue; // antes do range
if (intent.status !== 'succeeded') continue;
succeeded.push(intent);
}
const csv = [
'id,created,amount,currency,customer,description',
...succeeded.map(i =>
`${i.id},${new Date(i.created*1000).toISOString()},${i.amount},${i.currency},${i.customer},"${i.description ?? ''}"`,
),
].join('\n');
fs.writeFileSync('reconcile-2026-04.csv', csv);Compare com o Balance mensal da Zhex (GET /v1/balance no fim do mês) e com seu CRM/financeiro. Variances > 1% justificam investigação.
Edge cases
Cursor "perdido" (BD do reconcile zerado)
Você acabou de migrar BDs e perdeu reconcile_cursor. Não problema — apenas reconcile from scratch:
const cursor = await db.reconcileCursor.findUnique({ where: { resource: 'payment_intents' } });
// cursor é null → SDK começa do mais antigo
for await (const intent of zhex.paymentIntents.list({ limit: 100 }).autoPagingEach()) {
// ...
}Para milhões de transações isso pode demorar — considere usar created[gte] quando expor (roadmap) ou paralelizar por janela temporal manual.
Item processado entre webhook e reconcile
Webhook chegou às 14h, reconcile roda às 3am. Não há conflito: o INSERT ... ON CONFLICT IGNORE no processed_events garante idempotência. O reconcile só processa o que não foi visto pelo webhook.
Cliente cancelou antes do webhook chegar
Webhook chega 5min depois da liberação. Cliente já cancelou e abriu chamado. Reconcile detecta customer_subscription.canceled na próxima rodada e revoga.
API rate limit no SDK
autoPagingEach() faz 1 request por página de 100 items. 100k transações = 1000 requests = ~30min. Fica bem dentro do rate limit (500/min). Se precisar mais rápido, paralelize por filtro nativo (customer):
const customers = await db.users.findMany({ select: { zhexCustomerId: true } });
await Promise.all(
customers.map(({ zhexCustomerId }) =>
reconcileCustomer(zhexCustomerId), // cada worker faz cursor próprio
),
);Observabilidade
Métricas que valem trackear (Datadog/Prometheus/Grafana):
reconcile.duration_seconds(histograma) — quanto tempo levoureconcile.processed_total(counter) — quantos items processadosreconcile.divergences_total{type=...}(counter) — divergências por tiporeconcile.last_run_timestamp(gauge) — alerta se > 25h sem run
Logs estruturados para auditoria:
logger.info({
resource: 'payment_intents',
processed: 142,
divergences: 3,
cursor_from: 'pi_old',
cursor_to: 'pi_new',
duration_ms: 4200,
}, 'reconcile completed');Boas práticas
- Cursor por resource —
payment_intents,refunds,eventsseparados. Não compartilhe cursor. - Idempotente em todo lugar —
INSERT ... ON CONFLICT IGNOREem todas as escritas. - Não revogar acesso silenciosamente. Se reconcile detecta divergência crítica (chargeback, cancel), envie alerta humano antes da revogação automática em prod.
- Test mode dry-run — antes do primeiro reconcile em prod, rode contra
zsk_test_*e valide as queries de divergência. - Backfill window controlada. Primeira execução pode trazer milhões de transações antigas. Limite com
limit_runpor execução, ou stampreconciled_atem todas as rows existentes antes de ativar. - Alerta se cron não rodar. "No news is bad news" — se o cron silenciosamente falhar 3 dias, divergências acumulam. Use UpTime monitor (Healthchecks.io, Better Uptime) com ping no fim do job.
Próximos passos
Webhook → email
Recibo + dunning + chargeback notice
Auto-paginação
autoPagingEach() do SDK
Idempotência
Como não duplicar nada
Atualizado em