Skip to content

Customer Registration for E-Commerce

Learn how to build a customer registration system for your e-commerce website using Hantera's IAM APIs. This guide covers creating customer identities, email verification, and querying customer-scoped data.

Architecture Overview

Key Pattern:

  • Your e-commerce website is an OAuth client with full Hantera permissions
  • Customer login accounts are IAM principals (for authentication)
  • Customer business entities are Asset actors (for orders, data)
  • Principal links to Customer asset via actorId property
  • Website uses its own credentials to query customer's asset and orders

TIP

Two Separate Entities:

  • Principal = Login account (email, password, authentication)
  • Customer Asset = Business entity (orders, customer number, relations)

Customers have NO direct Hantera permissions. The website acts as a proxy, querying the customer's linked asset.

Why Separate Principal from Customer Asset?

Security & Flexibility:

  • Email verification required before asset access (activated: true)
  • Prevents querying orders by guessing email addresses
  • Principal can exist before Customer asset (registration → verification → asset creation)
  • Customer asset can exist before Principal (import existing customers, then allow registration)
  • One Customer asset can have multiple Principals (family accounts, B2B users)

Data Architecture:

  • Customer assets have auto-generated customer numbers (e.g., CUST100001)
  • Customer assets have relations to orders, payments, etc.
  • Principals store minimal data (name, email, phone, actorId link)

Prerequisites

  • E-commerce website/app with backend API
  • Hantera access token with IAM permissions
  • Understanding of OAuth 2.0 flows
  • Sendings API for email verification
  • Asset Actors - Understanding custom asset types

Step 1: Define Customer Asset Type

Before you can create Customer assets, you must define the customer type in the Registry.

Create Registry manifest at /registry/actors/custom/asset/types/customer:

yaml
uri: /registry/actors/custom/asset/types/customer
spec:
  value:
    graphSetName: customer
    defaultNumberPrefix: "CUST"
    relations:
      orders:
        node: order
        cardinality: many

Apply the manifest:

bash
h_ apply customer-type.yaml

This defines:

  • graphSetName: customer - Creates asset.customer graph node
  • defaultNumberPrefix: CUST - Auto-generates customer numbers like CUST100001
  • Relations: Links to orders for querying customer's order history

WARNING

Run h_ manage signals after applying to check for errors in your type definition.

Step 2: Create OAuth Client for Website

First, register your e-commerce website as an OAuth client with full order access.

http
PUT /resources/iam/clients/018c5f3a-1234-5678-9abc-def012345678
Content-Type: application/json
Authorization: Bearer {adminToken}

{
  "properties": {
    "name": "E-Commerce Website",
    "redirectUris": [
      "https://mystore.com/auth/callback"
    ],
    "grantTypes": [
      "authorization_code",
      "refresh_token",
      "client_credentials"
    ]
  }
}

Grant the client permissions:

http
PUT /resources/iam/clients/018c5f3a-1234-5678-9abc-def012345678
Content-Type: application/json
Authorization: Bearer {adminToken}

{
  "properties": { ... },
  "acl": {
    "entries": [
      { "resource": "orders:*", "permission": "read" },
      { "resource": "graph:*", "permission": "query" },
      { "resource": "iam:principals:*", "permission": "read" }
    ]
  }
}

Generate client secret:

http
POST /resources/iam/clients/018c5f3a-1234-5678-9abc-def012345678/secrets
Content-Type: application/json
Authorization: Bearer {adminToken}

{
  "expiresAt": "2026-12-31T23:59:59Z"
}

Response:

json
{
  "secretId": "secret-uuid",
  "secret": "client_secret_abc123xyz",
  "expiresAt": "2026-12-31T23:59:59Z"
}

WARNING

Store the client secret securely. It cannot be retrieved again.

Step 3: Create Customer Role

Create a customer role as a marker (no permissions needed).

http
PUT /resources/iam/roles/customer
Content-Type: application/json
Authorization: Bearer {adminToken}

{
  "description": "Customer role marker for e-commerce users",
  "acl": {
    "entries": []
  }
}

The role has no permissions because customers don't directly access Hantera - your website does.

Step 4: Build Registration Endpoint

Create an API endpoint on your website to handle customer registration.

