Pular para o conteúdo

Node.js

Node 20 ou mais recente já traz fetch embutido, então um cliente completo cabe em umas trinta linhas, sem axios nem node-fetch. Esta página monta esse cliente e usa ele em todos os exemplos seguintes.

Terminal window
export LIGGA_API_KEY='ligga_live_…'
export LIGGA_BASE='https://api.ligga.app/functions/v1/api'

O módulo abaixo concentra autenticação, montagem de URL e tradução do corpo de erro em exceção.

ligga.mjs
import { randomUUID } from 'node:crypto';
const API_KEY = process.env.LIGGA_API_KEY;
const BASE = process.env.LIGGA_BASE ?? 'https://api.ligga.app/functions/v1/api';
export class LiggaError extends Error {
constructor(status, problem) {
super(`HTTP ${status}: ${problem?.title ?? 'erro sem corpo'}`);
this.name = 'LiggaError';
this.status = status;
this.problem = problem ?? {};
}
}
export async function api(path, { method = 'GET', body, idempotencyKey } = {}) {
const headers = { Authorization: `Bearer ${API_KEY}` };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 204) return null;
const data = await res.json().catch(() => null);
if (!res.ok) throw new LiggaError(res.status, data);
return data;
}
export const novaChaveIdempotente = () => randomUUID();

GET /v1/me descreve a chave que fez a chamada e confirma que a configuração está correta.

me.mjs
import { api } from './ligga.mjs';
const me = await api('/v1/me');
console.log(me.data.team_name, me.data.scopes);
{
"data": {
"team_id": "bca09e2f-41ab-4417-b84b-f2ff50f991ca",
"team_name": "Pet Shop Aurora",
"scopes": ["*:read", "sales:write"],
"plan_code": "pro"
},
"meta": { "request_id": "req_01JZXQ8V2K5M7N9P0R1S2T3U4V" }
}

Uma listagem devolve os itens em data e o estado da paginação em pagination. O cursor da próxima página está em pagination.next_cursor e vem null quando acabou.

clientes.mjs
import { api } from './ligga.mjs';
export async function* listarClientes(filtros = {}) {
let cursor = null;
do {
const query = new URLSearchParams({
limit: '100',
...filtros,
...(cursor ? { cursor } : {}),
});
const pagina = await api(`/v1/customers?${query}`);
yield* pagina.data;
cursor = pagina.pagination.next_cursor;
} while (cursor);
}
for await (const cliente of listarClientes({ 'filter[is_active][eq]': 'true' })) {
console.log(cliente.id, cliente.full_name);
}

O limit vai de 1 a 100, com padrão 20. pagination.has_more responde a mesma pergunta que o cursor nulo, e serve quando você quer só saber se existe mais alguma coisa.

Gere a chave antes da primeira tentativa e reaproveite o mesmo valor em todas as repetições. Assim, uma requisição perdida no caminho não vira dois registros.

criar-venda.mjs
import { api, novaChaveIdempotente, LiggaError } from './ligga.mjs';
const RETENTAVEIS = new Set([408, 429, 500, 502, 503, 504]);
export async function criarVenda(payload, tentativas = 3) {
const chave = novaChaveIdempotente();
for (let tentativa = 1; tentativa <= tentativas; tentativa++) {
try {
return await api('/v1/sales', {
method: 'POST',
body: payload,
idempotencyKey: chave,
});
} catch (err) {
const retentavel = err instanceof LiggaError && RETENTAVEIS.has(err.status);
if (!retentavel || tentativa === tentativas) throw err;
await new Promise((r) => setTimeout(r, 250 * 2 ** (tentativa - 1)));
}
}
}
const venda = await criarVenda({
customer_id: '7c4b0a19-2f36-4d1e-9b58-1f0a6c3e5d24',
transaction_type: 'sale',
sale_date: '2026-05-18',
items: [
{
catalog_type: 'product',
catalog_item_id: '3d61f8ba-9c04-4a77-8e12-5b7a0d9f2c68',
quantity: 2,
unit_price: 29.90,
},
],
});
console.log(venda.data.id);

Reusar a mesma chave com um corpo diferente devolve 409 idempotency_conflict: é sinal de que a chave vazou para outra operação.

Respostas de erro vêm como application/problem+json. O cliente acima já as converte em LiggaError, com o corpo inteiro em err.problem.

tratar-erro.mjs
import { api, LiggaError } from './ligga.mjs';
try {
await api('/v1/customers', { method: 'POST', body: {} });
} catch (err) {
if (!(err instanceof LiggaError)) throw err;
console.error(err.problem.status, err.problem.type);
console.error(err.problem.detail ?? err.problem.title);
console.error('request_id:', err.problem.request_id);
for (const campo of err.problem.errors ?? []) {
console.error(' -', campo.field, campo.code, campo.message);
}
}
{
"type": "https://api.ligga.app/errors/validation",
"title": "Validation failed",
"status": 422,
"detail": "full_name is required",
"request_id": "req_01JZXQ8V2K5M7N9P0R1S2T3U4V"
}

type, title, status e request_id estão sempre presentes; detail, instance e errors aparecem quando fazem sentido. Guarde o request_id no seu log: é o que o time da Ligga pede para investigar uma chamada.

HTTPtypeO que fazer
401invalid_tokenConfira LIGGA_API_KEY e o cabeçalho Authorization
403insufficient_scopeA chave não carrega o escopo do recurso
403module_not_enabledO módulo do recurso não está no contrato
409idempotency_conflictA mesma Idempotency-Key foi reusada com outro corpo
422validationCampo obrigatório ausente ou com tipo errado
429rate-limit-exceededEspere o tempo do cabeçalho Retry-After

A especificação publicada gera tipos prontos para TypeScript:

Terminal window
npx openapi-typescript https://docs.ligga.app/openapi.json -o ./ligga-types.ts
clientes.ts
import type { paths } from './ligga-types';
type ListaDeClientes =
paths['/v1/customers']['get']['responses']['200']['content']['application/json'];
const pagina: ListaDeClientes = await api('/v1/customers?limit=20');
const proximo = pagina.pagination.next_cursor;