Integration Partner Widgets

Integration Partner Widgets Guide

Integration Partner Widgets allow third-party developers to embed interactive widgets directly into Sure Send CRM's contact detail pages. These widgets receive secure, signed context about the current contact and agent viewing the page, enabling rich, personalized experiences.

Overview

Partner widgets appear as tabs in contact detail views (both person and company views), allowing agents to access your integration's features without leaving the CRM. Examples include:

  • Property recommendation engines
  • Market analysis tools
  • Document preparation services
  • Communication tools
  • Task automation interfaces
  • Analytics dashboards
  • Company research tools
  • B2B lead enrichment services

How It Works

sequenceDiagram
    participant Agent
    participant CRM
    participant Widget

    Agent->>CRM: Opens contact detail page
    CRM->>CRM: Generates signed context
    CRM->>Widget: Loads iframe with context + signature
    Widget->>Widget: Verifies signature
    Widget->>Agent: Displays personalized content

Getting Started

1. Prerequisites

Before creating a widget, you'll need:

  • A web application that can be embedded in an iframe
  • Ability to verify HMAC SHA-256 signatures
  • HTTPS endpoint for your widget
  • Understanding of iframe security and postMessage communication

2. Widget Registration

Contact the Sure Send CRM team to register your widget. You'll need to provide:

  • Widget Name: Display name shown in the tab (e.g., "Property Search")
  • Description: Rich text description of your widget's functionality
  • Widget URL: HTTPS endpoint where your widget is hosted
  • View Types: Specify which views your widget supports:
    • Person View: Widget appears in person detail pages
    • Company View: Widget appears in company detail pages
    • Both: Widget appears in both person and company detail pages
  • Team ID (optional): For testing before global approval

You'll receive:

  • Secret Key: A secure key for signature verification (64 characters, hex-encoded)
  • Widget ID: Unique identifier for your widget

Note: You can specify one or both view types. At least one view type must be selected. Teams can enable your widget separately for person and company views based on their needs.

3. Approval Process

Widgets go through a two-stage approval process:

  1. Team Testing: When created with a team_id, the widget is visible only to that team for testing
  2. Global Approval: After testing, request approval from Sure Send CRM to make the widget available to all users

Widget Implementation

URL Structure

Your widget will be loaded in an iframe with the following query parameters:

https://your-widget-url.com/widget?context=BASE64_ENCODED_DATA&signature=HMAC_SIGNATURE

Parameters:

  • context: Base64-encoded JSON containing contact, agent, and team information
  • signature: HMAC SHA-256 signature of the context using your secret key

Context Data Structure

After decoding the context parameter, you'll receive a JSON object. The structure depends on whether the widget is being viewed from a person detail page or a company detail page.

Person View Context

When viewing from a person detail page, the context contains a person object:

{
  "team": {
    "id": "123e4567-e89b-12d3-a456-426614174000",
    "name": "Acme Real Estate"
  },
  "user": {
    "id": "123e4567-e89b-12d3-a456-426614174001",
    "email": "[email protected]"
  },
  "person": {
    "id": "123e4567-e89b-12d3-a456-426614174000",
    "firstName": "Jane",
    "lastName": "Doe",
    "stage": {
      "name": "Lead"
    },
    "emails": [
      {
        "id": "email-uuid",
        "email": "[email protected]",
        "value": "[email protected]",
        "isPrimary": 1,
        "created_at": "2025-10-01T12:00:00Z",
        "updated_at": "2025-10-01T12:00:00Z"
      }
    ],
    "phones": [
      {
        "id": "phone-uuid",
        "phone": "+1-555-1234",
        "type": "mobile",
        "isPrimary": 1,
        "normalized": "15551234",
        "created_at": "2025-10-01T12:00:00Z",
        "updated_at": "2025-10-01T12:00:00Z"
      }
    ]
  },
  "timestamp": "2025-10-27T15:30:00Z"
}

Company View Context

When viewing from a company detail page, the context contains a company object instead:

