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:
- Team Testing: When created with a
team_id, the widget is visible only to that team for testing - 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 informationsignature: 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):
| Field | Type | Description |
|---|---|---|
team.id | string | UUID of the agent's current team |
team.name | string | Name of the agent's current team |
user.id | string | UUID of the agent viewing the page |
user.email | string | Email of the agent viewing the page |
timestamp | string | ISO 8601 timestamp when the context was generated |
Person Fields (only present when viewing from person detail page):
| Field | Type | Description |
|---|---|---|
person.id | string | UUID of the person contact |
person.firstName | string | Person's first name |
person.lastName | string | Person's last name |
person.stage.name | string | Current stage of the person (e.g., "Lead", "Prospect") |
person.emails | array | All email addresses for the person |
person.phones | array | All phone numbers for the person |
Company Fields (only present when viewing from company detail page):
| Field | Type | Description |
|---|---|---|
company.id | string | UUID of the company |
company.name | string | Company name |
company.industry | string | Industry/sector (e.g., "Real Estate", "Technology") |
company.website | string | Company website URL |
company.description | string | Company description |
company.companySize | string | Company size range (e.g., "1-10", "201-500", "5001+") |
company.annualRevenue | number | Annual revenue (decimal or integer) |
company.foundedDate | string | ISO 8601 date when company was founded |
company.registrationNumber | string | Business registration number |
company.taxId | string | Tax ID number |
company.stage.name | string | Current stage of the company (e.g., "Lead", "Customer") |
company.emails | array | All email addresses for the company |
company.phones | array | All phone numbers for the company |
company.addresses | array | All addresses for the company |
Email Object:
| Field | Type | Description |
|---|---|---|
id | string | UUID of the email record |
email | string | Email address (original case) |
value | string | Email address (lowercase) |
isPrimary | integer | 1 if primary, 0 otherwise |
created_at | string | ISO 8601 timestamp |
updated_at | string | ISO 8601 timestamp |
Phone Object:
| Field | Type | Description |
|---|---|---|
id | string | UUID of the phone record |
phone | string | Phone number (formatted) |
type | string | Type: "mobile", "home", "work", etc. |
isPrimary | integer | 1 if primary, 0 otherwise |
normalized | string | Phone number with only digits |
created_at | string | ISO 8601 timestamp |
updated_at | string | ISO 8601 timestamp |
Address Object (Company contexts only):
| Field | Type | Description |
|---|---|---|
id | string | UUID of the address record |
type | string | Address type: "office", "billing", "shipping", etc. |
street | string | Street address |
city | string | City name |
state | string | State or province |
code | string | Postal/ZIP code |
country | string | Country name |
isPrimary | integer | 1 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
- Extract the
contextandsignaturequery parameters - Compute HMAC SHA-256 of the
contextstring (Base64-encoded, not decoded) using your secret key - Compare the computed signature with the provided signature using constant-time comparison
- 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'
endPHP:
<?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
- Fast Loading: Optimize for quick load times. Users expect instant feedback.
- Responsive Design: Support various widget sizes and screen resolutions
- Loading States: Show loading indicators while fetching data
- Error Handling: Display user-friendly error messages
- Accessibility: Follow WCAG 2.1 guidelines
Visual Design
- Consistent Branding: Use your brand colors but ensure readability
- Sure Send CRM Compatibility: Design should feel native to the CRM
- Mobile Friendly: Support responsive layouts
- 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
- 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));- Open the URL in your browser to test signature verification and rendering
Team Testing
- Request a test widget with your team's
team_idand specify which view types your widget supports - 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"
- 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:
- Users discover your widget in Settings > Integration Partner Widgets
- 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"
- 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
- 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:
- Widget registers with specific API scopes during setup
- When loaded, widget receives authorization status in context
- If not authorized, widget shows a "Connect" button that opens the consent popup
- User reviews permissions and approves/denies
- On approval, an API token is generated and sent to the widget via
postMessage - 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:
- Navigate to Settings > Integration Partner Widgets > Admin
- Click Create New Widget or edit an existing widget
- Scroll down to the API Access section
- Check the permissions your widget needs under Requested Permissions
- Optionally check Require team-wide authorization if you need a single authorization for the entire team
- Save the widget
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:
| Setting | Value |
|---|---|
| Requested Permissions | read_people, read_tasks, write_tasks, read_notes |
| Require team-wide authorization | No (each user authorizes individually) |
Authorization Types:
| Type | Description | Who Can Authorize |
|---|---|---|
user (default) | Each user authorizes individually for their own access | Any team member |
team | One authorization grants access for the entire team | Admin or Owner only |
Set requires_team_authorization: true if your widget needs team-wide access rather than per-user access.
Available Scopes
| Scope | Description |
|---|---|
read_people | View people/contacts |
write_people | Create and update people/contacts |
read_companies | View companies |
write_companies | Create and update companies |
read_tasks | View tasks |
write_tasks | Create and update tasks |
read_notes | View notes |
write_notes | Create and update notes |
read_appointments | View appointments |
write_appointments | Create and update appointments |
read_calls | View call logs |
write_calls | Create call logs |
read_events | View events/activities |
write_events | Create events/activities |
read_sms | View SMS messages |
write_sms | Send SMS messages |
read_emails | View emails |
write_emails | Send 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 PopupYour 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 issueReceiving 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:
- The message is sent immediately when the user clicks "Approve" and the popup closes automatically
- The postMessage uses strict origin validation - it will only be delivered to the origin registered in your widget's
widget_url - Your widget should store the token on its backend for security
- The
api_tokenis the full token needed for API calls - The
display_tokenis truncated (for UI display only)
Security Notes:
- The
authorization_urlis 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
- Never expose tokens in client-side code - If your widget runs entirely in the browser, consider a backend proxy
- Use encrypted storage - Don't store tokens in plain text localStorage
- Implement token expiration handling - Handle 401 responses gracefully and prompt for re-authorization
- Scope tokens appropriately - Use user-scoped tokens for user-specific widgets
- 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:
- Detect the invalid token (401 response)
- Clear the stored token
- Show the "Connect" button to prompt re-authorization
- 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 Type | Description | Success |
|---|---|---|
authorization_initiated | User started the authorization flow | ✓ |
authorization_approved | User approved widget access | ✓ |
authorization_denied | User denied widget access | ✓ |
authorization_revoked | User or admin revoked widget access | ✓ |
authorization_expired | State token expired before completion | ✗ |
authorization_failed | Authorization failed (various reasons) | ✗ |
invalid_signature | Invalid URL signature detected | ✗ |
invalid_state | Invalid or mismatched state token | ✗ |
permission_denied | User 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:
- Your API calls will return
401 Unauthorized - The context
authorization.statuswill change tonot_authorized - 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
| Parameter | Type | Required | Description |
|---|---|---|---|
context | string | Yes | Base64-encoded JSON context |
signature | string | Yes | HMAC 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
postMessageto 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
Updated 10 days ago
