@offcoin/sdk

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

Installation

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 to get your clientId and clientSecret. These credentials are available in your account dashboard under Settings > API Access.

Quick Start

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

// 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

// 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

// 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

// 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

// 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:

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

// 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:

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

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

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})
// 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:

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:

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.

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