@zemocapital/sdk
SDK oficial Node.js/TypeScript da API de Antecipação de Recebíveis da Zemo Capital.
- Zero dependências — usa
fetchecryptonativos (Node 20+) - 100% tipado — tipos gerados do contrato OpenAPI, expostos em
camelCaseidiomá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.
| Credencial | Ambiente | Base 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ção | https://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,netFutureValueerequestedNetFutureValuesã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 listarequireddoopenapi.jsonainda são os nomes antigos (grossFaceValue,netFaceValue,requestedAdvanceValue), e os tipos gerados refletem isso: um payload só-canônico não compila em TypeScriptstrict. 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 com422 VOCABULARY_CONFLICT; nada é escolhido em silêncio.)Em JavaScript, ou com
skipLibChecksemstrict, o payload só-canônico funciona: a API aceita qualquer um dos dois nomes. A obrigatoriedade é do tipo, não do servidor. Quando orequiredmigrar para os nomes canônicos, estas duas linhas saem daqui.Os nomes antigos seguem aceitos e devolvidos em toda a série
1.xe serão removidos na2.0(mudança maior, anunciada no Changelog) — migre para os canônicos antes de adotá-la. Uma ressalva independente disso:grossFutureValueexigegrossFutureValueDeductionsno mesmo payload (0se não houver deduções) — o antigogrossFaceValuecontinua 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 voltanull(redação deliberada do cache) — nesse caso usecontractSigners. 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 oexpress.rawnão substitui:req.bodychega 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
401de 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ê
| Entrada | Quem controla | Comportamento |
|---|---|---|
signature | quem faz a requisição | ausente, não-string ou não-ASCII ⇒ false. Nunca lança: o seu handler responde 401 deliberadamente. |
secret | só você (configuração) | ausente/vazio ⇒ lança TypeError. Verificado ANTES de tudo, para o erro ser o mesmo em toda requisição. |
body | você decide o que passar, mas o que CHEGA depende da requisição | nã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
requireddoopenapi.jsonainda declara. Os canônicos são os opcionais. Em TypeScriptstrict, 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
Links
- Documentação completa
- API Reference (Scalar) — com snippets deste SDK em todos os endpoints
- Códigos de erro
- Go-live checklist
Licença
MIT © Zemo Capital