npm package: @offcoin/sdk
Description: TypeScript/JavaScript SDK for the Offcoin API
Latest version: 0.0.10

# @offcoin/sdk

TypeScript/JavaScript SDK for the Offcoin API. Manage members, tokens, XP, achievements, and leaderboards.

## Installation

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

## Getting Started

Before using the SDK, you'll need to create a free or pro account on [https://offcoin.space](https://offcoin.space) to get your `clientId` and `clientSecret`. These credentials are available in your account dashboard under **Settings > API Access**.

## Quick Start

```typescript
import { OffcoinClient } from '@offcoin/sdk';

const offcoin = new OffcoinClient({
  clientId: 'your_client_id',
  clientSecret: 'your_client_secret'
});

// List all members
const members = await offcoin.members.list();

// List members with metadata filter
const filteredMembers = await offcoin.members.list({
  metadata: { department: 'engineering', role: 'senior' }
});

// Create a member
const newMember = await offcoin.members.create({
  name: 'John Doe',
  aliases: ['discord:123456', 'github:johndoe'],
  metadata: { department: 'engineering', role: 'developer' } // optional
});

// Add tokens
const result = await offcoin.members.addTokens('discord:123456', 100, 'Welcome bonus');
console.log(`New balance: ${result.newBalance}`);
```

## API Reference

### Members

```typescript
// List all members
const members = await offcoin.members.list();

// List members filtered by metadata
const filteredMembers = await offcoin.members.list({
  metadata: { department: 'engineering', role: 'senior' }
});

// Create a member
const member = await offcoin.members.create({
  name: 'John Doe',
  avatarUrl: 'https://example.com/avatar.png', // optional
  aliases: ['discord:123456', 'github:johndoe'], // optional
  metadata: { department: 'engineering', role: 'developer' } // optional
});

// Get a member by alias
const member = await offcoin.members.get('discord:123456');
// member.metadata contains the member's metadata

// Update a member
const updated = await offcoin.members.update('discord:123456', {
  name: 'Jane Doe',
  avatarUrl: null, // remove avatar
  metadata: { department: 'engineering', role: 'senior' } // merge with existing metadata
});

// Get token balance
const balance = await offcoin.members.getBalance('discord:123456');
console.log(`Balance: ${balance.balance}`);

// Get XP and level
const xp = await offcoin.members.getXp('discord:123456');
console.log(`XP: ${xp.xp}, Level: ${xp.level}`);

// Add tokens
const result = await offcoin.members.addTokens('discord:123456', 100, 'Purchase reward');

// Subtract tokens
const result = await offcoin.members.subtractTokens('discord:123456', 50, 'Item purchase');

// Add XP
const result = await offcoin.members.addXp('discord:123456', 25);
```

### Member Aliases

```typescript
// List all aliases for a member
const aliases = await offcoin.members.listAliases('discord:123456');

// Add alias 'github:johndoe' to a member with alias 'discord:johndoe'
const addResult = await offcoin.members.addAlias('discord:johndoe', 'github:johndoe');

// Update alias 'discord:123456' to 'discord:789012' for a member with alias 'github:123456'
const updateResult = await offcoin.members.updateAlias('github:123456', 'discord:123456', 'discord:789012');

// Remove alias 'github:johndoe' from a member with alias 'discord:123456'
const removeResult = await offcoin.members.removeAlias('discord:123456', 'github:johndoe');
```

### Achievements

```typescript
// List all achievements
const achievements = await offcoin.achievements.list();

// Unlock an achievement for a member
const result = await offcoin.achievements.unlock('achievement-id', 'discord:123456');
console.log(`Unlocked: ${result.achievementName}`);
console.log(`Cascading achievements: ${result.cascadingAchievementsGranted}`);
```

### Leaderboard

