Integration Partner Widgets

Integration Partner Widgets Guide

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

Overview

Partner widgets can appear in three different view types:

  • Person View: Widgets appear as tabs in person detail pages with full contact context
  • Company View: Widgets appear as tabs in company detail pages with business context
  • Tools View: Widgets appear as sidebar items and render as full-page iframes with team/user context only

Examples of widget use cases:

  • Property recommendation engines (Person/Company)
  • Market analysis tools (Person/Company/Tools)
  • Document preparation services (Person/Company)
  • Communication tools (Person/Company)
  • Task automation interfaces (Tools)
  • Analytics dashboards (Tools)
  • Company research tools (Company/Tools)
  • B2B lead enrichment services (Company)
  • Admin utilities and integrations (Tools)

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 or sidebar (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 as a tab in person detail pages
    • Company View: Widget appears as a tab in company detail pages
    • Tools View: Widget appears as a sidebar item with full-page rendering
  • 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, two, or all three view types. At least one view type must be selected. Teams can enable your widget separately for each view type 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 the view type: person, company, or tools.

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"
}

Tools View Context

When loaded as a sidebar tool, the context contains only team and user information (no contact data):

{
  "team": {
    "id": "123e4567-e89b-12d3-a456-426614174000",
    "name": "Acme Real Estate"
  },
  "user": {
    "id": "123e4567-e89b-12d3-a456-426614174001",
    "email": "[email protected]"
  },
  "timestamp": "2025-10-27T15:30:00Z"
}

Key difference: Tools view context does not contain person or company objects. This is intentional—tools widgets are standalone features that don't operate on a specific contact. They have access to full-page rendering and are suited for dashboards, admin utilities, and team-wide integrations.

Important: Your widget should check for the presence of person, company, or neither 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 {
  // This is a tools view context (no contact data)
  displayToolsWidget(data.team, data.user);
}
# 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:
    # This is a tools view context (no contact data)
    render_tools_widget(data['team'], data['user'])

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://suresend.ai
Content-Security-Policy: frame-ancestors https://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()
};

// Example: Tools context (no contact data)
const toolsContext = {
  team: {
    id: "team-uuid-here",
    name: "Test Team"
  },
  user: {
    id: "user-uuid-here",
    email: "[email protected]"
  },
  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));
console.log('Tools view:', generateTestUrl(toolsContext));
  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
    • Enable for Tools View: Widget appears in the sidebar under "Tools"
  3. Test your widget:
    • Person/Company: Navigate to person or company detail pages and click on your widget's tab
    • Tools: Click on the "Tools" menu in the sidebar and select your widget to see it in full-page view

Pre-Launch Checklist

  • Signature verification is working correctly
  • Context timestamp validation is implemented
  • Widget correctly handles all supported view types (person, company, and/or tools)
  • 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)
  • Tools widgets work well in full-page layout (if supporting tools view)

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 as a tab in person detail pages
    • Enable for Company View: Widget appears as a tab in company detail pages
    • Enable for Tools View: Widget appears as a sidebar item under "Tools"
  3. Widget appears in the enabled locations for all team members:
    • Person/Company views: Widget appears as a tab in the contact detail page
    • Tools view: Widget appears as a sidebar navigation item that opens a full-page view
  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, company, and tools views. If your widget supports multiple view types, teams can choose which views to enable based on their needs.

Tools View Behavior

Tools widgets have different behavior than Person/Company widgets:

  • Navigation: Tools appear in the sidebar under "Tools" menu (admin/owner access required to see)
  • Display: Tools render as full-page iframes, not tabs
  • Context: Tools receive team and user context only (no contact data)
  • Use cases: Dashboards, admin utilities, team-wide integrations, and features that don't need contact context

API Access Authorization

Widgets can request API access to interact with Sure Send CRM data beyond what's provided in the context. This enables powerful integrations like creating tasks, adding notes, updating contacts, and more.

Overview

The authorization flow is similar to OAuth 2.0:

  1. Widget registers with specific API scopes during setup
  2. When loaded, widget receives authorization status in context
  3. If not authorized, widget shows a "Connect" button that opens the consent popup
  4. User reviews permissions and approves/denies
  5. On approval, an API token is generated and sent to the widget via postMessage
  6. Popup closes automatically; widget stores the token securely and uses it for API calls

Authorization Flow Diagram

