@ecodrix/erix-api
The official TypeScript SDK for the ECODrIx platform. Use it from your own backend to work with your workspace: CRM leads and pipelines, Flow automations, the unified inbox (WhatsApp, Instagram, Facebook, Telegram, email, webchat), invoices and payment links, file storage, and live events. Every call is scoped to the workspace your key belongs to.
- Developer guide: https://api.ecodrix.com/docs
- API docs: https://api.ecodrix.com/docs
Install
pnpm add @ecodrix/erix-api
# or: npm install @ecodrix/erix-api
- Node.js 18+ (uses the global
fetchandBlob). Ships ESM and CommonJS builds with bundled type definitions. - Browsers: supported through a bundler (Vite, Next.js, webpack) in proxy mode only, plus the keyless website chat visitor client. There is no CDN build.
Quick start
Run this on your server. Keep the key in a server-only environment variable.
import { ECODrIxAPI } from "@ecodrix/erix-api";
const ecod = new ECODrIxAPI({
apiKey: process.env.ECOD_API_KEY!,
clientCode: "ERIX_CLNT_XXXXXX", // Settings → Developer in the dashboard
});
const { data: lead } = await ecod.product.erix.leads.create({
firstName: "Test",
lastName: "Customer",
phone: "+91XXXXXXXXXX",
source: "website",
});
await ecod.product.erix.events.trigger({
trigger: "enquiry_received",
phone: "+91XXXXXXXXXX",
});
// The client keeps a realtime socket open. Close it when your process stops.
process.on("SIGTERM", () => ecod.disconnect());
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | none | Workspace key or a scoped key. Server only. |
clientCode | string | none | Your workspace code, e.g. ERIX_CLNT_XXXXXX. |
version | "v1" | "v1" | API route version. |
timeout | number (ms) | 30000 | Per request. 0 disables. |
proxy | { url, ticket? } | none | Browser mode. Replaces apiKey. |
How the client is laid out
The client follows the API's route tree:
| Namespace | What it covers |
|---|---|
ecod.product.erix.* | CRM (leads, pipelines, activities, analytics), events (Flow triggers), chat (inbox), invoices, payments, ai |
ecod.infra.connect.* | Channels: whatsapp.templates, email, meet, connections |
ecod.infra.storage.* | Files: media, files, folders |
ecod.platform.* | Workspace: knowledge (AI knowledge base), users.notifications |
ecod.webhooks | Webhook signature verification |
ecod.on() / ecod.off() | Realtime events |
ecod.request() | Authenticated call to any endpoint without a typed method yet |
Browser use (proxy mode)
Never put a workspace key in the browser. That includes NEXT_PUBLIC_*,
VITE_* or any other variable that ends up in a client bundle. Anyone who
opens dev tools can read it, and a workspace key has full access.
In the browser, run the SDK in proxy mode:
- Your server exposes
/api/erix/*. It checks your signed-in user, adds the key, and forwards the request to ECODrIx. - For realtime, your server mints a 5-minute socket ticket. The browser gets the ticket, never the key.
Proxy route (Next.js App Router, app/api/erix/[...path]/route.ts):
const API = "https://api.ecodrix.com";
const PASS_HEADERS = ["content-type", "accept", "idempotency-key"];
const DROP_HEADERS = ["content-encoding", "content-length", "transfer-encoding", "connection"];
async function proxy(req: Request, ctx: { params: Promise<{ path: string[] }> }) {
const user = await requireUser(req); // your own session check
if (!user) {
return Response.json({ success: false, error: "Unauthorized" }, { status: 401 });
}
const { path } = await ctx.params;
const rel = `/${path.map(encodeURIComponent).join("/")}`;
if (!/^\/v\d+\/api\//.test(rel)) {
return Response.json({ success: false, error: "Not found" }, { status: 404 });
}
const headers = new Headers({
"x-api-key": process.env.ECOD_API_KEY!,
"x-client-code": "ERIX_CLNT_XXXXXX",
});
for (const name of PASS_HEADERS) {
const value = req.headers.get(name);
if (value) headers.set(name, value);
}
const hasBody = req.method !== "GET" && req.method !== "HEAD";
const upstream = await fetch(`${API}${rel}${new URL(req.url).search}`, {
method: req.method,
headers,
body: hasBody ? await req.arrayBuffer() : undefined,
redirect: "manual",
});
const out = new Headers(upstream.headers);
for (const name of DROP_HEADERS) out.delete(name);
return new Response(upstream.body, { status: upstream.status, headers: out });
}
export { proxy as GET, proxy as POST, proxy as PUT, proxy as PATCH, proxy as DELETE };
Ticket route (app/api/erix/ticket/route.ts):
export async function POST(req: Request) {
const user = await requireUser(req); // your own session check
if (!user) return new Response("Unauthorized", { status: 401 });
const res = await fetch("https://api.ecodrix.com/v1/api/platform/realtime/ticket", {
method: "POST",
headers: {
"x-api-key": process.env.ECOD_API_KEY!,
"x-client-code": "ERIX_CLNT_XXXXXX",
},
cache: "no-store",
});
const body = (await res.json().catch(() => null)) as { data?: { ticket?: string } } | null;
const ticket = body?.data?.ticket;
if (!res.ok || !ticket) return new Response("Ticket unavailable", { status: 502 });
return new Response(ticket, {
headers: { "content-type": "text/plain", "cache-control": "no-store" },
});
}
Browser client:
import { ECODrIxAPI } from "@ecodrix/erix-api";
export const ecod = new ECODrIxAPI({
proxy: {
url: "/api/erix",
ticket: () =>
fetch("/api/erix/ticket", { method: "POST" }).then((r) =>
r.ok ? r.text() : Promise.reject(new Error("No realtime ticket")),
),
},
});
The SDK fetches a fresh ticket on every reconnect. Without ticket, REST
calls work and no socket opens.
Anyone signed in to your app can now do whatever the workspace key can. If your users shouldn't have all of that, allow-list the paths they need in the proxy route.
Website chat visitor client
@ecodrix/erix-api/visitor is a separate entry for an anonymous website
visitor chatting with a workspace. It takes no key and reaches only that
visitor's own thread. It uses fetch and loads socket.io-client only when
you go live, so a chat widget doesn't ship the full SDK. The ERIX chat widget
runs on it.
import { WebchatVisitor, WebchatVisitorError } from "@ecodrix/erix-api/visitor";
const chat = new WebchatVisitor({
clientCode: "ERIX_CLNT_XXXXXX",
// The visitor's only secret: random, 20–80 characters of A-Z a-z 0-9 _ -.
// Keep it (e.g. in localStorage) to continue the same thread next visit.
sessionId,
// The site the chat runs on. It must be on the workspace's allowed domains.
parent: "https://www.example.in",
});
const { data: messages, widget, consentNotice } = await chat.messages();
try {
await chat.send("Do you open on Sunday?", {
messageId: crypto.randomUUID(), // resend with the same id: stored once
page: { path: location.pathname, title: document.title },
});
} catch (err) {
if (err instanceof WebchatVisitorError) {
console.warn(err.code, err.status, err.field, err.message);
}
}
await chat.contact({ name: "Ravi", phone: "98765 43210" });
Live replies and typing (/visitor socket):
chat.connect({
onMessage: (message) => upsert(message), // `from`: visitor | ai | team | system
onTyping: ({ on, waiting }) => showTyping(on, waiting), // waiting: the team will reply
onStatus: ({ ids, status }) => markTicks(ids, status), // "read": the team opened the chat
onError: () => startPolling(), // live is down: poll messages()
onReconnect: () => {
stopPolling();
chat.messages().then(redraw); // nothing missed is replayed
},
});
chat.disconnect();
Ticks, as on WhatsApp. Each message has status (sent ✓, delivered ✓✓,
read ✓✓ seen): the visitor's own are delivered once stored and read once
the team opened the chat. Report the business's replies back, up to 50 ids a
call; ticks only move forward:
await chat.receipts(replyIds, "delivered"); // the chat got them
await chat.receipts(replyIds, "read"); // open and on screen
err.code | Meaning |
|---|---|
VALIDATION_ERROR | Bad input. err.field names it, err.message is visitor copy |
ORIGIN_NOT_ALLOWED | The site isn't on the workspace's allowed domains |
NOT_FOUND | Unknown client code |
RATE_LIMITED | Too many messages or connects. Wait a minute |
MALICIOUS_INPUT | The text was refused as a script or injection |
INTERNAL_ERROR | Server error. Try again |
NETWORK_ERROR | No answer (offline, 30 s timeout). Try again |
The live socket only accepts the ERIX widget frame's origin. A chat UI on your
own page works over HTTP when that site is the workspace's website (browser
CORS), and polls messages() for replies.
Scoped keys
For an integration that does one job (a booking form that fires a Flow event, say), create a scoped key under Settings → Developer → Scoped API keys. Scoped keys can be revoked, can expire and have usage tracking. Each one carries a single grant:
| Purpose | Grant | Works for |
|---|---|---|
| Trigger events | flow:events | product.erix.events.trigger() |
| Send WhatsApp | connect:whatsapp | chat.messages.send({ to }), sendTemplate() |
| Send email | connect:email | POST /infra/connect/send/email |
| Send Telegram | connect:telegram | POST /infra/connect/send/telegram |
Keys look like erix_<scope>_<secret>. Pass one as apiKey. The server
enforces its grant, so any call outside the grant fails with
AuthenticationError (HTTP 403). Over plain HTTP you can also send it as
Authorization: Bearer erix_<scope>_….
import { ECODrIxAPI } from "@ecodrix/erix-api";
const events = new ECODrIxAPI({
apiKey: process.env.ECOD_EVENTS_KEY!, // erix_flow_… with flow:events
clientCode: "ERIX_CLNT_XXXXXX",
});
await events.product.erix.events.trigger({ trigger: "booking_created", phone: "+91XXXXXXXXXX" });
CRM: leads and pipelines
// Create or update by phone. `refs` are your own IDs, kept on the lead.
await ecod.product.erix.leads.upsert({
leadData: { phone: "+91XXXXXXXXXX", firstName: "Test", source: "website" },
refs: { orderId: "ORD-1042" },
});
const { data: leads, pagination } = await ecod.product.erix.leads.list({
status: "open", // open | won | lost | archived
search: "test",
limit: 25,
});
await ecod.product.erix.leads.move("LEAD_ID", "STAGE_ID"); // pipeline column
await ecod.product.erix.leads.setFollowUp("LEAD_ID", "2026-10-20T15:00:00+05:30", "Share the quote");
await ecod.product.erix.leads.tags("LEAD_ID", { add: ["hot"], remove: ["cold"] });
await ecod.product.erix.leads.convert("LEAD_ID", "won", "Advance received");
await ecod.product.erix.leads.delete("LEAD_ID"); // archives; history stays
Other lookups: retrieve(id), retrieveByPhone(phone),
retrieveByRef(key, value), update(id, patch), notes(id),
createNote(id, { content }), activities(id), profile(id),
import(leads) and merge(primaryId, secondaryId).
Data protection (DPDP). Only owners and admins can do this:
const { data: everything } = await ecod.product.erix.leads.export("LEAD_ID"); // (3.2.0)
// Irreversible. Wipes personal data across CRM, inbox and automations.
// Invoices are kept for GST records.
const { data: result } = await ecod.product.erix.leads.erase("LEAD_ID"); // (3.2.0)
console.log(result.invoicesKept);
Pipelines:
const { data: pipeline } = await ecod.product.erix.pipelines.create({
name: "Admissions",
stages: ["Enquiry", "Visit booked", "Fee paid", "Admitted"],
});
const { data: pipelines } = await ecod.product.erix.pipelines.list();
const { data: board } = await ecod.product.erix.pipelines.board("PIPELINE_ID");
await ecod.product.erix.pipelines.addStage("PIPELINE_ID", { name: "Follow-up", color: "#f59e0b" });
// Moves the stage's leads to another stage of the same pipeline, then deletes it.
await ecod.product.erix.pipelines.deleteStage("STAGE_ID", "TARGET_STAGE_ID");
Flow: events and custom triggers
events.trigger() fires an event. Every active flow with a matching trigger
starts a run for that contact. You need phone or email.
const res = await ecod.product.erix.events.trigger({
trigger: "appointment_booked",
phone: "+91XXXXXXXXXX",
variables: { slot: "Mon 11:00", branch: "Indore" },
createLeadIfMissing: true,
leadData: { firstName: "Test", source: "website" },
delayMinutes: 30, // or delaySeconds, or runAt (ISO with offset). Use one.
idempotencyKey: "booking-8812", // a retry with the same key won't start flows twice
});
console.log(res.data?.idempotencyKey, res.data?.leadId);
createLeadIfMissing, the delays and runAt are released per workspace.
Without the release the server ignores them and says so in the response.
Define your own triggers so flows can start on them:
await ecod.product.erix.events.createCustomEvent({
name: "appointment_confirm", // lowercase letters, digits, _ . -
displayName: "Appointment confirmed",
});
const { data } = await ecod.product.erix.events.listCustomEvents(); // data.triggers
await ecod.product.erix.events.deleteCustomEvent("TRIGGER_ID");
Some runs stop at an approval step and wait for a person. You can list those and decide on them:
const { data } = await ecod.product.erix.ai.approvals(); // { drafts, runs }
for (const run of data.runs) {
// Pass run.nodeId (3.2.0) so a stale view can't decide a different step.
await ecod.product.erix.ai.decideRun(run.id, "approve", run.nodeId);
}
Channels: inbox and direct send
All channels share one inbox. Any thread can be answered with
chat.messages.send({ conversationId }).
const { data: threads } = await ecod.product.erix.chat.conversations.list({
channel: "whatsapp",
limit: 20,
});
const { data: messages, hasMore } = await ecod.product.erix.chat.conversations.messages(
"CONVERSATION_ID",
{ limit: 50 }, // page back with { before: oldestMessage.createdAt }
);
await ecod.product.erix.chat.messages.send({
conversationId: "CONVERSATION_ID",
text: "Your order is packed and ships today.",
});
await ecod.product.erix.chat.conversations.markRead("CONVERSATION_ID");
// Open (or find) a thread for a number and link it to a lead.
const { data: thread } = await ecod.product.erix.chat.conversations.create({
phone: "+91XXXXXXXXXX",
channel: "whatsapp",
});
await ecod.product.erix.chat.conversations.linkLead(thread.id, "LEAD_ID");
Send a file. Upload it to your storage first, then attach it:
const input = document.querySelector<HTMLInputElement>("#attachment")!;
const media = await ecod.product.erix.chat.messages.upload(input.files![0]);
await ecod.product.erix.chat.messages.send({
conversationId: "CONVERSATION_ID",
media,
text: "Invoice attached",
});
Direct WhatsApp send, without an inbox thread ID:
// An approved template. Use this to start a conversation.
await ecod.product.erix.chat.messages.sendTemplate({
to: "+91XXXXXXXXXX",
templateName: "order_update",
language: "en",
variables: ["ORD-1042", "₹1,499"],
});
// Free text. Only works inside WhatsApp's 24-hour customer service window.
await ecod.product.erix.chat.messages.send({ to: "+91XXXXXXXXXX", text: "Thanks, we got your enquiry." });
Manage templates with ecod.infra.connect.whatsapp.templates: list(),
sync() and retrieve(name).
Invoices and payment links
Amounts are in rupees. This invoice comes to ₹25,000 + 18% GST = ₹29,500:
const { data: invoice } = await ecod.product.erix.invoices.create({
clientName: "Test Customer",
clientPhone: "+91XXXXXXXXXX",
leadId: "LEAD_ID",
items: [{ description: "Website design", quantity: 1, rate: 25000, hsnSac: "998314" }],
taxRate: 18,
dueDate: "2026-11-15",
});
// A link on your connected gateway (Razorpay or Stripe). Calling again returns the same link.
const { data: link } = await ecod.product.erix.invoices.paymentLink(invoice.id); // (3.2.0)
console.log(link.paymentLinkUrl);
await ecod.product.erix.invoices.sendWhatsApp(invoice.id); // (3.2.0) with the payment link
await ecod.product.erix.invoices.sendEmail(invoice.id); // (3.2.0) PDF + payment link
Invoices can't be deleted, only voided (invoices.void(id)), so GST records
stay intact. Other calls: list({ status }), getInvoice(id) and
update(id, patch). Accounting exports live under
product.erix.invoiceExport: csv(), tally() and zoho().
A one-off payment link that isn't tied to an invoice, or a payment you took offline:
await ecod.infra.connect.connections.createPaymentLink({
amount: 1499, // ₹1,499
description: "Consultation fee",
customer: { phone: "+91XXXXXXXXXX" },
});
// Cash, UPI or bank transfer. With orderId, a retry is counted once.
await ecod.product.erix.payments.capture({ leadId: "LEAD_ID", amount: 5000, orderId: "UPI-2026-0042" });
Storage uploads
media.upload() gets a presigned URL, uploads straight to storage and then
confirms the upload, all in one call. The file bytes never pass through the
API, and your key never goes to the storage host.
import { readFile } from "node:fs/promises";
const buffer = await readFile("./brochure.pdf");
const { data: file } = await ecod.infra.storage.media.upload(buffer, {
folder: "documents",
filename: "brochure.pdf",
contentType: "application/pdf",
});
const { data: download } = await ecod.infra.storage.files.getDownloadUrl<{
data: { downloadUrl: string };
}>(file.key);
console.log(download.downloadUrl); // short-lived URL
The same call takes a browser File. Other calls: files.list(folder) and
files.delete(key).
Realtime events
Events stream over a socket that's scoped to your workspace. You only receive your own workspace's events.
import type { Message } from "@ecodrix/erix-api";
const onMessage = ({ conversationId, message }: { conversationId: string; message: Message }) => {
console.log(conversationId, message.direction, message.text);
};
ecod
.on("inbox.message", onMessage)
.on("message_status_update", ({ messageId, status }) => console.log(messageId, status))
.on("resync", () => {
// Reconnected after a gap too large to replay. Refetch your data.
});
// Later, e.g. in a React effect cleanup:
ecod.off("inbox.message", onMessage);
| Event | Payload / meaning |
|---|---|
inbox.message | { conversationId, message }. Every stored message: any channel, inbound or outbound |
message_status_update | { messageId, conversationId, status }. Sent, delivered, read or failed |
ai_suggestion / ai_handoff | An AI draft is waiting / the AI handed a chat to a person |
workflow-run-update | Flow run progress |
platform:notification | A new in-app notification |
knowledge.source_updated | A knowledge base source changed status |
resync | Missed events couldn't be replayed. Refetch |
Each event's last argument is { seq }. After a reconnect the SDK sends back
the last seq it saw and the server replays what was missed. A replay can
repeat an event, so write handlers that are safe to run twice (upsert by ID).
Webhook signature verification
Flow's webhook step sends JSON to your URL. If you set a secret on the
Webhook connection, the request is signed with it in
x-ecodrix-signature: sha256=<hex>. Verify the signature against the raw
body, before you parse it. Node only.
import { APIError } from "@ecodrix/erix-api";
import { ecod } from "./ecodrix"; // your server-side client from Quick start
export async function POST(req: Request) {
const raw = await req.text();
try {
const event = await ecod.webhooks.constructEvent(
raw,
req.headers.get("x-ecodrix-signature") ?? undefined,
process.env.ECOD_WEBHOOK_SECRET!,
);
console.log("verified", event);
return Response.json({ received: true });
} catch (err) {
// WebhookSignatureError: APIError with code "invalid_signature"
const message = err instanceof APIError ? err.message : "Bad request";
return new Response(message, { status: 400 });
}
}
Raw requests
Use ecod.request() for an endpoint that has no typed method yet. You still
get auth, retries and the timeout. Paths are relative to /v1/api:
await ecod.request("POST", "/infra/connect/send/email", {
to: "[email protected]",
subject: "Your booking is confirmed",
html: "<p>See you on Monday at 11:00.</p>",
});
Errors
Failed calls throw. The API answers with an envelope like
{ success: false, error, message, code? }, and the SDK turns it into one of
these classes:
| Class | When |
|---|---|
EcodrixError | Base class of everything below |
APIError | Any other failure. Has .status, .code (when sent) and .message |
AuthenticationError | HTTP 401 or 403: bad key or client code, missing scope, or a role that isn't allowed |
RateLimitError | HTTP 429, after the SDK's own retries run out |
import { APIError, AuthenticationError, RateLimitError } from "@ecodrix/erix-api";
try {
await ecod.product.erix.leads.retrieve("LEAD_ID");
} catch (err) {
if (err instanceof AuthenticationError) {
console.error("Check the key, client code, scope or role:", err.message);
} else if (err instanceof RateLimitError) {
console.warn("Rate limited, back off and retry later");
} else if (err instanceof APIError) {
console.error(`API error ${err.status ?? "network"}: ${err.message}`, err.code);
} else {
throw err;
}
}
ecod.request() throws a plain APIError for every status.
Retries and timeouts. The SDK retries up to 3 times with exponential
backoff. It retries network errors (any method), 5xx responses on idempotent
methods (GET, PUT, DELETE) and 429 responses. Each request times out after
30 s; change that with the timeout option. A timed-out request isn't retried.
A network error can also hit a POST whose response was lost, and the retry
then sends it a second time. Pass your own reference where a method takes one
(refs, orderId) so a duplicate is safe.
TypeScript
Methods return the response body, so pass a type argument to get it typed.
The SDK exports the main entity and parameter types (Lead,
CreateLeadParams, ListLeadsParams, Pipeline, PipelineStage,
Conversation, Message, SendMessageParams, PausedRun, AiSuggestion,
KnowledgeSource, PaginatedResponse and more).
import type { Lead, PaginatedResponse } from "@ecodrix/erix-api";
const page = await ecod.product.erix.leads.list<PaginatedResponse<Lead>>({ limit: 50 });
console.log(page.pagination.total, page.data[0]?.firstName);
// Page through every lead without handling pages yourself.
for await (const lead of ecod.product.erix.leads.listAutoPaging<Lead>({ status: "open" })) {
console.log(lead.phone);
}
Versioning
- The SDK follows semver on its own line (3.x). Breaking changes only land in a major release. See CHANGELOG.md.
- The API server has its own version numbers: platform v1.0 is server 1.0.0.
The SDK calls the
/v1routes, and theversionoption chooses the route version. - A method marked (3.2.0) needs SDK 3.2.0 or later.
Guides and every endpoint: https://api.ecodrix.com/docs.