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

<Note title="This is not an installable package">
  See [SDKs overview](/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](/docs/sending-data/events) and [identify](/docs/sending-data/identify) contracts. It
  compiles with `fetch`, which is available natively in Node 18+, browsers, and edge runtimes — no HTTP
  library dependency either.
</Note>

## Add the module

Create `retidal.ts` in your project:

```typescript title="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;
  }
}
```

## Usage

```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" });
```

<Warning title="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](/docs/sending-data/events#the-response-contract).
</Warning>

## Error handling

`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](/docs/errors) for the full status-code reference across every endpoint, not just
ingestion.

## Next steps

<CardGroup cols={2}>
  <Card title="Ingestion & Decisioning API reference" href="/ingestion">
    Every endpoint, generated request/response schemas, and multi-language examples.
  </Card>
  <Card title="Identify" href="/docs/sending-data/identify">
    Trait rules, PII handling, and why identify has to run before the event you want to trigger email from.
  </Card>
</CardGroup>