{
  "team": {
    "id": "123e4567-e89b-12d3-a456-426614174000",
    "name": "Acme Real Estate"
  },
  "user": {
    "id": "123e4567-e89b-12d3-a456-426614174001",
    "email": "[email protected]"
  },
  "company": {
    "id": "123e4567-e89b-12d3-a456-426614174000",
    "name": "Acme Corporation",
    "industry": "Real Estate",
    "website": "https://acme.com",
    "description": "Leading real estate development company",
    "companySize": "201-500",
    "annualRevenue": 50000000,
    "foundedDate": "2010-01-15T00:00:00Z",
    "registrationNumber": "REG-12345",
    "taxId": "TAX-67890",
    "stage": {
      "name": "Customer"
    },
    "emails": [
      {
        "id": "email-uuid",
        "email": "[email protected]",
        "value": "[email protected]",
        "isPrimary": 1,
        "created_at": "2025-10-01T12:00:00Z",
        "updated_at": "2025-10-01T12:00:00Z"
      }
    ],
    "phones": [
      {
        "id": "phone-uuid",
        "phone": "+1-555-1234",
        "type": "work",
        "isPrimary": 1,
        "normalized": "15551234",
        "created_at": "2025-10-01T12:00:00Z",
        "updated_at": "2025-10-01T12:00:00Z"
      }
    ],
    "addresses": [
      {
        "id": "address-uuid",
        "type": "office",
        "street": "123 Main Street",
        "city": "San Francisco",
        "state": "CA",
        "code": "94105",
        "country": "United States",
        "isPrimary": 1
      }
    ]
  },
  "timestamp": "2025-10-27T15:30:00Z"
}

Important: Your widget should check for the presence of either person or company in the context to determine which view type it's being loaded in.

Determining View Type

Your widget code should check which type of context it received:

// JavaScript example
const data = parseContext(context);
if (data.person) {
  // This is a person view context
  displayPersonWidget(data.person);
} else if (data.company) {
  // This is a company view context
  displayCompanyWidget(data.company);
} else {
  // Invalid context - should not happen
  showError('Invalid context: missing person or company data');
}
# Python example
data = parse_context(context)
if 'person' in data:
    # This is a person view context
    render_person_widget(data['person'])
elif 'company' in data:
    # This is a company view context
    render_company_widget(data['company'])
else:
    # Invalid context
    return 'Invalid context', 400

Common Fields (present in all contexts):

FieldTypeDescription
team.idstringUUID of the agent's current team
team.namestringName of the agent's current team
user.idstringUUID of the agent viewing the page
user.emailstringEmail of the agent viewing the page
timestampstringISO 8601 timestamp when the context was generated

Person Fields (only present when viewing from person detail page):

FieldTypeDescription
person.idstringUUID of the person contact
person.firstNamestringPerson's first name
person.lastNamestringPerson's last name
person.stage.namestringCurrent stage of the person (e.g., "Lead", "Prospect")
person.emailsarrayAll email addresses for the person
person.phonesarrayAll phone numbers for the person

Company Fields (only present when viewing from company detail page):

FieldTypeDescription
company.idstringUUID of the company
company.namestringCompany name
company.industrystringIndustry/sector (e.g., "Real Estate", "Technology")
company.websitestringCompany website URL
company.descriptionstringCompany description
company.companySizestringCompany size range (e.g., "1-10", "201-500", "5001+")
company.annualRevenuenumberAnnual revenue (decimal or integer)
company.foundedDatestringISO 8601 date when company was founded
company.registrationNumberstringBusiness registration number
company.taxIdstringTax ID number
company.stage.namestringCurrent stage of the company (e.g., "Lead", "Customer")
company.emailsarrayAll email addresses for the company
company.phonesarrayAll phone numbers for the company
company.addressesarrayAll addresses for the company

Email Object:

FieldTypeDescription
idstringUUID of the email record
emailstringEmail address (original case)
valuestringEmail address (lowercase)
isPrimaryinteger1 if primary, 0 otherwise
created_atstringISO 8601 timestamp
updated_atstringISO 8601 timestamp

