Skip to main content

Advanced Topics

Deep dive into design decisions, edge cases, and technical specifications that help you get the most out of Sticky Calls.


customer_ref vs identity_hints

The Question

"Why would I use identity_hints in /calls/end when I already have customer_ref from /calls/start?"

The Answer

TL;DR: Use customer_ref 99% of the time. Use identity_hints only for these special cases:

  1. Standalone end call - Call ended without calling /calls/start first
  2. Backend-only tracking - Saving context after the call, not during
  3. Webhook-driven flows - Call data arrives via webhook, no real-time start

Normal Flow (Use customer_ref)

// 1. Call starts - get customer_ref
const startResponse = await fetch('https://api.stickycalls.com/v1/calls/start', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({
call_id: 'call_12345',
identity_hints: {
ani: '+14155551234',
external_ids: { crm_id: 'CRM_789' }
}
})
});

const { customer_ref } = await startResponse.json();
// customer_ref: "cust_abc123"

// 2. Call ends - use customer_ref (RECOMMENDED)
await fetch('https://api.stickycalls.com/v1/calls/end', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({
call_id: 'call_12345',
customer_ref: customer_ref, // Use this from /calls/start
intent: 'billing_inquiry',
intent_status: 'resolved'
})
});

Special Case 1: Standalone End (Use identity_hints)

Scenario: Call ended unexpectedly, or you're saving context after the call

// Call dropped before you could call /calls/start
// Or you're batch-processing call data later

await fetch('https://api.stickycalls.com/v1/calls/end', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({
call_id: 'call_12345',
identity_hints: { // Provide hints since you don't have customer_ref
ani: '+14155551234',
external_ids: { crm_id: 'CRM_789' }
},
intent: 'billing_inquiry',
intent_status: 'abandoned'
})
});

What happens: API will match or create customer_ref using identity_hints

Special Case 2: Backend-Only Tracking

Scenario: Saving call context hours after the call ended

// Your backend processes call recordings overnight
// and extracts intents/variables

async function processCallRecording(recording) {
// Extract data from recording
const { phoneNumber, intent, outcome } = await analyzeRecording(recording);

// Save to Sticky Calls (no real-time /calls/start)
await fetch('https://api.stickycalls.com/v1/calls/end', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({
call_id: recording.callId,
identity_hints: { ani: phoneNumber },
intent: intent,
intent_status: outcome
})
});
}

Special Case 3: Webhook-Driven Flow

Scenario: Your contact center platform sends webhook when call ends

// Webhook handler
app.post('/webhooks/call-ended', async (req, res) => {
const { callId, phoneNumber, disposition } = req.body;

// You never called /calls/start (no customer_ref)
await fetch('https://api.stickycalls.com/v1/calls/end', {
method: 'POST',
headers: { 'Authorization': `Bearer ${API_KEY}` },
body: JSON.stringify({
call_id: callId,
identity_hints: { ani: phoneNumber },
intent: disposition,
intent_status: 'resolved'
})
});

res.sendStatus(200);
});

Decision Tree

Do you have customer_ref from /calls/start?
├─ YES → Use customer_ref (99% of cases)
└─ NO → Use identity_hints (special cases)
├─ Standalone end call
├─ Backend batch processing
└─ Webhook-driven flows

Simple vs Advanced Forms

The Question

"Can I mix simple and advanced parameters? When should I use which?"

The Answer

No, you cannot mix them. Choose one mode per request:

ModeWhen To UseParameters
Simple90% of use cases - single intent per callintent, intent_status
AdvancedComplex scenarios - multiple intentsintent_updates array

When to use:

  • Call has one primary intent (e.g., "billing inquiry")
  • Straightforward conversation flow
  • Standard contact center operations

Example:

{
"call_id": "call_12345",
"customer_ref": "cust_abc123",
"intent": "billing_inquiry",
"intent_status": "resolved",
"variables": {
"invoice_number": "INV-2026-001",
"amount_paid": "49.99"
}
}

Advanced Mode (Multiple Intents)

When to use:

  • Call covers multiple topics (e.g., "billing inquiry" → "account update" → "product question")
  • Need to track intent progression
  • Complex IVR flows with branching

Example:

{
"call_id": "call_12345",
"customer_ref": "cust_abc123",
"intent_updates": [
{
"intent": "billing_inquiry",
"status": "resolved"
},
{
"intent": "account_update",
"status": "resolved"
},
{
"intent": "product_question",
"status": "open" // Still pending
}
],
"variable_updates": {
"invoice_number": {
"value": "INV-2026-001",
"ttl_seconds": 2592000 // 30 days
}
}
}