javascript
// POST /api/register
app.post('/api/register', async (req, res) => {
  const { email, name, password } = req.body;

  // Validate input
  if (!email || !name || !password) {
    return res.status(400).json({ error: 'Missing required fields' });
  }

  try {
    // Generate unique IDs
    const customerId = crypto.randomUUID();
    const customerNumber = `CUST-${Date.now()}`;
    const activationCode = crypto.randomBytes(32).toString('hex');

    // Create principal in Hantera
    const principal = await fetch(`https://`<tenant url>`/resources/iam/principals/${customerId}`, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${adminToken}`
      },
      body: JSON.stringify({
        properties: {
          name,
          email,
          customerNumber,
          activationCode,
          activated: false
        },
        roles: ['customer']
      })
    });

    if (!principal.ok) {
      const error = await principal.json();
      return res.status(principal.status).json(error);
    }

    // Set password
    await fetch(`https://`<tenant url>`/resources/iam/principals/${customerId}/password/reset`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${adminToken}`
      }
    });

    const passwordResponse = await passwordReset.json();
    
    // Update with actual password (not temp)
    await fetch(`https://`<tenant url>`/resources/me/password`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${userToken}` // Get token for user
      },
      body: JSON.stringify({
        currentPassword: passwordResponse.temporaryPassword,
        newPassword: password
      })
    });

    // Send activation email
    await fetch('https://`<tenant url>`/resources/sendings/email', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${adminToken}`
      },
      body: JSON.stringify({
        to: email,
        subject: 'Activate Your Account',
        body: {
          html: `
            <html>
            <body>
              <h1>Welcome ${name}!</h1>
              <p>Click the link below to activate your account:</p>
              <p><a href="https://mystore.com/activate?id=${customerId}&code=${activationCode}">Activate Account</a></p>
            </body>
            </html>
          `,
          plainText: `Welcome ${name}!\n\nClick the link below to activate your account:\n\nhttps://mystore.com/activate?id=${customerId}&code=${activationCode}`
        },
        category: 'customer_activation',
        dynamic: {
          customerId: customerId,
          activationCode: activationCode
        }
      })
    });

    res.json({
      message: 'Registration successful. Please check your email to activate your account.',
      customerId
    });

  } catch (error) {
    console.error('Registration error:', error);
    res.status(500).json({ error: 'Registration failed' });
  }
});

Step 5: Email Verification Endpoint

Handle activation code verification using the IAM API.

javascript
// GET /api/activate?id=`<principalId>`&code=xyz123
app.get('/api/activate', async (req, res) => {
  const { id, code } = req.query;

  if (!id || !code) {
    return res.status(400).json({ error: 'Principal ID and activation code required' });
  }

  try {
    // Fetch principal directly from IAM API (Graph doesn't expose activationCode)
    const principalResponse = await fetch(`https://`<tenant url>`/resources/iam/principals/${id}`, {
      headers: {
        'Authorization': `Bearer ${clientToken}` // Website's token
      }
    });

    if (!principalResponse.ok) {
      return res.status(404).json({ error: 'Invalid activation link' });
    }

    const principal = await principalResponse.json();

    // Verify activation code matches
    if (principal.properties.activationCode !== code) {
      return res.status(404).json({ error: 'Invalid activation code' });
    }

    // Check if already activated
    if (principal.properties.activated) {
      return res.json({
        message: 'Account already activated. You can log in.'
      });
    }

    // Check for existing Customer asset by email
    const existingCustomerQuery = await fetch('https://`<tenant url>`/resources/graph', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${clientToken}`
      },
      body: JSON.stringify([{
        edge: 'customers',
        filter: `dynamic.email == '${principal.properties.email}'`,
        node: {
          fields: ['assetId', 'assetNumber']
        }
      }])
    });

    const existingResult = await existingCustomerQuery.json();
    let assetId;

    if (existingResult[0]?.nodes?.length > 0) {
      // Link to existing Customer asset
      assetId = existingResult[0].nodes[0].assetId;
      console.log('Linking to existing customer asset:', assetId);
    } else {
      // Create new Customer asset
      const assetResponse = await fetch('https://`<tenant url>`/resources/actors/custom/asset/new', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${clientToken}`
      },
        body: JSON.stringify([{
          type: 'create',
          body: {
            typeKey: 'customer',
            // assetNumber auto-generated (e.g., CUST100001)
            // Dynamic properties for the customer
            commands: [{
              type: 'setDynamic',
              body: {
                fields: {
                  email: principal.properties.email,
                  name: principal.properties.name
                }
              }
            }]
          }
        }])
      });

      if (!assetResponse.ok) {
        const error = await assetResponse.json();
        console.error('Failed to create customer asset:', error);
        return res.status(500).json({ error: 'Failed to create customer account' });
      }

      const assetResult = await assetResponse.json();
      assetId = assetResult.paths[0].split('/').pop(); // Extract asset ID from path
    }

    // Update principal: activated + link to asset
    const updatedProperties = {
      ...principal.properties,
      activated: true,
      activationCode: null, // Remove code after use
      actorId: assetId // Link principal to Customer asset
    };

    await fetch(`https://`<tenant url>`/resources/iam/principals/${id}`, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${clientToken}`,
        'If-Match': principal.etag
      },
      body: JSON.stringify({
        properties: updatedProperties,
        roles: principal.roles
      })
    });

    res.json({
      message: 'Account activated successfully! You can now log in.',
      assetId: assetId
    });

  } catch (error) {
    console.error('Activation error:', error);
    res.status(500).json({ error: 'Activation failed' });
  }
});

TIP

Why use IAM API instead of Graph? The Identity Graph node only exposes name, email, and phone properties. Custom properties like activationCode must be accessed via the IAM API's /resources/iam/principals/{id} endpoint.

Step 6: Customer Login Flow

Implement OAuth login for customers.

javascript
// GET /api/auth/login
app.get('/api/auth/login', (req, res) => {
  const authUrl = `https://`<tenant url>`/oauth/authorize?` +
    `client_id=${clientId}&` +
    `redirect_uri=${encodeURIComponent('https://mystore.com/auth/callback')}&` +
    `response_type=code&` +
    `scope=me:*`;

  res.redirect(authUrl);
});