sequenceDiagram
    participant Widget
    participant CRM
    participant User
    participant API

    Widget->>CRM: Load with context
    CRM->>Widget: Context includes authorization status

    alt Not Authorized
        Widget->>User: Show "Connect to Sure Send" button
        User->>CRM: Click button, open consent popup
        CRM->>User: Show permissions & Approve/Deny
        User->>CRM: Click Approve
        CRM->>Widget: postMessage with API token
        CRM->>CRM: Close popup automatically
        Widget->>Widget: Store token securely
    end

    Widget->>API: Make API calls with token
    API->>Widget: Return data

Configuring API Access

To enable API access for your widget, configure the requested scopes through the widget form UI:

  1. Navigate to Settings > Integration Partner Widgets > Admin
  2. Click Create New Widget or edit an existing widget
  3. Scroll down to the API Access section
  4. Check the permissions your widget needs under Requested Permissions
  5. Optionally check Require team-wide authorization if you need a single authorization for the entire team
  6. Save the widget
Widget API Access Configuration

Important: If you don't configure any requested scopes, the authorization field will not be included in the widget context, and users won't see any authorization prompts.

Example Configuration:

SettingValue
Requested Permissionsread_people, read_tasks, write_tasks, read_notes
Require team-wide authorizationNo (each user authorizes individually)

Authorization Types:

TypeDescriptionWho Can Authorize
user (default)Each user authorizes individually for their own accessAny team member
teamOne authorization grants access for the entire teamAdmin or Owner only

Set requires_team_authorization: true if your widget needs team-wide access rather than per-user access.

Available Scopes

ScopeDescription
read_peopleView people/contacts
write_peopleCreate and update people/contacts
read_companiesView companies
write_companiesCreate and update companies
read_tasksView tasks
write_tasksCreate and update tasks
read_notesView notes
write_notesCreate and update notes
read_appointmentsView appointments
write_appointmentsCreate and update appointments
read_callsView call logs
write_callsCreate call logs
read_eventsView events/activities
write_eventsCreate events/activities
read_smsView SMS messages
write_smsSend SMS messages
read_emailsView emails
write_emailsSend emails

Best Practice: Request only the minimum scopes your widget needs. Users are more likely to approve widgets with fewer permissions.

Authorization Context

When your widget requests API access (requested_scopes is not empty), the context includes an authorization object:

Not Authorized:

{
  "team": { "id": "...", "name": "..." },
  "user": { "id": "...", "email": "..." },
  "person": { ... },
  "timestamp": "2026-01-23T15:30:00Z",
  "authorization": {
    "status": "not_authorized",
    "authorization_url": "https://suresend.ai/api/widget_authorizations/initiate?widget_id=abc123&expires_at=1737654321&sig=...",
    "requested_scopes": ["read_people", "read_tasks", "write_tasks"],
    "scope_descriptions": [
      { "scope": "read_people", "description": "View people/contacts" },
      { "scope": "read_tasks", "description": "View tasks" },
      { "scope": "write_tasks", "description": "Create and update tasks" }
    ],
    "requires_team_authorization": false
  }
}

Authorized:

{
  "team": { "id": "...", "name": "..." },
  "user": { "id": "...", "email": "..." },
  "person": { ... },
  "timestamp": "2026-01-23T15:30:00Z",
  "authorization": {
    "status": "authorized",
    "authorization_type": "user",
    "authorized_at": "2026-01-20T10:00:00Z",
    "scopes": ["read_people", "read_tasks", "write_tasks"]
  }
}

Important: The API token is never included in the context. It is shown only once during the consent approval and must be stored by your widget.

Handling Authorization in Your Widget

⚠️

Critical: Open Authorization URL in a Popup

Your widget runs in an iframe on a different domain than Sure Send CRM. Due to browser security restrictions (SameSite cookies), you must open the authorization URL in a popup window or new tab - not by navigating the iframe itself.

  • window.open(authUrl, 'suresend_auth', 'popup,width=600,height=700')
  • window.open(authUrl, '_blank')
  • window.location.href = authUrl (will fail with "Unauthorized")

Your widget should check the authorization status and show appropriate UI:

function initWidget() {
  const data = parseContext(context);

  // Check if widget requests API access
  if (data.authorization) {
    if (data.authorization.status === 'authorized') {
      // Widget is authorized - check if we have a stored token
      const token = getStoredToken(data.team.id, data.user.id);
      if (token) {
        initializeWithApiAccess(data, token);
      } else {
        // Authorized but token not stored - show reconnect prompt
        showReconnectPrompt(data.authorization);
      }
    } else {
      // Not authorized - show connect button
      showConnectButton(data.authorization);
    }
  } else {
    // Widget doesn't require API access
    initializeBasicWidget(data);
  }
}