Cannot Mix Modes

This will fail (400 error):

{
"intent": "billing_inquiry", // Simple mode
"intent_updates": [ // Advanced mode
{ "intent": "another_intent" }
]
}
// ERROR: Cannot use both simple and advanced forms

Decision Guide

Use Simple Mode If:

  • ✅ One primary intent per call
  • ✅ Standard TTL is acceptable (30 days)
  • ✅ Simple variable storage

Use Advanced Mode If:

  • ✅ Multiple intents per call
  • ✅ Need custom TTL per variable
  • ✅ Need to track intent progression

Variable Constraints

Complete Specifications

ConstraintLimitWhat Happens If Exceeded
Max variables per customer100Oldest variables auto-deleted
Max variable key length128 characters400 Bad Request error
Max variable value length1,024 characters400 Bad Request error
Max total payload size100 KB400 Bad Request error
Reserved key namesNoneAll valid (except empty string)
Allowed characters in keysAlphanumeric, _, -, .Other characters: 400 error

Variable Key Rules

Valid Keys:

{
"customer_name": "John Doe", // ✅ Alphanumeric + underscore
"account-id": "ACC-123", // ✅ Alphanumeric + hyphen
"invoice.number": "INV-001", // ✅ Alphanumeric + dot
"preferred_language": "en-US" // ✅ All combined
}

Invalid Keys:

{
"customer name": "...", // ❌ Space not allowed
"email@address": "...", // ❌ @ symbol not allowed
"price$amount": "...", // ❌ $ symbol not allowed
"": "...", // ❌ Empty string not allowed
}

Variable Value Rules

Accepted Types:

  • ✅ Strings (any content, up to 1024 chars)
  • ✅ Numbers (converted to strings internally)
  • ✅ Booleans (converted to "true"/"false")
  • ❌ Objects/Arrays (not supported - flatten first)

Examples:

{
"order_total": "149.99", // ✅ String
"items_purchased": "3", // ✅ Number as string
"is_vip": "true", // ✅ Boolean as string
"shipping_address": { // ❌ Object not supported
"street": "123 Main St"
}
}

Workaround for Complex Data:

// Option 1: Flatten
{
"shipping_street": "123 Main St",
"shipping_city": "San Francisco",
"shipping_state": "CA"
}

// Option 2: JSON stringify (if under 1024 chars)
{
"shipping_address": "{\"street\":\"123 Main St\",\"city\":\"SF\"}"
}

What Happens at Limits

Too Many Variables (>100)

Behavior: Oldest variables are automatically deleted (FIFO)

// Customer already has 100 variables
// Add a new one:
variables.new_variable = "value";

// Result: Oldest variable deleted, new one added
// Total remains 100

Value Too Long (>1024 chars)

Behavior: 400 Bad Request error

{
"error": "Bad Request",
"message": "Variable value exceeds 1024 character limit",
"details": {
"issues": [{
"path": ["variables", "long_description"],
"message": "Value length: 1500 (max: 1024)"
}]
}
}

Solution: Truncate or split the value

// Option 1: Truncate
const longValue = "...very long text...";
variables.description = longValue.substring(0, 1024);

// Option 2: Split into multiple variables
variables.description_part1 = longValue.substring(0, 1024);
variables.description_part2 = longValue.substring(1024, 2048);

TTL (Time-To-Live) Behavior

The Question

"What happens when variables expire? Can I recover them?"

The Answer

Expired variables are permanently deleted. No recovery possible.

TTL Specifications

SettingValue
Default TTL30 days (if not specified)
Minimum TTL1 hour (3,600 seconds)
Maximum TTL90 days (7,776,000 seconds)
TTL resets on update?Yes - every update resets the clock
Can extend TTL?Yes - update variable with new TTL
Can recover expired?No - permanent deletion

How TTL Works

Setting TTL (Advanced Mode):

{
"customer_ref": "cust_abc123",
"call_id": "call_12345",
"variable_updates": {
"invoice_number": {
"value": "INV-2026-001",
"ttl_seconds": 2592000 // 30 days
},
"temporary_note": {
"value": "Call back tomorrow",
"ttl_seconds": 86400 // 1 day
}
}
}

What Happens at Expiration:

T+0:  Variable created with 30-day TTL
T+29: Variable still available
T+30: Variable expires → hard deleted from database
T+31: Variable not returned in /calls/start response

No warning, no archival, no recovery.

TTL Reset on Update

Every update resets the TTL:

