npm package: @noctaly/sdk
Description: Official Node.js SDK for the Noctaly public API
Author: Noctaly
Latest version: 1.0.0

# @noctaly/sdk

Official Node.js SDK for the [Noctaly](https://noctaly.com) public API.

## Installation

```bash
npm install @noctaly/sdk
# or
pnpm add @noctaly/sdk
# or
yarn add @noctaly/sdk
```

> **Requires Node.js ≥ 18**

## Quick Start

```ts
import { NoctalyClient } from "@noctaly/sdk";

const noctaly = new NoctalyClient({ apiKey: "your-api-key" });

const user = noctaly.guilds("GUILD_ID").users("USER_ID");

// Get leveling profile
const { data: profile } = await user.getLevelProfile();
console.log(`Level ${profile.level} - ${profile.totalXp} total XP`);

// Give 500 XP (with server multipliers applied)
const { data: xpResult } = await user.updateXP({ xp: 500, multiply: true });
console.log(`XP given: ${xpResult.xp}`);
```

---

## API Reference

### `new NoctalyClient(options)`

| Option       | Type      | Default                          | Description                                                           |
|--------------|-----------|----------------------------------|-----------------------------------------------------------------------|
| `apiKey`     | `string`  | -                                | Your Noctaly API key.                                                 |
| `baseURL`    | `string`  | `https://noctaly.com/api/v1`     | Override the base URL. Useful for staging environments.               |
| `retry`      | `boolean` | `true`                           | Automatically retry requests that receive a `429` rate-limit response. |
| `maxRetries` | `number`  | `3`                              | Maximum number of automatic retries before the error is thrown.       |
| `timeout`    | `number`  | `30000`                          | Request timeout in milliseconds. Set to `0` to disable.              |

When `retry` is `true`, the SDK waits the `retryAfter` duration from the API response before each retry, capped at 60 seconds.

---

### `client.guilds(guildID)` → `GuildResource`

Returns a `GuildResource` scoped to the given Discord guild.

#### `guild.users(userID)` → `UserResource`

Returns a `UserResource` scoped to the given guild member.

All methods return `Promise<NoctalyResponse<T>>`:

```ts
interface NoctalyResponse<T> {
  data: T;
  rateLimit?: RateLimitInfo;
}
```

---

### UserResource

#### `user.getLevelProfile()`

```
GET /guilds/{guildID}/users/{userID}/level-profile
```

```ts
const { data } = await user.getLevelProfile();
// data: LevelProfile
```

| Field          | Type     | Description                               |
|----------------|----------|-------------------------------------------|
| `level`        | `number` | Current level.                            |
| `xp`           | `number` | XP accumulated in the current level.      |
| `neededXp`     | `number` | Remaining XP needed to reach next level.  |
| `totalXp`      | `number` | Total XP across all levels.               |
| `messages`     | `number` | Total messages sent.                      |
| `voiceMinutes` | `number` | Total minutes spent in voice channels.    |
| `reactions`    | `number` | Total reactions added.                    |

---

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

```
GET /guilds/{guildID}/users/{userID}/eco-profile
```

Only `money` is returned by default. Pass options to include optional relations.
The return type is narrowed automatically based on the options you pass.

```ts
// Basic
const { data } = await user.getEcoProfile();
// data: { money: number }

// With all relations
const { data } = await user.getEcoProfile({ items: true, chests: true, dailyStreak: true });
// data: { money: number, items: UserItem[], chests: UserChest[], dailyStreak: DailyStreak | null }
```

| Option        | Type      | Description                            |
|---------------|-----------|----------------------------------------|
| `items`       | `boolean` | Include the member's item inventory.   |
| `chests`      | `boolean` | Include the member's chest inventory.  |
| `dailyStreak` | `boolean` | Include the member's daily streak.     |

---

#### `user.updateXP(body)`

```
POST /guilds/{guildID}/users/{userID}/xp
```

```ts
const { data } = await user.updateXP({ xp: 1000, multiply: true });
// data: { xp: number }  ← actual XP given after multipliers
```

| Field      | Type      | Required | Description                                                  |
|------------|-----------|----------|--------------------------------------------------------------|
| `xp`       | `number`  | ✓        | XP to give (positive) or remove (negative). Range: ±100 M.  |
| `multiply` | `boolean` |          | Apply server and booster-role multipliers. Default: `false`. |

---

#### `user.updateLevel(body)`

```
POST /guilds/{guildID}/users/{userID}/level
```

```ts
const { data } = await user.updateLevel({ level: 5 });
// data: { level: number }
```

| Field   | Type     | Required | Description                                             |
|---------|----------|----------|---------------------------------------------------------|
| `level` | `number` | ✓        | Levels to give (positive) or remove (negative). ±1 000. |

---

#### `user.updateMoney(body)`

```
POST /guilds/{guildID}/users/{userID}/money
```

```ts
const { data } = await user.updateMoney({ money: 5000, multiply: true });
// data: { money: number }
```

| Field      | Type      | Required | Description                                                     |
|------------|-----------|----------|-----------------------------------------------------------------|
| `money`    | `number`  | ✓        | Money to give (positive) or remove (negative). Range: ±1 000 M. |
| `multiply` | `boolean` |          | Apply server and booster-role multipliers. Default: `false`.    |

---

### UserItemsResource - `user.items`

#### `user.items.add(body)`

```
POST /guilds/{guildID}/users/{userID}/items
```

```ts
const { data } = await user.items.add({ itemID: "uuid", quantity: 3 });
// data: { quantity: number }  ← new total quantity in inventory
```

#### `user.items.remove(body)`

```
DELETE /guilds/{guildID}/users/{userID}/items
```

```ts
const { data } = await user.items.remove({ itemID: "uuid", quantity: 1 });
// data: { quantity: number }  ← remaining quantity (0 if fully removed)
```

#### `user.items.set(body)`

```
PUT /guilds/{guildID}/users/{userID}/items
```

```ts
const { data } = await user.items.set({ itemID: "uuid", quantity: 10 });
// Passing 0 removes the item entirely.
// data: { quantity: number }
```

#### `user.items.use(itemID, body?)`

```
POST /guilds/{guildID}/users/{userID}/items/{itemID}/use
```

Triggers the item's configured actions. Only works for `CUSTOM` items with at least one action. The item is consumed unless it has the `KEEP_AFTER_USE` flag.

```ts
const { data } = await user.items.use("item-uuid", { quantity: 2 });
// data: UseItemResult
```

`UseItemResult` fields:

| Field           | Type                     | Description                                                        |
|-----------------|--------------------------|--------------------------------------------------------------------|
| `xp`            | `number`                 | Total XP awarded (negative if removed).                            |
| `money`         | `number`                 | Total money awarded (negative if removed).                         |
| `addedRoles`    | `string[]`               | Role IDs added to the member.                                      |
| `removedRoles`  | `string[]`               | Role IDs removed from the member.                                  |
| `addedItems`    | `Record<string, number>` | Map of item UUID → quantity added.                                 |
| `itemsRemoved`  | `Record<string, number>` | Map of item UUID → quantity removed.                               |
| `addedChests`   | `Record<string, number>` | Map of chest UUID → quantity added.                                |
| `chestsRemoved` | `Record<string, number>` | Map of chest UUID → quantity removed.                              |
| `rolesDuration` | `Record<string, number>` | Map of role ID → Unix timestamp when the temporary role expires.   |

---

### UserChestsResource - `user.chests`

#### `user.chests.add(body)`

```
POST /guilds/{guildID}/users/{userID}/chests
```

```ts
const { data } = await user.chests.add({ chestID: "uuid", quantity: 2 });
// data: { quantity: number }
```

#### `user.chests.remove(body)`

```
DELETE /guilds/{guildID}/users/{userID}/chests
```

```ts
const { data } = await user.chests.remove({ chestID: "uuid", quantity: 1 });
// data: { quantity: number }
```

#### `user.chests.set(body)`

```
PUT /guilds/{guildID}/users/{userID}/chests
```

```ts
const { data } = await user.chests.set({ chestID: "uuid", quantity: 0 });
// Passing 0 removes the chest entirely.
// data: { quantity: number }
```

---

### GuildItemsResource - `guild.items`

#### `guild.items.list()`

```
GET /guilds/{guildID}/items
```

```ts
const { data } = await guild.items.list();
// data: { items: Item[] }
```

`Item` fields:

| Field           | Type              | Description                                    |
|-----------------|-------------------|------------------------------------------------|
| `id`            | `string`          | UUID of the item.                              |
| `name`          | `string`          | Display name.                                  |
| `description`   | `string \| null`  | Optional description.                          |
| `type`          | `string`          | Item type (e.g. `"CUSTOM"`).                   |
| `flags`         | `string[]`        | Item flags (e.g. `["KEEP_AFTER_USE"]`).        |
| `emoji`         | `string \| null`  | Emoji string.                                  |
| `emojiType`     | `"UNICODE" \| "CUSTOM" \| null` | Emoji type.                     |
| `iconURL`       | `string \| null`  | Custom icon URL.                               |
| `buyPrice`      | `number`          | Buy price.                                     |
| `sellPrice`     | `number`          | Sell price.                                    |
| `cooldown`      | `number`          | Cooldown between uses in seconds (`0` = none). |
| `durability`    | `number \| null`  | Full durability per item (`0` = no durability).|
| `quantity`      | `number \| null`  | Quantity in the shop (`null` = unlimited).     |
| `quantityLimit` | `number \| null`  | Per-member quantity limit (`null` = unlimited).|

---

### GuildChestsResource - `guild.chests`

#### `guild.chests.list()`

```
GET /guilds/{guildID}/chests
```

```ts
const { data } = await guild.chests.list();
// data: { chests: Chest[] }
```

`Chest` fields:

| Field            | Type             | Description                                     |
|------------------|------------------|-------------------------------------------------|
| `id`             | `string`         | UUID of the chest.                              |
| `name`           | `string`         | Display name.                                   |
| `primaryColor`   | `string`         | Primary hex color (without `#`).                |
| `secondaryColor` | `string`         | Secondary hex color (without `#`).              |
| `iconURL`        | `string \| null` | Custom icon URL.                                |
| `flags`          | `string[]`       | Chest flags (e.g. `["ANIMATED"]`).              |
| `buyPrice`       | `number`         | Buy price.                                      |
| `sellPrice`      | `number`         | Sell price.                                     |
| `itemDrawCount`  | `number`         | Number of items drawn when the chest is opened. |
| `quantity`       | `number \| null` | Quantity in the shop (`null` = unlimited).      |
| `quantityLimit`  | `number \| null` | Per-member quantity limit (`null` = unlimited). |

---

## Error Handling

All methods throw a `NoctalyError` on non-2xx responses.

```ts
import { NoctalyError } from "@noctaly/sdk";

try {
  await user.updateXP({ xp: 500 });
} catch (err) {
  if (err instanceof NoctalyError) {
    console.error(err.code);       // "rate_limited" | "not_found" | …
    console.error(err.status);     // 429 | 404 | …
    console.error(err.message);    // Human-readable message
    console.error(err.retryAfter); // seconds (rate_limited only)
    console.error(err.global);     // boolean (rate_limited only)
    console.error(err.details);    // ValidationIssue[] (validation_error only)
  }
}
```

| Code               | Status | Description                                            |
|--------------------|--------|--------------------------------------------------------|
| `unauthenticated`  | 401    | No API key provided.                                   |
| `unauthorized`     | 403    | API key doesn't have access to this guild.             |
| `rate_limited`     | 429    | Rate limit exceeded. Check `retryAfter` and `global`.  |
| `not_found`        | 404    | The requested member or resource was not found.        |
| `module_disabled`  | 400    | The relevant module is disabled in this guild.         |
| `validation_error` | 400    | Invalid request body. Check `details` for field errors.|
| `unexpected_error` | 500    | Server-side error.                                     |

---

## Rate Limits

Every successful response exposes the endpoint's rate-limit info:

```ts
const { data, rateLimit } = await user.getLevelProfile();

if (rateLimit) {
  console.log(`${rateLimit.remaining}/${rateLimit.limit} remaining`);
  console.log(`Resets at ${new Date(rateLimit.reset * 1000).toISOString()}`);
  console.log(`Resets in ${rateLimit.resetAfter}s`);
}
```

| Field        | Type     | Description                                      |
|--------------|----------|--------------------------------------------------|
| `limit`      | `number` | Maximum requests allowed in the window.          |
| `remaining`  | `number` | Requests remaining in the current window.        |
| `reset`      | `number` | Unix timestamp (seconds) when the window resets. |
| `resetAfter` | `number` | Seconds until the window resets.                 |

The global rate limit is **50 req/s** across all routes. Exceeding it results in a `NoctalyError` with `code: "rate_limited"` and `global: true`.

When `retry: true` (the default), the SDK automatically handles `429` responses by waiting `retryAfter` seconds and retrying up to `maxRetries` times before throwing.