```typescript
// Get leaderboard (default: sorted by XP, limit 50, all-time)
const leaderboard = await offcoin.leaderboard.get();

// Get top 10 by tokens (all-time)
const tokenLeaderboard = await offcoin.leaderboard.get({
  sort: 'tokens',
  limit: 10
});

// Get top 25 by level (all-time)
const levelLeaderboard = await offcoin.leaderboard.get({
  sort: 'level',
  limit: 25
});

// Get current week leaderboard (gains this week)
const weeklyLeaderboard = await offcoin.leaderboard.get({
  sort: 'xp',
  period: 'current-week',
  limit: 20
});

// Get current month leaderboard (gains this month)
const monthlyLeaderboard = await offcoin.leaderboard.get({
  sort: 'tokens',
  period: 'current-month',
  limit: 50
});

// Get current year leaderboard (gains this year)
const yearlyLeaderboard = await offcoin.leaderboard.get({
  sort: 'level',
  period: 'current-year',
  limit: 100
});

// Get leaderboard filtered by metadata
const engineeringLeaderboard = await offcoin.leaderboard.get({
  sort: 'xp',
  period: 'current-month',
  limit: 20,
  metadata: { department: 'engineering', role: 'senior' }
});
```

**Period Options:**
- `'all-time'` (default): Shows current balances/XP (all-time leaderboard)
- `'current-week'`: Shows gains from the start of the current week (Monday 00:00 UTC)
- `'current-month'`: Shows gains from the start of the current month
- `'current-year'`: Shows gains from the start of the current year

**Note:** Time-based periods (`current-week`, `current-month`, `current-year`) show only members who have activity (gains) in that period. Members with zero activity are excluded.

### Permissions

```typescript
// List all purchasable permissions
const permissions = await offcoin.permissions.list();

// List permissions with ownership info for a specific member
const permissions = await offcoin.permissions.list({ alias: 'discord:123456' });
// Returns permissions with `owned` and `canPurchase` fields

// Purchase a permission for a member
const result = await offcoin.permissions.purchase('discord:123456', 'vip_access');
console.log(`Purchased ${result.permissionName} for ${result.tokenPrice} tokens`);
console.log(`New balance: ${result.newBalance}`);
```

## Webhooks

Offcoin can send HTTP notifications to your server when events occur. The SDK provides utilities for verifying webhook signatures to ensure authenticity.

### Setting Up Webhooks

1. Go to **Settings > Webhooks** in your Offcoin admin dashboard
2. Click **Create webhook** and enter your endpoint URL
3. Select the events you want to receive
4. Copy the signing secret (shown only once)

### Event Types

| Event | Description |
|-------|-------------|
| `member.balance_updated` | Token balance changed (add/subtract) |
| `member.xp_updated` | XP changed |
| `achievement.unlocked` | Member unlocked an achievement |
| `permission.granted` | Member received a permission (via level or purchase) |

### Verifying Webhook Signatures

Always verify webhook signatures to ensure requests are from Offcoin:

```typescript
import { verifyWebhookSignature, WebhookEventTypes } from '@offcoin/sdk';

// Express.js example - note: you need raw body, not parsed JSON
app.post('/webhooks/offcoin', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.headers['x-webhook-signature'] as string;
  const timestamp = req.headers['x-webhook-timestamp'] as string;
  const rawBody = req.body.toString();

  const result = await verifyWebhookSignature(
    rawBody,
    signature,
    timestamp,
    process.env.OFFCOIN_WEBHOOK_SECRET!
  );

  if (!result.valid) {
    console.error('Webhook verification failed:', result.error);
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Process the verified webhook
  const { type, data } = result.payload!;

  switch (type) {
    case WebhookEventTypes.MEMBER_BALANCE_UPDATED:
      console.log(`Balance: ${data.memberName} now has ${data.newBalance} tokens`);
      break;
    case WebhookEventTypes.MEMBER_XP_UPDATED:
      console.log(`XP: ${data.memberName} is now level ${data.newLevel}`);
      break;
    case WebhookEventTypes.ACHIEVEMENT_UNLOCKED:
      console.log(`Achievement: ${data.memberName} unlocked "${data.achievementName}"`);
      break;
    case WebhookEventTypes.PERMISSION_GRANTED:
      console.log(`Permission: ${data.memberName} received "${data.permissionName}"`);
      break;
  }

  res.status(200).json({ received: true });
});
```

### SvelteKit Example