// Day 1: Create variable with 30-day TTL
variables.customer_preference = {
value: "email",
ttl_seconds: 2592000 // 30 days
};

// Day 20: Update the variable
variables.customer_preference = {
value: "phone", // Changed value
ttl_seconds: 2592000 // TTL resets - expires in 30 days from now (Day 50)
};

Result: Variable expires on Day 50, not Day 31

Extending TTL

// Current: variable expires in 5 days

// Extend TTL to 30 days from now
variables.important_data = {
value: currentValue, // Keep same value
ttl_seconds: 2592000 // New 30-day TTL
};
Use CaseTTLRationale
Open intents7-14 daysFollow-ups typically happen within 2 weeks
Order information30-60 daysCustomer may call about recent orders
Account preferences90 daysLong-term settings, rarely change
Temporary notes1-3 daysOne-time callback reminders
VIP status90 daysPersistent, update as needed

Checking Variable Age

Variables don't include creation timestamp in API responses. Track this locally if needed:

// When saving variable, also save timestamp
const now = new Date().toISOString();
variables.last_order_date = "2026-01-15";
variables.last_order_saved_at = now; // Your own tracking

Confidence Score Calculation

The Question

"How is the confidence score calculated? What affects it?"

The Algorithm

Confidence scores range from 0.0 (no match) to 1.0 (certain match) and are calculated using weighted signals:

SignalWeightDescription
Mobile ANI match+0.5Mobile phone number previously seen
Landline ANI match+0.3Landline number previously seen
VoIP ANI match+0.2VoIP number (less reliable)
External ID match+0.4CRM ID, account number, etc.
Recent call (≤1 day)+0.1Called within last 24 hours
Open intent continuity+0.1Has unresolved intent from previous call
DNIS match+0.05Called same number as before

Maximum score: 1.0 (capped even if weights exceed)

Confidence Levels

