Ir para o conteúdo
DocsAPI 2026-11-13

Exemplos

Integrações de referência

Três serviços pequenos, em TypeScript, que integram produtos da Xpertech ao Eclipta Pay usando só a API pública pelo SDK. O código desta página é o mesmo que roda nos testes de ponta a ponta.

O que cada uma mostra

Integrações de referência
ProdutoCaso de usoPagamentoRegra no produto
EducatorsVenda de cursos: matrícula idempotente e checkout.Pix, boleto, cartão à vista e parcelado.Acesso liberado só com order.payment_completed; suspenso em estorno ou disputa; revogado com estorno concluído ou chargeback perdido.
ÓrbitaCRM de vendas de mentorias: link de pagamento por oportunidade.Pix, cartão, dois cartões e Pix em parcelas.Venda ganha só com evidência do provedor, nunca pela palavra do lead; progresso das parcelas; disputa e chargeback.
Agenda ExpertAgendamentos com sinal antecipado e plano mensal do profissional.Pix pela API (202), estorno e assinatura.Sinal pago confirma o horário; política de cancelamento própria (devolve ou retém); plano ativo com o ciclo pago.

O código fica em examples/educators, examples/orbita e examples/agenda-expert, com as peças comuns em examples/kit. Os testes em tests/integration/src/reference-*.int.test.ts sobem a API, o worker (que entrega os webhooks por HTTP ao serviço do exemplo) e o simulador do provedor, e fazem o papel do pagador no checkout.

Padrões comuns

  • Uma chave por intenção. Cada escrita usa uma Idempotency-Key derivada da intenção do produto (por exemplo educators:matricula:<id>:pedido). A intenção e o corpo são gravados antes da chamada; repetir depois de uma falha de rede envia o mesmo corpo com a mesma chave.
  • 202 sem repetir escrita. Operações incertas (UNKNOWN) aparecem como "em verificação"; o produto só consulta. Uma tentativa nova só depois de uma falha conclusiva.
  • Inbox de webhooks. A assinatura é verificada pelo SDK sobre o corpo cru; o evento é gravado uma vez por eventId e a resposta 2xx sai na hora. O processamento acontece depois, em ordem, com nova tentativa local se a regra do produto falhar.
  • Estado vem da API. O evento é o gatilho; a decisão usa o pedido, a cobrança, o plano ou o grupo lidos na API. Eventos de pedidos de outro produto da mesma conta são aceitos e ignorados.
  • Versão por agregado e tipo. aggregateVersion é comparada por agregado e tipo de evento: tipos diferentes do mesmo agregado podem ter sequências próprias.
examples/kit/src/inbox.ts (trecho)
/**
 * Receptor de webhooks com inbox: verifica a assinatura sobre o corpo cru, grava o evento uma única vez por
 * `eventId` e responde 2xx na hora; o processamento acontece depois, fora da requisição, em ordem de chegada.
 */
export class WebhookInbox<S extends InboxState> {
  private loop: Promise<void> | null = null;
  private again = false;
  private timer: NodeJS.Timeout | null = null;

  constructor(private readonly opts: WebhookInboxOptions<S>) {}

  private now(): number {
    return this.opts.now ? this.opts.now() : Date.now();
  }

