Skip to main content
Glama
modellers

ConsignCloud MCP Server

by modellers
client.ts30.6 kB
import axios, { AxiosInstance, AxiosError } from 'axios'; import { Item, Sale, Account, Batch, ItemCategory, Location, PaginatedResponse, InventoryValueResult, SalesTotalsResult, AccountMetricsResult } from './types.js'; export class ConsignCloudClient { private client: AxiosInstance; private locale: string; private currency: string; private currencyDecimals: number; constructor(apiKey: string, baseURL: string = 'https://api.consigncloud.com/api/v1') { // Initialize locale and currency settings this.locale = process.env.STORE_LOCALE || 'en-US'; this.currency = process.env.STORE_CURRENCY || 'USD'; // Determine decimal places for currency (some currencies like JPY, ISK have 0 decimals) const zeroDecimalCurrencies = ['JPY', 'KRW', 'ISK', 'CLP', 'VND', 'XAF', 'XOF', 'XPF']; this.currencyDecimals = zeroDecimalCurrencies.includes(this.currency) ? 0 : 2; this.client = axios.create({ baseURL, headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, }); // Add response interceptor for error handling this.client.interceptors.response.use( (response) => response, (error: AxiosError) => { if (error.response) { const data = error.response.data as any; const status = error.response.status; const method = error.config?.method?.toUpperCase(); const url = error.config?.url; let errorMessage = `API Error ${status}`; // Add context if (method && url) { errorMessage += ` (${method} ${url})`; } // Add specific error details if (status === 401) { errorMessage += ': Authentication failed - Invalid API key'; } else if (status === 403) { errorMessage += ': Access forbidden - Check permissions'; } else if (status === 404) { errorMessage += ': Resource not found'; } else if (status === 422) { errorMessage += ': Validation error'; } else if (status === 429) { errorMessage += ': Rate limit exceeded'; } else if (status >= 500) { errorMessage += ': Server error'; } // Add API error message if available if (data?.error) { errorMessage += ` - ${data.error}`; } else if (data?.message) { errorMessage += ` - ${data.message}`; } // Add validation errors if present if (data?.errors && typeof data.errors === 'object') { const validationErrors = Object.entries(data.errors) .map(([field, msgs]) => `${field}: ${Array.isArray(msgs) ? msgs.join(', ') : msgs}`) .join('; '); errorMessage += ` | Validation: ${validationErrors}`; } throw new Error(errorMessage); } else if (error.request) { // Request made but no response received throw new Error(`Network error: No response from ${this.client.defaults.baseURL} - Check your internet connection`); } else { // Error in request configuration throw new Error(`Request error: ${error.message}`); } } ); } // Items (Inventory) async listItems(params?: Record<string, any>): Promise<PaginatedResponse<Item>> { const response = await this.client.get('/items', { params }); return { data: response.data.data.map((item: any) => this.convertItemResponse(item)), next_cursor: response.data.next_cursor, }; } async getItem(id: string): Promise<Item> { const response = await this.client.get(`/items/${id}`); return this.convertItemResponse(response.data); } async createItem(data: Partial<Item>): Promise<Item> { // Convert user input to API cents const apiData = { ...data, tag_price: data.tag_price ? this.convertToApiCents(data.tag_price) : undefined, cost: data.cost ? this.convertToApiCents(data.cost) : undefined, }; const response = await this.client.post('/items', apiData); return this.convertItemResponse(response.data); } async updateItem(id: string, data: Partial<Item>): Promise<Item> { // Convert user input to API cents const apiData = { ...data, tag_price: data.tag_price ? this.convertToApiCents(data.tag_price) : undefined, cost: data.cost ? this.convertToApiCents(data.cost) : undefined, }; const response = await this.client.patch(`/items/${id}`, apiData); return this.convertItemResponse(response.data); } async deleteItem(id: string): Promise<void> { await this.client.delete(`/items/${id}`); } async restoreItem(id: string): Promise<Item> { const response = await this.client.post(`/items/${id}/restore`); return this.convertItemResponse(response.data); } async getItemStats(): Promise<any> { const response = await this.client.get('/items/stats'); return response.data; } async bulkEditItems(data: any): Promise<any> { const response = await this.client.post('/items/bulk-edits', data); return response.data; } async updateItemStatuses(itemIds: string[], status: string): Promise<any> { const response = await this.client.post('/items/update-statuses-bulk', { items: itemIds, status }); return response.data; } // Sales async listSales(params?: Record<string, any>): Promise<PaginatedResponse<Sale>> { const response = await this.client.get('/sales', { params }); return { data: response.data.data.map((sale: any) => this.convertSaleResponse(sale)), next_cursor: response.data.next_cursor, }; } async getSale(id: string): Promise<Sale> { const response = await this.client.get(`/sales/${id}`); return this.convertSaleResponse(response.data); } async createSale(data: Partial<Sale>): Promise<Sale> { // Convert user input to API cents const apiData = { ...data, total: data.total ? this.convertToApiCents(data.total) : undefined, subtotal: data.subtotal ? this.convertToApiCents(data.subtotal) : undefined, tax: data.tax ? this.convertToApiCents(data.tax) : undefined, }; const response = await this.client.post('/sales', apiData); return this.convertSaleResponse(response.data); } async updateSale(id: string, data: Partial<Sale>): Promise<Sale> { // Convert user input to API cents const apiData = { ...data, total: data.total ? this.convertToApiCents(data.total) : undefined, subtotal: data.subtotal ? this.convertToApiCents(data.subtotal) : undefined, tax: data.tax ? this.convertToApiCents(data.tax) : undefined, }; const response = await this.client.patch(`/sales/${id}`, apiData); return this.convertSaleResponse(response.data); } async voidSale(id: string): Promise<Sale> { const response = await this.client.post(`/sales/${id}/void`); return this.convertSaleResponse(response.data); } async refundSale(id: string, data?: any): Promise<Sale> { const response = await this.client.post(`/sales/${id}/refund`, data); return this.convertSaleResponse(response.data); } // Accounts async listAccounts(params?: Record<string, any>): Promise<PaginatedResponse<Account>> { const response = await this.client.get('/accounts', { params }); return { data: response.data.data.map((account: any) => this.convertAccountResponse(account)), next_cursor: response.data.next_cursor, }; } async getAccount(id: string): Promise<Account> { const response = await this.client.get(`/accounts/${id}`); return this.convertAccountResponse(response.data); } async createAccount(data: Partial<Account>): Promise<Account> { // Convert user input to API cents const apiData = { ...data, balance: data.balance ? this.convertToApiCents(data.balance) : undefined, }; const response = await this.client.post('/accounts', apiData); return this.convertAccountResponse(response.data); } async updateAccount(id: string, data: Partial<Account>): Promise<Account> { // Convert user input to API cents const apiData = { ...data, balance: data.balance ? this.convertToApiCents(data.balance) : undefined, }; const response = await this.client.patch(`/accounts/${id}`, apiData); return this.convertAccountResponse(response.data); } async deleteAccount(id: string): Promise<void> { await this.client.delete(`/accounts/${id}`); } async getAccountStats(id: string): Promise<any> { const response = await this.client.get(`/accounts/${id}/stats`); return response.data; } // Batches async listBatches(params?: Record<string, any>): Promise<PaginatedResponse<Batch>> { const response = await this.client.get('/batches', { params }); return response.data; } async getBatch(id: string): Promise<Batch> { const response = await this.client.get(`/batches/${id}`); return response.data; } async createBatch(data: Partial<Batch>): Promise<Batch> { const response = await this.client.post('/batches', data); return response.data; } async updateBatch(id: string, data: Partial<Batch>): Promise<Batch> { const response = await this.client.patch(`/batches/${id}`, data); return response.data; } async updateBatchStatus(id: string, status: 'draft' | 'submitted'): Promise<Batch> { const response = await this.client.post(`/batches/${id}/status`, { status }); return response.data; } // Item Categories async listCategories(params?: Record<string, any>): Promise<PaginatedResponse<ItemCategory>> { const response = await this.client.get('/item-categories', { params }); return response.data; } async getCategory(id: string): Promise<ItemCategory> { const response = await this.client.get(`/item-categories/${id}`); return response.data; } async createCategory(data: Partial<ItemCategory>): Promise<ItemCategory> { const response = await this.client.post('/item-categories', data); return response.data; } async updateCategory(id: string, data: Partial<ItemCategory>): Promise<ItemCategory> { const response = await this.client.patch(`/item-categories/${id}`, data); return response.data; } async deleteCategory(id: string): Promise<void> { await this.client.delete(`/item-categories/${id}`); } // Locations async listLocations(params?: Record<string, any>): Promise<PaginatedResponse<Location>> { const response = await this.client.get('/locations', { params }); return response.data; } async getLocation(id: string): Promise<Location> { const response = await this.client.get(`/locations/${id}`); return response.data; } async createLocation(data: Partial<Location>): Promise<Location> { const response = await this.client.post('/locations', data); return response.data; } async updateLocation(id: string, data: Partial<Location>): Promise<Location> { const response = await this.client.patch(`/locations/${id}`, data); return response.data; } async deleteLocation(id: string): Promise<void> { await this.client.delete(`/locations/${id}`); } // Search & Suggestions async search(query: string, entities?: string[]): Promise<any> { const params: any = { query }; if (entities && entities.length > 0) { // ConsignCloud expects entities[] as array parameter params.entities = entities; } const response = await this.client.get('/search', { params }); return response.data; } async suggestFieldValues(entity: 'items' | 'accounts', field: string, value: string): Promise<any> { const params = { entity, field, value }; const response = await this.client.get('/suggest', { params }); return response.data; } // Sales Trends async getSalesTrends(params?: { start_date: string; end_date: string; bucket_size: 'day' | 'week' | 'month'; }): Promise<any> { const response = await this.client.get('/trends/sales', { params }); return response.data; } // Balance Entries async listBalanceEntries(params?: Record<string, any>): Promise<any> { const response = await this.client.get('/balance-entries', { params }); return response.data; } // Utility Methods /** * Convert API cents to store currency (numeric value) * @param apiCents - Amount in cents from API (100 cents = 1.00 USD) * @returns Numeric value in store currency */ private convertFromApiCents(apiCents: number | null | undefined): number { if (apiCents == null) return 0; // API stores in cents (100 cents = 1.00 USD = 1 ISK) return apiCents / 100; } /** * Convert store currency to API cents * @param amount - Amount in store currency (e.g., 1000 ISK or 10.00 USD) * @returns Amount in API cents */ private convertToApiCents(amount: number | null | undefined): number { if (amount == null) return 0; // Convert to API cents (1000 ISK = 100000 cents, 10.00 USD = 1000 cents) return Math.round(amount * 100); } /** * Format currency value without symbol (locale-aware number formatting) * @param apiCents - Amount in cents from API (API always uses 2-decimal system) * @returns Formatted number string (e.g., "1,234.56" for USD, "1.234" for ISK) */ private formatCurrency(apiCents: number): string { const amount = this.convertFromApiCents(apiCents); return new Intl.NumberFormat(this.locale, { minimumFractionDigits: this.currencyDecimals, maximumFractionDigits: this.currencyDecimals, useGrouping: true, }).format(amount); } /** * Format already-converted currency value (for calculation results) * @param amount - Amount already in store currency (ISK, USD, etc.) * @returns Formatted number string */ private formatAmount(amount: number): string { return new Intl.NumberFormat(this.locale, { minimumFractionDigits: this.currencyDecimals, maximumFractionDigits: this.currencyDecimals, useGrouping: true, }).format(amount); } /** * Convert Item response from API (cents) to store currency */ private convertItemResponse(item: any): Item { return { ...item, tag_price: this.convertFromApiCents(item.tag_price), cost: item.cost ? this.convertFromApiCents(item.cost) : undefined, }; } /** * Convert Sale response from API (cents) to store currency */ private convertSaleResponse(sale: any): Sale { return { ...sale, total: this.convertFromApiCents(sale.total), subtotal: this.convertFromApiCents(sale.subtotal), tax: this.convertFromApiCents(sale.tax), items: sale.items?.map((item: any) => ({ ...item, tag_price: item.tag_price ? this.convertFromApiCents(item.tag_price) : undefined, total: item.total ? this.convertFromApiCents(item.total) : undefined, })), payments: sale.payments?.map((payment: any) => ({ ...payment, amount: payment.amount ? this.convertFromApiCents(payment.amount) : undefined, })), }; } /** * Convert Account response from API (cents) to store currency */ private convertAccountResponse(account: any): Account { return { ...account, balance: this.convertFromApiCents(account.balance), }; } // Aggregation Methods /** * Fetch all items with pagination and calculate inventory value */ async calculateInventoryValue(params?: { status?: string; category?: string; account?: string; location?: string; inventory_type?: string; tag_price_gte?: number; tag_price_lte?: number; date_from?: string; date_to?: string; batch?: string; group_by?: 'category' | 'location' | 'account' | 'inventory_type' | 'status'; }): Promise<InventoryValueResult> { const { group_by, ...filterParams } = params || {}; const filters: string[] = []; // Track applied filters if (filterParams.status) filters.push(`status=${filterParams.status}`); if (filterParams.category) filters.push(`category=${filterParams.category}`); if (filterParams.account) filters.push(`account=${filterParams.account}`); if (filterParams.location) filters.push(`location=${filterParams.location}`); if (filterParams.inventory_type) filters.push(`inventory_type=${filterParams.inventory_type}`); if (filterParams.tag_price_gte) filters.push(`tag_price>=${filterParams.tag_price_gte}`); if (filterParams.tag_price_lte) filters.push(`tag_price<=${filterParams.tag_price_lte}`); if (filterParams.date_from) filters.push(`date_from=${filterParams.date_from}`); if (filterParams.date_to) filters.push(`date_to=${filterParams.date_to}`); if (filterParams.batch) filters.push(`batch=${filterParams.batch}`); let allItems: Item[] = []; let cursor: string | null = null; // Build query params with only defined values (excluding date filters - not supported by API) const queryParams: Record<string, any> = { limit: 100 }; if (filterParams.status) queryParams.status = filterParams.status; if (filterParams.category) queryParams.category = filterParams.category; if (filterParams.account) queryParams.account = filterParams.account; if (filterParams.location) queryParams.location = filterParams.location; if (filterParams.inventory_type) queryParams.inventory_type = filterParams.inventory_type; if (filterParams.tag_price_gte !== undefined) queryParams.tag_price_gte = filterParams.tag_price_gte; if (filterParams.tag_price_lte !== undefined) queryParams.tag_price_lte = filterParams.tag_price_lte; if (filterParams.batch) queryParams.batch = filterParams.batch; // NOTE: date_from/date_to NOT sent to API (not supported) - will filter client-side // Fetch all pages do { if (cursor) queryParams.cursor = cursor; const response = await this.listItems(queryParams); allItems = allItems.concat(response.data); cursor = response.next_cursor; } while (cursor); // Apply client-side date filtering (API doesn't support this) if (filterParams.date_from || filterParams.date_to) { allItems = allItems.filter(item => { if (!item.created) return false; const itemDate = new Date(item.created); if (filterParams.date_from && itemDate < new Date(filterParams.date_from)) return false; if (filterParams.date_to && itemDate > new Date(filterParams.date_to)) return false; return true; }); } // Calculate totals let totalValue = 0; let totalItems = 0; const breakdown: Record<string, { value: number; value_formatted: string; count: number }> = {}; for (const item of allItems) { // Items don't have a simple quantity field - use 1 as default or count from status const quantity = item.quantity || 1; const itemValue = item.tag_price * quantity; totalValue += itemValue; totalItems += quantity; // Group by if specified if (group_by) { let groupKey: string; switch (group_by) { case 'category': groupKey = item.category || 'uncategorized'; break; case 'location': groupKey = item.location || 'no_location'; break; case 'account': groupKey = item.account || 'no_account'; break; case 'inventory_type': groupKey = item.inventory_type; break; case 'status': groupKey = item.status; break; default: groupKey = 'all'; } if (!breakdown[groupKey]) { breakdown[groupKey] = { value: 0, value_formatted: '', count: 0 }; } breakdown[groupKey].value += itemValue; breakdown[groupKey].count += quantity; } } // Format breakdown values const formattedBreakdown: Record<string, { value: number; value_formatted: string; count: number }> | undefined = group_by ? Object.fromEntries( Object.entries(breakdown).map(([key, data]) => [ key, { value: data.value, value_formatted: this.formatAmount(data.value), count: data.count } ]) ) : undefined; const avgValue = totalItems > 0 ? Math.round(totalValue / totalItems) : 0; return { total_value: totalValue, total_value_formatted: this.formatAmount(totalValue), total_items: totalItems, average_value: avgValue, average_value_formatted: this.formatAmount(avgValue), breakdown: formattedBreakdown, filters_applied: filters, currency: this.currency, locale: this.locale, }; } /** * Fetch all sales with pagination and calculate totals */ async calculateSalesTotals(params?: { status?: string; location?: string; customer?: string; date_from?: string; date_to?: string; payment_type?: string; total_gte?: number; total_lte?: number; group_by?: 'status' | 'location' | 'date'; date_interval?: 'day' | 'week' | 'month'; }): Promise<SalesTotalsResult> { const { group_by, date_interval, ...filterParams } = params || {}; const filters: string[] = []; // Track applied filters if (filterParams.status) filters.push(`status=${filterParams.status}`); if (filterParams.location) filters.push(`location=${filterParams.location}`); if (filterParams.customer) filters.push(`customer=${filterParams.customer}`); if (filterParams.date_from) filters.push(`date_from=${filterParams.date_from}`); if (filterParams.date_to) filters.push(`date_to=${filterParams.date_to}`); if (filterParams.payment_type) filters.push(`payment_type=${filterParams.payment_type}`); if (filterParams.total_gte !== undefined) filters.push(`total>=${filterParams.total_gte}`); if (filterParams.total_lte !== undefined) filters.push(`total<=${filterParams.total_lte}`); let allSales: Sale[] = []; let cursor: string | null = null; // Build query params with only defined values (excluding date filters - not supported by API) const queryParams: Record<string, any> = { limit: 100 }; if (filterParams.status) queryParams.status = filterParams.status; if (filterParams.location) queryParams.location = filterParams.location; if (filterParams.customer) queryParams.customer = filterParams.customer; if (filterParams.payment_type) queryParams.payment_type = filterParams.payment_type; if (filterParams.total_gte !== undefined) queryParams.total_gte = filterParams.total_gte; if (filterParams.total_lte !== undefined) queryParams.total_lte = filterParams.total_lte; // NOTE: date_from/date_to NOT sent to API (not supported) - will filter client-side // Fetch all pages do { if (cursor) queryParams.cursor = cursor; const response = await this.listSales(queryParams); allSales = allSales.concat(response.data); cursor = response.next_cursor; } while (cursor); // Apply client-side date filtering (API doesn't support this) if (filterParams.date_from || filterParams.date_to) { allSales = allSales.filter(sale => { if (!sale.created) return false; const saleDate = new Date(sale.created); if (filterParams.date_from && saleDate < new Date(filterParams.date_from)) return false; if (filterParams.date_to && saleDate > new Date(filterParams.date_to)) return false; return true; }); } // Calculate totals let totalRevenue = 0; let totalTax = 0; let totalSales = 0; const breakdown: Record<string, { revenue: number; revenue_formatted: string; tax: number; tax_formatted: string; count: number }> = {}; for (const sale of allSales) { totalRevenue += sale.total || 0; totalTax += sale.tax || 0; totalSales += 1; // Group by if specified if (group_by) { let groupKey: string; switch (group_by) { case 'status': groupKey = sale.status; break; case 'location': groupKey = sale.location || 'no_location'; break; case 'date': if (date_interval) { const date = new Date(sale.created); if (date_interval === 'day') { groupKey = date.toISOString().split('T')[0]; } else if (date_interval === 'week') { const weekStart = new Date(date); weekStart.setDate(date.getDate() - date.getDay()); groupKey = weekStart.toISOString().split('T')[0]; } else if (date_interval === 'month') { groupKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; } else { groupKey = date.toISOString().split('T')[0]; } } else { groupKey = sale.created.split('T')[0]; } break; default: groupKey = 'all'; } if (!breakdown[groupKey]) { breakdown[groupKey] = { revenue: 0, revenue_formatted: '', tax: 0, tax_formatted: '', count: 0 }; } breakdown[groupKey].revenue += sale.total || 0; breakdown[groupKey].tax += sale.tax || 0; breakdown[groupKey].count += 1; } } // Format breakdown values const formattedBreakdown: Record<string, { revenue: number; revenue_formatted: string; tax: number; tax_formatted: string; count: number }> | undefined = group_by ? Object.fromEntries( Object.entries(breakdown).map(([key, data]) => [ key, { revenue: data.revenue, revenue_formatted: this.formatAmount(data.revenue), tax: data.tax, tax_formatted: this.formatAmount(data.tax), count: data.count } ]) ) : undefined; const avgSale = totalSales > 0 ? Math.round(totalRevenue / totalSales) : 0; return { total_revenue: totalRevenue, total_revenue_formatted: this.formatAmount(totalRevenue), total_tax: totalTax, total_tax_formatted: this.formatAmount(totalTax), total_sales: totalSales, average_sale: avgSale, average_sale_formatted: this.formatAmount(avgSale), breakdown: formattedBreakdown, filters_applied: filters, currency: this.currency, locale: this.locale, }; } /** * Calculate metrics for a specific account */ async calculateAccountMetrics(params: { account_id: string; date_from?: string; date_to?: string; inventory_type?: string; }): Promise<AccountMetricsResult> { const { account_id, date_from, date_to, inventory_type } = params; const filters: string[] = [`account_id=${account_id}`]; if (date_from) filters.push(`date_from=${date_from}`); if (date_to) filters.push(`date_to=${date_to}`); if (inventory_type) filters.push(`inventory_type=${inventory_type}`); // Fetch account details const account = await this.getAccount(account_id); // Fetch all items for this account let allItems: Item[] = []; let cursor: string | null = null; const itemQueryParams: Record<string, any> = { limit: 100, account: account_id }; if (inventory_type) itemQueryParams.inventory_type = inventory_type; do { if (cursor) itemQueryParams.cursor = cursor; const response = await this.listItems(itemQueryParams); allItems = allItems.concat(response.data); cursor = response.next_cursor; } while (cursor); // Calculate inventory metrics let inventoryValue = 0; let itemsAvailable = 0; let itemsSold = 0; for (const item of allItems) { const quantity = item.quantity || 1; if (item.status === 'sold') { itemsSold += quantity; } else if (item.status === 'available') { inventoryValue += item.tag_price * quantity; itemsAvailable += quantity; } } // Fetch sales for this account (items sold by this consignor) let totalSalesRevenue = 0; let commissionOwed = 0; // Fetch all sales (no date filter - API doesn't support it) let allSales: Sale[] = []; cursor = null; const salesQueryParams: Record<string, any> = { limit: 100 }; // NOTE: date_from/date_to NOT sent to API (not supported) - will filter client-side do { if (cursor) salesQueryParams.cursor = cursor; const response = await this.listSales(salesQueryParams); allSales = allSales.concat(response.data); cursor = response.next_cursor; } while (cursor); // Apply client-side date filtering (API doesn't support this) if (date_from || date_to) { allSales = allSales.filter(sale => { if (!sale.created) return false; const saleDate = new Date(sale.created); if (date_from && saleDate < new Date(date_from)) return false; if (date_to && saleDate > new Date(date_to)) return false; return true; }); } // Calculate sales revenue for items from this account for (const sale of allSales) { if (sale.status === 'completed' && sale.items) { for (const saleItem of sale.items) { // Check if this sale item belongs to our account const matchingItem = allItems.find(i => i.id === saleItem.item); if (matchingItem) { const itemRevenue = saleItem.price || 0; totalSalesRevenue += itemRevenue; // Calculate commission based on split commissionOwed += Math.round(itemRevenue * matchingItem.split); } } } } return { account_id: account.id, account_name: [account.first_name, account.last_name].filter(Boolean).join(' ') || account.company || account.number, current_balance: account.balance, current_balance_formatted: this.formatAmount(account.balance), inventory_value: inventoryValue, inventory_value_formatted: this.formatAmount(inventoryValue), items_available: itemsAvailable, items_sold: itemsSold, total_sales_revenue: totalSalesRevenue, total_sales_revenue_formatted: this.formatAmount(totalSalesRevenue), commission_owed: commissionOwed, commission_owed_formatted: this.formatAmount(commissionOwed), filters_applied: filters, currency: this.currency, locale: this.locale, }; } }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/modellers/mcp-consigncloud'

If you have feedback or need assistance with the MCP directory API, please join our Discord server