```typescript
// src/routes/webhooks/offcoin/+server.ts
import { json } from '@sveltejs/kit';
import { verifyWebhookSignature } from '@offcoin/sdk';
import { OFFCOIN_WEBHOOK_SECRET } from '$env/static/private';

export async function POST({ request }) {
  const signature = request.headers.get('x-webhook-signature');
  const timestamp = request.headers.get('x-webhook-timestamp');
  const rawBody = await request.text();

  const result = await verifyWebhookSignature(
    rawBody,
    signature ?? '',
    timestamp ?? '',
    OFFCOIN_WEBHOOK_SECRET
  );

  if (!result.valid) {
    return json({ error: result.error }, { status: 401 });
  }

  const { type, data } = result.payload!;
  // Handle webhook...

  return json({ received: true });
}
```

### Webhook Payload Structure

All webhooks include these fields:

```typescript
interface WebhookPayload {
  id: string;        // Unique event ID
  type: string;      // Event type (e.g., "member.balance_updated")
  timestamp: string; // ISO 8601 timestamp
  tenantId: string;  // Your workspace ID
  data: object;      // Event-specific data
}
```

### Webhook Types

```typescript
import type {
  WebhookPayload,
  BalanceUpdatedData,
  XpUpdatedData,
  AchievementUnlockedData,
  PermissionGrantedData
} from '@offcoin/sdk';

// Type-safe webhook handling
function handleBalanceUpdate(payload: WebhookPayload<BalanceUpdatedData>) {
  const { memberId, memberName, previousBalance, newBalance, amount, reason } = payload.data;
}
```

### Verification Options

```typescript
const result = await verifyWebhookSignature(
  rawBody,
  signature,
  timestamp,
  secret,
  {
    maxAgeSeconds: 300 // Reject webhooks older than 5 minutes (default)
  }
);
```

### Manual Signature Verification

If you prefer to implement verification yourself, here's the algorithm:

1. Concatenate timestamp and payload: `{timestamp}.{payload}`
2. Compute HMAC-SHA256 using your webhook secret
3. Compare with signature header (format: `v1={hex}`)

```typescript
// Headers sent with each webhook
// X-Webhook-Signature: v1=abc123...
// X-Webhook-Timestamp: 1702828800
// X-Webhook-Id: evt_xxx

const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = 'v1=' + hmacSHA256(signedPayload, secret);
```

## Error Handling

The SDK throws typed errors for different scenarios:

```typescript
import {
  OffcoinClient,
  AuthenticationError,
  NotFoundError,
  ValidationError
} from '@offcoin/sdk';

try {
  await offcoin.members.get('unknown-alias');
} catch (error) {
  if (error instanceof AuthenticationError) {
    console.error('Invalid credentials');
  } else if (error instanceof NotFoundError) {
    console.error('Member not found');
  } else if (error instanceof ValidationError) {
    console.error('Validation failed:', error.message);
  }
}
```

### Error Types

| Error Class | Code | Description |
|------------|------|-------------|
| `AuthenticationError` | `UNAUTHORIZED` | Invalid client_id or client_secret |
| `ForbiddenError` | `FORBIDDEN` | Not authorized for this action |
| `NotFoundError` | `NOT_FOUND` | Resource not found |
| `ValidationError` | `VALIDATION_ERROR` | Invalid input data |
| `BadRequestError` | `BAD_REQUEST` | Malformed request |
| `InternalError` | `INTERNAL_ERROR` | Server error |
| `OffcoinError` | Various | Base error class |

## TypeScript Types

All types are exported for use in your application:

```typescript
import type {
  Member,
  MemberBalance,
  MemberXp,
  Achievement,
  LeaderboardEntry,
  TokenOperationResult,
  XpOperationResult,
  UnlockResult,
  Permission,
  PurchasePermissionResult,
  ListMembersOptions,
  // Webhook types
  WebhookPayload,
  WebhookEventType,
  BalanceUpdatedData,
  XpUpdatedData,
  AchievementUnlockedData,
  PermissionGrantedData
} from '@offcoin/sdk';
```

## Authentication

The SDK uses Basic Authentication with your API credentials. You can find your `clientId` and `clientSecret` in the Offcoin admin dashboard under Settings > API Access.

```typescript
const offcoin = new OffcoinClient({
  clientId: process.env.OFFCOIN_CLIENT_ID!,
  clientSecret: process.env.OFFCOIN_CLIENT_SECRET!
});
```

## Requirements

- Node.js 18+ (for native fetch support) or a browser environment
- TypeScript 5.0+ (for type definitions)

## License

MIT