// GET /api/auth/callback
app.get('/api/auth/callback', async (req, res) => {
  const { code } = req.query;

  try {
    // Exchange code for token
    const tokenResponse = await fetch('https://`<tenant url>`/oauth/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        client_id: clientId,
        client_secret: clientSecret,
        redirect_uri: 'https://mystore.com/auth/callback'
      })
    });

    const tokens = await tokenResponse.json();

    // Get customer identity
    const identityResponse = await fetch('https://`<tenant url>`/resources/me', {
      headers: {
        'Authorization': `Bearer ${tokens.access_token}`
      }
    });

    const identity = await identityResponse.json();

    // Check if activated
    if (!identity.properties.activated) {
      return res.status(403).json({
        error: 'Please activate your account via email before logging in'
      });
    }

    // Store session
    req.session.customerId = identity.identityId;
    req.session.customerNumber = identity.properties.customerNumber;
    req.session.customerToken = tokens.access_token;

    res.redirect('/dashboard');

  } catch (error) {
    console.error('Login error:', error);
    res.status(500).json({ error: 'Login failed' });
  }
});

Step 7: Query Customer Orders

Use customer's customerNumber to scope order queries.

javascript
// GET /api/my-orders
app.get('/api/my-orders', async (req, res) => {
  if (!req.session.customerNumber) {
    return res.status(401).json({ error: 'Not authenticated' });
  }

  try {
    // Website uses ITS OWN token (not customer's)
    const orders = await fetch('https://`<tenant url>`/resources/graph', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${clientToken}` // Website token, NOT customer token
      },
      body: JSON.stringify([{
        edge: 'orders',
        filter: `customerNumber == '${req.session.customerNumber}'`,
        orderBy: 'createdAt desc',
        node: {
          fields: [
            'orderId',
            'orderNumber',
            'createdAt',
            'totalAmount',
            'status'
          ]
        }
      }])
    });

    const results = await orders.json();

    res.json({
      orders: results[0]?.nodes || []
    });

  } catch (error) {
    console.error('Orders query error:', error);
    res.status(500).json({ error: 'Failed to fetch orders' });
  }
});

TIP

The website queries with its own credentials, not the customer's token. The customer's customerNumber property is used to filter results.

Complete Flow Diagram

Customer Registration & Login Flow:

1. Registration
   Customer → Website: Submit email, name, password
   Website → Hantera IAM: Create principal (activated=false)
   Website → Hantera Sendings: Send activation email
   
2. Activation
   Customer → Email: Click activation link
   Website → Hantera IAM: Verify activation code
   Website → Hantera Assets: Create Customer asset (auto-generates CUST100001)
   Website → Hantera IAM: Update principal (activated=true, actorId=`<asset>`)
   
3. Login
   Customer → Website: Click login
   Website → Hantera OAuth: Redirect to authorization
   Customer → Hantera: Enter credentials
   Hantera → Website: Authorization code
   Website → Hantera OAuth: Exchange code for token
   Website → Hantera IAM: GET /resources/me
   Website: Check activated=true, store actorId in session

4. View Orders
   Customer → Website: Request /my-orders
   Website → Hantera Graph: Query asset.customer node
   Website → Hantera Graph: Follow orders relation
   Filter: Using actorId from session
   Hantera → Website: Return customer's orders
   Website → Customer: Display orders

Key Security Point: Principal cannot access Customer asset until activated: true. This prevents email-guessing attacks where someone registers with another person's email.

Key Concepts

Customer Has No Permissions

json
{
  "type": "principal",
  "properties": {
    "customerNumber": "CUST-12345"
  },
  "roles": ["customer"],
  "acl": { "entries": [] }  // Empty! No permissions.
}

The customer role is just a marker. All actual data access happens through the website's OAuth client.

Website Acts as Proxy

javascript
// ❌ WRONG: Using customer's token
Authorization: Bearer {customerToken}

// ✅ CORRECT: Using website's token
Authorization: Bearer {clientToken}

The website has full permissions and filters data based on customer identity properties.

Identity Properties Drive Access

javascript
// Customer's identity
{
  customerNumber: "CUST-12345",
  activated: true
}

// Query scoped by property
filter: "customerNumber == 'CUST-12345'"

Security Considerations

Validate Activation Status

Always check activated: true before allowing login:

javascript
if (!identity.properties.activated) {
  return res.status(403).json({ 
    error: 'Please activate your account first' 
  });
}

Secure Session Management

Store customer data in secure, HTTP-only cookies:

javascript
req.session.customerNumber = identity.properties.customerNumber;
// Never expose raw Hantera tokens to frontend

Input Validation

Sanitize all user input before creating principals:

javascript
const email = validator.isEmail(req.body.email) 
  ? req.body.email 
  : null;

if (!email) {
  return res.status(400).json({ error: 'Invalid email' });
}

Troubleshooting

Customer Can't Log In After Registration

Problem: User registered but login fails.

Check:

  • Is activated: true in their principal properties?
  • Did the activation email send successfully?
  • Is the activation code correct?

Orders Not Showing

Problem: Customer logged in but sees no orders.

Check:

  • Is customerNumber stored in session?
  • Do orders have matching customerNumber field?
  • Is website's OAuth client token valid?
  • Does client have orders:* read permission?

Email Verification Not Working

Problem: Activation link doesn't work.

Check:

  • Does the activation URL include both id and code parameters?
  • Is the principal ID valid (exists in IAM)?
  • Does the activationCode in the principal match the URL parameter?
  • Is the activation code URL-encoded properly?

Best Practices

1. Handle Existing Customer Assets

If importing existing customers, check for existing assets by email before creating a new one:

javascript
// Query for existing customer asset by email
const existingCustomer = await fetch('https://`<tenant url>`/resources/graph', {
  method: 'POST',
  body: JSON.stringify([{
    edge: 'customers',
    filter: `email == '${email}'`,
    node: { fields: ['assetId', 'assetNumber'] }
  }])
});

if (existingCustomer.nodes.length > 0) {
  // Link principal to existing asset
  actorId = existingCustomer.nodes[0].assetId;
} else {
  // Create new asset
  // ...
}

2. Expire Activation Codes

Store expiration timestamp:

javascript
properties: {
  activationCode: 'xyz123',
  activationCodeExpires: new Date(Date.now() + 24*60*60*1000).toISOString()
}

3. Validate Asset Creation

Always check that the Customer asset was created successfully before linking:

javascript
const assetResult = await assetResponse.json();

if (!assetResult.paths || assetResult.paths.length === 0) {
  console.error('Asset creation failed:', assetResult);
  return res.status(500).json({ error: 'Failed to create customer account' });
}

const assetId = assetResult.paths[0].split('/').pop();

4. Handle Email Uniqueness

Check before registration:

javascript
// Query existing customers by email
const existing = await fetch('https://`<tenant url>`/resources/graph', {
  method: 'POST',
  body: JSON.stringify([{
    edge: 'identities',
    filter: `type == 'principal' and properties.email == '${email}'`
  }])
});

if (existing.nodes.length > 0) {
  return res.status(409).json({ error: 'Email already registered' });
}

5. Rate Limit Registration

Prevent spam registrations:

javascript
const rateLimit = require('express-rate-limit');

const registerLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5 // 5 registrations per IP
});

app.post('/api/register', registerLimiter, async (req, res) => {
  // ...
});

© 2024 Hantera AB. All rights reserved.