ScoreLevelRecommendationAction
≥0.5HighreuseUse customer context immediately
0.3-0.5MediumconfirmAsk for verification (last 4 of account #)
<0.3LowignoreTreat as new customer

Example Calculations

Example 1: Returning Customer, Mobile Phone

Signals:
- Mobile ANI match: +0.5
- Recent call (yesterday): +0.1
- Open intent (billing): +0.1
- DNIS match: +0.05

Total: 0.75"high" confidence → Recommendation: "reuse"

Response:

{
"identity": {
"confidence": 0.75,
"level": "high",
"sources": ["ani:mobile", "recency:1day", "open_intent", "dnis"],
"recommendation": "reuse"
}
}

Example 2: New Customer

Signals:
- No ANI match: 0
- No external IDs: 0
- No previous calls: 0

Total: 0.0"low" confidence → Recommendation: "ignore"

Response:

{
"identity": {
"confidence": 0.0,
"level": "none",
"sources": ["ani:unknown"],
"recommendation": "ignore"
}
}

Example 3: VoIP + CRM ID Match

Signals:
- VoIP ANI match: +0.2
- External ID (CRM) match: +0.4

Total: 0.6"high" confidence → Recommendation: "reuse"

Response:

{
"identity": {
"confidence": 0.6,
"level": "high",
"sources": ["ani:voip", "external_id:crm_id"],
"recommendation": "reuse"
}
}

Optimizing Confidence

To get higher confidence scores:

  1. Provide external IDs (CRM, account number)

    identity_hints: {
    ani: "+14155551234",
    external_ids: {
    crm_id: "CRM_789",
    account_number: "ACC_456"
    }
    }
  2. Include telco metadata (when available)

    telco: {
    line_type: "mobile" // Higher confidence than VoIP
    }
  3. Use consistent DNIS (call same number repeatedly)

  4. Save open intents (continuity signal)


PII Redaction Details

The Question

"What qualifies as PII? Does this affect saved variables?"

The Answer

PII redaction only applies to server-side logs, not your data.

What Is Redacted

Data TypeServer LogsDatabase (variables)API Responses
Phone numbers (ANI/DNIS)✅ Redacted❌ Stored as-is❌ Returned as-is
Email addresses✅ Redacted❌ Stored as-is❌ Returned as-is
Names✅ Redacted❌ Stored as-is❌ Returned as-is
Account numbers✅ Redacted❌ Stored as-is❌ Returned as-is
Custom variables✅ Redacted❌ Stored as-is❌ Returned as-is

Where Redaction Applies

Server-side logs only:

  • Cloud Logging entries
  • Error reports
  • Debug traces
  • Audit logs

Example log output:

INFO: Call started for ANI [REDACTED] to DNIS [REDACTED]
INFO: Customer context loaded: customer_ref=cust_abc123 variables=[REDACTED]

Where Redaction Does NOT Apply

Your data remains unredacted:

  1. Variables object - Stored exactly as you send it
  2. API responses - Full data returned
  3. Database - Raw data persisted
  4. Your application - You receive unredacted data

Example API response (NOT redacted):

{
"customer_ref": "cust_abc123",
"variables": {
"customer_name": "John Doe",
"email": "john@example.com",
"phone": "+14155551234"
}
}

Why Logs Are Redacted

Compliance: GDPR/CCPA require limiting PII in logs

Security: Prevents PII exposure in:

  • Support tickets
  • Error reports shared with vendors
  • Debug sessions
  • Log analysis tools

You Control Variable Storage

You decide what to save:

// Option 1: Store full details
variables.customer_name = "John Doe";
variables.customer_email = "john@example.com";

// Option 2: Store references only (more private)
variables.customer_id = "CUST_789"; // Reference to your CRM

// Option 3: Store minimal data
variables.name_first_letter = "J";
variables.email_domain = "example.com";

Can You Opt Out?

No. Log redaction is mandatory for compliance. But you can:

  • Store whatever you want in variables
  • Receive unredacted API responses
  • Query your database directly

Data Retention Policy

Storage Duration

Data TypeRetention PeriodDeletion Process
Customer contextsUntil TTL expires (30-90 days default)Auto-deleted at TTL expiration
VariablesUntil TTL expiresAuto-deleted at TTL expiration
Open intentsUntil marked resolved or 90 daysAuto-deleted
Usage events90 daysAuto-deleted (rolling window)
API keysUntil revoked/deletedManual deletion via dashboard
Billing records7 yearsRequired by law (tax/accounting)
Account dataActive + 30 days after deletion requestManual deletion via support

Geographic Storage

Primary Region: us-central1 (Iowa, USA)

  • All production data
  • PostgreSQL database (Cloud SQL)

Backup Region: us-east1 (South Carolina, USA)

  • Automated backups (daily)
  • Disaster recovery

No cross-region replication - Data stays within USA

GDPR Right to Erasure

Process: Manual (email-based)

Timeline: 30 days from request

What gets deleted:

  • All customer_ref records
  • All variables associated with customer
  • All open intents
  • All usage events (tied to customer_ref)

What persists (anonymized):

  • Billing records (legal requirement - 7 years)
  • Aggregated usage statistics (no PII)
  • API key metadata (no customer link)

How to request:

  1. Email nate@bananaintelligence.ai
  2. Include: customer_ref(s) to delete
  3. Receive confirmation within 30 days

Future: Self-service deletion API (planned)

Data Minimization

Best practice: Store only what you need

// Instead of:
variables.customer_full_name = "John Doe";
variables.customer_email = "john@example.com";
variables.customer_ssn = "123-45-6789"; // ❌ Don't store sensitive data

// Store references:
variables.customer_id = "CRM_789"; // ✅ Reference to your system
variables.vip_tier = "gold"; // ✅ Derived attribute

Idempotency Key Behavior

The Question

"What's the exact behavior when retrying with the same idempotency key?"

The Answer

Idempotency keys allow safe retries for 24 hours without duplicate processing.

How It Works

First Request (with idempotency key):

POST /v1/calls/end
X-Idempotency-Key: idem_abc123

Response: HTTP 200 OK
{
"customer_ref": "cust_xyz",
"intents_updated": 1,
"variables_updated": 2
}

Retry (same idempotency key within 24h):

POST /v1/calls/end
X-Idempotency-Key: idem_abc123 # Same key

Response: HTTP 200 OK
{
"customer_ref": "cust_xyz",
"intents_updated": 1,
"variables_updated": 2
}

Result: Cached response returned, no duplicate processing

Key Details

AspectBehavior
Cache duration24 hours
ResponseExact same response (cached)
HTTP status200 OK (success)
ProcessingSkipped (not executed twice)
ChargingFirst request only (no duplicate charge)
After 24hKey expires, treated as new request

When To Use

Use idempotency keys for:

  • /v1/calls/end (recommended)
  • Network retry logic
  • Webhook processing
  • Batch operations

Not needed for:

  • /v1/calls/start (read-only operations safe to retry)
  • /v1/health (always safe)

Example Usage

import { v4 as uuidv4 } from 'uuid';

async function endCallWithRetry(callData) {
const idempotencyKey = uuidv4();
let attempts = 0;
const maxAttempts = 3;

while (attempts < maxAttempts) {
try {
const response = await fetch('https://api.stickycalls.com/v1/calls/end', {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'X-Idempotency-Key': idempotencyKey, // Same key for all retries
'Content-Type': 'application/json'
},
body: JSON.stringify(callData)
});

if (response.ok) {
return await response.json();
}

// Handle errors
if (response.status === 500) {
attempts++;
await sleep(Math.pow(2, attempts) * 1000);
continue; // Retry with same idempotency key
}

throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempts === maxAttempts - 1) throw error;
attempts++;
await sleep(Math.pow(2, attempts) * 1000);
}
}
}