function showConnectButton(authorization) {
  const container = document.getElementById('auth-container');
  container.innerHTML = `
    <div class="connect-prompt">
      <h3>Connect to Sure Send CRM</h3>
      <p>This widget needs access to your CRM data to work properly.</p>
      <p><strong>Requested permissions:</strong></p>
      <ul>
        ${authorization.scope_descriptions.map(s =>
          `<li>${s.description}</li>`
        ).join('')}
      </ul>
      <button onclick="startAuthorization('${authorization.authorization_url}')">
        Connect to Sure Send
      </button>
    </div>
  `;
}

function startAuthorization(authUrl) {
  // IMPORTANT: Must open in a popup or new tab, NOT navigate the iframe!
  // This is required because:
  // 1. The iframe runs on your domain, but auth happens on suresend.ai
  // 2. Browser security blocks session cookies in cross-site iframe navigation
  // 3. Opening a popup creates a top-level navigation where cookies work properly
  window.open(authUrl, 'suresend_auth', 'popup,width=600,height=700');
}

// Alternative: Open in new tab (also works)
function startAuthorizationTab(authUrl) {
  window.open(authUrl, '_blank');
}

// DO NOT do this - will fail with "Unauthorized" error:
// window.location.href = authUrl;  // ❌ Navigates iframe, cookies blocked
// iframe.src = authUrl;            // ❌ Same issue

Receiving the API Token

When the user approves authorization, the consent page automatically sends the API token to your widget via postMessage and closes the popup. Your widget must listen for this message to receive the token.

Message Format:

{
  type: 'suresend_widget_authorized',
  widget_id: 'uuid-of-widget',
  authorization_id: 'uuid-of-authorization',
  api_token: 'suresendcrm_xxxxx...',  // Full API token
  display_token: 'suresendcrm_xxxx...xxxx',  // Truncated for display
  scopes: ['read_people', 'read_tasks', 'write_tasks']
}

Implementation:

// Store reference to popup for cleanup
let authPopup = null;

function startAuthorization(authUrl) {
  // Open consent page in popup
  authPopup = window.open(authUrl, 'suresend_auth', 'popup,width=600,height=700');

  // Listen for the authorization callback
  window.addEventListener('message', handleAuthMessage);
}

function handleAuthMessage(event) {
  // Verify origin - use Sure Send's production domain
  // Note: 'message' is the standard Web API event for postMessage communication.
  // The origin + type checks below ensure only legitimate messages are processed.
  const allowedOrigins = [
    'https://suresend.ai'
  ];

  if (!allowedOrigins.includes(event.origin)) {
    return;
  }

  // Handle successful authorization
  if (event.data.type === 'suresend_widget_authorized') {
    console.log('Authorization received!', {
      widget_id: event.data.widget_id,
      scopes: event.data.scopes
    });

    // Store the token securely on your backend
    saveTokenToBackend(event.data.api_token, event.data.scopes)
      .then(() => {
        showSuccess('Connected to Sure Send CRM!');
        // Reload widget with API access
        initializeWithApiAccess(event.data.api_token);
      })
      .catch(err => {
        showError('Failed to save token: ' + err.message);
      });

    // Close popup if still open
    if (authPopup && !authPopup.closed) {
      authPopup.close();
    }

    // Clean up listener
    window.removeEventListener('message', handleAuthMessage);
  }
}

// Example: Save token to your widget's backend
async function saveTokenToBackend(token, scopes) {
  const response = await fetch('https://your-widget.com/api/save-token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      team_id: currentContext.team.id,
      user_id: currentContext.user.id,
      api_token: token,
      scopes: scopes
    })
  });

  if (!response.ok) {
    throw new Error('Failed to save token');
  }
}

Important Notes:

  1. The message is sent immediately when the user clicks "Approve" and the popup closes automatically
  2. The postMessage uses strict origin validation - it will only be delivered to the origin registered in your widget's widget_url
  3. Your widget should store the token on its backend for security
  4. The api_token is the full token needed for API calls
  5. The display_token is truncated (for UI display only)

Security Notes:

  • The authorization_url is signed with HMAC and expires after 30 minutes - if users try to use an old URL, they'll need to get a fresh one from the widget
  • The postMessage is sent only to your widget's registered origin - malicious sites cannot intercept the token even if they open the authorization popup

Token Security Best Practices

  1. Never expose tokens in client-side code - If your widget runs entirely in the browser, consider a backend proxy
  2. Use encrypted storage - Don't store tokens in plain text localStorage
  3. Implement token expiration handling - Handle 401 responses gracefully and prompt for re-authorization
  4. Scope tokens appropriately - Use user-scoped tokens for user-specific widgets
  5. Validate on your backend - If you have a backend, validate tokens there before use

