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

bash
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):

bash
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.

bash
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

bash
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

bash
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.

bash
# Mint Property 1: Luxury Penthouse
curl -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

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

  1. The system captures the buyer's wallet address
  2. A payment request is generated with the property price
  3. The buyer completes the payment
  4. 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.

bash
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

bash
{
"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".

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

  1. Changes the property status from "available" to "reserved"
  2. Records the buyer's wallet address as the reserver
  3. Triggers a webhook notification to your application
  4. 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.

bash
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.

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

typescript
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 execution
async 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 template
await app.createPropertyTemplate();
// 2. Add visual faces
await app.addPropertyFaces();
// 3. Mint sample properties
const 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 notifications
await app.registerWebhook("https://your-domain.com/webhooks/property-events");
// 5. Reserve the property
const buyerWallet = "0x742d35Cc6634C0532925a3b844Bc9e7595f42D07";
await app.reserveProperty(propertyId1, buyerWallet);
// 6. Simulate payment and transfer ownership
const sellerWallet = "0xOriginalOwner...";
const paymentTxHash = "0x123abc...";
await app.transferProperty(propertyId1, sellerWallet, buyerWallet, paymentTxHash);
// 7. Query final state
await 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.