What If Request Body Changes?

Scenario: Retry with same idempotency key but different body

// First request
POST /v1/calls/end
X-Idempotency-Key: idem_abc123
Body: { intent: "billing_inquiry", intent_status: "resolved" }

// Retry with DIFFERENT body
POST /v1/calls/end
X-Idempotency-Key: idem_abc123
Body: { intent: "account_update", intent_status: "pending" }

Result: Cached response returned (from first request), body ignored

Best practice: Generate new idempotency key if you intentionally change the request


External IDs Format

Specifications

ConstraintLimit
Max external IDs per call10
Key formatAlphanumeric + underscore ([a-zA-Z0-9_])
Value formatString only (no arrays/objects)
Max key length64 characters
Max value length128 characters
Case-sensitive matchingYes
EncodingUTF-8

Valid Examples

external_ids: {
crm_id: "CRM_123456",
account_number: "ACC-789",
user_id: "USER_abc",
order_id: "ORD-2026-001",
customer_uuid: "550e8400-e29b-41d4-a716-446655440000"
}

Invalid Examples

external_ids: {
"customer id": "...", // ❌ Space in key
"email@address": "...", // ❌ @ symbol in key
"account#": "...", // ❌ # symbol in key
"id": ["123", "456"], // ❌ Array value
"data": { "nested": "..." } // ❌ Object value
}

Matching Behavior

Case-sensitive:

// These are DIFFERENT:
{ crm_id: "CRM_123" }
{ CRM_ID: "CRM_123" }
{ crm_id: "crm_123" }

Exact match required:

// Stored:
{ account_number: "ACC-123" }

// Matches:
{ account_number: "ACC-123" } // ✅

// Does NOT match:
{ account_number: "acc-123" } // ❌ Case mismatch
{ account_number: "ACC123" } // ❌ Hyphen missing
ID TypeKey NameExample ValueNotes
CRM IDcrm_idCRM_123456Most reliable
Account #account_numberACC-789Good for existing customers
User UUIDuser_uuid550e8400-...Universal identifier
Order IDorder_idORD-2026-001For order-related calls
Email hashemail_hash5d41402a...If email not in CRM

Multiple IDs Strategy

Provide multiple for higher confidence:

identity_hints: {
ani: "+14155551234",
external_ids: {
crm_id: "CRM_123",
account_number: "ACC-789",
user_uuid: "550e8400-..."
}
}

Matching logic: ANY match triggers identification


Pagination Support

Current Limitations

No pagination is currently supported for these responses:

EndpointFieldMax ItemsBehavior When Exceeded
/v1/calls/startopen_intents100Oldest items dropped (FIFO)
/v1/calls/startvariables100Oldest items dropped (FIFO)

What This Means

If a customer has more than 100 open intents or variables:

  • Only most recent 100 returned
  • Oldest items automatically deleted
  • No way to retrieve full history

Current Behavior

// Customer has 150 variables
GET /v1/calls/start

Response:
{
"variables": {
// Only most recent 100 variables
// 50 oldest variables dropped
}
}

Workarounds

1. Store Large Data Externally

// Instead of 100+ variables in Sticky Calls:
variables.data_reference = "S3://bucket/customer_123.json";

// Retrieve from your storage:
const fullData = await s3.getObject({
Bucket: 'bucket',
Key: 'customer_123.json'
});

2. Expire Old Variables

// Set short TTL for temporary data
variable_updates: {
temp_note: {
value: "Callback scheduled",
ttl_seconds: 86400 // 1 day - auto-deleted
}
}

3. Archive Resolved Intents

// Mark intents as resolved to remove from open_intents
intent_updates: [
{
intent: "old_issue",
status: "resolved" // Removed from open_intents list
}
]

Future Plans

Pagination may be added in future versions. Subscribe to updates at stickycalls.com.



Need Help?

Questions about edge cases or advanced usage?