Token Expiration and Refresh Flow

Understanding Token Lifecycle

Widget API tokens have the following lifecycle:

┌─────────────────────────────────────────────────────────────────────────┐
│                        TOKEN LIFECYCLE                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  1. AUTHORIZATION INITIATED                                              │
│     └── User clicks "Connect to Sure Send"                               │
│     └── State token created (expires in 5 minutes)                       │
│                                                                          │
│  2. USER CONSENT                                                         │
│     └── User reviews permissions in consent popup                        │
│     └── State token validated                                            │
│                                                                          │
│  3. TOKEN ISSUED                                                         │
│     └── API token generated and sent via postMessage                     │
│     └── Token is valid indefinitely (until revoked)                      │
│     └── Token shown ONCE - widget must store it                          │
│                                                                          │
│  4. TOKEN IN USE                                                         │
│     └── Widget uses token for API calls                                  │
│     └── Token remains valid as long as authorization is active           │
│                                                                          │
│  5. TOKEN REVOCATION (any of these)                                      │
│     └── User revokes from Settings > Widget Authorizations               │
│     └── Admin revokes team-wide authorization                            │
│     └── API token deleted by team admin                                  │
│     └── Widget re-authorizes (previous token revoked)                    │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Important: No Automatic Token Refresh

Unlike OAuth 2.0 refresh tokens, widget API tokens do not automatically refresh. When a token becomes invalid (revoked or expired), your widget must:

  1. Detect the invalid token (401 response)
  2. Clear the stored token
  3. Show the "Connect" button to prompt re-authorization
  4. Guide the user through the authorization flow again

This design prioritizes security and explicit user consent over convenience.

Detecting Token Expiration

Your widget should handle these scenarios:

// Token states to handle:
// 1. Token never obtained (first use)
// 2. Token valid and working
// 3. Token revoked by user
// 4. Token expired (if expiration is set)
// 5. Token lost (localStorage cleared, etc.)

async function makeApiCall(endpoint, options = {}) {
  const token = getStoredToken();

  if (!token) {
    // Case 1 or 5: No token available
    showAuthorizationPrompt();
    throw new Error('No API token - authorization required');
  }

  try {
    const response = await fetch(`https://api.suresend.ai/api/partner${endpoint}`, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json'
      }
    });

    if (response.status === 401) {
      // Case 3 or 4: Token revoked or expired
      handleTokenInvalid();
      throw new Error('Token invalid - re-authorization required');
    }

    if (response.status === 403) {
      // Scope insufficient for this operation
      throw new Error('Insufficient permissions for this action');
    }

    return response;
  } catch (error) {
    if (error.message.includes('authorization')) {
      throw error;
    }
    // Network or other error
    throw new Error(`API call failed: ${error.message}`);
  }
}

function handleTokenInvalid() {
  // 1. Clear the invalid token
  clearStoredToken();

  // 2. Update UI to show disconnected state
  showAuthorizationPrompt();

  // 3. Log for debugging (optional)
  console.log('Widget token invalidated - re-authorization required');
}

Checking Authorization Status on Load

Each time your widget loads, check both the stored token AND the context authorization status:

function initWidget() {
  const data = parseContext(context);
  const storedToken = getStoredToken(data.team.id, data.user.id);

  // Check context authorization status
  if (data.authorization) {
    if (data.authorization.status === 'authorized') {
      // CRM says we're authorized
      if (storedToken) {
        // We have a token - verify it works
        verifyTokenAndInitialize(storedToken, data);
      } else {
        // Authorized but no token stored
        // This happens if:
        // - User cleared browser storage
        // - Token was stored on a different device
        // - Previous authorization but widget never received token
        showReauthorizationPrompt(
          'Your authorization is active, but the API token was not found. ' +
          'Please reconnect to receive a new token.'
        );
      }
    } else {
      // Not authorized according to CRM
      if (storedToken) {
        // We have a stale token - clear it
        clearStoredToken(data.team.id, data.user.id);
      }
      showAuthorizationPrompt(data.authorization);
    }
  } else {
    // Widget doesn't request API access
    initializeBasicWidget(data);
  }
}