Phone Object:

FieldTypeDescription
idstringUUID of the phone record
phonestringPhone number (formatted)
typestringType: "mobile", "home", "work", etc.
isPrimaryinteger1 if primary, 0 otherwise
normalizedstringPhone number with only digits
created_atstringISO 8601 timestamp
updated_atstringISO 8601 timestamp

Address Object (Company contexts only):

FieldTypeDescription
idstringUUID of the address record
typestringAddress type: "office", "billing", "shipping", etc.
streetstringStreet address
citystringCity name
statestringState or province
codestringPostal/ZIP code
countrystringCountry name
isPrimaryinteger1 if primary, 0 otherwise

Signature Verification

CRITICAL: Always verify the signature before trusting the context data. This prevents tampering and ensures the data comes from Sure Send CRM.

Verification Steps

  1. Extract the context and signature query parameters
  2. Compute HMAC SHA-256 of the context string (Base64-encoded, not decoded) using your secret key
  3. Compare the computed signature with the provided signature using constant-time comparison
  4. Only decode and use the context if signatures match

Example Implementations

Node.js:

const crypto = require('crypto');

function verifySignature(context, signature, secretKey) {
  // Compute HMAC SHA-256 of the Base64-encoded context
  const computedSignature = crypto
    .createHmac('sha256', secretKey)
    .update(context)
    .digest('hex');

  // Use constant-time comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(computedSignature)
  );
}

function parseContext(contextBase64) {
  const contextJson = Buffer.from(contextBase64, 'base64').toString('utf-8');
  return JSON.parse(contextJson);
}

// Usage in your widget
const urlParams = new URLSearchParams(window.location.search);
const context = urlParams.get('context');
const signature = urlParams.get('signature');

if (verifySignature(context, signature, YOUR_SECRET_KEY)) {
  const data = parseContext(context);
  // Check if this is a person or company context
  if (data.person) {
    console.log('Person:', data.person.firstName, data.person.lastName);
  } else if (data.company) {
    console.log('Company:', data.company.name);
  }
} else {
  console.error('Invalid signature!');
}

Python (Flask):

import hmac
import hashlib
import base64
import json
from flask import request

