Pular para o conteúdo principal

@zemocapital/sdk

SDK oficial Node.js/TypeScript da API de Antecipação de Recebíveis da Zemo Capital.

  • Zero dependências — usa fetch e crypto nativos (Node 20+)
  • 100% tipado — tipos gerados do contrato OpenAPI, expostos em camelCase idiomático
  • Auth automática — troca as credenciais de API por JWT de curta duração, com cache e refresh transparentes
  • Ambiente inferido — zk_sbx_* / zk_test_* → sandbox, zk_live_* → produção
  • Idempotência automática — header Idempotency-Key (UUID) em todo POST/PATCH/DELETE
  • Retries com backoff exponencial em 502/503/504
  • Paginação transparente via for await
  • Verificação de webhooks (HMAC SHA-256, comparação constant-time) sem rede

Instalação​

npm install @zemocapital/sdk
# ou
yarn add @zemocapital/sdk

Requer Node.js 20 ou superior. Builds CJS e ESM inclusos.

Autenticação​

import { Zemo } from "@zemocapital/sdk";

const zemo = new Zemo({
clientId: process.env.ZEMO_CLIENT_ID, // zk_sbx_* → sandbox | zk_live_* → produção
clientSecret: process.env.ZEMO_CLIENT_SECRET,
});

O SDK chama POST /v1/auth/token (client credentials) por baixo dos panos e renova o JWT antes de expirar. Você nunca lida com token manualmente.

CredencialAmbienteBase URL
zk_sbx_*Sandbox (homologação)https://receivables-api-sandbox.zemocapital.com
zk_test_*Sandbox (prefixo legado)https://receivables-api-sandbox.zemocapital.com
zk_live_*Produçãohttps://receivables-api.zemocapital.com

Para forçar um ambiente: new Zemo({ ..., environment: "production" }).

Fluxo completo: registrar → simular → antecipar → conciliar​

1. Cadastre o cedente e o sacado​

const assignor = await zemo.assignors.create({
document: "12345678000195",
legalName: "Cedente XPTO SA",
type: "J", // "F" (CPF) ou "J" (CNPJ)
email: "financeiro@xpto.com",
});

const payer = await zemo.payers.create({
cnpj: "98765432000198",
legalName: "Sacado ABC SA",
});

Cadastro de cedente é deduplicado por documento: se o CPF/CNPJ já existe, o cedente existente é retornado.

2. Registre o recebível no estoque​

const item = await zemo.stock.create({
assignorId: assignor.id,
payerId: payer.id,
externalId: "NF-2026-001", // seu identificador (ex.: número da NF)
backingType: "NFE", // tem default no spec, mas os tipos exigem
grossFutureValue: 12000.0, // valor de face BRUTO do lastro
grossFutureValueDeductions: 2000.0, // exigido junto do bruto — envie 0 se não houver
netFutureValue: 10000.0, // 12000 − 2000; é o teto do que se antecipa
grossFaceValue: 12000.0, // required do spec ainda pede o nome antigo
netFaceValue: 10000.0, // required do spec ainda pede o nome antigo
dueDate: "2026-08-15",
preAuthorized: true,
});
console.log(item.id, item.status); // IN_STOCK

Por que o payload tem os dois nomes. grossFutureValue, netFutureValue e requestedNetFutureValue são os nomes canônicos — são eles que você deve ler e passar a usar. Só que, no contrato de hoje, quem está na lista required do openapi.json ainda são os nomes antigos (grossFaceValue, netFaceValue, requestedAdvanceValue), e os tipos gerados refletem isso: um payload só-canônico não compila em TypeScript strict. Por isso os snippets deste README enviam o par, com o mesmo número nos dois — o que é sempre aceito. (Números diferentes no mesmo par são recusados com 422 VOCABULARY_CONFLICT; nada é escolhido em silêncio.)

Em JavaScript, ou com skipLibCheck sem strict, o payload só-canônico funciona: a API aceita qualquer um dos dois nomes. A obrigatoriedade é do tipo, não do servidor. Quando o required migrar para os nomes canônicos, estas duas linhas saem daqui.

