@offcoin/sdk
Advanced tools
| /** | ||
| * Webhook utilities for verifying Offcoin webhook signatures | ||
| */ | ||
| /** | ||
| * Webhook event types that can be subscribed to | ||
| */ | ||
| export declare const WebhookEventTypes: { | ||
| readonly MEMBER_BALANCE_UPDATED: "member.balance_updated"; | ||
| readonly MEMBER_XP_UPDATED: "member.xp_updated"; | ||
| readonly ACHIEVEMENT_UNLOCKED: "achievement.unlocked"; | ||
| readonly PERMISSION_GRANTED: "permission.granted"; | ||
| }; | ||
| export type WebhookEventType = (typeof WebhookEventTypes)[keyof typeof WebhookEventTypes]; | ||
| /** | ||
| * Webhook payload structure sent by Offcoin | ||
| */ | ||
| export interface WebhookPayload<T = unknown> { | ||
| id: string; | ||
| type: WebhookEventType; | ||
| timestamp: string; | ||
| tenantId: string; | ||
| data: T; | ||
| } | ||
| /** | ||
| * Data payload for member.balance_updated events | ||
| */ | ||
| export interface BalanceUpdatedData { | ||
| memberId: string; | ||
| memberName: string; | ||
| previousBalance: number; | ||
| newBalance: number; | ||
| amount: number; | ||
| reason?: string; | ||
| } | ||
| /** | ||
| * Data payload for member.xp_updated events | ||
| */ | ||
| export interface XpUpdatedData { | ||
| memberId: string; | ||
| memberName: string; | ||
| previousXp: number; | ||
| newXp: number; | ||
| amount: number; | ||
| previousLevel: number; | ||
| newLevel: number; | ||
| } | ||
| /** | ||
| * Data payload for achievement.unlocked events | ||
| */ | ||
| export interface AchievementUnlockedData { | ||
| memberId: string; | ||
| memberName: string; | ||
| achievementId: string; | ||
| achievementKey: string; | ||
| achievementName: string; | ||
| automatic: boolean; | ||
| } | ||
| /** | ||
| * Data payload for permission.granted events | ||
| */ | ||
| export interface PermissionGrantedData { | ||
| memberId: string; | ||
| memberName: string; | ||
| permissionId: string; | ||
| permissionKey: string; | ||
| permissionName: string; | ||
| grantType: 'level' | 'purchase'; | ||
| } | ||
| /** | ||
| * Result of webhook signature verification | ||
| */ | ||
| export interface VerifyWebhookResult { | ||
| valid: boolean; | ||
| payload?: WebhookPayload; | ||
| error?: string; | ||
| } | ||
| /** | ||
| * Options for webhook verification | ||
| */ | ||
| export interface VerifyWebhookOptions { | ||
| /** | ||
| * Maximum age of the webhook in seconds (default: 300 = 5 minutes) | ||
| * Set to 0 to disable timestamp validation | ||
| */ | ||
| maxAgeSeconds?: number; | ||
| } | ||
| /** | ||
| * Verify a webhook signature from Offcoin | ||
| * | ||
| * @param payload - The raw request body as a string | ||
| * @param signature - The value from the X-Webhook-Signature header | ||
| * @param timestamp - The value from the X-Webhook-Timestamp header | ||
| * @param secret - Your webhook signing secret (starts with 'whsec_') | ||
| * @param options - Verification options | ||
| * @returns Verification result with parsed payload if valid | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * import { verifyWebhookSignature } from '@offcoin/sdk'; | ||
| * | ||
| * // In your webhook handler (e.g., Express) | ||
| * app.post('/webhooks/offcoin', async (req, res) => { | ||
| * const signature = req.headers['x-webhook-signature'] as string; | ||
| * const timestamp = req.headers['x-webhook-timestamp'] as string; | ||
| * | ||
| * const result = await verifyWebhookSignature( | ||
| * req.body, // raw body string | ||
| * signature, | ||
| * timestamp, | ||
| * process.env.OFFCOIN_WEBHOOK_SECRET! | ||
| * ); | ||
| * | ||
| * if (!result.valid) { | ||
| * return res.status(401).json({ error: result.error }); | ||
| * } | ||
| * | ||
| * // Process the verified webhook | ||
| * const { type, data } = result.payload!; | ||
| * switch (type) { | ||
| * case 'member.balance_updated': | ||
| * console.log(`Balance updated for ${data.memberName}`); | ||
| * break; | ||
| * // ... handle other events | ||
| * } | ||
| * | ||
| * res.status(200).json({ received: true }); | ||
| * }); | ||
| * ``` | ||
| */ | ||
| export declare function verifyWebhookSignature(payload: string, signature: string, timestamp: string, secret: string, options?: VerifyWebhookOptions): Promise<VerifyWebhookResult>; |
+125
| /** | ||
| * Webhook utilities for verifying Offcoin webhook signatures | ||
| */ | ||
| /** | ||
| * Webhook event types that can be subscribed to | ||
| */ | ||
| export const WebhookEventTypes = { | ||
| MEMBER_BALANCE_UPDATED: 'member.balance_updated', | ||
| MEMBER_XP_UPDATED: 'member.xp_updated', | ||
| ACHIEVEMENT_UNLOCKED: 'achievement.unlocked', | ||
| PERMISSION_GRANTED: 'permission.granted' | ||
| }; | ||
| /** | ||
| * Compute an HMAC-SHA256 signature for webhook verification | ||
| * @internal | ||
| */ | ||
| async function computeSignature(payload, timestamp, secret) { | ||
| const signedPayload = `${timestamp}.${payload}`; | ||
| const encoder = new TextEncoder(); | ||
| // Use Web Crypto API (works in Node.js 18+, browsers, and edge runtimes) | ||
| const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); | ||
| const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(signedPayload)); | ||
| const hashArray = Array.from(new Uint8Array(signature)); | ||
| const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); | ||
| return `v1=${hashHex}`; | ||
| } | ||
| /** | ||
| * Verify a webhook signature from Offcoin | ||
| * | ||
| * @param payload - The raw request body as a string | ||
| * @param signature - The value from the X-Webhook-Signature header | ||
| * @param timestamp - The value from the X-Webhook-Timestamp header | ||
| * @param secret - Your webhook signing secret (starts with 'whsec_') | ||
| * @param options - Verification options | ||
| * @returns Verification result with parsed payload if valid | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * import { verifyWebhookSignature } from '@offcoin/sdk'; | ||
| * | ||
| * // In your webhook handler (e.g., Express) | ||
| * app.post('/webhooks/offcoin', async (req, res) => { | ||
| * const signature = req.headers['x-webhook-signature'] as string; | ||
| * const timestamp = req.headers['x-webhook-timestamp'] as string; | ||
| * | ||
| * const result = await verifyWebhookSignature( | ||
| * req.body, // raw body string | ||
| * signature, | ||
| * timestamp, | ||
| * process.env.OFFCOIN_WEBHOOK_SECRET! | ||
| * ); | ||
| * | ||
| * if (!result.valid) { | ||
| * return res.status(401).json({ error: result.error }); | ||
| * } | ||
| * | ||
| * // Process the verified webhook | ||
| * const { type, data } = result.payload!; | ||
| * switch (type) { | ||
| * case 'member.balance_updated': | ||
| * console.log(`Balance updated for ${data.memberName}`); | ||
| * break; | ||
| * // ... handle other events | ||
| * } | ||
| * | ||
| * res.status(200).json({ received: true }); | ||
| * }); | ||
| * ``` | ||
| */ | ||
| export async function verifyWebhookSignature(payload, signature, timestamp, secret, options = {}) { | ||
| const { maxAgeSeconds = 300 } = options; | ||
| // Validate inputs | ||
| if (!payload) { | ||
| return { valid: false, error: 'Missing payload' }; | ||
| } | ||
| if (!signature) { | ||
| return { valid: false, error: 'Missing signature' }; | ||
| } | ||
| if (!timestamp) { | ||
| return { valid: false, error: 'Missing timestamp' }; | ||
| } | ||
| if (!secret) { | ||
| return { valid: false, error: 'Missing secret' }; | ||
| } | ||
| // Parse timestamp | ||
| const timestampNum = parseInt(timestamp, 10); | ||
| if (isNaN(timestampNum)) { | ||
| return { valid: false, error: 'Invalid timestamp format' }; | ||
| } | ||
| // Check timestamp age (prevent replay attacks) | ||
| if (maxAgeSeconds > 0) { | ||
| const now = Math.floor(Date.now() / 1000); | ||
| const age = now - timestampNum; | ||
| if (age > maxAgeSeconds) { | ||
| return { valid: false, error: `Webhook too old (${age}s > ${maxAgeSeconds}s)` }; | ||
| } | ||
| if (age < -60) { | ||
| // Allow 1 minute clock skew | ||
| return { valid: false, error: 'Webhook timestamp is in the future' }; | ||
| } | ||
| } | ||
| // Compute expected signature | ||
| const expectedSignature = await computeSignature(payload, timestampNum, secret); | ||
| // Constant-time comparison to prevent timing attacks | ||
| if (signature.length !== expectedSignature.length) { | ||
| return { valid: false, error: 'Invalid signature' }; | ||
| } | ||
| let match = true; | ||
| for (let i = 0; i < signature.length; i++) { | ||
| if (signature[i] !== expectedSignature[i]) { | ||
| match = false; | ||
| } | ||
| } | ||
| if (!match) { | ||
| return { valid: false, error: 'Invalid signature' }; | ||
| } | ||
| // Parse and return payload | ||
| try { | ||
| const parsedPayload = JSON.parse(payload); | ||
| return { valid: true, payload: parsedPayload }; | ||
| } | ||
| catch { | ||
| return { valid: false, error: 'Failed to parse payload JSON' }; | ||
| } | ||
| } |
+1
-0
@@ -60,1 +60,2 @@ /** | ||
| export { OffcoinError, AuthenticationError, NotFoundError, ValidationError, ForbiddenError, BadRequestError, InternalError, RateLimitedError, NetworkError } from './errors.js'; | ||
| export { verifyWebhookSignature, WebhookEventTypes, type WebhookEventType, type WebhookPayload, type BalanceUpdatedData, type XpUpdatedData, type AchievementUnlockedData, type PermissionGrantedData, type VerifyWebhookResult, type VerifyWebhookOptions } from './webhooks.js'; |
+2
-0
@@ -66,1 +66,3 @@ /** | ||
| export { OffcoinError, AuthenticationError, NotFoundError, ValidationError, ForbiddenError, BadRequestError, InternalError, RateLimitedError, NetworkError } from './errors.js'; | ||
| // Re-export webhook utilities | ||
| export { verifyWebhookSignature, WebhookEventTypes } from './webhooks.js'; |
+1
-1
| { | ||
| "name": "@offcoin/sdk", | ||
| "version": "0.0.4", | ||
| "version": "0.0.5", | ||
| "description": "TypeScript/JavaScript SDK for the Offcoin API", | ||
@@ -5,0 +5,0 @@ "scripts": { |
+169
-1
@@ -128,2 +128,163 @@ # @offcoin/sdk | ||
| ## 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 | ||
@@ -181,3 +342,10 @@ | ||
| Permission, | ||
| PurchasePermissionResult | ||
| PurchasePermissionResult, | ||
| // Webhook types | ||
| WebhookPayload, | ||
| WebhookEventType, | ||
| BalanceUpdatedData, | ||
| XpUpdatedData, | ||
| AchievementUnlockedData, | ||
| PermissionGrantedData | ||
| } from '@offcoin/sdk'; | ||
@@ -184,0 +352,0 @@ ``` |
51242
34.33%20
11.11%1218
26.88%371
82.76%