Build a Real Estate Tokenisation App
End-to-end guide: from org setup to minting property tokens, transfers, payments, webhooks, and faces.
What You'll Build
In this comprehensive tutorial, you'll build a real estate tokenisation platform where each property is a Dual object with rich metadata, visual faces, payment integration, and automated webhook notifications. By the end, you'll have a complete TypeScript application that demonstrates how to mint property tokens, handle transfers, process payments, and react to state changes via webhooks.
Step 1, Set Up Your Organization
Create an organization to manage your real estate tokenisation platform. This organization will contain your templates, objects, and team members with different roles.
Create an Organization
curl -X POST https://api-testnet.dual.network/organizations \\-H "x-api-key: YOUR_API_KEY" \\-H "Content-Type: application/json" \\-d '{"name": "Real Estate Tokens Inc","description": "Property tokenisation platform"}'
The response includes an id field, save it as your ORG_ID.
Invite Team Members
Add team members with specific roles (admin, agent, viewer):
curl -X POST https://api-testnet.dual.network/organizations/{ORG_ID}/members \\-H "x-api-key: YOUR_API_KEY" \\-H "Content-Type: application/json" \\-d '{"email": "agent@realestate.com","role": "agent"}'
Step 2, Design Your Property Template
Create a template that defines the structure of property tokens. This template includes properties for address, size, amenities, pricing, and status.
curl -X POST https://api-testnet.dual.network/templates \\-H "x-api-key: YOUR_API_KEY" \\-H "Content-Type: application/json" \\-d '{"name": "Property","organizationId": "{ORG_ID}","properties": [{"name": "address","type": "string","required": true,"description": "Full property address"},{"name": "squareMeters","type": "number","required": true,"description": "Property size in square meters"},{"name": "bedrooms","type": "number","required": true},{"name": "bathrooms","type": "number","required": true},{"name": "price","type": "number","required": true,"description": "Price in USD"},{"name": "status","type": "enum","values": ["available", "reserved", "sold"],"default": "available","required": true},{"name": "listingDate","type": "string","format": "date-time","required": true},{"name": "geoLocation","type": "object","properties": {"latitude": { "type": "number" },"longitude": { "type": "number" }},"required": true}],"actions": [{"name": "reserve","description": "Reserve the property"},{"name": "purchase","description": "Complete purchase of the property"},{"name": "transfer","description": "Transfer ownership to another wallet"}]}'
Save the returned templateId for the next steps.
Step 3, Add Visual Faces
Attach visual representations of your property token. Add an image face for property photos and a web face for interactive viewing.
Add Image Face
curl -X POST https://api-testnet.dual.network/templates/{TEMPLATE_ID}/faces \\-H "x-api-key: YOUR_API_KEY" \\-H "Content-Type: application/json" \\-d '{"name": "image","type": "image","mimeType": "image/jpeg","url": "https://example.com/property-photo.jpg","description": "Property exterior photo"}'
Add Web Face
curl -X POST https://api-testnet.dual.network/templates/{TEMPLATE_ID}/faces \\-H "x-api-key: YOUR_API_KEY" \\-H "Content-Type: application/json" \\-d '{"name": "web","type": "web","url": "https://your-domain.com/viewer/{objectId}","description": "Interactive property viewer"}'
Step 4, Mint Property Objects
Create token objects from your template. Each property becomes a unique, tokenized object on the Dual network.
# Mint Property 1: Luxury Penthousecurl -X POST https://api-testnet.dual.network/objects \\-H "x-api-key: YOUR_API_KEY" \\-H "Content-Type: application/json" \\-d '{"templateId": "{TEMPLATE_ID}","organizationId": "{ORG_ID}","properties": {"address": "123 Park Avenue, New York, NY 10016","squareMeters": 500,"bedrooms": 4,"bathrooms": 3,"price": 8500000,"status": "available","listingDate": "2026-01-15T00:00:00Z","geoLocation": {"latitude": 40.7829,"longitude": -73.9654}}}'
Repeat this process for 2-3 additional properties, varying the details. Save each objectId returned.
Step 5, Set Up Payments
Configure payment processing for your real estate platform. This enables buyers to purchase properties using supported payment methods.
Check Payment Configuration
curl -X GET https://api-testnet.dual.network/payments/config \\-H "x-api-key: YOUR_API_KEY"
Payment Flow
When a buyer executes the purchase action on a property:
- The system captures the buyer's wallet address
- A payment request is generated with the property price
- The buyer completes the payment
- Upon confirmation, ownership transfers to the buyer's wallet
Tip: Payment flows integrate seamlessly with Dual's object transfer mechanism. Configure your webhook (Step 6) to receive confirmation when payments complete.
Step 6, Register Webhooks
Set up webhooks to receive real-time notifications when property statuses change. This allows your application to react to important events like reservations and purchases.
curl -X POST https://api-testnet.dual.network/webhooks \\-H "x-api-key: YOUR_API_KEY" \\-H "Content-Type: application/json" \\-d '{"url": "https://your-domain.com/webhooks/property-events","events": ["object.status.changed", "action.executed", "object.transferred"],"organizationId": "{ORG_ID}"}'
Expected Webhook Payload
{"eventId": "evt_abc123","eventType": "object.status.changed","timestamp": "2026-03-14T10:30:00Z","objectId": "obj_property001","previousStatus": "available","newStatus": "reserved","actor": {"walletAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f42D07","email": "buyer@example.com"},"metadata": {"actionId": "act_reserve123","transactionHash": "0x123abc..."}}
Step 7, Execute Actions
Use the Event Bus to execute property actions. Let's reserve a property, which triggers the status change to "reserved".
curl -X POST https://api-testnet.dual.network/ebus/actions \\-H "x-api-key: YOUR_API_KEY" \\-H "Content-Type: application/json" \\-d '{"objectId": "{OBJECT_ID_1}","action": "reserve","actor": {"walletAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f42D07"},"parameters": {"reservationPeriod": 30}}'
This action:
- Changes the property status from "available" to "reserved"
- Records the buyer's wallet address as the reserver
- Triggers a webhook notification to your application
- Creates an immutable on-chain record of the reservation
Step 8, Transfer Ownership
After payment completes, transfer the property token to the buyer's wallet using the transfer action.
curl -X POST https://api-testnet.dual.network/ebus/actions \\-H "x-api-key: YOUR_API_KEY" \\-H "Content-Type: application/json" \\-d '{"objectId": "{OBJECT_ID_1}","action": "transfer","actor": {"walletAddress": "{SELLER_WALLET}"},"parameters": {"recipientWallet": "{BUYER_WALLET}","transactionHash": "{PAYMENT_TX_HASH}"}}'
After transfer, the buyer owns the property token, which they can view, further transfer, or use in other applications compatible with Dual objects.
Step 9, Query & Verify
Use the Indexer (Public API) to verify the on-chain state of your property tokens at any time.
curl -X GET "https://api-testnet.dual.network/indexer/objects/{OBJECT_ID}" \\-H "x-api-key: YOUR_API_KEY"
The response includes:
- Current owner wallet address
- All properties with their current values
- Complete action history
- All transfers and ownership changes
- Links to faces and visual representations
Step 10, Full TypeScript App
Here's a complete TypeScript application that ties everything together using the Dual SDK:
import { DualClient, Template, DualObject, Webhook } from "dual-sdk";interface Property {address: string;squareMeters: number;bedrooms: number;bathrooms: number;price: number;status: "available" | "reserved" | "sold";listingDate: string;geoLocation: { latitude: number; longitude: number };}class RealEstateApp {private client: DualClient;private orgId: string;private templateId: string;constructor(apiKey: string, orgId: string) {this.client = new DualClient({ token: apiKey, authMode: "api_key" });this.orgId = orgId;}async createPropertyTemplate(): Promise {try {const template: Template = await this.client.templates.create({name: "Property",organizationId: this.orgId,properties: [{ name: "address", type: "string", required: true },{ name: "squareMeters", type: "number", required: true },{ name: "bedrooms", type: "number", required: true },{ name: "bathrooms", type: "number", required: true },{ name: "price", type: "number", required: true },{name: "status",type: "enum",values: ["available", "reserved", "sold"],default: "available",required: true,},{ name: "listingDate", type: "string", required: true },{name: "geoLocation",type: "object",required: true,},],actions: [{ name: "reserve", description: "Reserve the property" },{ name: "purchase", description: "Complete purchase" },{ name: "transfer", description: "Transfer ownership" },],});this.templateId = template.id;console.log("✓ Template created: " + this.templateId);} catch (error) {console.error("Failed to create template:", error);throw error;}}async addPropertyFaces(): Promise {try {await this.client.templates.addFace(this.templateId, {name: "image",type: "image",mimeType: "image/jpeg",url: "https://example.com/property.jpg",description: "Property photo",});await this.client.templates.addFace(this.templateId, {name: "web",type: "web",url: "https://your-domain.com/viewer/{objectId}",description: "Interactive viewer",});console.log("✓ Faces added to template");} catch (error) {console.error("Failed to add faces:", error);throw error;}}async mintProperty(property: Property): Promise {try {const obj: DualObject = await this.client.objects.mint({templateId: this.templateId,organizationId: this.orgId,properties: property,});console.log("✓ Property minted: " + obj.id);return obj.id;} catch (error) {console.error("Failed to mint property:", error);throw error;}}async registerWebhook(url: string): Promise {try {const webhook: Webhook = await this.client.webhooks.create({url,events: ["object.status.changed", "action.executed", "object.transferred"],organizationId: this.orgId,});console.log("✓ Webhook registered: " + webhook.id);} catch (error) {console.error("Failed to register webhook:", error);throw error;}}async reserveProperty(objectId: string, buyerWallet: string): Promise {try {await this.client.eventBus.execute({objectId,action: "reserve",actor: { walletAddress: buyerWallet },parameters: { reservationPeriod: 30 },});console.log("✓ Property reserved: " + objectId);} catch (error) {console.error("Failed to reserve property:", error);throw error;}}async transferProperty(objectId: string,sellerWallet: string,buyerWallet: string,txHash: string): Promise {try {await this.client.eventBus.execute({objectId,action: "transfer",actor: { walletAddress: sellerWallet },parameters: {recipientWallet: buyerWallet,transactionHash: txHash,},});console.log("✓ Property transferred: " + objectId);} catch (error) {console.error("Failed to transfer property:", error);throw error;}}async queryPropertyState(objectId: string): Promise {try {const obj = await this.client.indexer.get(objectId);console.log("✓ Property state retrieved:", obj);return obj;} catch (error) {console.error("Failed to query property:", error);throw error;}}}// Main executionasync function main() {const apiKey = process.env.DUAL_API_KEY!;const orgId = process.env.DUAL_ORG_ID!;const app = new RealEstateApp(apiKey, orgId);try {// 1. Create templateawait app.createPropertyTemplate();// 2. Add visual facesawait app.addPropertyFaces();// 3. Mint sample propertiesconst property1: Property = {address: "123 Park Avenue, New York, NY 10016",squareMeters: 500,bedrooms: 4,bathrooms: 3,price: 8500000,status: "available",listingDate: new Date().toISOString(),geoLocation: { latitude: 40.7829, longitude: -73.9654 },};const propertyId1 = await app.mintProperty(property1);// 4. Register webhook for notificationsawait app.registerWebhook("https://your-domain.com/webhooks/property-events");// 5. Reserve the propertyconst buyerWallet = "0x742d35Cc6634C0532925a3b844Bc9e7595f42D07";await app.reserveProperty(propertyId1, buyerWallet);// 6. Simulate payment and transfer ownershipconst sellerWallet = "0xOriginalOwner...";const paymentTxHash = "0x123abc...";await app.transferProperty(propertyId1, sellerWallet, buyerWallet, paymentTxHash);// 7. Query final stateawait app.queryPropertyState(propertyId1);console.log("\\n✓ Real estate tokenisation workflow complete!");} catch (error) {console.error("Application error:", error);process.exit(1);}}main();
Key Takeaways
- Organization Management: Dual organizations provide role-based access control and multi-user collaboration
- Rich Templates: Properties are defined once, minted many times with unique data per object
- Visual Faces: Combine image and web faces for rich representation across platforms
- Payment Integration: Properties can be purchased directly with built-in payment flows
- Real-time Events: Webhooks notify your application of every state change
- Immutable History: All transfers, reservations, and actions are recorded on-chain
- Public Verification: Anyone can query the on-chain state of any property using the Indexer
Next Steps: Deploy this to production by adding user authentication, implementing the webhook handler endpoint, integrating with your payment processor, and setting up a web interface to browse and purchase properties. For more on integrating with the Dual SDK, see Dual SDK Documentation.