def verify_signature(context, signature, secret_key):
    """Verify HMAC SHA-256 signature"""
    computed_signature = hmac.new(
        secret_key.encode('utf-8'),
        context.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Use constant-time comparison
    return hmac.compare_digest(signature, computed_signature)

def parse_context(context_base64):
    """Decode and parse the context"""
    context_json = base64.b64decode(context_base64).decode('utf-8')
    return json.loads(context_json)

@app.route('/widget')
def widget():
    context = request.args.get('context')
    signature = request.args.get('signature')

    if not verify_signature(context, signature, YOUR_SECRET_KEY):
        return 'Invalid signature', 403

    data = parse_context(context)
    # Check if this is a person or company context
    if 'person' in data:
        contact = data['person']
        contact_type = 'person'
    elif 'company' in data:
        contact = data['company']
        contact_type = 'company'
    else:
        return 'Invalid context', 400

    return render_template('widget.html', contact=contact, contact_type=contact_type)

Ruby:

require 'openssl'
require 'base64'
require 'json'

def verify_signature(context, signature, secret_key)
  computed_signature = OpenSSL::HMAC.hexdigest(
    'sha256',
    secret_key,
    context
  )

  # Use constant-time comparison
  Rack::Utils.secure_compare(signature, computed_signature)
end

def parse_context(context_base64)
  context_json = Base64.strict_decode64(context_base64)
  JSON.parse(context_json)
end

# Usage in Sinatra/Rails controller
context = params[:context]
signature = params[:signature]

if verify_signature(context, signature, YOUR_SECRET_KEY)
  data = parse_context(context)
  # Check if this is a person or company context
  if data['person']
    @contact = data['person']
    @contact_type = 'person'
  elsif data['company']
    @contact = data['company']
    @contact_type = 'company'
  else
    halt 400, 'Invalid context'
  end
  render :widget
else
  halt 403, 'Invalid signature'
end

PHP:

<?php
function verifySignature($context, $signature, $secretKey) {
    $computedSignature = hash_hmac('sha256', $context, $secretKey);
    return hash_equals($signature, $computedSignature);
}

function parseContext($contextBase64) {
    $contextJson = base64_decode($contextBase64);
    return json_decode($contextJson, true);
}

// Usage
$context = $_GET['context'];
$signature = $_GET['signature'];

if (verifySignature($context, $signature, YOUR_SECRET_KEY)) {
    $data = parseContext($context);
    // Check if this is a person or company context
    if (isset($data['person'])) {
        $contact = $data['person'];
        echo "Person: " . $contact['firstName'] . " " . $contact['lastName'];
    } elseif (isset($data['company'])) {
        $contact = $data['company'];
        echo "Company: " . $contact['name'];
    }
} else {
    http_response_code(403);
    echo "Invalid signature";
}
?>

Security Best Practices

1. Always Verify Signatures

Never trust context data without verifying the signature. Malicious users could craft fake contexts.

// ❌ BAD: Using context without verification
const data = parseContext(context);

// ✅ GOOD: Verify first, then use
if (verifySignature(context, signature, secretKey)) {
  const data = parseContext(context);
}

2. Keep Secret Keys Secure

  • Never commit secret keys to version control
  • Store keys in environment variables or secure vaults
  • Use different keys for development and production
  • Rotate keys periodically

3. Use Constant-Time Comparison

Prevent timing attacks by using constant-time comparison functions:

// ❌ BAD: Vulnerable to timing attacks
if (signature === computedSignature) { ... }

// ✅ GOOD: Constant-time comparison
if (crypto.timingSafeEqual(signature, computedSignature)) { ... }

4. Validate Context Timestamp

Check that the context is recent to prevent replay attacks:

function isContextFresh(context, maxAgeSeconds = 300) {
  const timestamp = new Date(context.timestamp);
  const now = new Date();
  const ageSeconds = (now - timestamp) / 1000;
  return ageSeconds <= maxAgeSeconds;
}

5. Iframe Security

Set appropriate headers on your widget endpoint:

X-Frame-Options: ALLOW-FROM https://app.suresend.ai
Content-Security-Policy: frame-ancestors https://app.suresend.ai

6. HTTPS Only

Your widget URL must use HTTPS. HTTP URLs will be rejected.

Widget Design Guidelines

User Experience

  1. Fast Loading: Optimize for quick load times. Users expect instant feedback.
  2. Responsive Design: Support various widget sizes and screen resolutions
  3. Loading States: Show loading indicators while fetching data
  4. Error Handling: Display user-friendly error messages
  5. Accessibility: Follow WCAG 2.1 guidelines

Visual Design

  1. Consistent Branding: Use your brand colors but ensure readability
  2. Sure Send CRM Compatibility: Design should feel native to the CRM
  3. Mobile Friendly: Support responsive layouts
  4. Dark Mode (optional): Consider supporting dark mode

Example Widget HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Property Recommendations</title>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            margin: 0;
            padding: 20px;
            background: white;
        }
        .loading {
            text-align: center;
            padding: 40px;
            color: #666;
        }
        .error {
            background: #fee;
            border: 1px solid #fcc;
            padding: 15px;
            border-radius: 4px;
            color: #c33;
        }
        .contact-header {
            background: #f5f5f5;
            padding: 15px;
            border-radius: 8px;
            margin-bottom: 20px;
        }
        .property-card {
            border: 1px solid #e0e0e0;
            border-radius: 8px;
            padding: 15px;
            margin-bottom: 15px;
            transition: box-shadow 0.2s;
        }
        .property-card:hover {
            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
        }
    </style>
