# Sell Domains with the Partner API The Partner API v3 provides you with the ability to lookup, register and manage Web3 domains. The API exposes a RESTful interface for interacting with Web3 domains and the Unstoppable Domains registry. Capabilities include: - Lookup Domains: Search for specific domains or find suggested alternatives, to determine pricing, availability and on-chain details - Registering Domains: Secure domains into your dedicated Custody wallets to maintain the domains on the blockchain - Manage Domains: Update records on the blockchain or transfer the domain to external owners, all through a simple API interface In this integration guide, you will create a Partner API flow focussing on domain lookup and registration. To complete this integration, you should be a JavaScript developer with experience in RESTful APIs. If you'd like to skip ahead or follow along, you can clone the [full example](https://github.com/unstoppabledomains/demos/tree/vincent/full-flow/Unstoppable%20Partner%20API%20Example) from GitHub beforehand. ## Step 1: Project Setup Before you get started, you'll need to install Node >= v18 and npm. Then, download the following setup script in a unix-like environment (MacOS, Linux, WSL, etc) to create the project directory, install the suggested packages, and create the suggested configuration files. If you do not have access to a unix-environment, clone the [full example](https://github.com/unstoppabledomains/demos/tree/vincent/full-flow/Unstoppable%20Partner%20API%20Example) from GitHub and follow along. [Download Setup Script](https://gist.github.com/V-Shadbolt/ea43bd25d63c1b10fdf8ea1740073290/archive/63f8da87228028c83cad16493ac84de8700de398.zip) After downloading the script, extract and move the `setup-pav3-guide.sh` file to your desired directory and run the following commands: ```shell chmod +x setup-pav3-guide.sh ./setup-pav3-guide.sh ``` This will create a `project` folder in your chosen directory that will use throughout this guide. **@uauth/js** will be the library used for implementing Unstoppable Login on the frontend, **axios** will be used for the API calls on both the client and server, **nodemon** will be used for easier typescript server development, and **lowdb** will act as an interim database on the server to keep the guide self-contained. ## Step 2: Setup Express.js Express.js will serve as your backend throughout this guide. It will handle all interactions with the Partner API, any necessary database operations, and ideally also implement [webhooks](https://docs.unstoppabledomains.com/domain-distribution-and-management/guides/implementing-webhooks/). To keep this guide self-contained, you will be utilizing `lowdb` as an interim database and will forego webhooks to avoid needing an absolute URL. It's very important that the Partner API is not directly accessed from a frontend client as the API key is very sensitive. The API does not handle checkout payments and Unstoppable Domains keeps track of a running balance against the API key for periodic invoicing. It is up to the partner to collect payment from users and subsequently keep their API key secure. There is no charge for developing with the Partner API on the **sandbox** environment. Once you migrate to **production**, a running balance will be kept against your **production API key**. ### Environment Variables Build out your `./server/.env` file per the below. You can retrieve your Partner API key by following the [Set up Partner API Access Guide](https://docs.unstoppabledomains.com/domain-distribution-and-management/quickstart/retrieve-an-api-key/). ```javascript API_URL = 'https://api.ud-sandbox.com/partner/v3' API_KEY_VALUE = 'xxxxx' PORT = 3001 ``` ### Express Endpoints With your environment variables configured, you can start outlining the endpoints needed throughout the guide. You will need a way to lookup domain suggestions based on a search query, register domains, and check domain availability. This means the server will need to be prepared to receive HTTP `POST` requests that have an `application/json` body as well as general HTTP `GET` requests with url query parameters. Other considerations: - As the Partner API is dependant on the blockchain, it provides an operation ID for you to use to check current status. You should have some way of tracking these API operations so you know when the operations complete or if there are any problems and handle them appropriately. - As there is a running balance against the `production` API, you should implement a way to know whether the frontend checkout was successful or not, and handle each case. While there is no cost for using `sandbox`, the recommendation is to return the registered domain to Unstoppable should checkout fail for any reason. Returns can be made within 14 days of registration and will be deducted from the running balance. - Should checkout succeed, you should transfer the registered domain to the end-user to custody. Here is a basic implementation of the three necessary endpoints using Node and `express`. You'll add this to the `./server/src/server.ts` file. ```typescript import express, { Express, Request, Response } from 'express'; import cors from 'cors'; import dotenv from 'dotenv'; import axios from 'axios'; // Load environment variables from .env file dotenv.config(); // Set up the Express application instance const app: Express = express(); const port = process.env.PORT || 3001; // Unstoppable Domains Sandbox API configurations const UNSTOPPABLE_SANDBOX_API_KEY = process.env.API_KEY_VALUE as string; const UNSTOPPABLE_SANDBOX_API_URL = process.env.API_URL as string; // Middleware setup app.use(express.json()); // For parsing JSON request bodies app.use(cors()); // Enable Cross-Origin Resource Sharing /** * GET /api/domains - Fetch domain suggestions based on a query string. * * @query {string} query - The search term for domain suggestions. * @returns {Response} - Returns a JSON response with domain suggestions or an error message. */ app.get('/api/domains', async (req: Request, res: Response) => { const query = req.query.query as string; try { const domains = await searchDomains(query); res.json(domains); } catch (error: any) { res.status(500).json({ error: 'Error fetching domains', details: error.message }); } }); /** * POST /api/register - Registers a domain by its ID. * * @body {string} domainId - The ID of the domain to be registered. * @returns {Response} - Returns a JSON response with registration status or an error message. */ app.post('/api/register', async (req: Request, res: Response) => { const domainId = req.body.domainId as string; try { const register = await registerDomain(domainId); if (register.error) { res.status(500).json(register); } else { res.json(register); } } catch (error: any) { res.status(500).json({ error: 'Error registering domain', details: error.message }); } }); /** * POST /api/availability - Checks availability of an array of domains. * * @body {string[]} domains - Array of domains to check availability. * @returns {Response} - Returns a JSON response with availability status or an error message. */ app.post('/api/availability', async (req: Request, res: Response) => { const domains = req.body.domains as string[]; try { const availability = await checkAvailability(domains); if (availability.error) { res.status(500).json(availability); } else { res.json(availability); } } catch (error: any) { res.status(500).json({ error: 'Error checking domain availability', details: error.message }); } }); /** * Starts the Express server and listens on the specified port. * Logs a message to the console once the server is running. */ app.listen(port, () => { console.log('Server is running on http://localhost:%s', port); }); ``` ### Partner API Proxy Now that the Express.js server has appropriate endpoints for domain suggestions, domain registration, and domain availability, you need to proxy these endpoints to the Partner API. You'll use the `searchDomains`, `registerDomain`, and `checkAvailability` functions previously defined for this task. Import the appropriate typings from the `./types` directory and make an `axios` request to the appropriate endpoint: - `searchDomains()` will be proxied to the [suggestions endpoint](https://docs.unstoppabledomains.com/apis/partner/#operation/getSuggestions) - `registerDomain()` will be proxied to the [registration endpoint](https://docs.unstoppabledomains.com/apis/partner/#operation/mintSingleDomain) - `checkAvailability()` will be proxied to the [domain details endpoint](https://docs.unstoppabledomains.com/apis/partner/#operation/getMultipleDomains) You'll also add error handling here to encompass any issues with `express`, `axios`, or the Partner API. Add these functions to the existing `server/src/server.ts` file. searchDomains ```typescript import { Suggestions } from './types/suggestions'; /** * Searches for domain suggestions based on the provided domain name. * * This function makes an API call to the Unstoppable Domains suggestions endpoint * to retrieve a list of suggested domains related to the provided 'domainName'. * It returns the suggestions data or an error object if the request fails. * * @param {string} domainName - The domain name query string to search suggestions for. * @returns {Promise} - A promise that resolves to the Suggestions object, * containing either the suggestions data or an error object if an error occurs. * * @throws {Error} - If an error occurs during the API call, this function catches the error * and returns an error object with a descriptive message and details about the failure: * - 'Server error' if the server responded with an error * - 'No response received' if there was no response from the server * - 'Error setting up request' if the request could not be configured properly */ const searchDomains = async (domainName: string): Promise => { let data = {}; try { const response = await axios.get( UNSTOPPABLE_SANDBOX_API_URL + '/suggestions/domains?query=' + encodeURIComponent(domainName), { headers: { Authorization: 'Bearer ' + UNSTOPPABLE_SANDBOX_API_KEY } } ); console.log('Suggestions:', response.data); data = response.data as Suggestions; return data } catch (error: any) { if (error.response) { console.error('Server error:', error.response.data); data.error = { message: 'Server error', details: error.response.data }; return data; } else if (error.request) { console.error('No response received:', error.request); data.error = { message: 'No response received', details: error.request }; return data; } else { console.error('Error setting up request:', error.message); data.error = { message: 'Error setting up request', details: error.message }; return data; } } }; ``` registerDomain ```typescript import { Order } from './types/orders'; /** * Registers a domain with the provided domain ID. * * This function sends a POST request to the Unstoppable Domains API to register a domain to the default API wallet. * On successful registration, it returns the registration details as an 'Order' object. * If an error occurs, it returns an error object with relevant details. * * @param {string} domainId - The ID of the domain to register. * @returns {Promise} - A promise that resolves to the 'Order' object containing the registration details or an error object. * * @throws {Error} - If an error occurs, it catches the error and returns an error object with: * - 'Server error' if the server responded with an error * - 'No response received' if there was no response from the server * - 'Error setting up request' if the request configuration failed */ const registerDomain = async (domainId: string): Promise => { let data = {}; try { const response = await axios.post( UNSTOPPABLE_SANDBOX_API_URL + '/domains?query=' + encodeURIComponent(domainId), JSON.stringify({ name: domainId, records: {} }), { headers: { Authorization: 'Bearer ' + UNSTOPPABLE_SANDBOX_API_KEY, 'Content-Type': 'application/json' } } ); console.log('Domain registered:', response.data); data = response.data as Order; return data } catch (error: any) { if (error.response) { console.error('Server error:', error.response.data); data.error = { message: 'Server error', details: error.response.data }; return data; } else if (error.request) { console.error('No response received:', error.request); data.error = { message: 'No response received', details: error.request }; return data; } else { console.error('Error setting up request:', error.message); data.error = { message: 'Error setting up request', details: error.message }; return data; } } }; ``` checkAvailability ```typescript import { Domains } from './types/domains'; /** * Checks the availability of a list of domains. * * This function sends a GET request to the Unstoppable Domains API to check the * availability of a given list of domain names. It returns the domain details or an * error object if an error occurs. * * @param {Array} domains - The ID of the operation to check. * @returns {Promise} - A promise that resolves to an 'Operation' object with status details or an error object. * * @throws {Error} - If an error occurs, it catches the error and returns an error object with: * - 'Server error' if the server responded with an error * - 'No response received' if there was no response from the server * - 'Error setting up request' if the request configuration failed */ const checkAvailability = async (domains: Array): Promise => { let data = {}; const query = domains.join('&query='); try { const response = await axios.get( UNSTOPPABLE_SANDBOX_API_URL + '/domains?query=' + encodeURIComponent(query), { headers: { Authorization: 'Bearer ' + UNSTOPPABLE_SANDBOX_API_KEY, 'Content-Type': 'application/json' } } ); console.log('Domain Availability:', response.data); data = response.data as Domains; return data; } catch (error: any) { if (error.response) { console.error('Server error:', error.response.data); data.error = { message: 'Server error', details: error.response.data }; return data; } else if (error.request) { console.error('No response received:', error.request); data.error = { message: 'No response received', details: error.request }; return data; } else { console.error('Error setting up request:', error.message); data.error = { message: 'Error setting up request', details: error.message }; return data; } } }; ``` You can also take this opportunity to take into account the earlier considerations: - Partner API Operations - Returns - Transfers Partner API operation tracking will ideally be handled by [webhooks](https://docs.unstoppabledomains.com/domain-distribution-and-management/guides/implementing-webhooks/) but, as mentioned, this guide will not encompass public hosting. As such, you'll rely on the [operations endpoint](https://docs.unstoppabledomains.com/apis/partner/#operation/checkOperation). Similarly, you will use the [returns endpoint](https://docs.unstoppabledomains.com/apis/partner/#operation/returnDomain) to handle returning domains and will use the [overwriting update endpoint](https://docs.unstoppabledomains.com/apis/partner/#operation/updateDomainPut) to transfer the domain to the end user. You would ideally register a webhook for each Partner API operation that is initiated, including a return, registration, transfer, etc. For the purposes of this guide, you can use the `checkOperation()` function as a synchronous polling approach within `trackOperation()`. checkOperation ```typescript import { Operation } from './types/orders'; /** * Checks the status of a domain-related operation. * * This function sends a GET request to the Unstoppable Domains API to check the * status of a given operation by its ID. It returns the operation details or an * error object if an error occurs. * * @param {string} operationId - The ID of the operation to check. * @returns {Promise} - A promise that resolves to an 'Operation' object with status details or an error object. * * @throws {Error} - If an error occurs, it catches the error and returns an error object with: * - 'Server error' if the server responded with an error * - 'No response received' if there was no response from the server * - 'Error setting up request' if the request configuration failed */ const checkOperation = async (operationId: string): Promise => { let data = {}; try { const response = await axios.get( UNSTOPPABLE_SANDBOX_API_URL + '/operations/' + encodeURIComponent(operationId), { headers: { Authorization: 'Bearer ' + UNSTOPPABLE_SANDBOX_API_KEY, 'Content-Type': 'application/json' } } ); console.log('Operation Status:', response.data); data = response.data as Operation; return data; } catch (error: any) { if (error.response) { console.error('Server error:', error.response.data); data.error = { message: 'Server error', details: error.response.data }; return data; } else if (error.request) { console.error('No response received:', error.request); data.error = { message: 'No response received', details: error.request }; return data; } else { console.error('Error setting up request:', error.message); data.error = { message: 'Error setting up request', details: error.message }; return data; } } }; ``` trackOperation ```typescript /** * Periodically tracks the status of an operation and updates the database. * * This function polls the Unstoppable Domains API at a set interval to check the * status of a specified operation. It stops tracking if the operation completes. * * @param {string} operationId - The ID of the operation to track. */ const trackOperation = async (operationId: string) => { const interval = setInterval(async () => { const operation = await checkOperation(operationId); if (operation.error) { console.log('Error:', operation.error); } else { if (operation.status === 'COMPLETED') { // Handle completed operation clearInterval(interval); } if (operation.status === 'FAILED') { // Handle failed operation clearInterval(interval); } // You would want to ensure you're handling other status cases here } }, 60000); // 1 minute timer }; ``` returnDomain ```typescript import { Return } from './types/returns'; /** * Returns a domain to Unstoppable Domains. * * This function sends a DELETE request to the Unstoppable Domains API to remove * the specified domain from the default API wallet and returns it to Unstoppable Domains. * It returns a confirmation or an error object in case of failure. Domains must be returned within 14 days. * * @param {string} domainId - The ID of the domain to return. * @returns {Promise} - A promise that resolves to a 'Return' object with return details or an error object. * * @throws {Error} - If an error occurs, it catches the error and returns an error object with: * - 'Server error' if the server responded with an error * - 'No response received' if there was no response from the server * - 'Error setting up request' if the request configuration failed */ const returnDomain = async (domainId: string): Promise => { let data = {}; try { const response = await axios.delete( UNSTOPPABLE_SANDBOX_API_URL + '/domains/' + encodeURIComponent(domainId), { headers: { Authorization: 'Bearer ' + UNSTOPPABLE_SANDBOX_API_KEY, 'Content-Type': 'application/json' } } ); data = response.data as Return; return data } catch (error: any) { if (error.response) { console.error('Server error:', error.response.data); data.error = { message: 'Server error', details: error.response.data }; return data; } else if (error.request) { console.error('No response received:', error.request); data.error = { message: 'No response received', details: error.request }; return data; } else { console.error('Error setting up request:', error.message); data.error = { message: 'Error setting up request', details: error.message }; return data; } } }; ``` transferDomain ```typescript import { Transfer } from './types/transfers'; /** * Transfers a domain to a specified wallet address. * * This function sends a PUT request to the Unstoppable Domains API to transfer ownership * of the specified domain to the provided wallet address. It returns the transfer details * or an error object in case of a failure. * * @param {string} domainId - The ID of the domain to transfer. * @param {string} walletAddress - The wallet address to transfer the domain ownership to. * @returns {Promise} - A promise that resolves to a 'Transfer' object with transfer details or an error object. * * @throws {Error} - If an error occurs, it catches the error and returns an error object with: * - 'Server error' if the server responded with an error * - 'No response received' if there was no response from the server * - 'Error setting up request' if the request configuration failed */ const transferDomain = async (domainId: string, walletAddress: string): Promise => { let data = {}; try { const response = await axios.put( UNSTOPPABLE_SANDBOX_API_URL + '/domains/' + encodeURIComponent(domainId), JSON.stringify({ name: domainId, owner: { type: 'EXTERNAL', address: walletAddress }, records: {} }), { headers: { Authorization: 'Bearer ' + UNSTOPPABLE_SANDBOX_API_KEY, 'Content-Type': 'application/json' } } ); data = response.data as Transfer; return data } catch (error: any) { if (error.response) { console.error('Server error:', error.response.data); data.error = { message: 'Server error', details: error.response.data }; return data; } else if (error.request) { console.error('No response received:', error.request); data.error = { message: 'No response received', details: error.request }; return data; } else { console.error('Error setting up request:', error.message); data.error = { message: 'Error setting up request', details: error.message }; return data; } } }; ``` Update the original `/api/register` endpoint with the `trackOperation()` function as registrations are blockchain dependant. You do not need to worry about the `transfer` or `return` functions just yet. ```typescript app.post('/api/register', async (req: Request, res: Response) => { const domainId = req.body.domainId as string; try { const register = await registerDomain(domainId); if (register.error) { res.status(500).json(register); } else { res.json(register); trackOperation(register.operation.id); } } catch (error: any) { res.status(500).json({ error: 'Error registering domain', details: error.message }); } }); ``` ### Mock Database As the focus of this guide is not databases, `lowdb` will be used as an interim solution which is a type-safe local JSON database. You'll use these mock databases to store Partner API responses for orders, transfers, and returns in an easily-digestible format. These responses will be used in conjunction with the `trackOperation()` function to know when you can complete other actions on the domain. Only one operation can be done on a domain at a time so it's important to now when you can act on it again. Add the below to the `./server/src/server.ts` file. To start, specify the storage directory for the JSON files as well as the default data for the JSON. In this case, you can use the `./server/src/data/` directory for the JSON files and specify `items` as the default data: ```typescript import path from 'path'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { JSONFile } from 'lowdb/node'; import { Low } from 'lowdb'; import { Orders } from './types/orders'; import { Transfers } from './types/transfers'; import { Returns } from './types/returns'; // Directory setup for the databases const __dirname = dirname(fileURLToPath(import.meta.url)); // Default data for databases const defaultOrderData: Orders = { items: [] } const defaultTransferData: Transfers = { items: [] } const defaultReturnData: Returns = { items: [] } // Paths for local JSON databases const orderDBPath = path.join(__dirname, 'data/orders.json') const transferDBPath = path.join(__dirname, 'data/transfers.json') const returnDBPath = path.join(__dirname, 'data/returns.json') ``` Then instantiate and start the databases: ```typescript // LowDB instances for each JSON database const orderDB = new Low(new JSONFile(orderDBPath), defaultOrderData); const transferDB = new Low(new JSONFile(transferDBPath), defaultTransferData); const returnDB = new Low(new JSONFile(returnDBPath), defaultReturnData); /** * Initializes local JSON databases for orders, transfers, and returns. * Reads data from the database files and writes default data if none exists. */ const initDB = async () => { // Initialize Orders database await orderDB.read(); orderDB.data = orderDB.data || defaultOrderData; await orderDB.write(); // Initialize Transfers database await transferDB.read(); transferDB.data = transferDB.data || defaultTransferData; await transferDB.write(); // Initialize Returns database await returnDB.read(); returnDB.data = returnDB.data || defaultReturnData; await returnDB.write(); console.log('Databases initialized'); }; // Initialize the databases on server start initDB().catch((error) => console.error('Error initializing DB:', error)); ``` Currently, the `trackOperation()` function only sets an interval for synchronously checking an operation ID until it either succeeds or fails. However, you can utilize this function to keep the `orders.json` file up-to-date. To do this, you'll need a way to check the operation status currently saved in the `orders` database as well as a way to update it. The below two functions will be able to handle these tasks: ```typescript /** * Updates an operation in the database with new operation data. * * Reads the database, searches for an existing operation by its ID, and updates it * if found. Writes the updated data back to the database. * * @param {Operation} operation - The operation data to update in the database. * @param {Low} db - The database instance to perform the update. * @returns {Promise} - A promise that resolves when the operation is updated. */ const updateOperation = async (operation: Operation, db: Low): Promise => { await db.read(); const item = db.data.items.find((item: any) => item.operation.id === operation.id); if (item) { item.operation = operation; await db.write(); } }; /** * Retrieves the current status of an operation from the database. * * This function reads the database, searches for an operation by its ID, * and returns its status. If the operation is not found, it returns the provided * default status. * * @param {string} operationId - The ID of the operation to retrieve the status for. * @param {string} status - The default status to return if the operation is not found. * @param {Low} db - The database instance to search. * @returns {Promise} - A promise that resolves to the status of the operation. */ const getCurrentOperationStatus = async (operationId: string, status: string, db: Low): Promise => { await db.read(); const item = db.data.items.find((item: any) => item.operation.id === operationId); if (item) { return item.operation.status; } return status; }; ``` Update the `trackOperation()` function with database support. Now, it will check if there has been a change in the operation status: ```typescript const trackOperation = async (operationId: string, db: Low) => { const interval = setInterval(async () => { const operation = await checkOperation(operationId); const status = await getCurrentOperationStatus(operationId, operation.status, db); if (operation.error) { console.log('Error:', operation.error); } else { if (operation.status != status) { await updateOperation(operation, db); if (operation.status === 'COMPLETED') { // Handle completed operation clearInterval(interval); } if (operation.status === 'FAILED') { // Handle failed operation clearInterval(interval); } // You would want to ensure you're handling other status cases here } } }, 60000); // 1 minute timer }; ``` With the databases initialized, update the `/api/register` endpoint with the JSON `orderDB` accordingly. This will add the Partner API response for each registration to `./server/src/data/orders.json`: ```typescript app.post('/api/register', async (req: Request, res: Response) => { const domainId = req.body.domainId as string; try { const register = await registerDomain(domainId); if (register.error) { res.status(500).json(register); } else { res.json(register); await orderDB.update(({ items }) => items.push(register)); trackOperation(register.operation.id, orderDB); } } catch (error: any) { res.status(500).json({ error: 'Error registering domain', details: error.message }); } }); ``` One final consideration with `lowdb` is that the database stops running when the `express` server is stopped. As such, it is possible for operations to complete and not be properly tracked. As a workaround, call `trackOperation()` when the server starts for any operation in the databases that are neither `COMPLETED` or `FAILED`. ```typescript /** * Initializes tracking for any pending operations in the order, transfer, and return databases. * Loads the database data and identifies entries where the 'operation.status' is not 'COMPLETED'. * For each pending operation, it triggers tracking functions to monitor ongoing processes. * * @async * @function initializeTracking * @returns {Promise} - Resolves once all pending operations have been re-tracked. */ const initializeTracking = async (): Promise => { // Load databases await orderDB.read(); await transferDB.read(); await returnDB.read(); // Function to check and track pending operations const checkAndTrackPendingOperations = async (db: Low) => { // Update according to the appropriate status of the operation const pendingItems = db.data?.items?.filter((item: any) => item.operation.status !== 'COMPLETED' && item.operation.status !== 'FAILED') || []; for (const item of pendingItems) { await trackOperation(item.operation.id, db); } }; // Check and track pending operations in each database await checkAndTrackPendingOperations(orderDB); await checkAndTrackPendingOperations(transferDB); await checkAndTrackPendingOperations(returnDB); console.log('Pending operations re-tracked'); } // Call initializeTracking when server starts initializeTracking().catch((error) => console.error('Error initializing tracking:', error)); ``` ### Checkout At this point, everything is tied together with the exception of these two unused functions: `returnDomain()` and `transferDomain()`. As a reminder, both the `return` and `transfer` functions depend on the status of the frontend checkout. Should checkout succeed, transfer the registered domain to the end user. If checkout fails, return the registered domain to Unstoppable. While this will be fully dependent on the frontend solution, keep things simple and leverage the `lowdb` databases with a set interval. First, you need another endpoint for the frontend to provide checkout updates for each order. Set up a `POST` endpoint on the `express` server that accepts `application/json`. You'll need the domain that was purchased, the operation ID of the initial registration, a `TRUE` / `FALSE` boolean for checkout success, and the wallet address the domain should be transferred to. The below function will use these parameters to update the `orderDB` with the appropriate data. ```typescript /** * POST /api/checkout/:domain - Processes checkout for a domain by updating the order details. * * @param {string} domain - The domain ID in the URL path. * @body {string} wallet - Wallet address for the domain transfer. * @body {boolean} payment - Payment confirmation status. * @body {string} operationId - Operation ID associated with the checkout. * @returns {Response} - Returns a JSON response indicating order processing status. */ app.post('/api/checkout/:domain', async (req: Request, res: Response) => { const domain = req.params.domain as string; const walletAddress = req.body.wallet as string; const payment = req.body.payment as boolean; const operationId = req.body.operationId as string; try { await orderDB.read(); const order = orderDB.data.items.find(order => order.operation.id === operationId); if (order) { order.walletAddress = walletAddress; order.payment = payment; await orderDB.write(); } res.json('Order for domain ' + domain + ' is being processed'); } catch (error: any) { res.status(500).json({ error: 'Error processing checkout', details: error.message }); } }); ``` Next, you'll need a function similar to `trackOperation()` that will track the status of the order checkout against the database. If the registration operation is complete, check if the `payment` boolean is `TRUE` and subsequently check if there is a `wallet address`. Presumably, that will be a successful order. Otherwise, assume failure and return the domain. Depending on the state, call the `returnDomain()` or `transferDomain` functions and their associated databases. The below does not account for edge cases and is meant as a starting point. ```typescript /** * Monitors the checkout process and handles domain transfer or return based on payment status. * * This function periodically checks the status of an order associated with the provided domain ID. * If the order status is 'COMPLETED' and payment is successful, it transfers the domain to the user's * wallet address. If payment is unsuccessful, it returns the domain to Unstoppable Domains. * * @param {string} operationId - The ID of the operation to monitor during checkout. */ const trackCheckout = async (operationId: string) => { const interval = setInterval(async () => { await orderDB.read(); const order = orderDB.data.items.find(order => order.operation.id === operationId); if (order) { // Successful checkout if (order.operation.status === 'COMPLETED' && order.walletAddress && order.payment === true) { try { const domainTransfer = await transferDomain(order.operation.domain, order.walletAddress); if (domainTransfer.error) { console.log('Error transferring domain:', domainTransfer.error); // Handle failed init transfer } else { console.log('Domain transferred:', domainTransfer); // Handle successful init transfer clearInterval(interval); await transferDB.update(({ items }) => items.push(domainTransfer)); trackOperation(domainTransfer.operation.id, transferDB); } } catch (error: any) { console.log('Error transferring domain:', error.message); } // Unsuccessful Checkout } else if (order.operation.status === 'COMPLETED' && order.payment != true) { try { const domainReturn = await returnDomain(order.operation.domain); if (domainReturn.error) { console.log('Error returning domain:', domainReturn.error); // Handle failed init return } else { console.log('Domain returned:', domainReturn); // Handle successful init return clearInterval(interval); await returnDB.update(({ items }) => items.push(domainReturn)); trackOperation(domainReturn.operation.id, returnDB); } } catch (error: any) { console.log('Error returning domain:', error.message); } } // You would want to ensure you're handling other status cases here } }, 180000); // 3 minute timer }; ``` Finally, update the original `/api/register` endpoint with the `trackCheckout()` function: ```typescript app.post('/api/register', async (req: Request, res: Response) => { const domainId = req.body.domainId as string; try { const register = await registerDomain(domainId); if (register.error) { res.status(500).json(register); } else { res.json(register); await orderDB.update(({ items }) => items.push(register)); trackOperation(register.operation.id, orderDB); trackCheckout(register.operation.id); } } catch (error: any) { res.status(500).json({ error: 'Error registering domain', details: error.message }); } }); ``` At this point, you have a completed backend built with Node and `express`! ## Step 3: Setup Next.js With the backend completed, it is now time to focus on the frontend. Next.js will serve this purpose throughout the remainder of this guide. While there are many viable alternatives, Next.js provides easy page and API management. In this section of the guide, you will create functions to call the backend, build out an e-commerce cart, checkout and order pages, as well as a general search page. The following sections will not focus on CSS or visual improvements but the initial setup script did include `Tailwind CSS` and the [full example](https://github.com/unstoppabledomains/demos/tree/vincent/full-flow/Unstoppable%20Partner%20API%20Example) can be referenced for a CSS outline. ### Environment Variables Build out your `./client/.env` file per the below. You can retrieve your UAuth Client ID key by following the [Retrieve Client Credentials guide](https://docs.unstoppabledomains.com/identity/quickstart/retrieve-client-credentials/). ```javascript NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 NEXT_PUBLIC_CLIENT_ID=1234567890 NEXT_PUBLIC_REDIRECT_URI=http://localhost:3000 NEXT_PUBLIC_SCOPES=openid wallet profile ``` ### Express.js API With your environment variables configured, you can start outlining the backend function calls. Per `Step 2`, you have four exposed endpoints on the `express` server: - `POST` to `/api/availability` - `POST` to `/api/register` - `GET` to `/api/domains` - `POST` to `/api/checkout/:domain` The general outline for each function will be very similar and, with the exception of `/api/domains`, will contain a JSON body. You'll need to call the backend server running on `port 3001` and handle both the expected result and any possible errors. In the `./client/src/app/api` directory, create the following files and add the outlined example functions. - `fetchAvailability.ts` - `claimDomain.ts` - `fetchSuggestions.ts` - `initCheckout.ts` These four functions will serve as the core of your frontend. fetchAvailability.ts ```typescript import axios from 'axios'; import { Domains } from '@/types/domains'; /** * Checks the availability of a list of domains. * * @param {string[]} domains - An array of domain names to check for availability. * @returns {Promise} - A promise that resolves to a 'Domains' object containing availability data for each domain. * @throws {Error} - If an error occurs during the request, throws an error with details. */ export const fetchAvailability = async (domains: string[]) => { try { const url = process.env.NEXT_PUBLIC_API_BASE_URL + '/api/availability'; const res = await axios.post(url, { domains: domains, } ); return res.data as Domains; } catch (err: unknown) { if (err instanceof Error) { throw new Error('Error domain(s) availability: ', err); } } } ``` claimDomain.ts ```typescript import axios from 'axios'; import { DomainSuggestion } from '../../types/suggestions'; import { Order } from '@/types/orders'; /** * Attempts to claim a specific domain. * * @param {DomainSuggestion} selectedDomain - The domain to claim, specified by a 'DomainSuggestion' object. * @returns {Promise} - A promise that resolves to an 'Order' object if the domain is successfully claimed. * @throws {Error} - If an error occurs during the request, throws an error with details. */ export const claimDomain = async (selectedDomain: DomainSuggestion) => { try { const url = process.env.NEXT_PUBLIC_API_BASE_URL + '/api/register'; const res = await axios.post(url, { domainId: selectedDomain.name, } ); return res.data as Order; } catch (err: unknown) { if (err instanceof Error) { throw new Error('Error registering domain(s): ', err); } } } ``` fetchSuggestions.ts ```typescript import { Suggestions } from '@/types/suggestions'; import axios from 'axios'; /** * Fetches domain suggestions based on a search query. * * @param {string} query - The search term used to find domain suggestions. * @returns {Promise} - A promise that resolves to a 'Suggestions' object containing domain suggestions. * @throws {Error} - If an error occurs during the request, throws an error with details. */ export const fetchSuggestions = async (query: string) => { try { const url = process.env.NEXT_PUBLIC_API_BASE_URL + '/api/domains?query=' + encodeURIComponent(query); const res = await axios.get(url); return res.data as Suggestions; } catch (err: unknown) { if (err instanceof Error) { throw new Error('Error fetching domains: ', err); } } } ``` initCheckout.ts ```typescript import axios from 'axios'; /** * Initializes the checkout process for a specific domain. * * @param {string} domain - The domain name being checked out. * @param {string} walletAddress - The wallet address for the domain transfer. * @param {boolean} payment - The payment status; 'true' if payment is confirmed. * @param {string} operationId - The unique ID for the checkout operation. * @returns {Promise} - A promise that resolves to the server response on checkout initiation. * @throws {Error} - If an error occurs during the request, throws an error with details. */ export const initCheckout = async (domain: string, walletAddress: string, payment: boolean, operationId: string) => { try { const url = process.env.NEXT_PUBLIC_API_BASE_URL + '/api/checkout/' + encodeURIComponent(domain); const res = await axios.post(url, { wallet: walletAddress, payment: payment, operationId: operationId, } ); return res.data; } catch (err: unknown) { if (err instanceof Error) { throw new Error('Error processing checkout: ', err); } } } ``` ### Search In the `./client/src/app/page.tsx` file you'll find the default `Home()` function for a Next.js app. You'll utilize this file for the domain search results. To start, add the necessary imports at the very top of the file for the required functions and declare the file as a Client Component module with `use client`. If `use client` isn't at the very top of your file, you'll run into compilation errors. ```typescript 'use client'; import React, { useState } from 'react'; import { fetchSuggestions } from './api/fetchSuggestions'; import { Suggestions } from '../types/suggestions'; ``` From there, instantiate the states within the page at the start of the `Home()` function. This includes user input for the domain search, the domain search results, any errors, and pagination information. ```typescript export default function Home() { const [query, setQuery] = useState(''); const [domains, setDomains] = useState(null); const [error, setError] = useState(''); const [currentPage, setCurrentPage] = useState(1); const [loading, setLoading] = useState(false); const domainsPerPage = 5; return ( ... ``` Next, you will setup the `pagination` and `search` functions as well as handle the user input. As `pagination` will be a standalone function outside of `Home()`, start there. The `types` needed for the pagination function are not pre-included with the setup script and are provided below. After the closing brace for the `Home()` function, add the following interface definition and related `Pagination()` function. This function will split the returned list of domain suggestions into equal parts up to a maximum number per page as defined by `domainsPerPage`. There is some `Tailwind CSS` included here to make the button usage easier. ```typescript interface PaginationProps { domainsPerPage: number; totalDomains: number; paginate: (pageNumber: number) => void; } /** * Pagination component to render page numbers for navigating through domain results. * * @param {number} domainsPerPage - Number of domains displayed per page. * @param {number} totalDomains - Total number of domain results. * @param {function} paginate - Callback function to change the page number. * @returns {JSX.Element} Pagination buttons for navigation. */ const Pagination: React.FC = ({ domainsPerPage, totalDomains, paginate }) => { const pageNumbers = []; for (let i = 1; i <= Math.ceil(totalDomains / domainsPerPage); i++) { pageNumbers.push(i); } return ( ); }; ``` With the `Pagination()` function finished, you will implement the `search` function and remaining logic. Add the following within the `Home()` function before the `return`. This will be a simple implementation as you'll only need to call the `fetchSuggestions()` function and set the appropriate states like so: ```typescript /** * Fetches domain suggestions based on the current search query. * Updates the 'domains' state with the response or sets an error message if the fetch fails. */ const searchDomains = async () => { try { const response = await fetchSuggestions(query); setDomains(response!); setError(''); setCurrentPage(1); } catch (error) { console.error(error); setError('Error fetching domains. Please try again.'); } }; ``` You'll tie this to the pagination function shortly by doing some preliminary logic: ```typescript // Calculate indexes for pagination based on current page const indexOfLastDomain = currentPage * domainsPerPage; const indexOfFirstDomain = indexOfLastDomain - domainsPerPage; const currentDomains = domains?.items?.slice(indexOfFirstDomain, indexOfLastDomain); /** * Sets the current page for pagination. * @param {number} pageNumber - The page number to navigate to. */ const paginate = (pageNumber: number) => setCurrentPage(pageNumber); ``` Next, you need to handle user input. An easy way to handle this will be to leverage HTML forms. However, there are some considerations to make here. Notably, Unstoppable domains have limitations on what constitutes a valid domain name. In this guide, you'll handle this validation within the form submission function but it can be handled at any stage throughout the user input. ```typescript /** * Handles form submission for domain search. * Validates user input, resets domain state, and initiates domain search. * * @param {React.FormEvent} event - The form submit event. */ const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); const inputElement = document.getElementById('search') as HTMLInputElement; const inputValue = inputElement.value; // Validation checks const isValidLength = inputValue.length >= 1 && inputValue.length <= 24; const hasValidChars = ![...inputValue].some(char => !/[a-zA-Z0-9-]/.test(char)); const startsWithHyphen = inputValue.startsWith('-'); const endsWithHyphen = inputValue.endsWith('-'); const isValid = isValidLength && hasValidChars && !startsWithHyphen && !endsWithHyphen; if (!isValid) { setDomains(null); setError('Must be 1-24 characters in length, Contain only letters, numbers, or hyphens, and cannot start or end with a hyphen.'); return; } setLoading(true); try { setDomains(null); // Clear previous results await searchDomains(); // Fetch new search results } catch (error) { console.error('Error:', error); } finally { setLoading(false); // Reset loading state } } ``` The last step of the search will be the UI. Again, this guide will not focus on CSS but will provide some to get you started. Add the HTML form to the function return. Remove the existing `
Hello world!
`, rename the remaining `
` tags to `
` tags, and add the below HTML snippets between them. ```html
) => {handleSubmit(e)}}>
setQuery(e.target.value)} required />
Must be 1-24 characters in length, Contain only letters, numbers, or hyphens, and cannot start or end with a hyphen.
{error &&
{error}
} ``` Below the form, add the list of suggested domains: ```html
{loading && } {currentDomains?.map((domain) => (

{domain?.name}

{((domain?.price?.listPrice?.usdCents ?? 0) / 100).toFixed(2)} USD

))}
``` Finally, add the pagination buttons below the search results: ```html ``` You should now have a user-interactable search bar with domain name validation, search results, and search result pagination. Feel free to clean up the default HTML provided by Next.js and to tune the CSS as you see fit! ### Helper Functions Before proceeding with the rest of the e-commerce experience, you need to implement a nav bar, a helper function, and add create two contexts: one for authentication, and one for the shopping cart. Start with the helper function first as the contexts rely on it, and the nav bar relies on the contexts. While you utilized `lowdb` on the `express` server to act as a mock database, you're going to utilize the browsers' local storage to handle the data needs on the frontend. There are several caveats with using local storage exclusively for an e-commerce experience but for the purposes of this guide, it will suffice. Create a `useLocalStorage.ts` file in `./client/src/app/utils`. ```typescript import { useCallback, useEffect, useState } from 'react'; /** * Custom React hook to manage state with localStorage, syncing updates across browser tabs. * Provides a value stored in localStorage and an updater function to modify it. * * @template T - The type of the state value to be stored. * @param {string} storageKey - The localStorage key under which the state is saved. * @param {T} fallbackState - The initial value to be used if no item exists in localStorage. * @returns {[T, (newValue: T) => void]} - An array containing the current state value and a function to update it. */ function useLocalStorage(storageKey: string, fallbackState: T) { const isClient = typeof window !== 'undefined'; const [value, setValue] = useState(() => { if (isClient) { const storedValue = localStorage.getItem(storageKey); return storedValue ? JSON.parse(storedValue) : fallbackState; } return fallbackState; }); useEffect(() => { if (isClient) { const storedValue = localStorage.getItem(storageKey); setValue(storedValue ? JSON.parse(storedValue) : fallbackState); } }, [storageKey, isClient]); useEffect(() => { const handleChanges = (e: StorageEvent) => { if (e.key === storageKey) { setValue(e.newValue ? JSON.parse(e.newValue) : fallbackState); } } if (isClient) { window.addEventListener('storage', handleChanges); } return () => { if (isClient) { window.removeEventListener('storage', handleChanges); } }; }, [storageKey, fallbackState, isClient]); const updateStorage = useCallback( (newValue: T) => { setValue(newValue) if (isClient) { localStorage.setItem(storageKey, JSON.stringify(newValue)) } }, [storageKey, isClient] ) return [value, updateStorage] as const; }; export default useLocalStorage ``` Next, you'll handle the contexts. Contexts are designed to share data across multiple React components such as selected theme, user authentication, preferred language, etc. For the cart context, you'll need functions for: - Adding an item to the cart - Removing an item from the cart - Clearing the cart - Updating the cart items with backend responses Create a `CartContext.tsx` file in `./client/src/app/context` and add the following: ```typescript 'use client'; import { DomainSuggestion } from '@/types/suggestions'; import { createContext, useContext, ReactNode } from 'react'; import useLocalStorage from '../utils/useLocalStorage'; import { CartItem } from '@/types/cart'; /** * @typedef {Object} CartContextType - Defines the context type for the cart. * @property {CartItem[]} cart - Array of items in the cart. * @property {(item: DomainSuggestion) => void} addToCart - Function to add a domain suggestion to the cart. * @property {(name: string) => void} removeFromCart - Function to remove a domain by name from the cart. * @property {(name: string, operationId: string) => void} updateCartItemOperation - Updates the operation ID of a cart item. * @property {(name: string, availability: boolean) => void} updateCartItemAvailability - Updates availability status of a cart item. * @property {() => void} clearCart - Clears all items from the cart. */ interface CartContextType { cart: CartItem[]; addToCart: (item: DomainSuggestion) => void; removeFromCart: (name: string) => void; updateCartItemOperation: (name: string, operationId: string) => void; updateCartItemAvailability: (name: string, availability: boolean) => void; clearCart: () => void; } /** Context to manage cart state throughout the application */ const CartContext = createContext(undefined); /** * CartProvider component to wrap children and provide cart context. * * @param {Object} props - Props passed to the provider component. * @param {ReactNode} props.children - The components that will consume cart context. * @returns {JSX.Element} Context provider with cart functionalities. */ export const CartProvider = ({ children }: { children: ReactNode }) => { const [cart, setCart] = useLocalStorage('CART_STORAGE', []); /** * Adds a new item to the cart if it doesn't already exist. * @param {DomainSuggestion} item - The domain suggestion to add. */ const addToCart = (item: DomainSuggestion) => { const newItem = { suggestion: item, available: true, operationId: '' }; const newCart = cart.some(cartItem => cartItem.suggestion.name === newItem.suggestion.name) ? cart : [...cart, newItem]; setCart(newCart); }; /** * Removes an item from the cart by its domain name. * @param {string} name - The name of the domain to remove. */ const removeFromCart = (name: string) => { setCart(cart.filter((item: CartItem) => item.suggestion.name !== name)); }; /** * Updates the operation ID for a specific cart item. * @param {string} name - Name of the cart item to update. * @param {string} operationId - The new operation ID to set. */ const updateCartItemOperation = (name: string, operationId: string) => { const updatedCart = cart.map((item) => item.suggestion.name === name ? { ...item, operationId } : item ); setCart(updatedCart); }; /** * Updates the availability status of a specific cart item. * @param {string} name - Name of the cart item to update. * @param {boolean} available - Availability status to set. */ const updateCartItemAvailability = (name: string, available: boolean) => { const updatedCart = cart.map((item) => item.suggestion.name === name ? { ...item, available } : item ); setCart(updatedCart); }; /** Clears all items from the cart. */ const clearCart = () => setCart([]); return ( {children} ); }; /** * Custom hook to use the CartContext. * Throws an error if used outside of CartProvider. * @returns {CartContextType} The cart context value. */ export const useCart = (): CartContextType => { const context = useContext(CartContext); if (!context) { throw new Error('useCart must be used within a CartProvider'); } return context; }; ``` Repeat the process above for the auth context. This guide uses Unstoppable Login for the auth provider and will need two functions: - Login - Logout You can safely ignore the typescript error on `@uauth/js` in regards to a missing declaration file. Create a `AuthContext.tsx` file in `./client/src/app/context` and add the following: ```typescript 'use client'; import { createContext, useContext, ReactNode, useState } from 'react'; import useLocalStorage from '../utils/useLocalStorage'; import UAuth from '@uauth/js'; import { Authorization } from '@/types/auth'; /** * @typedef {Object} AuthContextType - Defines the context type for authentication. * @property {Authorization | null} auth - The current authentication details. * @property {boolean} authorizing - Indicates if authentication is in progress. * @property {() => void} login - Initiates the login process. * @property {() => void} logout - Logs the user out. */ interface AuthContextType { auth: Authorization | null; authorizing: boolean; login: () => void; logout: () => void; } /** Context to manage authentication state throughout the application */ const AuthContext = createContext(undefined); // UAuth instance for managing user authentication const uauth = new UAuth({ clientID: process.env.NEXT_PUBLIC_CLIENT_ID, redirectUri: process.env.NEXT_PUBLIC_REDIRECT_URI, scopes: process.env.NEXT_PUBLIC_SCOPES }); /** * AuthProvider component to wrap children and provide authentication context. * * @param {Object} props - Props passed to the provider component. * @param {ReactNode} props.children - The components that will consume auth context. * @returns {JSX.Element} Context provider with authentication functionalities. */ export const AuthProvider = ({ children }: { children: ReactNode }) => { const [auth, setAuth] = useLocalStorage('AUTH_STORAGE', null); const [authorizing, setAuthorizing] = useState(false); /** * Initiates the login process with Unstoppable Domains. * Sets auth state on successful verification. */ const login = async () => { try { setAuthorizing(true); const authorization = await uauth.loginWithPopup(); setAuth(authorization || null); } catch (error) { setAuth(null); console.log('Error logging in: ' + error); } finally { setAuthorizing(false); } }; /** * Logs the user out and clears the auth state. */ const logout = async() => { await uauth.logout(); setAuth(null) }; return ( {children} ); }; /** * Custom hook to use the AuthContext. * Throws an error if used outside of AuthProvider. * @returns {AuthContextType} The auth context value. */ export const useAuth = (): AuthContextType => { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within an AuthProvider'); } return context; }; ``` Finally, add these contexts to the `layout.tsx` file in `./client/src/app` like so: ```typescript import { CartProvider } from './context/CartContext'; import { AuthProvider } from './context/AuthContext'; ... return ( {children} ); ``` For the Navbar, you'll need a way for users to login with Unstoppable and to access their cart. As this is mainly CSS, this guide will jump ahead to the implementation. Add the following to your `NavBar.tsx` file in `./client/src/app/components`: ```typescript import Link from 'next/link'; import { useCart } from '../context/CartContext'; import { useAuth } from '../context/AuthContext'; import { useEffect, useState } from 'react'; /** * Nav component that renders the application header with links to the cart and account information. * Displays a loading spinner during client-side hydration. * * @returns {JSX.Element} The header and navigation elements of the application. */ const Nav = () => { const { cart } = useCart(); const { auth, authorizing, login, logout } = useAuth(); const [isClient, setIsClient] = useState(false); // Tracks client-side rendering status /** * Initiates the login process for wallet connection. */ const connectWallet = () => { try { login(); } catch (error) { console.error('Error:', error); } }; /** * Initiates the logout process to disconnect the wallet. */ const disconnectWallet = () => { try { logout(); } catch (error) { console.error('Error:', error); } }; useEffect(() => { setIsClient(true); // Set to true once the component has mounted client-side }, []); // If rendering server-side, display loading state to avoid flash of un-hydrated content. if (!isClient) { return (

Unstoppable Domains Partner API Example

); } return (

Unstoppable Domains Partner API Example

); }; export default Nav; ``` Finally, add the Navbar and cart context to the search HTML in `./client/src/app/page.tsx`: ```typescript import Nav from './components/NavBar'; import { useCart } from './context/CartContext'; ... const [currentPage, setCurrentPage] = useState(1); const [loading, setLoading] = useState(false); const { cart, addToCart, removeFromCart } = useCart(); const domainsPerPage = 5; ... return (