  async receive(rawBody: string | Uint8Array, headers: HeadersLike): Promise<{ status: number; body: Record<string, unknown> }> {
    let event: Event;
    try {
      event = verifyWebhook(rawBody, headers, this.opts.secrets(), { toleranceSeconds: this.opts.toleranceSeconds ?? 300, now: () => this.now() });
    } catch (e) {
      // Assinatura inválida, timestamp fora da janela ou corpo que não é evento: nada é gravado.
      if (e instanceof WebhookVerificationError) return { status: 400, body: { error: e.code } };
      throw e;
    }
    const duplicate = await this.opts.store.update((s) => {
      const existing = s.inbox[event.eventId];
      if (existing) {
        existing.deliveries += 1;
        return true;
      }
      s.inbox[event.eventId] = { event, receivedAt: new Date(this.now()).toISOString(), deliveries: 1, state: "PENDING", attempts: 0, nextAttemptAt: null, lastError: null };
      return false;
    });
    void this.kick();
    return { status: 200, body: duplicate ? { received: true, duplicate: true } : { received: true } };
  }

Educators: venda de cursos

POST /matriculas cria (ou devolve) a matrícula do aluno no curso: cliente, pedido com ON_FULLY_PAID e sessão de checkout. Duplo clique, retentativa ou recarga devolvem o mesmo link, sem nova escrita. O pedido leva só identificadores em metadata, nunca nome, e-mail ou CPF.

examples/educators/src/educators.ts (trecho)
/**
 * Matricula o aluno: cliente, pedido e sessão de checkout, cada um com Idempotency-Key derivada da intenção.
 * Repetir o pedido (duplo clique, retentativa depois de timeout) devolve a mesma matrícula e o mesmo link.
 */
async enroll(courseId: string, student: StudentInput): Promise<EnrollmentView> {
  const course = this.catalog.get(courseId);
  if (!course) throw new ProductError(404, "CURSO_NAO_ENCONTRADO", "Curso não encontrado.");
  if (course.coproducers.length > 0) {
    // Bloqueio só do que depende da lacuna: sem cadastro de recebedores na API pública, o split do coprodutor
    // não pode ser enviado. Nenhuma cobrança é criada (a venda sem split mudaria quem recebe o dinheiro).
    throw new ProductError(422, "COPRODUCAO_INDISPONIVEL", "Cursos em coprodução dependem do cadastro de recebedores no Eclipta Pay, ainda indisponível. Nenhuma cobrança foi criada.");
  }
  const id = `mat_${course.id}_${student.id}`;
  await this.opts.store.update((s) => {
    s.enrollments[id] ??= {
      id,
      courseId: course.id,
      studentId: student.id,
      amountCents: course.priceCents,
      orderId: null,
      checkout: null,
      paymentIds: [],
      access: "PENDENTE",
      accessReason: "Aguardando a confirmação do pagamento.",
      orderStatus: null,
      grant: null,
      cancellation: null,
      history: [{ at: this.now().toISOString(), what: "matrícula iniciada" }],
      createdAt: this.now().toISOString(),
    };
  });
  let e = this.enrollment(id);
  if (e.grant) return this.view(id); // já paga: nada a cobrar

  let customerId = this.opts.store.read((s) => s.students[student.id]?.customerId ?? null);
  if (!customerId) {
    const customer = await this.client.createCustomer(
      { name: student.name, taxId: student.taxId, ...(student.email ? { email: student.email } : {}), externalReference: `aluno:${student.id}` },
      { idempotencyKey: intentKey(PRODUCT, "aluno", student.id, "cliente") },
    );
    customerId = customer.id;
    await this.opts.store.update((s) => {
      s.students[student.id] = { customerId: customer.id };
    });
  }

  if (!e.orderId) {
    // Sem dados pessoais em metadata (B docs/10): só identificadores do produto.
    const order = await this.client.createOrder(
      {
        customerId,
        externalOrderReference: `matricula:${id}`,
        // Valor gravado na matrícula: a repetição envia exatamente o mesmo corpo (mesma chave, mesmo conteúdo).
        amountCents: e.amountCents,
        currency: "BRL",
        fulfillmentPolicy: "ON_FULLY_PAID",
        description: `Matrícula: ${course.title}`,
        items: [{ name: course.title, quantity: 1, unitAmountCents: e.amountCents }],
        metadata: { produto: PRODUCT, matricula: id, curso: course.id },
      },
      { idempotencyKey: intentKey(PRODUCT, "matricula", id, "pedido") },
    );
    e = await this.patch(id, "pedido criado", (x, s) => {
      x.orderId = order.id;
      x.orderStatus = order.status;
      s.orders[order.id] = id;
    });
  }

  const now = this.now().getTime();
  if (!e.checkout || Date.parse(e.checkout.expiresAt) - now < 60_000) {
    if (e.checkout) {
      // A sessão anterior expirou (só localmente): se o pagamento já entrou, não abre outra.
      const order = await this.client.getOrder(e.orderId as string);
      if (order.status !== "OPEN") return this.view(id);
    }
    const attempt = (e.checkout?.attempt ?? 0) + 1;
    const session = await this.client.createCheckout(
      { orderId: e.orderId as string, allowedModes: course.modes, returnUrl: `${this.opts.publicUrl}/matriculas/${id}`, expiresInSeconds: this.opts.checkoutTtlSeconds ?? 3600 },
      { idempotencyKey: intentKey(PRODUCT, "matricula", id, "checkout", attempt) },
    );
    await this.patch(id, `checkout aberto (tentativa ${attempt})`, (x) => {
      x.checkout = { attempt, sessionId: session.id, url: session.url, expiresAt: session.expiresAt };
    });
  }
  return this.view(id);
}

O acesso nasce no evento order.payment_completed, conferido na API. Estorno e disputa revisam o acesso já liberado: cancelamento dentro da garantia (política de exemplo do produto) pede o estorno e suspende o acesso; só o estorno DONE revoga. Disputa aberta suspende; ganha restabelece; perdida revoga.

examples/educators/src/educators.ts (trecho)
/** Regra de um evento já verificado e deduplicado pela inbox. O estado de verdade vem da API. */
async handleEvent(event: Event): Promise<void> {
  const orderId = await this.resolver.orderOf(event);
  const id = orderId ? this.opts.store.read((s) => s.orders[orderId] ?? null) : null;
  if (!orderId || !id) return; // pedido de outro produto da conta, ou evento sem pedido
  const paymentId = typeof event.data.paymentId === "string" ? event.data.paymentId : null;
  if (paymentId) {
    await this.patch(id, null, (e) => {
      if (!e.paymentIds.includes(paymentId)) e.paymentIds.push(paymentId);
    });
  }
  if (event.type === "order.payment_completed") {
    const order = await this.client.getOrder(orderId);
    if (BigInt(order.confirmedCents) >= BigInt(order.amountCents)) {
      await this.patch(id, `pagamento confirmado (evento ${event.eventId})`, (e) => {
        e.grant ??= { eventId: event.eventId, grantedAt: this.now().toISOString(), confirmedCents: order.confirmedCents };
      });
    }
  }
  await this.reconcile(id);
}
examples/educators/src/policy.ts (trecho)
/**
 * Acesso derivado do estado atual do pedido lido na API. A primeira liberação exige o evento
 * `order.payment_completed`; disputa e estorno revisam o acesso já concedido.
 */
export function accessFor(input: AccessInput, policy: AccessPolicy): { access: Access; reason: string } {
  if (!input.granted) return { access: "PENDENTE", reason: "Aguardando a confirmação do pagamento." };
  const status = input.order.status;
  if (status === "DISPUTED") {
    return input.payments.some((p) => p.reversalStatus === "CHARGED_BACK")
      ? { access: "REVOGADO", reason: "Chargeback perdido." }
      : { access: "SUSPENSO", reason: "Pagamento em disputa." };
  }
  if (status === "REFUNDED") return { access: "REVOGADO", reason: "Reembolso concluído." };
  if (input.cancellationRequested) return { access: "SUSPENSO", reason: "Cancelamento solicitado; reembolso em andamento." };
  if (status === "REFUND_PENDING") return { access: "SUSPENSO", reason: "Estorno em andamento." };
  if (status === "PARTIALLY_REFUNDED") {
    return policy.keepAccessOnPartialRefund ? { access: "LIBERADO", reason: "Estorno parcial; acesso mantido." } : { access: "SUSPENSO", reason: "Estorno parcial." };
  }
  if (status === "PAYMENT_COMPLETED" && input.order.fulfillmentEligible) return { access: "LIBERADO", reason: "Pagamento confirmado." };
  return { access: "SUSPENSO", reason: "Pedido sem pagamento completo." };
}

Órbita: venda por evidência

O vendedor abre a oportunidade e gera o link de pagamento (POST /oportunidades/{id}/link). O lead escolhe Pix, cartão, dois cartões ou Pix em parcelas no checkout. Quando o lead diz "já paguei", o Órbita registra a fala para o vendedor e não muda a venda.

examples/orbita/src/orbita.ts (trecho)
/** Pedido + sessão de checkout da oportunidade. O link vai para o lead; o valor nunca sai do navegador dele. */
async paymentLink(id: string): Promise<OpportunityView> {
  let o = this.opportunity(id);
  if (o.evidence) return this.view(id);
  const offer = this.offers.get(o.offerId) as Offer;
  const customerId = this.opts.store.read((s) => s.leads[o.leadId]?.customerId) as string;
  if (!o.orderId) {
    const order = await this.client.createOrder(
      {
        customerId,
        externalOrderReference: `oportunidade:${id}`,
        amountCents: o.amountCents,
        currency: "BRL",
        // Dois cartões e a venda ganha exigem o total confirmado.
        fulfillmentPolicy: "ON_FULLY_PAID",
        description: offer.title,
        items: [{ name: offer.title, quantity: 1, unitAmountCents: o.amountCents }],
        metadata: { produto: PRODUCT, oportunidade: id, vendedor: o.sellerId, oferta: offer.id },
      },
      { idempotencyKey: intentKey(PRODUCT, "oportunidade", id, "pedido") },
    );
    o = await this.patch(id, "pedido criado", (x, s) => {
      x.orderId = order.id;
      s.orders[order.id] = id;
    });
  }
  if (!o.checkout || Date.parse(o.checkout.expiresAt) - this.now().getTime() < 60_000) {
    const attempt = (o.checkout?.attempt ?? 0) + 1;
    const session = await this.client.createCheckout(
      { orderId: o.orderId as string, allowedModes: offer.modes, returnUrl: `${this.opts.publicUrl}/obrigado/${id}`, expiresInSeconds: 86_400 },
      { idempotencyKey: intentKey(PRODUCT, "oportunidade", id, "checkout", attempt) },
    );
    await this.patch(id, `link de pagamento gerado (tentativa ${attempt})`, (x) => {
      x.checkout = { attempt, sessionId: session.id, url: session.url, expiresAt: session.expiresAt };
    });
  }
  await this.reconcile(id);
  return this.view(id);
}
examples/orbita/src/sale.ts (trecho)
/**
 * Situação da venda no CRM, só a partir de evidência do provedor lida na API (B docs/10: "não pela fala do lead").
 * Venda ganha exige o evento `order.payment_completed`; parcelas pagas mostram progresso, nunca venda ganha.
 */
export function saleStatusFor(f: SaleFacts): { status: SaleStatus; reason: string } {
  if (f.evidence) {
    if (f.order.status === "DISPUTED") {
      return f.payments.some((p) => p.reversalStatus === "CHARGED_BACK") ? { status: "PERDIDA_CHARGEBACK", reason: "Chargeback perdido." } : { status: "EM_DISPUTA", reason: "O pagador contestou o pagamento." };
    }
    if (f.order.status === "REFUNDED") return { status: "REVERTIDA", reason: "Pagamento devolvido ao lead." };
    if (f.order.status === "REFUND_PENDING") return { status: "ESTORNO_EM_ANDAMENTO", reason: "Estorno em andamento." };
    return { status: "GANHA", reason: "Pagamento confirmado pelo provedor." };
  }
  if (f.order.status === "PARTIALLY_PAID" || f.plans.some((p) => BigInt(p.paidCents) > 0n)) return { status: "PAGAMENTO_PARCIAL", reason: "Parte do valor paga; a venda fecha com o total." };
  if (f.order.status === "PAYMENT_COMPLETED") return { status: "PAGAMENTO_EM_ANDAMENTO", reason: "Aguardando o evento assinado de pedido pago." };
  // A tentativa mais recente (dois cartões ou plano) diz o que o lead está fazendo agora.
  const last = latest<{ createdAt: string; kind: "group" | "plan"; state: string }>([
    ...f.groups.map((g) => ({ createdAt: g.createdAt, kind: "group" as const, state: g.outcome })),
    ...f.plans.map((p) => ({ createdAt: p.createdAt, kind: "plan" as const, state: p.status })),
  ]);
  if (last?.kind === "group") {
    if (last.state.startsWith("NOT_COMPLETED")) return { status: "PAGAMENTO_NAO_CONCLUIDO", reason: "Pagamento com dois cartões não concluído; nada ficou cobrado. O lead pode tentar de novo." };
    return { status: "PAGAMENTO_EM_ANDAMENTO", reason: last.state === "MANUAL_REVIEW" ? "Pagamento com dois cartões em revisão pelo suporte." : "Pagamento com dois cartões em processamento." };
  }
  if (last?.kind === "plan") {
    if (last.state === "PENDING_ACTIVATION") return { status: "PAGAMENTO_EM_ANDAMENTO", reason: "Parcelamento sendo gerado." };
    if (last.state === "FAILED" || last.state === "CANCELLED") return { status: "PAGAMENTO_NAO_CONCLUIDO", reason: "Parcelamento não concluído. O lead pode tentar de novo." };
    return { status: "LINK_ENVIADO", reason: "Parcelamento gerado; aguardando a primeira parcela." };
  }
  return f.linkSent ? { status: "LINK_ENVIADO", reason: "Aguardando o pagamento do lead." } : { status: "ABERTA", reason: "Oportunidade sem link de pagamento." };
}

Dois cartões com um recusado mostram "pagamento não concluído" (nada ficou cobrado) e o lead pode tentar de novo na mesma sessão. No Pix em parcelas, cada parcela paga aparece como progresso; a venda só é ganha com todas.

Agenda Expert: sinal, cancelamento e plano mensal

A reserva cobra o sinal por Pix direto pela API: POST /v1/payments responde 202 e o serviço espera a operação por alguns segundos. Se não terminar, responde "gerando cobrança"; a consulta seguinte mostra o QR. Uma resposta perdida do provedor (UNKNOWN) nunca gera outra cobrança.

examples/agenda-expert/src/agenda.ts (trecho)
/**
 * Cobrança do sinal: a intenção (tentativa, vencimento) é gravada antes da chamada; repetir depois de uma falha de
 * rede usa a mesma Idempotency-Key e o mesmo corpo, e a API devolve a operação original. Uma tentativa nova só
 * depois de uma falha conclusiva (FAILED). UNKNOWN nunca gera outra cobrança.
 */
private async chargeDeposit(id: string): Promise<void> {
  let b = this.booking(id);
  if (!b.charge || b.status === "FALHA_NA_COBRANCA") {
    const attempt = (b.charge?.attempt ?? 0) + 1;
    b = await this.patchBooking(id, `cobrança do sinal (tentativa ${attempt})`, (x) => {
      x.charge = { attempt, dueDate: civilDate(this.now()), operationId: null, operationStatus: null, paymentId: null, pix: null };
      this.setStatus(x, "GERANDO_COBRANCA", "Gerando o Pix do sinal.");
    });
  }
  const charge = b.charge;
  if (!charge || charge.operationId) return;
  const op = await this.client.createPayment(
    { orderId: b.orderId as string, method: "PIX", dueDate: charge.dueDate },
    { idempotencyKey: intentKey(PRODUCT, "agendamento", id, "sinal", charge.attempt) },
  );
  await this.patchBooking(id, null, (x) => {
    if (x.charge) Object.assign(x.charge, { operationId: op.id, operationStatus: op.status, paymentId: op.resourceId ?? null });
  });
}

/** Acompanha a operação 202 sem repetir a escrita: espera curta e, se não terminou, só consulta depois. */
async refreshCharge(id: string, waitMs = 0): Promise<void> {
  const b = this.booking(id);
  const operationId = b.charge?.operationId;
  if (!operationId || b.status !== "GERANDO_COBRANCA") return;
  let op: Operation;
  try {
    op = waitMs > 0 ? await this.client.waitForOperation(operationId, { timeoutMs: waitMs, intervalMs: 200 }) : await this.client.getOperation(operationId);
  } catch (e) {
    if (!(e instanceof OperationTimeoutError)) throw e;
    op = e.operation; // ainda ACCEPTED/DISPATCHING/UNKNOWN: o cliente vê "processando"
  }
  if (op.status === "SUCCEEDED" && op.resourceId) {
    const pix = await this.client.getPixQr(op.resourceId);
    await this.patchBooking(id, "Pix do sinal disponível", (x) => {
      if (x.charge) Object.assign(x.charge, { operationStatus: op.status, paymentId: op.resourceId ?? null, pix: { payload: pix.payload, encodedImage: pix.encodedImage, expiresAt: pix.expiresAt } });
      if (x.status === "GERANDO_COBRANCA") this.setStatus(x, "AGUARDANDO_SINAL", "Aguardando o pagamento do sinal.");
    });
  } else if (op.status === "FAILED") {
    await this.patchBooking(id, `cobrança do sinal recusada (${op.error?.code ?? "sem código"})`, (x) => {
      if (x.charge) x.charge.operationStatus = op.status;
      this.setStatus(x, "FALHA_NA_COBRANCA", "Não foi possível gerar o Pix. Tente de novo.");
    });
  } else {
    await this.patchBooking(id, null, (x) => {
      if (x.charge) x.charge.operationStatus = op.status;
      x.statusReason =
        op.status === "UNKNOWN" ? "Resultado em verificação pela plataforma; não gere outra cobrança." : op.status === "MANUAL_REVIEW" ? "Cobrança em revisão pelo suporte." : "Gerando o Pix do sinal.";
    });
  }
}

O cancelamento segue a política do produto, separada do pagamento: com antecedência mínima o sinal é devolvido (estorno 202, concluído pelo evento); em cima da hora fica retido, sem chamada de estorno. Se a API recusar a devolução (por exemplo, saldo insuficiente), o agendamento fica com a devolução pendente para o suporte, que repete com a mesma chave.

examples/agenda-expert/src/policy.ts (trecho)
/** Decide o que fazer com o sinal ao cancelar. Pura: o estorno em si é uma intenção 202 na API. */
export function cancellationDecision(input: { depositPaid: boolean; startsAt: Date; now: Date }, policy: CancellationPolicy): { decision: CancellationDecision; noticeHours: number } {
  const noticeHours = (input.startsAt.getTime() - input.now.getTime()) / 3_600_000;
  if (!input.depositPaid) return { decision: "SEM_PAGAMENTO", noticeHours };
  return { decision: noticeHours >= policy.fullRefundMinNoticeHours ? "DEVOLVER_SINAL" : "RETER_SINAL", noticeHours };
}

O profissional assina o plano mensal (Individual ou Empresarial, preços sintéticos) por Pix. O provedor gera as cobranças; cada ciclo pago conta pelo order.payment_completed do pedido do ciclo.

examples/agenda-expert/src/agenda.ts (trecho)
/** Assina o plano mensal por Pix: a assinatura gera as cobranças no provedor; o produto não cria cobranças. */
async subscribe(professionalId: string, person: PersonInput, tier: PlanTier) {
  const plan = EXAMPLE_PLANS[tier];
  if (!this.opts.store.read((s) => s.professionals[professionalId])) {
    const customer = await this.client.createCustomer(
      { name: person.name, taxId: person.taxId, ...(person.email ? { email: person.email } : {}), externalReference: `profissional:${professionalId}` },
      { idempotencyKey: intentKey(PRODUCT, "profissional", professionalId, "cliente") },
    );
    await this.opts.store.update((s) => {
      s.professionals[professionalId] ??= { id: professionalId, customerId: customer.id, plan: null, history: [] };
    });
  }
  let p = this.professional(professionalId);
  const live = p.plan && LIVE_PLAN.includes(p.plan.status);
  if (live && p.plan?.operationId) return this.planView(professionalId);
  if (!live) {
    const attempt = (p.plan?.attempt ?? 0) + 1;
    p = await this.patchProfessional(professionalId, `plano ${tier.toLowerCase()} solicitado`, (x) => {
      x.plan = { tier, attempt, nextDueDate: addDays(civilDate(this.now()), this.opts.firstDueInDays ?? 3), operationId: null, subscriptionId: null, subscriptionStatus: null, status: "AGUARDANDO_ATIVACAO", paidOrders: [], cancelRequest: null };
    });
  }
  const intent = p.plan as ProfessionalPlan;
  const op = await this.client.createSubscription(
    {
      customerId: p.customerId,
      method: "PIX",
      amountCents: EXAMPLE_PLANS[intent.tier].amountCents,
      cycle: "MONTHLY",
      nextDueDate: intent.nextDueDate,
      description: EXAMPLE_PLANS[intent.tier].title,
      externalReference: `profissional:${professionalId}:plano:${intent.attempt}`,
    },
    { idempotencyKey: intentKey(PRODUCT, "profissional", professionalId, "plano", intent.attempt) },
  );
  await this.patchProfessional(professionalId, `assinatura ${plan.title} aceita (202)`, (x) => {
    if (x.plan) Object.assign(x.plan, { operationId: op.id, subscriptionId: op.resourceId ?? null });
  });
  return this.planView(professionalId);
}

Como rodar

Serviço do exemplo
export ECLIPTA_PAY_API="<endereço da API do ambiente de testes>"
export ECLIPTA_PAY_KEY="ep_test_..."          # chave própria do produto
export ECLIPTA_PAY_ACCOUNT="acc_..."
export PUBLIC_URL="https://educators.exemplo.com.br"

# 1. Cadastra o endpoint de webhook e mostra o segredo uma única vez (guarde no cofre).
pnpm --filter @eclipta-pay/example-educators registrar-webhook

# 2. Sobe o serviço com o segredo (dois, separados por vírgula, durante a rotação).
export ECLIPTA_PAY_WEBHOOK_SECRETS="whsec_..."
pnpm --filter @eclipta-pay/example-educators start
Testes de ponta a ponta
pnpm vitest run --project integration tests/integration/src/reference-*.int.test.ts

Limitações conhecidas

  • Não há cadastro de recebedores para split de coprodutores, e o campo splits de POST /v1/payments ainda não tem efeito: não o use.
  • GET /v1/payments não filtra por pedido: os exemplos guardam o vínculo cobrança → pedido a partir dos eventos de cobrança (payment.created, payment.confirmed).
  • Os eventos não trazem o produto da chave: cada produto reconhece os próprios pedidos e ignora os demais.
  • Assinatura no cartão ainda não tem página de captura própria (veja Assinaturas); os exemplos usam Pix.

Próximo: Referência da API