async function verifyTokenAndInitialize(token, data) {
  try {
    // Make a lightweight API call to verify token
    const response = await fetch('https://api.suresend.ai/api/partner/me', {
      headers: { 'Authorization': `Bearer ${token}` }
    });

    if (response.ok) {
      // Token is valid
      initializeWithApiAccess(data, token);
    } else if (response.status === 401) {
      // Token was revoked but context hasn't updated yet
      clearStoredToken(data.team.id, data.user.id);
      showReauthorizationPrompt(
        'Your previous authorization has been revoked. Please reconnect.'
      );
    }
  } catch (error) {
    // Network error - initialize with stored token optimistically
    initializeWithApiAccess(data, token);
  }
}

Re-Authorization Flow

When re-authorization is needed, guide users through the same flow:

function showReauthorizationPrompt(message) {
  const container = document.getElementById('auth-container');
  container.innerHTML = `
    <div class="reauth-prompt">
      <div class="warning-icon">⚠️</div>
      <h3>Reconnection Required</h3>
      <p>${message || 'Please reconnect to continue using this widget.'}</p>
      <button onclick="startAuthorization('${currentAuthUrl}')">
        Reconnect to Sure Send
      </button>
    </div>
  `;
}

Authorization Events Audit Log

Sure Send CRM maintains an audit log of all widget authorization events for security monitoring. The following events are tracked:

Event TypeDescriptionSuccess
authorization_initiatedUser started the authorization flow
authorization_approvedUser approved widget access
authorization_deniedUser denied widget access
authorization_revokedUser or admin revoked widget access
authorization_expiredState token expired before completion
authorization_failedAuthorization failed (various reasons)
invalid_signatureInvalid URL signature detected
invalid_stateInvalid or mismatched state token
permission_deniedUser lacks permission (e.g., non-admin for team auth)

Team admins can view authorization events for their team in the admin dashboard for security monitoring and compliance purposes.

Making API Calls

Once you have the API token, use it to make authenticated requests:

async function createTask(personId, title, dueDate) {
  const token = secureStorage.get('suresend_api_token');

  const response = await fetch('https://api.suresend.ai/api/partner/tasks', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      task: {
        taskable_type: 'Person',
        taskable_id: personId,
        title: title,
        due_date: dueDate
      }
    })
  });

  if (response.status === 401) {
    // Token expired or revoked - prompt for re-authorization
    handleTokenExpired();
    return null;
  }

  return await response.json();
}

API Base URL: https://api.suresend.ai/api/partner

Authentication Header: Authorization: Bearer <token>

Handling Token Revocation

Users can revoke widget access from Settings > Widget Authorizations. When revoked:

  1. Your API calls will return 401 Unauthorized
  2. The context authorization.status will change to not_authorized
  3. Your widget should detect this and show the connect button again
async function makeApiCall(endpoint, options) {
  const response = await fetch(`https://api.suresend.ai/api/partner${endpoint}`, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': `Bearer ${getToken()}`
    }
  });

  if (response.status === 401) {
    // Token was revoked or expired
    clearStoredToken();
    showConnectButton();
    throw new Error('Authorization revoked - please reconnect');
  }

  return response;
}

User Management of Authorizations

Users can manage widget authorizations at: Settings > Widget Authorizations (/settings/widget-authorizations)

From this page, users can:

  • View all widgets they've authorized
  • See granted scopes and authorization date
  • Revoke access to any widget

For team-scoped authorizations, only admins and owners can revoke access.

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

  • 2026-01-24: Added Token Refresh Documentation and Authorization Audit Logging

    • Documented token lifecycle and expiration handling
    • Added detailed guidance for detecting and handling token revocation
    • Implemented authorization audit logging for security monitoring
    • Added event tracking table for all authorization lifecycle events
    • Team admins can now monitor authorization activity
  • 2026-01-23: Added API Access Authorization

    • Widgets can now request API scopes to access CRM data beyond the context
    • OAuth 2.0-style authorization flow with user consent
    • Support for user-scoped and team-scoped authorizations
    • API tokens sent automatically via postMessage, stored securely by widget
    • Users can manage and revoke widget authorizations from Settings
    • Available scopes: people, companies, tasks, notes, appointments, calls, events, SMS, emails
    • Automatic postMessage callback: On approval, the consent page sends API token via postMessage to the widget's opener window with strict origin validation
    • Security improvements: Authorization URLs are signed with HMAC and expire after 30 minutes; postMessage only delivers to the widget's registered origin
  • 2025-12-27: Added tools view support

    • Widgets can now be configured for person view, company view, tools view, or any combination
    • Tools widgets appear as sidebar items under "Tools" menu and render as full-page iframes
    • Tools context contains team and user information only (no contact data)
    • Ideal for dashboards, admin utilities, and team-wide integrations
  • 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