docs

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:

divergenceSignificadoAção
webhook_missingExistia na Zhex mas não no seu BDInvestigar webhook delivery; processar retroativo
status_drift_processing_to_succeededCliente pagou mas seu BD ainda mostra "processing"Liberar produto agora
status_drift_succeeded_to_canceledVocê liberou e a Zhex cancelou (chargeback?)Revogar acesso
amount_drift_4990_vs_4900Valor divergenteAuditar, 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 levou
  • reconcile.processed_total (counter) — quantos items processados
  • reconcile.divergences_total{type=...} (counter) — divergências por tipo
  • reconcile.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 resourcepayment_intents, refunds, events separados. Não compartilhe cursor.
  • Idempotente em todo lugarINSERT ... ON CONFLICT IGNORE em 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_run por execução, ou stamp reconciled_at em 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

Esta página foi útil?

Atualizado em

Nesta página