</head>
<body>
    <div id="app">
        <div class="loading">Loading...</div>
    </div>

    <script>
        const SECRET_KEY = 'YOUR_SECRET_KEY_HERE';

        async function initWidget() {
            const urlParams = new URLSearchParams(window.location.search);
            const context = urlParams.get('context');
            const signature = urlParams.get('signature');

            if (!context || !signature) {
                showError('Missing context or signature');
                return;
            }

            if (!await verifySignature(context, signature)) {
                showError('Invalid signature');
                return;
            }

            const data = parseContext(context);
            renderWidget(data);
        }

        async function verifySignature(context, signature) {
            const encoder = new TextEncoder();
            const key = await crypto.subtle.importKey(
                'raw',
                encoder.encode(SECRET_KEY),
                { name: 'HMAC', hash: 'SHA-256' },
                false,
                ['sign']
            );

            const signatureBuffer = await crypto.subtle.sign(
                'HMAC',
                key,
                encoder.encode(context)
            );

            const computed = Array.from(new Uint8Array(signatureBuffer))
                .map(b => b.toString(16).padStart(2, '0'))
                .join('');

            return signature === computed;
        }

        function parseContext(contextBase64) {
            const contextJson = atob(contextBase64);
            return JSON.parse(contextJson);
        }

        function renderWidget(data) {
            const app = document.getElementById('app');
            let contact, contactType, contactName;

            // Determine if this is person or company context
            if (data.person) {
                contact = data.person;
                contactType = 'person';
                contactName = `${contact.firstName} ${contact.lastName}`;
            } else if (data.company) {
                contact = data.company;
                contactType = 'company';
                contactName = contact.name;
            } else {
                showError('Invalid context: missing person or company data');
                return;
            }

            app.innerHTML = `
                <div class="contact-header">
                    <h2>${contactName}</h2>
                    <p>Type: ${contactType === 'person' ? 'Person' : 'Company'}</p>
                    <p>Stage: ${contact.stage?.name || 'Unknown'}</p>
                    <p>Agent: ${data.user.email}</p>
                    ${contactType === 'company' ? `<p>Industry: ${contact.industry || 'N/A'}</p>` : ''}
                </div>

                <h3>Recommended Properties</h3>
                <div class="property-card">
                    <h4>Beautiful 3BR Home</h4>
                    <p>123 Main St, Anytown, USA</p>
                    <p><strong>$450,000</strong></p>
                    <button onclick="viewProperty('prop-123')">View Details</button>
                </div>

                <div class="property-card">
                    <h4>Spacious 4BR Colonial</h4>
                    <p>456 Oak Ave, Anytown, USA</p>
                    <p><strong>$575,000</strong></p>
                    <button onclick="viewProperty('prop-456')">View Details</button>
                </div>
            `;
        }

        function showError(message) {
            const app = document.getElementById('app');
            app.innerHTML = `<div class="error">${message}</div>`;
        }

        function viewProperty(id) {
            alert('Viewing property: ' + id);
            // In a real widget, you'd navigate or show details
        }

        // Initialize on page load
        initWidget();
    </script>
</body>
</html>

Testing Your Widget

Local Development

  1. Create a test URL with sample context and signature:
const crypto = require('crypto');

// Example: Person context
const personContext = {
  team: {
    id: "team-uuid-here",
    name: "Test Team"
  },
  user: {
    id: "user-uuid-here",
    email: "[email protected]"
  },
  person: {
    id: "123e4567-e89b-12d3-a456-426614174000",
    firstName: "Test",
    lastName: "User",
    stage: { name: "Lead" },
    emails: [{ value: "[email protected]", isPrimary: 1 }],
    phones: [{ phone: "+1-555-1234", type: "mobile", isPrimary: 1, normalized: "15551234" }]
  },
  timestamp: new Date().toISOString()
};