Os nomes antigos seguem aceitos e devolvidos em toda a série 1.x e serão removidos na 2.0 (mudança maior, anunciada no Changelog) — migre para os canônicos antes de adotá-la. Uma ressalva independente disso: grossFutureValue exige grossFutureValueDeductions no mesmo payload (0 se não houver deduções) — o antigo grossFaceValue continua aceito sozinho. De-para completo em Valores do Recebível.

3. Simule a antecipação (read-only)​

const sim = await zemo.stock.simulateAnticipation({
stockItemIds: [item.id],
requestedNetFutureValue: 10000.0, // = soma dos netFutureValue dos itens (sempre 100%)
requestedAdvanceValue: 10000.0, // required do spec ainda pede o nome antigo
fees: { monthlyRatePct: 3.5 },
});
console.log(sim.oprNetPresentLiquidValue); // valor líquido que o cedente recebe

Nada é criado — use à vontade para exibir cotações.

4. Solicite a antecipação (cria a operação)​

const op = await zemo.stock.requestAnticipation({
stockItemIds: [item.id],
requestedNetFutureValue: 10000.0,
requestedAdvanceValue: 10000.0, // required do spec ainda pede o nome antigo
bank: { useDocumentPix: true }, // PIX pela chave-documento do cedente
fees: { monthlyRatePct: 3.5 },
});
console.log(op.id, op.displayNumber);

A operação entra no funil de aprovação/funding. Acompanhe por webhooks (recomendado) ou polling:

const operation = await zemo.operations.get(op.id);
console.log(operation.lifecycleStatus);

4b. (Opcional) Assinatura embedded​

Ao ser aprovada, a operação dispara o contrato sozinha e o provedor notifica os signatários por e-mail. Você só precisa desta etapa se quiser apresentar o link de assinatura na sua própria interface — o modo embedded, em que o e-mail automático é suprimido e a responsabilidade de levar o signatário até a assinatura passa a ser sua.

const dispatch = await zemo.operations.sendContractForSignature(op.id, {
embedded: true, // suprime a notificação automática
});

// signUrl é CREDENCIAL: entregue à sua tela, nunca a um log.
const links = dispatch.signers.map((s) => ({ name: s.name, signUrl: s.signUrl }));

// Sempre que precisar das URLs de novo (ou do status de cada signatário):
const contract = await zemo.operations.contractSigners(op.id);
console.log(contract.documentStatus); // texto do provedor: só para exibir
const pendentes = contract.signers.filter((s) => !s.signed); // `signed` para lógica

signUrl é credencial. Quem tem a URL assina o documento. Não a registre em log, não a guarde em claro e não a mande por canal não confiável. No replay de idempotência ela volta null (redação deliberada do cache) — nesse caso use contractSigners. E não há watchdog: em modo embedded, operação cujo link nunca for apresentado nunca será assinada.

5. Concilie: títulos e saldo devedor​

Cada operação gera títulos (parcelas). O saldo devedor é mantido pela API (evento a evento) — concilie pelo ledger:

// Títulos da operação
const titles = await zemo.operations.titles(op.id);

// Ledger completo de um título: juros, pagamentos, descontos
for (const title of titles.items) {
const events = await zemo.titles.balanceEvents(title.id);
for (const event of events) {
console.log(event.eventType, event.paymentDeltaBrl, event.outstandingAfterBrl);
}
}

// Posição consolidada do cedente (atrasado + a vencer)
const balance = await zemo.assignors.outstandingBalance(assignor.id);
console.log(balance.totalOutstandingBrl, balance.overdueBalanceBrl);

Webhooks​

// 1. Inscreva-se (o hmacSecret é retornado UMA ÚNICA VEZ — persista com segurança)
const webhook = await zemo.webhooks.create({
url: "https://suaempresa.com/webhooks/zemo",
events: ["operation.approved", "title.paid"],
});
console.log(webhook.hmacSecret);

// 2. Valide a assinatura no seu handler (sem rede, constant-time).
import express from "express";
import { verifySignature } from "@zemocapital/sdk";

