Skip to content

TypeScript

A typed fetch wrapper around the real ingestion contract — no package to install, because none is published yet.

This is not an installable package

See SDKs overview — there’s no published npm package for Retidal today. Everything below is a small TypeScript module you add to your own project, built directly from the documented event-ingestion and identify contracts. It compiles with fetch, which is available natively in Node 18+, browsers, and edge runtimes — no HTTP library dependency either.

Create retidal.ts in your project:

retidal.ts
// Mirrors the documented IngestEvent / IngestAsyncResult / IngestSyncResult
// shapes returned by POST /api/v1/t (Ingestion & Decisioning API) — see
// /docs/sending-data/events.
export interface RetidalEvent {
eventName: string;
visitorId?: string;
userId?: string;
sessionId?: string;
properties?: Record<string, unknown>;
clientTimestamp?: string;
eventId?: string;
shortLinkId?: string;
shortLinkCode?: string;
clickIds?: Record<string, string>;
}
export interface IngestAsyncResult {
accepted: number;
failed: number;
queued: number;
processed: false;
invalid?: Array<{ index: number; rawSample: string; reason: string }>;
}
export interface IngestSyncResult {
accepted: number;
failed: number;
queued: number;
processed: true;
errors?: Array<{
index: number;
eventName: string;
errors: Array<{ field: string; rule: string; expected?: unknown; actual?: unknown; message: string }>;
}>;
enforceRejected?: number;
}
export interface RetidalIdentifyTraits {
email?: string | null;
phone?: string | null;
name?: string | null;
locale?: string | null;
timezone?: string | null;
[key: string]: unknown;
}
export interface RetidalClientConfig {
/** Ingestion base URL. Production: https://api.retidal.com */
apiUrl: string;
apiKey: string;
}
export class RetidalApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly body: unknown
) {
super(message);
this.name = "RetidalApiError";
}
}
export class RetidalClient {
constructor(private readonly config: RetidalClientConfig) {}
/** Fire-and-forget style: default async 202 path. Returns accepted/failed/queued counts. */
async track(event: RetidalEvent): Promise<IngestAsyncResult> {
return this.post<IngestAsyncResult>("/api/v1/t", event);
}
/** Batch variant of track() — up to 100 events per call. */
async trackBatch(events: RetidalEvent[]): Promise<IngestAsyncResult> {
if (events.length > 100) {
throw new Error("Retidal accepts at most 100 events per batch request.");
}
return this.post<IngestAsyncResult>("/api/v1/t", { events });
}
/**
* Synchronous verification path (?sync=1). `processed: true` is the only
* proof a non-metering event reached storage. Use for health checks and
* integration tests, not production traffic (it trades latency for that
* guarantee) — see /docs/sending-data/events.
*/
async trackSync(event: RetidalEvent): Promise<IngestSyncResult> {
return this.post<IngestSyncResult>("/api/v1/t?sync=1", event);
}
/** Link an anonymous visitor to a logged-in user. See /docs/sending-data/identify. */
async identify(
visitorId: string,
userId: string,
traits?: RetidalIdentifyTraits
): Promise<{ ok: true; dropped_keys: string[] }> {
return this.post("/api/v1/identify", { visitorId, userId, ...(traits ? { traits } : {}) });
}
private async post<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(`${this.config.apiUrl}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": this.config.apiKey,
},
body: JSON.stringify(body),
});
let data: unknown;
try {
data = await response.json();
} catch {
data = null;
}
// 200 (sync) and 202 (async) are both success. Everything else is an error,
// including 422 (enforce mode rejected every event) — see error handling below.
if (response.status !== 200 && response.status !== 202) {
const message =
(data as { error?: string } | null)?.error ?? `Retidal request failed with ${response.status}`;
throw new RetidalApiError(message, response.status, data);
}
return data as T;
}
}
typescript
import { RetidalClient } from "./retidal";
const retidal = new RetidalClient({
apiUrl: "https://api.retidal.com",
apiKey: process.env.RETIDAL_API_KEY!,
});
// Fire-and-forget event
const result = await retidal.track({
eventName: "user_paid",
visitorId: "vid_abc123",
userId: "uid_456",
properties: { amount: 9900, currency: "CNY" }, // amount is in minor units — 9900 = ¥99.00
});
console.log(result); // { accepted: 1, failed: 0, queued: 0, processed: false }
// Link the visitor to the logged-in user, with a trait that unlocks event-triggered email
await retidal.identify("vid_abc123", "uid_456", { email: "user@example.com" });

202 is not proof of persistence

track() returns as soon as the event passes validation and is dispatched to background processing — it does not prove the event reached storage. Use trackSync() only to verify a specific integration path works end to end; keep production traffic on track(). Full explanation in Sending events § The response contract.

post() throws a RetidalApiError (carrying status and the parsed body) for anything POST /api/v1/t returns that isn’t 200 or 202. Handle the documented status codes explicitly rather than treating every throw the same way:

typescript
import { RetidalApiError } from "./retidal";
// Status codes below are POST /api/v1/t's documented responses.
try {
await retidal.track({ eventName: "user_paid", visitorId: "vid_abc123" });
} catch (err) {
if (err instanceof RetidalApiError) {
switch (err.status) {
case 400:
// Malformed JSON, or batch over 100 events.
console.error("Bad request:", err.body);
break;
case 401:
// Missing or invalid X-API-Key.
console.error("Check your API key.");
break;
case 422:
// Enforce-mode validation rejected every event in the batch.
console.error("Validation rejected:", err.body);
break;
case 503: {
// Transient: SELECTOR_LOOKUP_FAILED or QUEUE_UNAVAILABLE — both carry retryAfter.
const retryAfter = (err.body as { retryAfter?: number })?.retryAfter ?? 30;
console.error(`Retidal temporarily unavailable, retry after ${retryAfter}s`);
break;
}
default:
throw err;
}
} else {
throw err;
}
}

See Errors & status codes for the full status-code reference across every endpoint, not just ingestion.