// Example: Company context
const companyContext = {
  team: {
    id: "team-uuid-here",
    name: "Test Team"
  },
  user: {
    id: "user-uuid-here",
    email: "[email protected]"
  },
  company: {
    id: "123e4567-e89b-12d3-a456-426614174000",
    name: "Test Company",
    industry: "Real Estate",
    website: "https://test.com",
    stage: { name: "Lead" },
    emails: [{ value: "[email protected]", isPrimary: 1 }],
    phones: [{ phone: "+1-555-1234", type: "work", isPrimary: 1, normalized: "15551234" }],
    addresses: [{
      type: "office",
      street: "123 Main St",
      city: "San Francisco",
      state: "CA",
      code: "94105",
      country: "United States",
      isPrimary: 1
    }]
  },
  timestamp: new Date().toISOString()
};

function generateTestUrl(context) {
  const contextBase64 = Buffer.from(JSON.stringify(context)).toString('base64');
  const signature = crypto.createHmac('sha256', YOUR_SECRET_KEY)
    .update(contextBase64)
    .digest('hex');
  return `http://localhost:3000/widget?context=${contextBase64}&signature=${signature}`;
}

console.log('Person view:', generateTestUrl(personContext));
console.log('Company view:', generateTestUrl(companyContext));
  1. Open the URL in your browser to test signature verification and rendering

Team Testing

  1. Request a test widget with your team's team_id and specify which view types your widget supports
  2. Enable the widget from Settings > Integration Partner Widgets for the desired view types:
    • Enable for Person View: Widget appears in person detail pages
    • Enable for Company View: Widget appears in company detail pages
  3. Navigate to person or company detail pages (depending on your enabled view types)
  4. Your widget will appear as a tab in the enabled views

Pre-Launch Checklist

  • Signature verification is working correctly
  • Context timestamp validation is implemented
  • Widget correctly handles both person and company contexts (if supporting both view types)
  • Error handling covers all edge cases
  • Loading states are shown during async operations
  • Widget loads in under 2 seconds
  • Mobile responsive design works
  • No console errors or warnings
  • HTTPS endpoint is configured
  • Security headers are set
  • Secret key is stored securely (not in code)

User Enablement

Once your widget is approved and published:

  1. Users discover your widget in Settings > Integration Partner Widgets
  2. Users enable the widget by toggling it on for the desired view types:
    • Enable for Person View: Widget appears in person detail pages
    • Enable for Company View: Widget appears in company detail pages
    • Enable for Both: Widget appears in both view types
  3. Widget appears as a tab on the enabled view types for all team members
  4. Users can disable at any time from the same settings page, independently for each view type

Note: Teams can enable your widget separately for person and company views. If your widget supports both view types, teams can choose to enable it in one or both views based on their needs.

API Reference

Widget URL Parameters

ParameterTypeRequiredDescription
contextstringYesBase64-encoded JSON context
signaturestringYesHMAC SHA-256 signature of context

Context Schema

See Context Data Structure section above for the complete schema.

Troubleshooting

Common Issues

Problem: "Invalid signature" error

Solutions:

  • Ensure you're using the correct secret key
  • Verify you're signing the Base64-encoded context (not the decoded JSON)
  • Check for leading/trailing whitespace in the secret key
  • Use constant-time comparison functions

Problem: Widget not loading

Solutions:

  • Check that your URL is HTTPS
  • Verify X-Frame-Options and CSP headers allow framing
  • Check browser console for JavaScript errors
  • Test signature verification endpoint separately

Problem: Context seems old or stale

Solutions:

  • Implement timestamp validation
  • Check if user's system clock is correct
  • Verify timezone handling in your code

Problem: Widget loads slowly

Solutions:

  • Optimize initial bundle size
  • Lazy load non-critical features
  • Use CDN for static assets
  • Implement proper caching headers

Changelog

  • 2025-10-30: Added company view support

    • Widgets can now be configured for person view, company view, or both
    • Company context includes business-specific fields (industry, company size, revenue, addresses)
    • Teams can enable widgets separately for person and company views
    • Updated context structure to support both person and company data
  • 2025-10-27: Initial release

    • Widget registration and approval process
    • Secure context passing with HMAC SHA-256 signatures
    • Team-based testing before global approval
    • Person view support