npm package: @radmas/mtx-external-api
Description: TypeScript SDK for the MTX ticketing platform API
Author: Radmas Technologies
Latest version: 0.1.1

# @mtx/api

> TypeScript SDK for the MTX ticketing platform API

[![npm version](https://img.shields.io/npm/v/@mtx/api.svg)](https://www.npmjs.com/package/@mtx/api)
[![license](https://img.shields.io/npm/l/@mtx/api.svg)](https://github.com/mtx/api/blob/main/LICENSE)

---

## Table of Contents

- [Installation](#installation)
- [Quick Start](#quick-start)
- [Usage Example](#usage-example)
- [Configuration](#configuration)
- [Authentication](#authentication)
- [Resources](#resources)
  - [Base](#base)
  - [Auth](#auth)
  - [User](#user)
  - [Jurisdictions](#jurisdictions)
  - [Jurisdiction Elements](#jurisdiction-elements)
  - [Services](#services)
  - [Typologies](#typologies)
  - [Location](#location)
  - [Requests](#requests)
  - [Request Interactions](#request-interactions)
  - [Additional Data Queries](#additional-data-queries)
- [Request Builder](#request-builder)
- [Async Iterator (Pagination)](#async-iterator-pagination)
- [Validation](#validation)
- [Error Handling](#error-handling)
- [File Uploads](#file-uploads)
- [Logging](#logging)
- [Schemas](#schemas)
- [Environment Variables](#environment-variables)
- [License](#license)

---

## Installation

```bash
npm install @mtx/api
```

The package ships as ESM with full TypeScript type declarations.

---

## Quick Start

```typescript
import { createMTXClient } from '@mtx/api';

// 1. Create the client
const mtx = createMTXClient({
  baseUrl: 'https://api.example-city.mtx.com',
  clientId: 'my-app-client-id',
});

// 2. Authenticate
const auth = await mtx.auth.login({
  username: 'citizen@example.com',
  password: 's3cret',
});
console.log('Logged in, token expires in', auth.expiresIn, 'seconds');

// 3. List services for a jurisdiction
const services = await mtx.services.list({
  jurisdictionCodes: ['city_example'],
});
console.log(`Found ${services.length} services`);

// 4. Create a request (issue report)
const created = await mtx.requests.create({
  serviceId: services[0].id!,
  jurisdictionCode: 'city_example',
  description: 'Pothole on Main Street',
  lat: 40.4168,
  lng: -3.7038,
  addressString: 'Main Street 42',
});
console.log('Request created:', created.serviceRequestId);
```

---

## Usage Example

End-to-end example: register a user, discover services, create a request with both the direct API and the fluent builder, then retrieve the created requests.

```typescript
import { createMTXClient } from '@mtx/api';

const JURISDICTION = 'your_jurisdiction_code';

// 1. Create the client
const mtx = createMTXClient({
  baseUrl: 'https://api.your-city.mtx.com',
  clientId: 'your-client-id',
});

// 2. Register a new user
await mtx.auth.register({
  username: 'user@example.com',
  password: 'SecurePass123!',
  firstName: 'Jane',
  lastName: 'Doe',
  legalTermsAccepted: true,
  jurisdictionCode: JURISDICTION,
});

// 3. Get jurisdiction detail for origin device and jurisdiction element
const jurisdiction = await mtx.jurisdictions.detail({
  jurisdictionCode: JURISDICTION,
});

const originDeviceId = jurisdiction.originDevices?.[0]?.id;
const jurisdictionElementId = jurisdiction.jurisdictionElements?.[0]?.id;

// 4. Find a service (e.g. street lighting)
const services = await mtx.services.list({
  jurisdictionCodes: [JURISDICTION],
});

const service = services.find(
  (s) => s.visibleName?.toLowerCase().includes('luminarias'),
);

// 5. Get service detail to discover required additional data questions
const serviceDetail = await mtx.services.detail({
  serviceId: service.id,
  jurisdictionCode: JURISDICTION,
});

// 6. Build additional data answers from the service's configurable questions
const additionalData = [];

for (const cq of serviceDetail.additionalData?.configurableQuestions ?? []) {
  const q = cq.question;
  if (!q?.id || q.type === 'label' || cq.omitInForm || !cq.required) continue;

  if (q.possibleAnswers?.length > 0) {
    additionalData.push({ question: q.id, value: q.possibleAnswers[0].value });
  } else {
    additionalData.push({ question: q.id, value: 'Your answer here' });
  }
}

// 7a. Create a request using the params object
const created = await mtx.requests.create({
  serviceId: service.id,
  jurisdictionCode: JURISDICTION,
  jurisdictionElementId,
  originDeviceId,
  description: 'Street light is off, has been broken for days.',
  addressString: 'Main Street, 64',
  lat: 40.4168,
  lng: -3.7038,
  firstName: 'Jane',
  lastName: 'Doe',
  phone: '+34600000000',
  public: true,
  additionalData,
});

console.log('Created:', created.serviceRequestId);

// 7b. Create a second request using the fluent builder
const builder = mtx.requests
  .build()
  .service(service.id)
  .jurisdiction(JURISDICTION)
  .jurisdictionElement(jurisdictionElementId)
  .originDeviceId(originDeviceId)
  .withServiceContext(serviceDetail)
  .description('Flickering street light near the school.')
  .address('Main Street, 80')
  .location(40.4170, -3.7035)
  .firstName('Jane')
  .lastName('Doe')
  .phone('+34600000000')
  .public(true);

// Add additional data answers to the builder
for (const ad of additionalData) {
  builder.additionalData(ad.question, ad.value);
}

// Validate before submitting
const validation = builder.isValid();
if (!validation.valid) {
  console.error('Validation errors:', validation.errors);
  process.exit(1);
}

const created2 = await builder.submit();
console.log('Created via builder:', created2.serviceRequestId);

// 8. Retrieve both requests
const request1 = await mtx.requests.detailByServiceRequestId({
  jurisdictionCode: JURISDICTION,
  serviceRequestId: created.serviceRequestId,
});
const request2 = await mtx.requests.detailByServiceRequestId({
  jurisdictionCode: JURISDICTION,
  serviceRequestId: created2.serviceRequestId,
});

for (const req of [request1, request2]) {
  console.log(`${req.serviceRequestId} — ${req.statusNode?.visibleName}`);
  console.log(`  ${req.description}`);
  console.log(`  ${req.addressString} (${req.lat}, ${req.lng})`);
}
```

---

## Configuration

Create a client with `createMTXClient(config?)`. Every option can be supplied directly or via an environment variable fallback.

| Option | Type | Default | Env Variable | Description |
|---|---|---|---|---|
| `baseUrl` | `string` | -- (required) | `MTX_BASE_URL` | Base URL of the MTX API instance |
| `clientId` | `string` | -- (required) | `MTX_CLIENT_ID` | OAuth client identifier for your application |
| `jwtSecret` | `string` | `undefined` | `MTX_JWT_SECRET` | Base64-encoded HMAC secret for JWT / SSO login |
| `geocoderSuffix` | `string` | `undefined` | -- | String appended to addresses in `location.resolve()` (e.g. `", Spain"`) |
| `logger` | `MtxLogger` | `defaultLogger` (console) | -- | Custom logger implementation |
| `onLegalTermsRequired` | `() => void \| Promise<void>` | `undefined` | -- | Callback invoked when the API returns HTTP 451 (legal terms not accepted) |

```typescript
import { createMTXClient } from '@mtx/api';

const mtx = createMTXClient({
  baseUrl: 'https://api.city.mtx.com',
  clientId: 'my-client',
  jwtSecret: 'bXktc2VjcmV0',          // only needed for loginJwt
  geocoderSuffix: ', Barcelona, Spain', // appended to addresses
  onLegalTermsRequired: () => {
    // redirect to terms acceptance screen
  },
});
```

### Environment Variable Fallback

When an option is not provided in the config object, the SDK reads from `process.env`:

```bash
export MTX_BASE_URL=https://api.city.mtx.com
export MTX_CLIENT_ID=my-client
export MTX_JWT_SECRET=bXktc2VjcmV0
```

```typescript
// No config needed if env vars are set
const mtx = createMTXClient();
```

---

## Authentication

All authentication methods return an `Authentication` object and automatically store the tokens internally. Subsequent API calls use the stored access token.

```typescript
interface Authentication {
  accessToken: string;
  refreshToken: string;
  expiresIn: number;
  tokenType: string;
}
```

### Login with Username / Password

```typescript
const auth = await mtx.auth.login({
  username: 'citizen@example.com',
  password: 's3cret',
});
```

| Parameter | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | User's email or username |
| `password` | `string` | Yes | User's password |

### Anonymous Login

```typescript
const auth = await mtx.auth.loginAnonymous({
  deviceId: 'device-uuid-1234',
});
```

| Parameter | Type | Required | Description |
|---|---|---|---|
| `deviceId` | `string` | Yes | Unique device identifier |

### JWT Login (SSO)

Requires `jwtSecret` in the client config. The SDK signs a JWT internally using HS256.

```typescript
const auth = await mtx.auth.loginJwt({
  email: 'sso-user@corp.com',
  firstName: 'Jane',
  lastName: 'Doe',
  register: true,
  legalTermsAccepted: true,
});
```

| Parameter | Type | Required | Description |
|---|---|---|---|
| `email` | `string` | Yes | User's email (validated against email regex) |
| `firstName` | `string` | No | First name to set on the account |
| `lastName` | `string` | No | Last name to set on the account |
| `register` | `boolean` | No | If `true`, creates the account if it does not exist |
| `legalTermsAccepted` | `boolean` | No | If `true`, marks legal terms as accepted |

### Token Refresh

Manually refresh a session using a refresh token:

```typescript
const auth = await mtx.auth.loginRefresh({
  refreshToken: 'existing-refresh-token',
});
```

| Parameter | Type | Required | Description |
|---|---|---|---|
| `refreshToken` | `string` | Yes | A previously obtained refresh token |

### Registration

Register a new user and authenticate in one step:

```typescript
const auth = await mtx.auth.register({
  username: 'newuser@example.com',
  password: 'str0ngPa$$',
  firstName: 'John',
  lastName: 'Smith',
  legalTermsAccepted: true,
  jurisdictionCode: 'city_example', // optional
});
```

| Parameter | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Email or username for the new account |
| `password` | `string` | Yes | Password for the new account |
| `firstName` | `string` | Yes | First name |
| `lastName` | `string` | Yes | Last name |
| `legalTermsAccepted` | `boolean` | Yes | Whether legal terms are accepted |
| `jurisdictionCode` | `string` | No | Default jurisdiction for the user |

### Auto-Refresh

The SDK automatically refreshes expired tokens. When any API call is made:

1. The `AuthManager` checks if the current access token will expire within the next 30 seconds.
2. If so, it transparently calls the refresh endpoint using the stored refresh token.
3. Concurrent requests share the same refresh promise (no duplicate refreshes).
4. If the refresh fails, tokens are cleared and the request proceeds without authentication.

No manual intervention is needed after the initial login.

### Logout

Close the server-side session and clear all local tokens:

```typescript
await mtx.auth.logout();
```

| Parameter | Type | Required | Description |
|---|---|---|---|
| `appKey` | `number` | No | Application identifier (defaults to `2`) |
| `deviceId` | `string` | No | Device identifier for browser-specific sessions |

After logout, subsequent authenticated calls will fail until a new login is performed.

### Password Reset

Request a password reset email for a user account. Public endpoint — no authentication required.

```typescript
await mtx.auth.resetPassword({ username: 'user@example.com' });
```

| Parameter | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Email or username |
| `jurisdictionCode` | `string` | No | Jurisdiction ID |
| `appKey` | `string` | No | Application key |

### Password Recovery

Initiate password recovery. Public endpoint — no authentication required.

```typescript
await mtx.auth.recoverPassword({ username: 'user@example.com' });
```

| Parameter | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Email or username |
| `jurisdictionCode` | `string` | No | Jurisdiction ID |
| `appKey` | `string` | No | Application key |

### Token Override Per-Request

Every resource method accepts an optional `RequestOptions` object. Pass `accessToken` to override the stored token for a single call:

```typescript
const profile = await mtx.user.profile({
  accessToken: 'another-users-token',
});
```

All resource methods also accept an `AbortSignal`:

```typescript
const controller = new AbortController();
const services = await mtx.services.list(
  { jurisdictionCodes: ['city_example'] },
  { signal: controller.signal },
);
```

```typescript
interface RequestOptions {
  accessToken?: string;
  signal?: AbortSignal;
}
```

---

## Resources

### Base

Health check and OpenAPI spec retrieval.

#### `base.status(options?)`

Checks the operational status of the API.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `options` | `RequestOptions` | No | Optional access token / abort signal |

**Returns:** `Promise<boolean>` -- `true` if the platform is reachable.

```typescript
const isUp = await mtx.base.status();
console.log('API is up:', isUp); // true
```

#### `base.openapi(options?)`

Retrieves the OpenAPI specification.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `options` | `RequestOptions` | No | Optional access token / abort signal |

**Returns:** `Promise<string>` -- The raw OpenAPI spec as a string.

```typescript
const spec = await mtx.base.openapi();
```

---

### Auth

See [Authentication](#authentication) above for full details on all auth methods.

| Method | Parameters | Returns |
|---|---|---|
| `auth.login({ username, password })` | `{ username: string; password: string }` | `Promise<Authentication>` |
| `auth.loginAnonymous({ deviceId })` | `{ deviceId: string }` | `Promise<Authentication>` |
| `auth.loginJwt({ email, firstName?, lastName?, register?, legalTermsAccepted? })` | See table above | `Promise<Authentication>` |
| `auth.loginRefresh({ refreshToken })` | `{ refreshToken: string }` | `Promise<Authentication>` |
| `auth.register({ username, password, firstName, lastName, legalTermsAccepted, jurisdictionCode? })` | See table above | `Promise<Authentication>` |
| `auth.logout({ appKey?, deviceId? })` | `{ appKey?: number; deviceId?: string }` | `Promise<void>` |

---

### User

Manage the current user's profile and legal terms.

#### `user.profile(options?)`

Retrieves the authenticated user's profile.

**Returns:** `Promise<Profile>`

```typescript
interface Profile {
  id?: string | null;
  email?: string | null;
  username?: string | null;
  firstName?: string | null;
  lastName?: string | null;
  twitterNickname?: string | null;
  anonymous?: boolean | null;
  phone?: string | null;
  gender?: 'male' | 'female' | 'other' | 'not_specified' | null;
  birthday?: string | null;
}
```

```typescript
const profile = await mtx.user.profile();
console.log(`Hello, ${profile.firstName}`);
```

#### `user.updateProfile(params, options?)`

Updates the authenticated user's profile. Only provided fields are changed.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `email` | `string` | No | New email address |
| `username` | `string` | No | New username |
| `firstName` | `string` | No | New first name |
| `lastName` | `string` | No | New last name |
| `twitterNickname` | `string` | No | Twitter handle |
| `phone` | `string` | No | Phone number |
| `gender` | `'male' \| 'female' \| 'other' \| 'not_specified'` | No | Gender |
| `birthday` | `string` | No | Birthday (date string) |

**Returns:** `Promise<Profile>`

```typescript
const updated = await mtx.user.updateProfile({
  firstName: 'Jane',
  phone: '+34600000000',
});
```

#### `user.acceptTerms(options?)`

Accepts the legal terms for the current user. Requires authentication.

**Returns:** `Promise<boolean>` -- `true` on success.

```typescript
await mtx.user.acceptTerms();
```

#### `user.legalTerms(options?)`

Retrieves the current legal terms and conditions.

**Returns:** `Promise<LegalTerms>`

```typescript
interface LegalTerms {
  legalText?: LegalText | null;
}

interface LegalText {
  privacyPolicy?: string | null;
  termsOfUse?: string | null;
  cookiesPolicy?: string | null;
}
```

```typescript
const terms = await mtx.user.legalTerms();
console.log(terms.legalText?.termsOfUse);
```

#### `user.legalTermsByJurisdiction(params, options?)`

Retrieves legal text URLs configured for a specific jurisdiction.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `jurisdictionCode` | `string` | Yes | Functional ID of the jurisdiction |
| `type` | `string` | Yes | Application type (e.g., `'citizen'`) |

**Returns:** `Promise<LegalTerms>` — same shape as `legalTerms()`, but values are URLs.

```typescript
const terms = await mtx.user.legalTermsByJurisdiction({
  jurisdictionCode: 'madrid',
  type: 'citizen',
});
console.log(terms.legalText?.privacyPolicy); // URL to privacy policy
```

#### `user.legalText(params, options?)`

Returns the raw HTML content of a legal document.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `legalTermsId` | `string` | Yes | Legal terms document ID |
| `legalPolicyType` | `'privacy_policy' \| 'terms_of_use' \| 'accessibility' \| 'cookies_policy'` | Yes | Type of legal document |

**Returns:** `Promise<string>` — raw HTML content.

```typescript
const html = await mtx.user.legalText({
  legalTermsId: '66ed366137c0c32c020b54ea',
  legalPolicyType: 'privacy_policy',
});
```

#### `user.updateAvatar(params, options?)`

Uploads an avatar image for the current user. Requires authentication. Accepts any `FileParam` (File, Blob, Buffer, file path, etc.).

| Parameter | Type | Required | Description |
|---|---|---|---|
| `file` | `FileParam` | Yes | Image file to upload |

**Returns:** `Promise<Profile>` — updated profile with new avatar URL.

```typescript
const updated = await mtx.user.updateAvatar({
  file: new Blob([imageData], { type: 'image/png' }),
});
```

#### `user.updateRegisteredAddress(options?)`

Triggers sync of the user's registered address with jurisdiction webhooks. Requires authentication.

**Returns:** `Promise<RegisteredAddress>`

```typescript
interface RegisteredAddress {
  registeredAddress?: {
    street?: string | null;
    number?: string | null;
    city?: string | null;
    postalCode?: string | null;
    country?: string | null;
  } | null;
  jurisdiction?: string | null;
  webhookId?: string | null;
  processedAt?: string | null;
}
```

```typescript
const result = await mtx.user.updateRegisteredAddress();
console.log(result.registeredAddress?.city);
```

#### `user.sendDeleteConfirmationMail(params, options?)`

Sends a confirmation email with a deletion link. Public endpoint — no authentication required.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `email` | `string` | Yes | User's email address |
| `jurisdictionCode` | `string` | Yes | Jurisdiction ID |

**Returns:** `Promise<boolean>` — `true` on success.

```typescript
await mtx.user.sendDeleteConfirmationMail({
  email: 'user@example.com',
  jurisdictionCode: 'madrid',
});
```

#### `user.deleteUser(params, options?)`

Deletes and anonymizes a user account using the confirmation token from the email. Public endpoint.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `token` | `string` | Yes | Confirmation token from delete email |

**Returns:** `Promise<boolean>` — `true` on success.

```typescript
await mtx.user.deleteUser({ token: 'confirm-tok-from-email' });
```

---

### Jurisdictions

#### `jurisdictions.list(params?, options?)`

Retrieves all jurisdictions, optionally filtered by geographic coordinates.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `lat` | `number` | No | Latitude to filter by proximity |
| `lng` | `number` | No | Longitude to filter by proximity |

**Returns:** `Promise<Jurisdiction[]>`

```typescript
const all = await mtx.jurisdictions.list();

// Filter by coordinates
const nearby = await mtx.jurisdictions.list({
  lat: 41.3851,
  lng: 2.1734,
});
```

#### `jurisdictions.detail(params, options?)`

Retrieves detailed information for a specific jurisdiction.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `jurisdictionCode` | `string` | Yes | The unique jurisdiction code |

**Returns:** `Promise<Jurisdiction>`

```typescript
const jurisdiction = await mtx.jurisdictions.detail({
  jurisdictionCode: 'city_barcelona',
});
console.log(jurisdiction);
```

---

### Jurisdiction Elements

Jurisdiction elements represent subdivisions within a jurisdiction (districts, neighborhoods, etc.).

#### `jurisdictionElements.list(params, options?)`

Retrieves jurisdiction elements for a given jurisdiction.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `jurisdictionCode` | `string` | Yes | The jurisdiction code to list elements for |

**Returns:** `Promise<JurisdictionElement[]>`

```typescript
const elements = await mtx.jurisdictionElements.list({
  jurisdictionCode: 'city_barcelona',
});
console.log(`${elements.length} districts found`);
```

---

### Services

Services represent the types of issues citizens can report (e.g., potholes, broken streetlights).

#### `services.list(params, options?)`

Lists services available within one or more jurisdictions.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `jurisdictionCodes` | `string[]` | Yes | One or more jurisdiction codes |
| `lat` | `number` | No | Latitude for location-based filtering |
| `lng` | `number` | No | Longitude for location-based filtering |
| `typologyIds` | `string[]` | No | Filter by typology IDs |

**Returns:** `Promise<Service[]>`

```typescript
const services = await mtx.services.list({
  jurisdictionCodes: ['city_barcelona'],
  typologyIds: ['typ_infrastructure'],
});
```

#### `services.detail(params, options?)`

Retrieves detailed information for a specific service, including its additional data questions and mandatory informant configuration.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `serviceId` | `string` | Yes | The service ID |
| `jurisdictionCode` | `string` | Yes | The jurisdiction code |

**Returns:** `Promise<Service>`

```typescript
const service = await mtx.services.detail({
  serviceId: 'svc_pothole',
  jurisdictionCode: 'city_barcelona',
});

// Inspect required fields
console.log('Has location:', service.typology?.hasLocation);
console.log('Needs description:', service.typology?.withDescription);
console.log('Additional questions:', service.additionalData?.configurableQuestions?.length);
```

#### `services.validatePosition(params, options?)`

Checks if a given lat/lng is valid for a specific service area. Useful before creating a request to ensure the location is within the service boundary. Requires authentication.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `serviceId` | `string` | Yes | The service ID |
| `jurisdictionElementId` | `string` | Yes | Jurisdiction element ID |
| `lat` | `number` | Yes | Latitude |
| `lng` | `number` | Yes | Longitude |
| `level` | `number` | No | Jurisdiction element depth level |
| `projection` | `number` | No | Spatial reference system SRID (default: 4326) |

**Returns:** `Promise<ValidatePositionResult>`

```typescript
interface ValidatePositionResult {
  valid: boolean;
}
```

```typescript
const result = await mtx.services.validatePosition({
  serviceId: 'svc_pothole',
  jurisdictionElementId: 'je_downtown',
  lat: 41.3851,
  lng: 2.1734,
});

if (result.valid) {
  // proceed to create request
}
```

---

### Typologies

Typologies categorize services into groups (e.g., infrastructure, environment, mobility).

#### `typologies.list(params, options?)`

Retrieves typologies for one or more jurisdictions.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `jurisdictionCodes` | `string[]` | Yes | One or more jurisdiction codes |
| `jurisdictionElementId` | `string` | No | Filter by jurisdiction element |
| `typologyIds` | `string[]` | No | Filter by specific typology IDs |
| `lat` | `number` | No | Latitude for location-based filtering |
| `lng` | `number` | No | Longitude for location-based filtering |
| `page` | `number` | No | Page number for pagination |
| `limit` | `number` | No | Number of results per page |

**Returns:** `Promise<Typology[]>`

```typescript
const typologies = await mtx.typologies.list({
  jurisdictionCodes: ['city_barcelona'],
  lat: 41.3851,
  lng: 2.1734,
});

for (const t of typologies) {
  console.log(t.name, t.id);
}
```

---

### Location

Resolve location-specific additional data for a jurisdiction element (geocoding integration).

#### `location.resolve(params, options?)`

Retrieves location additional data. If `geocoderSuffix` was set in the client config and `formattedAddress` is provided, the suffix is appended automatically.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `jurisdictionElementId` | `string` | Yes | The jurisdiction element ID |
| `formattedAddress` | `string` | No | A formatted street address |
| `lat` | `number` | No | Latitude |
| `lng` | `number` | No | Longitude |

**Returns:** `Promise<LocationAdditionalData[]>`

```typescript
const locationData = await mtx.location.resolve({
  jurisdictionElementId: 'je_eixample',
  formattedAddress: 'Carrer de Mallorca 401',
  lat: 41.3995,
  lng: 2.1748,
});
```

---

### Requests

Create, list, detail, and annotate citizen issue reports.

#### `requests.create(params, options?)`

Creates a new request (issue report) within a jurisdiction.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `serviceId` | `string` | Yes | The service ID for this request |
| `jurisdictionCode` | `string` | Yes | The jurisdiction code |
| `jurisdictionElementId` | `string` | No | Jurisdiction element (district/neighborhood) |
| `originDeviceId` | `string` | No | Device type identifier |
| `description` | `string` | No | Description of the issue |
| `public` | `boolean` | No | Whether the request is publicly visible |
| `lat` | `number` | No | Latitude of the issue location |
| `lng` | `number` | No | Longitude of the issue location |
| `addressString` | `string` | No | Street address of the issue |
| `email` | `string` | No | Reporter's email |
| `firstName` | `string` | No | Reporter's first name |
| `lastName` | `string` | No | Reporter's last name |
| `phone` | `string` | No | Reporter's phone number |
| `twitterNickname` | `string` | No | Reporter's Twitter handle |
| `additionalData` | `AdditionalDataInput[]` | No | Answers to the service's additional data questions |

**Returns:** `Promise<CreatedRequest>`

```typescript
const created = await mtx.requests.create({
  serviceId: 'svc_pothole',
  jurisdictionCode: 'city_barcelona',
  description: 'Large pothole near bus stop',
  lat: 41.3851,
  lng: 2.1734,
  addressString: 'Carrer de Mallorca 401',
  email: 'citizen@example.com',
  firstName: 'Maria',
  lastName: 'Garcia',
  additionalData: [
    { question: 'q_severity', value: 'high' },
  ],
});
console.log('Created request:', created.serviceRequestId);
```

#### `requests.list(params?, options?)`

Lists requests with filters and pagination.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `jurisdictionCodes` | `string[]` | No | Filter by jurisdiction codes |
| `serviceRequestIds` | `string[]` | No | Filter by specific request IDs |
| `serviceIds` | `string[]` | No | Filter by service IDs |
| `startDate` | `string` | No | Start date filter |
| `endDate` | `string` | No | End date filter |
| `own` | `boolean` | No | Only show the authenticated user's requests |
| `lat` | `number` | No | Latitude for proximity search |
| `lng` | `number` | No | Longitude for proximity search |
| `page` | `number` | No | Page number (default: 1) |
| `limit` | `number` | No | Results per page (default: 20) |
| `addressAndServiceRequestId` | `string` | No | Free-text search on address and request ID |
| `status` | `string[]` | No | Filter by status codes |
| `typologyIds` | `string[]` | No | Filter by typology IDs |
| `distance` | `number` | No | Max distance in meters from lat/lng |
| `following` | `boolean` | No | Only show followed requests |
| `order` | `string` | No | Sort order |
| `complaints` | `boolean` | No | Filter by complaints |
| `reiterations` | `boolean` | No | Filter by reiterations |
| `userReiterated` | `boolean` | No | Filter requests the user reiterated |
| `userComplaint` | `boolean` | No | Filter requests the user complained about |
| `jurisdictionElementIds` | `string[]` | No | Filter by jurisdiction element IDs |
| `level` | `number` | No | Filter by level |
| `polygon` | `number[]` | No | Polygon coordinates for geographic filtering |
| `finalOk` | `boolean` | No | Filter by final OK status |
| `finalNotOk` | `boolean` | No | Filter by final Not OK status |
| `finalStatus` | `boolean` | No | Filter by final status |
| `interested` | `boolean` | No | Filter by interested flag |
| `timezone` | `string` | No | Timezone for date filters |

**Returns:** `Promise<Request[]>`

```typescript
const requests = await mtx.requests.list({
  jurisdictionCodes: ['city_barcelona'],
  own: true,
  page: 1,
  limit: 20,
  status: ['open', 'in_progress'],
});
```

#### `requests.detail(params, options?)`

Retrieves detailed information about a specific request.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID |

**Returns:** `Promise<Request>`

```typescript
const request = await mtx.requests.detail({
  requestId: 'req_abc123',
});
console.log(request.status, request.description);
```

#### `requests.attachMedia(params, options?)`

Attaches a media file (photo/video) to an existing request.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |
| `jurisdictionCode` | `string` | Yes | The jurisdiction code |
| `file` | `[string, Blob]` | Yes | Tuple of `[filename, blob]` |

**Returns:** `Promise<RequestMedia>`

```typescript
const media = await mtx.requests.attachMedia({
  requestId: 'req_abc123',
  jurisdictionCode: 'city_barcelona',
  file: ['photo.jpg', imageBlob],
});
```

#### `requests.attachComment(params, options?)`

Attaches a comment (with optional media files) to an existing request.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |
| `comment` | `string` | No | Comment text |
| `files` | `[string, Blob][]` | No | Array of `[filename, blob]` tuples |

**Returns:** `Promise<RequestComment>`

```typescript
const comment = await mtx.requests.attachComment({
  requestId: 'req_abc123',
  comment: 'The pothole has gotten larger after the rain',
  files: [
    ['update1.jpg', photoBlob1],
    ['update2.jpg', photoBlob2],
  ],
});
```

#### `requests.listComments(params, options?)`

Lists all comments attached to a specific request.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |
| `jurisdictionCode` | `string` | No | Filter by jurisdiction code |

**Returns:** `Promise<RequestComment[]>`

```typescript
const comments = await mtx.requests.listComments({
  requestId: 'req_abc123',
});
for (const c of comments) {
  console.log(c.description, c.createdDatetime);
}
```

#### `requests.listFiles(params, options?)`

Lists all files attached to a specific request.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |

**Returns:** `Promise<RequestFile[]>`

```typescript
const files = await mtx.requests.listFiles({
  requestId: 'req_abc123',
});
for (const f of files) {
  console.log(f.url, f.type);
}
```

#### `requests.uploadFile(params, options?)`

Uploads a file to an existing request.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |
| `file` | `FileParam` | Yes | The file to upload |
| `public` | `boolean` | No | Whether the file is publicly visible |

**Returns:** `Promise<RequestFile>`

```typescript
const uploaded = await mtx.requests.uploadFile({
  requestId: 'req_abc123',
  file: new Blob([data], { type: 'application/pdf' }),
  public: true,
});
console.log('Uploaded:', uploaded.url);
```

#### `requests.editFile(params, options?)`

Edits a file's visibility on an existing request.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |
| `fileId` | `string` | Yes | The file ID to edit |
| `public` | `boolean` | Yes | New visibility setting |

**Returns:** `Promise<RequestFile>`

```typescript
const updated = await mtx.requests.editFile({
  requestId: 'req_abc123',
  fileId: 'file_xyz',
  public: false,
});
```

#### `requests.deleteFile(params, options?)`

Deletes a file from an existing request.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |
| `fileId` | `string` | Yes | The file ID to delete |

**Returns:** `Promise<boolean>` -- `true` on success.

```typescript
await mtx.requests.deleteFile({
  requestId: 'req_abc123',
  fileId: 'file_xyz',
});
```

#### `requests.listByCoordinates(params, options?)`

Lists requests near given geographic coordinates.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `jurisdictionCodes` | `string[]` | No | Filter by jurisdiction codes |
| `lat` | `number` | Yes | Latitude |
| `lng` | `number` | Yes | Longitude |
| `limit` | `number` | No | Maximum number of results |
| `own` | `boolean` | No | Only show the authenticated user's requests |

**Returns:** `Promise<RequestCoordinate[]>`

```typescript
const coords = await mtx.requests.listByCoordinates({
  jurisdictionCodes: ['city_barcelona'],
  lat: 41.3851,
  lng: 2.1734,
  limit: 20,
});
for (const c of coords) {
  console.log(c.token, c.lat, c.lng, c.addressString);
}
```

#### `requests.detailByServiceRequestId(params, options?)`

Retrieves a request using its jurisdiction code and service request ID (human-readable ID).

| Parameter | Type | Required | Description |
|---|---|---|---|
| `jurisdictionCode` | `string` | Yes | The jurisdiction code |
| `serviceRequestId` | `string` | Yes | The service request ID |

**Returns:** `Promise<Request>`

```typescript
const request = await mtx.requests.detailByServiceRequestId({
  jurisdictionCode: 'city_barcelona',
  serviceRequestId: 'SR-2026-001234',
});
console.log(request.description);
```

#### `requests.detectDuplicate(params, options?)`

Checks if a potential duplicate request exists near the given coordinates for a service. Returns `null` if no duplicate is found.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `lat` | `number` | Yes | Latitude |
| `lng` | `number` | Yes | Longitude |
| `serviceId` | `string` | Yes | The service ID to check |

**Returns:** `Promise<Request | null>`

```typescript
const duplicate = await mtx.requests.detectDuplicate({
  lat: 41.3851,
  lng: 2.1734,
  serviceId: 'svc_pothole',
});
if (duplicate) {
  console.log('Possible duplicate:', duplicate.serviceRequestId);
}
```

#### `requests.listRelated(params, options?)`

Lists related requests in the same family (parent/child relationships).

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |

**Returns:** `Promise<Request[]>`

```typescript
const related = await mtx.requests.listRelated({
  requestId: 'req_abc123',
});
for (const r of related) {
  console.log(r.serviceRequestId, r.description);
}
```

#### `requests.validate(params, service)`

Validates request parameters against a service's configuration. See [Validation](#validation) for details.

#### `requests.listAll(params?, options?)`

Async generator for automatic pagination. See [Async Iterator](#async-iterator-pagination).

### Request Interactions

Methods for following, supporting, complaining about, evaluating, and reiterating requests.

#### Follow / Unfollow

##### `requests.follow(params, options?)`

Follows a request to receive notifications about updates.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `jurisdictionCode` | `string` | Yes | The jurisdiction code |
| `serviceRequestId` | `string` | Yes | The service request ID |
| `emailNotification` | `boolean` | No | Receive email notifications |
| `pushNotification` | `boolean` | No | Receive push notifications |
| `smsNotification` | `boolean` | No | Receive SMS notifications |

**Returns:** `Promise<Request>`

```typescript
const request = await mtx.requests.follow({
  jurisdictionCode: 'city_barcelona',
  serviceRequestId: 'SR-2026-001234',
  emailNotification: true,
  pushNotification: true,
});
```

##### `requests.listFollowed(params?, options?)`

Lists all requests the authenticated user is following.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `appKey` | `number` | No | Application key filter |

**Returns:** `Promise<Request[]>`

```typescript
const followed = await mtx.requests.listFollowed();
for (const r of followed) {
  console.log(r.serviceRequestId, r.description);
}
```

##### `requests.unfollow(params, options?)`

Unfollows a request to stop receiving notifications.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `jurisdictionCode` | `string` | Yes | The jurisdiction code |
| `serviceRequestId` | `string` | Yes | The service request ID |

**Returns:** `Promise<Request>`

```typescript
await mtx.requests.unfollow({
  jurisdictionCode: 'city_barcelona',
  serviceRequestId: 'SR-2026-001234',
});
```

#### Support

##### `requests.support(params, options?)`

Supports (upvotes) a request to signal agreement.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `jurisdictionCode` | `string` | Yes | The jurisdiction code |
| `serviceRequestId` | `string` | Yes | The service request ID |

**Returns:** `Promise<Request>`

```typescript
const request = await mtx.requests.support({
  jurisdictionCode: 'city_barcelona',
  serviceRequestId: 'SR-2026-001234',
});
console.log('Supporting count:', request.supportingCount);
```

#### Complaints

##### `requests.complain(params, options?)`

Files a complaint on a request, with optional file attachments.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |
| `description` | `string` | No | Complaint description |
| `email` | `string` | No | Complainant's email |
| `firstName` | `string` | No | Complainant's first name |
| `lastName` | `string` | No | Complainant's last name |
| `followRequest` | `boolean` | No | Follow the request after complaining |
| `emailNotification` | `boolean` | No | Receive email notifications |
| `pushNotification` | `boolean` | No | Receive push notifications |
| `smsNotification` | `boolean` | No | Receive SMS notifications |
| `files` | `FileParam[]` | No | File attachments |

**Returns:** `Promise<Complaint>`

```typescript
const complaint = await mtx.requests.complain({
  requestId: 'req_abc123',
  description: 'The issue has not been resolved',
  followRequest: true,
  emailNotification: true,
  files: [new Blob([photoData], { type: 'image/jpeg' })],
});
console.log('Complaint filed:', complaint.id);
```

##### `requests.listComplaints(params, options?)`

Lists complaints filed on a specific request.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |

**Returns:** `Promise<ComplaintSummary[]>`

```typescript
const complaints = await mtx.requests.listComplaints({
  requestId: 'req_abc123',
});
for (const c of complaints) {
  console.log(c.id, c.datetime, c.originCreate);
}
```

#### Evaluations

##### `requests.evaluate(params, options?)`

Submits an evaluation (rating) for a request.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |
| `evaluation` | `number` | Yes | Rating value (1-5) |

**Returns:** `Promise<Evaluation>`

```typescript
const evaluation = await mtx.requests.evaluate({
  requestId: 'req_abc123',
  evaluation: 5,
});
console.log('Evaluation submitted:', evaluation.id);
```

##### `requests.listEvaluations(params, options?)`

Lists evaluations for a specific request.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |

**Returns:** `Promise<Evaluation[]>`

```typescript
const evaluations = await mtx.requests.listEvaluations({
  requestId: 'req_abc123',
});
for (const e of evaluations) {
  console.log('Rating:', e.evaluation, 'by', e.user?.nickname);
}
```

#### Reiterations

##### `requests.reiterate(params, options?)`

Reiterates (re-reports) a request to escalate it.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |
| `description` | `string` | No | Additional description |
| `emailNotification` | `boolean` | No | Receive email notifications |
| `pushNotification` | `boolean` | No | Receive push notifications |
| `smsNotification` | `boolean` | No | Receive SMS notifications |
| `followRequest` | `boolean` | No | Follow the request after reiterating |
| `source` | `string` | No | Source identifier |

**Returns:** `Promise<Reiteration>`

```typescript
const reiteration = await mtx.requests.reiterate({
  requestId: 'req_abc123',
  description: 'This issue is still unresolved after 2 weeks',
  followRequest: true,
});
console.log('Reiteration submitted:', reiteration.id);
```

##### `requests.listReiterations(params, options?)`

Lists reiterations for a specific request.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `requestId` | `string` | Yes | The request ID (token) |

**Returns:** `Promise<ReiterationSummary[]>`

```typescript
const reiterations = await mtx.requests.listReiterations({
  requestId: 'req_abc123',
});
for (const r of reiterations) {
  console.log(r.id, r.datetime);
}
```

---

### Additional Data Queries

Query additional data questions, answers, and external question lists.

#### `additionalData.listAnswers(params, options?)`

Retrieves a question with its possible answers and answer count.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `questionId` | `string` | Yes | The question ID |
| `limit` | `number` | No | Results per page |
| `page` | `number` | No | Page number |

**Returns:** `Promise<QuestionWithAnswers>`

```typescript
const question = await mtx.additionalData.listAnswers({
  questionId: 'q-123',
  limit: 20,
  page: 1,
});
console.log(question.question, question.answersCount);
for (const answer of question.possibleAnswers ?? []) {
  console.log(answer.value);
}
```

#### `additionalData.searchAnswers(params, options?)`

Searches answers for a question by text query.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `questionId` | `string` | Yes | The question ID |
| `query` | `string` | Yes | Search text |
| `limit` | `number` | No | Results per page |
| `page` | `number` | No | Page number |

**Returns:** `Promise<Answer[]>`

```typescript
const answers = await mtx.additionalData.searchAnswers({
  questionId: 'q-123',
  query: 'pothole',
});
for (const a of answers) {
  console.log(a.value, a.valueTranslations);
}
```

#### `additionalData.externalQuestions(params, options?)`

Retrieves external questions for a question list.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `questionListId` | `string` | Yes | The external question list ID |

**Returns:** `Promise<ExternalQuestionList>`

```typescript
const list = await mtx.additionalData.externalQuestions({
  questionListId: 'ql-456',
});
for (const item of list.data ?? []) {
  console.log(item.question, item.required, item.responseAttribute);
}
```

#### `additionalData.externalAnswers(params, options?)`

Retrieves answers for an external data question, with optional search.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `questionId` | `string` | Yes | The external question ID |
| `limit` | `number` | No | Results per page |
| `page` | `number` | No | Page number |
| `query` | `string` | No | Optional search text |

**Returns:** `Promise<Answer[]>`

```typescript
const answers = await mtx.additionalData.externalAnswers({
  questionId: 'eq-789',
  query: 'street',
  limit: 10,
});
for (const a of answers) {
  console.log(a.value);
}
```

#### `additionalData.questionsByUseCase(params, options?)`

Retrieves the additional data question list for a given jurisdiction and use case type.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `jurisdictionCode` | `string` | Yes | The jurisdiction code |
| `useCaseType` | `string` | Yes | The use case type identifier |
| `config` | `Record<string, unknown>` | No | Optional configuration object |

**Returns:** `Promise<AdditionalData>`

```typescript
const additionalData = await mtx.additionalData.questionsByUseCase({
  jurisdictionCode: 'city_barcelona',
  useCaseType: 'incident',
});
for (const cq of additionalData.configurableQuestions ?? []) {
  console.log(cq.question?.question, cq.required);
}
```

---

## Request Builder

The `RequestBuilder` provides a fluent, chainable API for constructing requests step by step. It is especially useful in form-based UIs where fields are filled incrementally.

```typescript
import { RequestBuilder } from '@mtx/api';

// Get the service detail first (needed for validation)
const service = await mtx.services.detail({
  serviceId: 'svc_pothole',
  jurisdictionCode: 'city_barcelona',
});

// Build the request using chained setters
const builder = mtx.requests.build()
  .service('svc_pothole')
  .jurisdiction('city_barcelona')
  .withServiceContext(service)
  .description('Pothole on Carrer de Mallorca')
  .location(41.3995, 2.1748)
  .address('Carrer de Mallorca 401')
  .email('citizen@example.com')
  .firstName('Maria')
  .lastName('Garcia')
  .public(true)
  .additionalData('q_severity', 'high');

// Validate before submitting
const validation = builder.isValid();
if (!validation.valid) {
  console.error('Validation errors:', validation.errors);
} else {
  const created = await builder.submit();
  console.log('Request submitted:', created.serviceRequestId);
}
```

### Builder Methods

| Method | Parameter | Returns | Description |
|---|---|---|---|
| `service(serviceId)` | `string` | `this` | Sets the service ID |
| `jurisdiction(code)` | `string` | `this` | Sets the jurisdiction code |
| `jurisdictionElement(id)` | `string` | `this` | Sets the jurisdiction element ID |
| `description(text)` | `string` | `this` | Sets the issue description |
| `location(lat, lng)` | `number, number` | `this` | Sets the geographic coordinates |
| `address(addr)` | `string` | `this` | Sets the street address |
| `email(e)` | `string` | `this` | Sets the reporter's email |
| `firstName(n)` | `string` | `this` | Sets the reporter's first name |
| `lastName(n)` | `string` | `this` | Sets the reporter's last name |
| `phone(p)` | `string` | `this` | Sets the reporter's phone number |
| `twitterNickname(n)` | `string` | `this` | Sets the reporter's Twitter handle |
| `public(b)` | `boolean` | `this` | Sets public visibility |
| `originDeviceId(id)` | `string` | `this` | Sets the origin device identifier |
| `additionalData(code, value)` | `string, AdditionalDataInput['value']` | `this` | Adds a single additional data answer |
| `attach(file)` | `FileParam` | `this` | Adds a file attachment |
| `withServiceContext(service)` | `Service` | `this` | Sets the service context (required for `isValid()`); also sets `serviceId` if not already set |
| `isValid()` | -- | `ValidationResult` | Validates against the service config |
| `submit(options?)` | `RequestOptions` | `Promise<CreatedRequest>` | Submits the request to the API |
| `toParams()` | -- | `CreateRequestParams` | Returns a copy of the current parameters |

---

## Async Iterator (Pagination)

The `requests.listAll()` method returns an `AsyncGenerator` that automatically paginates through all results. It fetches pages on demand as you iterate.

```typescript
// Iterate through ALL open requests in a jurisdiction
for await (const request of mtx.requests.listAll({
  jurisdictionCodes: ['city_barcelona'],
  status: ['open'],
  limit: 20, // page size (default: 20)
})) {
  console.log(request.serviceRequestId, request.description);
}
```

The generator:
1. Starts at `page` (default 1) and fetches `limit` results per call.
2. Yields each `Request` object one by one.
3. Fetches the next page when the current page is exhausted.
4. Stops when a page returns fewer results than `limit`.

You can also collect results into an array:

```typescript
const allRequests: Request[] = [];
for await (const request of mtx.requests.listAll({ own: true })) {
  allRequests.push(request);
}
```

Pass `RequestOptions` as the second argument to use a custom token or abort signal:

```typescript
const controller = new AbortController();

for await (const request of mtx.requests.listAll(
  { jurisdictionCodes: ['city_barcelona'] },
  { signal: controller.signal },
)) {
  if (someCondition) {
    controller.abort();
    break;
  }
}
```

---

## Validation

The SDK provides client-side validation against a service's configuration before submitting a request.

### `requests.validate(params, service)`

Validates `CreateRequestParams` against a `Service` object.

| Parameter | Type | Description |
|---|---|---|
| `params` | `CreateRequestParams` | The request parameters to validate |
| `service` | `Service` | The service detail object (from `services.detail()`) |

**Returns:** `ValidationResult`

```typescript
interface ValidationResult {
  valid: boolean;
  errors: ValidationError[];
}

interface ValidationError {
  field: string;
  message: string;
  code: string;
  expectedType?: string;  // present on additional data type errors
  receivedType?: string;  // present on additional data type errors
}
```

The validator checks:

1. **Mandatory informant fields** -- fields required by `service.mandatoryInformantConfig` (e.g., `firstName`, `lastName`, `email`, `phone`).
2. **Typology requirements** -- whether the service's typology requires a `description` or `location`.
3. **Required additional data questions** -- required answers defined in `service.additionalData.configurableQuestions`.
4. **Additional data type validation** -- each answer's value is validated against the question's declared type.

```typescript
const service = await mtx.services.detail({
  serviceId: 'svc_pothole',
  jurisdictionCode: 'city_barcelona',
});

const result = mtx.requests.validate(
  {
    serviceId: 'svc_pothole',
    jurisdictionCode: 'city_barcelona',
    // intentionally missing description and location
  },
  service,
);

if (!result.valid) {
  for (const err of result.errors) {
    console.error(`${err.field}: ${err.message} (${err.code})`);
    // e.g. "description: Description is required by this service (required)"
    // e.g. "lat: Location is required by this service (required)"
  }
}
```

### Additional Data Type Validation

When a service defines additional data questions with a `type`, `validate()` checks that each answer's value matches the expected type and format:

| Question Type | Expected Value | Error Code |
|---|---|---|
| `number` | A finite number or numeric string | `invalid_type` |
| `boolean` | `true`/`false` or `"true"`/`"false"` | `invalid_type` / `invalid_format` |
| `singleValueList` | A string from `possibleAnswers` (not an array) | `invalid_type` / `invalid_answer` |
| `multiValueList` | An array of strings, all from `possibleAnswers` | `invalid_type` / `invalid_answer` |
| `datetime` | An ISO 8601 date string | `invalid_type` / `invalid_format` |
| `position` | `{ lat: number, lng: number }` with valid ranges | `invalid_type` / `invalid_format` |
| `url` | A valid http or https URL string | `invalid_type` / `invalid_format` |
| `color` | A hex color string (`#RRGGBB`) | `invalid_type` / `invalid_format` |
| `audioClip` | `{ audio_clip: { es: string } }` | `invalid_type` |
| `videoClip` | `{ video_clip: { es: string } }` | `invalid_type` |

Errors include structured fields for programmatic handling (useful for LLM agents):

```typescript
const result = mtx.requests.validate(params, service);

for (const err of result.errors) {
  console.error(err.field);        // "additionalData.q1"
  console.error(err.code);         // "invalid_type"
  console.error(err.message);      // 'Expected a numeric value, got "tres"'
  console.error(err.expectedType); // "number"
  console.error(err.receivedType); // "string"
}
```

Unknown or new question types pass validation without error, ensuring forward-compatibility with future API changes.

### Using Validation with the Request Builder

The `RequestBuilder.isValid()` method wraps `requests.validate()`, using the service set via `withServiceContext()`:

```typescript
const builder = mtx.requests.build()
  .service('svc_pothole')
  .jurisdiction('city_barcelona')
  .withServiceContext(service);

// Check validity as the user fills in fields
builder.description('Pothole');
console.log(builder.isValid()); // { valid: false, errors: [...] }

builder.location(41.39, 2.17);
console.log(builder.isValid()); // { valid: true, errors: [] }
```

If `withServiceContext()` has not been called, `isValid()` returns a single error with code `missing_service`.

---

## Error Handling

### Error Hierarchy

```
MtxError (base)
├── MtxApiError (HTTP errors from the API)
│   └── LegalTermsRequiredError (HTTP 451)
├── MtxDecodingError (response Zod validation failed)
├── MtxValidationError (client-side validation errors)
├── MtxConfigError (missing or invalid configuration)
└── MtxArgumentError (missing or invalid method arguments)
    └── InvalidEmailError (invalid email format)
```

### Error Properties

| Error Class | Properties | Description |
|---|---|---|
| `MtxError` | `message` | Base error class for all SDK errors |
| `MtxApiError` | `statusCode`, `code`, `description` | HTTP error returned by the API |
| `LegalTermsRequiredError` | (inherits `MtxApiError`, statusCode=451) | User must accept legal terms |
| `MtxDecodingError` | `zodErrors` (`ZodIssue[]`) | API response did not match the expected schema |
| `MtxValidationError` | `errors` (`ValidationError[]`) | Client-side validation failure |
| `MtxConfigError` | `message` | Missing `baseUrl`, `clientId`, or `jwtSecret` |
| `MtxArgumentError` | `argument` | A required argument was missing or invalid |
| `InvalidEmailError` | `argument` (= `'email'`) | Email format validation failed |

### Examples

#### Catching API Errors

```typescript
import { MtxApiError, LegalTermsRequiredError } from '@mtx/api';

try {
  await mtx.auth.login({ username: 'user@test.com', password: 'wrong' });
} catch (error) {
  if (error instanceof LegalTermsRequiredError) {
    // User needs to accept legal terms first
    const terms = await mtx.user.legalTerms();
    console.log('Please accept:', terms.legalText?.termsOfUse);
    await mtx.user.acceptTerms();
  } else if (error instanceof MtxApiError) {
    console.error(`API error ${error.statusCode}: ${error.description}`);
    console.error('Error code:', error.code);
  }
}
```

#### Catching Decoding Errors

```typescript
import { MtxDecodingError } from '@mtx/api';

try {
  const services = await mtx.services.list({ jurisdictionCodes: ['test'] });
} catch (error) {
  if (error instanceof MtxDecodingError) {
    console.error('Unexpected API response shape:', error.zodErrors);
  }
}
```

#### Catching Configuration Errors

```typescript
import { MtxConfigError } from '@mtx/api';

try {
  const mtx = createMTXClient({ baseUrl: 'https://api.city.mtx.com' });
  // Throws: Missing clientId
} catch (error) {
  if (error instanceof MtxConfigError) {
    console.error('Config problem:', error.message);
  }
}
```

#### Catching Argument Errors

```typescript
import { MtxArgumentError, InvalidEmailError } from '@mtx/api';

try {
  await mtx.auth.loginJwt({ email: 'not-an-email' });
} catch (error) {
  if (error instanceof InvalidEmailError) {
    console.error('Bad email format');
  } else if (error instanceof MtxArgumentError) {
    console.error(`Missing argument: ${error.argument}`);
  }
}
```

#### Using the `onLegalTermsRequired` Callback

```typescript
const mtx = createMTXClient({
  baseUrl: 'https://api.city.mtx.com',
  clientId: 'my-client',
  onLegalTermsRequired: async () => {
    // This callback fires automatically on HTTP 451
    console.log('Legal terms required -- redirecting user');
    // Show a dialog, navigate to terms page, etc.
  },
});
```

---

## File Uploads

The SDK supports file uploads through `requests.attachMedia()` and `requests.attachComment()`. Files are sent as multipart/form-data.

### Browser (File from Input)

```typescript
// <input type="file" id="photo" accept="image/*" />
const input = document.getElementById('photo') as HTMLInputElement;
const file = input.files![0];

// Attach a photo to an existing request
await mtx.requests.attachMedia({
  requestId: 'req_abc123',
  jurisdictionCode: 'city_barcelona',
  file: [file.name, file],  // [filename, Blob]
});
```

### Browser (Blob from Canvas)

```typescript
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const blob = await new Promise<Blob>((resolve) =>
  canvas.toBlob((b) => resolve(b!), 'image/jpeg'),
);

await mtx.requests.attachMedia({
  requestId: 'req_abc123',
  jurisdictionCode: 'city_barcelona',
  file: ['snapshot.jpg', blob],
});
```

### Node.js (Buffer from File System)

```typescript
import { readFileSync } from 'node:fs';

const buffer = readFileSync('/path/to/photo.jpg');
const blob = new Blob([buffer], { type: 'image/jpeg' });

await mtx.requests.attachMedia({
  requestId: 'req_abc123',
  jurisdictionCode: 'city_barcelona',
  file: ['photo.jpg', blob],
});
```

### Multiple Files in a Comment

```typescript
const files: [string, Blob][] = [
  ['photo1.jpg', blob1],
  ['photo2.jpg', blob2],
  ['video.mp4', videoBlob],
];

await mtx.requests.attachComment({
  requestId: 'req_abc123',
  comment: 'Here are additional photos of the issue',
  files,
});
```

---

## Logging

The SDK uses a logger for HTTP request/response tracing. By default it logs to the console.

### Logger Interface

```typescript
interface MtxLogger {
  debug(message: string): void;
  info(message: string): void;
  error(message: string): void;
}
```

### Default Logger

The default logger forwards to `console.debug`, `console.info`, and `console.error`:

```typescript
const mtx = createMTXClient({
  baseUrl: 'https://api.city.mtx.com',
  clientId: 'my-client',
  // logger defaults to console
});
```

### Custom Logger

Pass any object implementing `MtxLogger`:

```typescript
import { createMTXClient, type MtxLogger } from '@mtx/api';

const myLogger: MtxLogger = {
  debug: (msg) => myLoggingService.log('debug', msg),
  info: (msg) => myLoggingService.log('info', msg),
  error: (msg) => myLoggingService.log('error', msg),
};

const mtx = createMTXClient({
  baseUrl: 'https://api.city.mtx.com',
  clientId: 'my-client',
  logger: myLogger,
});
```

### Silencing the Logger

To suppress all SDK log output, pass a no-op logger:

```typescript
const silentLogger: MtxLogger = {
  debug: () => {},
  info: () => {},
  error: () => {},
};

const mtx = createMTXClient({
  baseUrl: 'https://api.city.mtx.com',
  clientId: 'my-client',
  logger: silentLogger,
});
```

---

## Schemas

The SDK uses [Zod](https://zod.dev) schemas internally to validate and transform every API response. All schemas are exported so you can reuse them for your own form validation, data parsing, or type inference.

### Importing Schemas

```typescript
import {
  // Auth
  AuthenticationSchema,
  ProfileSchema,

  // Common
  TagSchema,
  UserSchema,
  LocationSchema,
  LegalTextSchema,
  LegalTermsSchema,

  // Jurisdictions
  OriginDeviceSchema,
  JurisdictionElementSchema,
  JurisdictionSchema,
  JurisdictionElementListSchema,
  JurisdictionListSchema,

  // Typologies
  TypologySchema,
  TypologyListSchema,

  // Additional Data
  QuestionSchema,
  ConfigurableQuestionSchema,
  AdditionalDataSchema,
  PossibleAnswerSchema,
  AdditionalDataAnswerSchema,
  AdditionalDataSingleValueSchema,
  AdditionalDataMultivalueSchema,
  AdditionalDataDatetimeSchema,
  AdditionalDataLocationSchema,
  AdditionalDataAudioClipSchema,
  AdditionalDataVideoClipSchema,
  LocationAdditionalDataSchema,
  LocationAdditionalDataListSchema,

  // Additional Data Queries
  AnswerSchema,
  AnswerListSchema,
  QuestionWithAnswersSchema,
  ExternalQuestionItemSchema,
  ExternalQuestionListSchema,

  // Services
  MandatoryInformantFieldSchema,
  StatusNodeSchema,
  ServiceSchema,
  ServiceListSchema,

  // Media
  MediaMetadataSchema,
  FileSchema,
  MediaSchema,

  // Requests
  FollowingSchema,
  CreatedRequestSchema,
  CreatedRequestListSchema,
  RequestSchema,
  RequestListSchema,

  // Request Media & Comments
  RequestMediaSchema,
  RequestCommentMediaSchema,
  RequestCommentSchema,
  RequestCommentListSchema,

  // Request Files & Coordinates
  RequestFileSchema,
  RequestFileListSchema,
  RequestCoordinateSchema,
  RequestCoordinateListSchema,

  // Request Interactions
  SourceSchema,
  InteractionUserSchema,
  ComplaintSchema,
  ComplaintSummarySchema,
  ComplaintSummaryListSchema,
  EvaluationSchema,
  EvaluationListSchema,
  ReiterationSchema,
  ReiterationSummarySchema,
  ReiterationSummaryListSchema,
} from '@mtx/api';
```

> **Note:** `AdditionalDataAnswerSchema` is a **response/read** schema -- its `value` field is `z.any()`, so it accepts whatever the API returns. For **form validation** (write side), use the specific write schemas (`AdditionalDataSingleValueSchema`, `AdditionalDataMultivalueSchema`, `AdditionalDataDatetimeSchema`, `AdditionalDataLocationSchema`, `AdditionalDataAudioClipSchema`, `AdditionalDataVideoClipSchema`) or the `AdditionalDataInput` type, which constrains `value` to the allowed union of `string | string[] | { lat, lng, srs? } | { audio_clip } | { video_clip }`.

### Example: Form Validation with Zod

```typescript
import { z } from 'zod';
import { AdditionalDataSingleValueSchema } from '@mtx/api';

// Build a form schema reusing the SDK's write schema for strict validation
const MyRequestFormSchema = z.object({
  description: z.string().min(10, 'Description must be at least 10 characters'),
  lat: z.number(),
  lng: z.number(),
  answers: z.array(AdditionalDataSingleValueSchema),
});

// Validate user input
const result = MyRequestFormSchema.safeParse(formData);
if (!result.success) {
  console.error(result.error.issues);
}
```

### Type Inference

All response types are inferred from their schemas. You can also infer types yourself:

```typescript
import { z } from 'zod';
import { ServiceSchema } from '@mtx/api';

type MyService = z.infer<typeof ServiceSchema>;
```

---

## Environment Variables

| Variable | Required | Description |
|---|---|---|
| `MTX_BASE_URL` | Yes (if not in config) | Base URL of the MTX API instance |
| `MTX_CLIENT_ID` | Yes (if not in config) | OAuth client identifier |
| `MTX_JWT_SECRET` | No | Base64-encoded HMAC secret for JWT login |

Environment variables are only read when the corresponding config option is not provided. The SDK checks `process.env` and gracefully handles environments where `process` is undefined (e.g., browsers).

---

## License

[MIT](./LICENSE)