// O segredo é OBRIGATÓRIO e não pode ser vazio: uma string vazia produziria um
// HMAC que qualquer um consegue calcular. Falhe no boot, não no 1º evento.
const WEBHOOK_SECRET = process.env.ZEMO_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) {
throw new Error("ZEMO_WEBHOOK_SECRET ausente ou vazio — configure antes de subir o serviço.");
}

// `express.raw` é o que preserva os BYTES recebidos. Com `express.json` o corpo
// chega já parseado e re-serializá-lo muda os bytes: a assinatura NUNCA baterá.
// (Não existe `req.rawBody` no Express por padrão.)
//
// `type: () => true` aceita QUALQUER Content-Type de propósito: com o filtro
// padrão (`"application/json"`), quem chama escolhe um Content-Type diferente,
// o parser não roda, `req.body` chega como `{}` e o handler quebra — ou seja, o
// chamador decidiria o status da sua resposta.
app.post("/webhooks/zemo", express.raw({ type: () => true }), (req, res) => {
// Cinto de segurança: só chame o SDK com os bytes crus. Se outro parser
// (um `express.json()` global, por exemplo) tiver consumido o corpo antes,
// `req.body` não é Buffer — responda 400 em vez de deixar estourar 500.
if (!Buffer.isBuffer(req.body)) return res.status(400).end();

const ok = verifySignature({
body: req.body, // Buffer com os bytes exatos
signature: req.headers["x-zemo-signature"], // ausente/torta => false, nunca exceção
secret: WEBHOOK_SECRET,
});

// 401 é a resposta correta para assinatura inválida — fica registrada em
// `GET /v1/webhooks/{id}/deliveries` e diz exatamente o que aconteceu.
if (!ok) return res.status(401).end();

const event = JSON.parse(req.body.toString("utf8"));
console.log(event.event_type);
res.status(200).end(); // confirme rápido; processe fora do handler
});

// 3. Audite entregas quando precisar:
const deliveries = await zemo.webhooks.deliveries(webhook.id);

Ordem dos parsers importa. Se a sua app monta express.json() global ANTES desta rota, ele consome o corpo primeiro e o express.raw não substitui: req.body chega como objeto e o guard acima devolve 400 em TODA entrega — inclusive nas legítimas. Monte a rota do webhook antes do parser global, ou exclua o caminho dela. O 400 é o sintoma dessa fiação, não o conserto.

Como a Zemo trata a sua resposta. Só 2xx conta como entregue. Qualquer outra coisa — 401, 500, timeout — é reagendada com backoff exponencial: no máximo 7 tentativas no total, com intervalos de 1, 2, 4, 8, 16 e 32 minutos, e um prazo-limite de ~2h; depois disso o evento é descartado. Não há tratamento terminal para 4xx — ou seja, responder 401 não interrompe as retentativas. Duas consequências práticas:

  • Um 401 de verdade quase sempre significa segredo divergente entre os dois lados (rotação pela metade, por exemplo). Nesse caso as retentativas jogam a seu favor: dá tempo de corrigir o segredo antes de o evento ser descartado.
  • Entrega forjada não vem da Zemo, então a política de retentativa dela não se aplica ali — o que importa é o seu handler responder e seguir vivo, em vez de estourar numa exceção não tratada a cada requisição forjada.

Prefere verificar e fazer o parse num passo só? parseEvent faz os dois, mas lança AuthError quando a assinatura é inválida — capture, ou uma entrega forjada derruba o handler:

import { AuthError, parseEvent } from "@zemocapital/sdk";

try {
const event = parseEvent<{ event_type: string }>({
body: req.body,
signature: req.headers["x-zemo-signature"],
secret: WEBHOOK_SECRET,
});
console.log(event.event_type);
res.status(200).end();
} catch (err) {
if (err instanceof AuthError) return res.status(401).end();
throw err;
}

Quem responde o quê​

EntradaQuem controlaComportamento
signaturequem faz a requisiçãoausente, não-string ou não-ASCII ⇒ false. Nunca lança: o seu handler responde 401 deliberadamente.
secretsó você (configuração)ausente/vazio ⇒ lança TypeError. Verificado ANTES de tudo, para o erro ser o mesmo em toda requisição.
bodyvocê decide o que passar, mas o que CHEGA depende da requisiçãonão-Buffer/string ⇒ lança TypeError.

A linha do body é a que exige o cinto de segurança do snippet. Você escolhe passar req.body, mas o Content-Type é escolhido por quem chama — e isso decide se o parser do Express te entrega bytes ou {}. Sem o Buffer.isBuffer(...) antes da chamada, quem faz a requisição escolheria entre 401 e 500 só trocando um cabeçalho.

Por que secret e body lançam em vez de devolver false: os dois quebram a verificação de TODAS as entregas, inclusive as legítimas. Convertê-los em "assinatura inválida" produziria um webhook silenciosamente morto — falhar alto, com mensagem acionável, é o mal menor.

Paginação​

Todos os recursos de listagem oferecem dois modos:

// Manual: uma página por vez
const page = await zemo.titles.page({ status: "OPEN", limit: 50 });
console.log(page.total, page.items.length);

// Transparente: itera tudo, busca páginas sob demanda
for await (const title of zemo.titles.query({ status: "OPEN" })) {
console.log(title.id, title.currentOutstandingBalanceBrl);
}

Tratamento de erros​

Todos os erros HTTP viram exceções tipadas com o código de erro da API:

import { ZemoError, InputError, AuthError, RateLimitError } from "@zemocapital/sdk";

try {
await zemo.stock.create({ /* ... */ });
} catch (err) {
if (err instanceof InputError) {
console.error(err.code, err.fieldErrors); // 400/422 com detalhes por campo
} else if (err instanceof RateLimitError) {
console.error(`retry em ${err.retryAfter}s`);
} else if (err instanceof ZemoError) {
console.error(err.code, err.message);
}
}

Hierarquia: AuthError (401) · ForbiddenError (403) · NotFoundError (404) · ConflictError (409) · InputError (400/422) · RateLimitError (429) · ServerError (5xx) · ZemoTimeoutError.

Idempotência​

POST/PATCH/DELETE enviam Idempotency-Key (UUID) automaticamente. Para controlar a chave (ex.: retry seguro entre processos), use o escape hatch:

await zemo.request("POST", "/v1/stock/request-anticipation", {
body: params,
idempotencyKey: "0190a1b2-...", // sua chave estável (UUIDv7 recomendado)
});

Escape hatch​

Endpoint ainda sem wrapper dedicado? Use zemo.request() — auth, casing e idempotência continuam automáticos:

const products = await zemo.request<unknown>("GET", "/v1/products");

Convenção de nomes​

O SDK fala camelCase; a API REST fala snake_case. A conversão é automática e profunda nos dois sentidos (grossFutureValue ↔ gross_future_value), inclusive em query params.

Todo valor monetário tem dois nomes aceitos e devolvidos: o canônico (netFutureValue, presentValueDiscount, oprNetPresentLiquidValue, ...) e o deprecado (netFaceValue, discountedValue, oprLiquidValue, ...). Os tipos gerados do OpenAPI expõem os dois, mas não de forma simétrica — e a diferença muda o seu código:

  • Nas respostas, os dois nomes são opcionais. Leia o canônico com fallback no deprecado (sim.oprNetPresentLiquidValue ?? sim.oprLiquidValue) quando precisar tolerar respostas gravadas antes desta versão — o caso concreto é a re-entrega de webhook de um evento emitido antes da mudança.
  • Nos requests, os nomes deprecados continuam obrigatórios no tipo, porque é o que a lista required do openapi.json ainda declara. Os canônicos são os opcionais. Em TypeScript strict, um payload só-canônico não compila — envie o par com o mesmo número, como fazem os snippets acima. Do lado da API os dois são intercambiáveis: a obrigatoriedade é do tipo, não do servidor.

Essa convivência é da série 1.x: na 2.0 os nomes deprecados saem do contrato, e com eles as duas linhas transitórias dos snippets acima. O de-para campo a campo está em Valores do Recebível.

Desenvolvimento​

yarn install
yarn typecheck # tsc --noEmit (src + tests)
yarn test # vitest
yarn lint # biome
yarn build # tsup → ESM + CJS + d.ts

Licença​

MIT © Zemo Capital