Podium to Unthread Migration: The CTO's Technical Guide
Technical guide to migrating from Podium to Unthread. Covers API extraction, data model mapping, conversation threading, rate limits, and validation.
Planning a migration?
Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.
Schedule a free call- 1,500+ migrations completed
- Zero downtime guaranteed
- Transparent, fixed pricing
- Project success responsibility
- Post-migration support included
Last verified: July 27, 2026, against Podium API v4 and Unthread's public docs/API. (docs.podium.com)
Migrating from Podium to Unthread means moving data between two platforms that share almost no structural overlap. Podium is a customer interaction platform for local businesses built around SMS messaging, webchat, reviews, payments, and campaigns. Unthread is a Slack-native AI helpdesk that converts Slack conversations into tracked, routed, and resolved tickets. There is no native migration path between them. Every record requires deliberate schema translation.
The fastest viable path is a custom API-based ETL pipeline or a managed migration service. Podium's CSV exports cover contacts but miss conversation threading, message history, and relational context. Middleware tools like Zapier can forward new records but cannot handle bulk historical migration at scale.
This guide covers the technical architecture, data mapping rules, API constraints, edge cases, and step-by-step process for executing a high-fidelity migration from Podium to Unthread.
For related Unthread migrations, see:
- Freshchat to Unthread Migration: Covers Freshchat-specific extraction patterns and Unthread's API import behavior.
- Kustomer to Unthread Migration: For teams coming from a CRM-centric helpdesk.
- Gladly to Unthread Migration: Handles people-centric data model translation.
Why Teams Move from Podium to Unthread
The shift is architectural, not incremental. Teams migrate for these reasons:
- Channel realignment. Podium is built for external-facing SMS, webchat, and review management for local businesses. Teams whose support operations have shifted to Slack-based B2B customer communication or internal IT/HR support need a platform built for that workflow. Among Slack-native helpdesks (Halp, Thena, Foqal, ClearFeed), Unthread differentiates by offering full ticket lifecycle management with SLAs, AI agents, and native email/in-app chat channels — making it suitable as a primary helpdesk rather than a Slack add-on.
- Operational maturity. Podium has no concept of SLAs, ticket types, escalation paths, or structured routing. When support operations grow beyond "reply to texts" into tracked, prioritized ticket management, Unthread is purpose-built for that.
- AI-native support. Unthread's AI agents operate directly inside Slack channels for auto-classification, suggested responses, and ticket resolution. Podium's AI features are add-ons focused on review responses and receptionist functionality, not ticket resolution.
- Cost structure. Podium's published plans start at $399/month (Core) and scale to $599+/month (Pro) with per-location fees and add-ons. Unthread starts at $50/agent/month (Basic) or $75/agent/month (Pro). For a 5-agent team, that's $250–$375/month on Unthread vs. $399–$599+/month on Podium — a 35–60% reduction for teams that primarily need ticketing and conversation tracking. Verify current pricing directly with each vendor before planning, as both have changed pricing in the past 12 months.
Core Data Model Differences
Understanding how these platforms model data is the prerequisite for any migration plan.
| Concept | Podium | Unthread |
|---|---|---|
| Primary entity | Conversation (SMS/webchat thread) | Conversation (ticket) |
| Contact/Customer | Contact (name, phone, email, tags, custom fields) | Customer (Slack channel, email domains, tags) + Account |
| Organization | Organization → Location (multi-level hierarchy) | Account (flat, with support assignees) |
| Messages | Conversation Items (SMS, email, webchat, internal notes) | Messages (Slack messages, email, in-app chat) |
| Categories | Inboxes (custom, reviews) | Ticket Types (with custom fields) |
| Tags | Contact tags, campaign opt-in tags | Tags (applied to conversations and customers) |
| Reviews | Review invites, review ratings, review body | No equivalent — archive or discard |
| Payments | Invoices, payment records | No equivalent — archive or discard |
| Campaigns | Campaign messages, interactions, opt-in/opt-out | No equivalent — Unthread outbound is Slack-only |
| Custom fields | Up to 8 contact custom fields | Ticket Type Fields (short-answer, multi-select, checkbox, user-select) |
| Statuses | Open / Closed (binary) | Open / In Progress / On Hold / Closed |
Podium's data model is conversation-first and channel-agnostic (SMS, email, webchat, Apple Chat, Facebook). Unthread's model is ticket-first and Slack-native, with email and in-app chat as secondary channels. Reviews, payments, and campaign data have no native Unthread equivalent — plan to archive these before migration.
Migration Approaches
1. CSV Export + Manual Import
How it works: Export contacts from Podium as CSV (available via the Contacts tab or by requesting an export from Podium support). Manually create corresponding Customer and Account records in Unthread via the web UI or API. (podium.com)
When to use it: Small datasets (<500 contacts) where conversation history is not required.
Pros:
- No engineering effort for the contact portion
- Quick for contact-only migrations
Cons:
- Podium's CSV export only includes Name, Phone, Email, Contact Status, Address, Tags, and Date Added — no conversation history, no messages, no attachments
- Unthread has no bulk CSV import UI; you still need API calls to create Customer/Account records
- No relationship preservation between contacts and conversations
Complexity: Low (contacts only) / Not viable (full migration)
2. FTP Raw Data Export + Custom ETL
How it works: Request access to Podium's FTP raw data export (requires Account Owner approval). Podium exposes flat data tables: Messages, Contacts, Leads, Campaigns, Campaign Contacts, Reviews, Review Invites, Payments, Feedback, and Locations/Organizations. Download these tables, transform them into Unthread's schema, and load via the Unthread API.
When to use it: Mid-size to large datasets where you need conversation history and can invest engineering time.
Pros:
- Access to the full Podium dataset including message bodies, conversation metadata, and lead data
- Flat table structure is straightforward to parse
- No API rate limit concerns on the extraction side
Cons:
- FTP access must be requested and approved by Podium — budget 3–5 business days
- Data is denormalized; you need to reconstruct conversation threads from
conversation_uidgroupings - Message attachments are not included in FTP exports — binary files require separate handling via the API
- Requires significant transformation logic to map Podium's continuous conversation model to Unthread's discrete ticket model
Complexity: Medium–High
3. API-Based Migration (Podium REST API → Unthread REST API)
How it works: Extract data programmatically from Podium's REST API using OAuth 2.0 authentication, transform in-memory or via intermediate storage, and load into Unthread's API using service account API keys. (docs.podium.com)
When to use it: Full-fidelity migrations where you need conversation threading, message history, and relational context preserved.
Pros:
- Most granular control over what gets migrated
- Can preserve conversation→message relationships
- Real-time access to current data state
- Can handle incremental/delta migrations
Cons:
- Podium's API enforces rate limits (the docs specify HTTP
429responses for exceeded limits but do not publish an exact requests-per-second ceiling — empirically, sustained extraction rates above ~2 requests/second trigger throttling). Extraction of 10K+ conversations with associated messages can take 2–4 days of continuous API calls. (docs.podium.com) - Podium developer account requires application and multi-day approval
- OAuth 2.0 tokens need refresh logic (tokens expire; build automatic refresh before expiry)
- Unthread API pagination is capped at 100 records per page; Unthread's write endpoint rate limits are not published in the public docs — start conservatively at ~1 request/second and adjust based on
429response frequency
Complexity: High
4. Middleware Platforms (Zapier, Make)
How it works: Use iPaaS connectors to bridge Podium triggers/actions to Unthread actions. Typically limited to forwarding new records as they arrive.
When to use it: Ongoing sync of new conversations during a transition period — not for historical data migration.
Pros:
- No code required for simple forwarding
- Useful as a bridge during cutover
Cons:
- Cannot migrate historical data in bulk
- No native Unthread connector on most middleware platforms as of mid-2026
- Limited transformation capabilities for schema translation
- Per-task pricing makes high-volume operations expensive
- Podium webhooks time out after 5 seconds and queued events live for around 10 days (docs.podium.com)
Complexity: Low (but limited scope)
5. Managed Migration Service
How it works: A migration partner handles extraction, transformation, loading, validation, and rollback planning end-to-end.
When to use it: When engineering bandwidth is limited, the dataset is large, or the migration is business-critical with zero tolerance for data loss.
Pros:
- Fastest time-to-completion
- Expert handling of edge cases (duplicate contacts, orphaned messages, encoding issues)
- Validation and QA built into the process
- No internal engineering hours consumed
Cons:
- External cost (typically $5K–$25K depending on dataset size and complexity)
- Requires sharing data access with a third party
Complexity: Low (for your team)
Migration Approach Comparison
| Approach | Historical Data | Conversation Threading | Engineering Effort | Typical Timeline | Best For |
|---|---|---|---|---|---|
| CSV Export | Contacts only | ❌ | Low | 1–2 days | Contact-only moves |
| FTP Raw Data + ETL | ✅ Full tables | ⚠️ Requires reconstruction | Medium–High | 2–4 weeks | Mid-size with dev team |
| API-to-API | ✅ Full fidelity | ✅ Preserved | High | 3–6 weeks | Full migration with dev team |
| Middleware (Zapier/Make) | ❌ New records only | ❌ | Low | 1–2 days setup | Transition bridge |
| Managed Service | ✅ Full fidelity | ✅ Preserved | Low (outsourced) | 1–3 weeks | Business-critical migrations |
Recommendations by scenario:
- Small business, contacts only: CSV export + Unthread API script
- Mid-size, full history needed, dev team available: FTP export + custom ETL pipeline
- Enterprise or business-critical: Managed migration service
- Ongoing sync during transition: Middleware bridge for net-new data + API migration for historical data
When to Use a Managed Migration Service
Building a Podium-to-Unthread migration pipeline in-house is straightforward to start and deceptively hard to finish. The common failure modes:
- Conversation thread reconstruction. Podium's FTP export delivers flat message rows keyed by
conversation_uid. Reassembling these into threaded Unthread conversations with correct chronological ordering, sender attribution, and status mapping requires careful deduplication logic. - Contact deduplication. Podium contacts are identified by phone number (
channel_unique_identifier). The same person can have multiple contact channel entries across different channel types (phone, email, secure). Merging these into a single Unthread Customer or Account without creating duplicates requires fuzzy matching on name + phone + email combinations. - Rate limit math. Podium's API throttle (~2 req/s empirically) combined with Unthread's 100-record pagination limit mean a dataset of 25K+ conversations with associated messages can take 3–5 days of continuous API calls. Most internal teams underestimate this by 3–5x because they calculate based on conversation count alone, forgetting that each conversation requires N+1 API calls (1 conversation + N messages).
- Accidental re-sending. Historical messages replayed into Unthread can trigger outbound notifications to customers if conversation type and note visibility are not configured correctly. This is a non-recoverable mistake — you cannot unsend a message that was delivered to a customer's Slack channel or email inbox. (docs.podium.com)
- Hidden engineering cost. Internal migrations consistently consume 80–160 engineer-hours for mid-size datasets (5K–25K conversations). At a fully-loaded engineering cost of $150–$200/hour, that's $12K–$32K in opportunity cost — often more than a managed service.
Do not build this in-house if: you need attachments, you have more than one Podium location, you rely on adjacent CRM relationships, or you need a short cutover window. The expensive part is not writing HTTP calls. It is identity matching, archive strategy, replay design, QA, and rollback.
Why ClonePartner: We have executed 1,500+ migrations across helpdesk platforms, including complex schema translation projects where source and target share no structural overlap. For Podium migrations specifically, we handle the FTP/API extraction, conversation threading reconstruction, contact deduplication, and Unthread API loading — with validation at every stage. If your migration involves more than 5,000 conversations or requires zero downtime, talk to us.
Pre-Migration Planning
Compliance and Data Residency
Before extracting any customer PII from Podium:
- GDPR: If you process data of EU residents, your staging database constitutes a new processing activity. Update your Records of Processing Activities (ROPA). If your staging environment is hosted outside the EU, ensure you have appropriate transfer mechanisms (SCCs or adequacy decisions) in place.
- CCPA/CPRA: California consumer data must be handled per your privacy policy's stated purposes. Migration is generally covered under "business purposes," but verify your specific disclosures.
- Data residency: Confirm where Podium stores your data (US by default) and where Unthread stores data. If your staging database is in a different region, you may be creating a cross-border transfer. PostgreSQL on a cloud provider in the same region as your Unthread instance minimizes risk.
- Data minimization: Only extract and stage what you intend to migrate. Purge the staging database within 30 days of migration completion.
- Third-party access: If using a managed migration service, execute a Data Processing Agreement (DPA) before granting access.
Data Audit Checklist
Before extracting anything, audit what exists in Podium and what matters:
| Data Category | Podium Source | Migrate? | Notes |
|---|---|---|---|
| Contacts | Contacts table | ✅ Yes | Map to Unthread Customers or Accounts |
| Conversations | Messages table | ✅ Yes (selective) | Decide on date cutoff — migrate last 12–24 months |
| Message History | Messages table (conversation_item_body) |
✅ Yes | Reconstruct threads from conversation_uid |
| Locations | Location and Organizations table | ✅ Yes | Map to Unthread Accounts or Tags |
| Reviews | Reviews + Review Invites tables | ❌ Archive | No Unthread equivalent |
| Payments | Payments table | ❌ Archive | No Unthread equivalent |
| Campaigns | Campaigns + Campaign Contacts tables | ❌ Archive | Unthread outbound is Slack-only |
| Feedback/Surveys | Feedback table | ❌ Archive | No Unthread equivalent |
| Custom Fields | Contact custom fields (up to 8) | ⚠️ Map | Map to Unthread Ticket Type Fields |
| Attachments | Via API (expiring URLs) | ✅ Yes | Download immediately — URLs expire after 7 days (docs.podium.com) |
Define Migration Scope
- Date cutoff. Migrating all historical data is rarely necessary. Most teams migrate the last 12–24 months of conversations and all active contacts.
- Channel filter. Podium supports SMS, email, webchat, Apple Chat, Facebook. Decide which channel conversations are worth migrating vs. archiving.
- Status filter. Migrate only open/active conversations as live tickets. Closed conversations can be imported as closed tickets for reference or archived externally.
- Identifier resolution. Podium relies heavily on phone numbers. Unthread uses emails and Slack IDs. Decide how to handle Podium contacts that lack an email address — options include enrichment from a CRM or generating deterministic placeholder emails (e.g.,
{phone}@podium-archive.local).
Migration Strategy
- Big bang: Extract everything, transform, load, cut over in one weekend. Best for datasets under 10K conversations.
- Phased: Migrate contacts and accounts first, then conversations in batches by date range or location. Best for 10K–100K conversations.
- Incremental: Migrate historical data in phases while running both platforms in parallel, using middleware or webhooks to sync new records. Best for enterprise datasets or zero-downtime requirements.
If you need a broader prep template, start with our Help Desk Data Migration Checklist.
Data Model and Object Mapping
Contacts → Customers + Accounts
Podium Contacts map to either Unthread Customers (channel-level entities tied to Slack channels) or Accounts (organization-level entities with support assignees and email domains).
Decision framework:
- If the Podium contact represents a company/organization → create an Unthread Account
- If the Podium contact represents an individual end-user → create an Unthread Customer linked to an Account
- If both → create an Account first, then associate the Customer
Unthread's public API exposes emailDomains on Customers, not a direct phone field. Keep exact phone numbers in ticket fields, metadata, or your CRM if agents need them post-migration. (docs.unthread.io)
Locations → Accounts
Podium's Organization → Location hierarchy maps to flat Unthread Accounts. Each Podium Location becomes a separate Unthread Account with the location's name. The Podium Organization name can be stored in a custom field or tag.
Conversations → Conversations (Tickets)
A Podium conversation is a continuous thread. You must decide how to segment these into discrete Unthread tickets. Two common approaches:
- Import the entire Podium thread as a single closed ticket — simplest, works for archival purposes.
- Group messages by time decay (e.g., a gap of 7+ days creates a new ticket) — more useful if agents need to search by incident.
Key field mappings:
| Podium Field | Unthread Field | Transformation |
|---|---|---|
conversation_uid |
metadata.podium_conv_uid |
Store for traceability |
conversation_inserted_at |
createdAt |
ISO 8601 format |
conversation_is_closed |
status |
true → closed, false → open |
conversation_assigned_user_uid |
assignedToUserId |
Map to Unthread user ID |
inbox_name |
ticketTypeId |
Map inbox to Ticket Type |
conversation_channel_type |
type |
Map to email or triage |
First conversation_item_body |
markdown (initial message) |
Plain text or Slack-compatible markdown |
Unthread conversations are typed (slack, email, triage). Historical Podium conversations without an active Slack channel should use triage. The Unthread public docs do not show timestamp override fields for imported conversations or messages, so exact historical chronology may require transcript-style preservation (e.g., prepending [2025-03-15 14:32 UTC] Agent Name: to each message body) rather than native backdating. (docs.unthread.io)
Messages → Messages
Each conversation_item in Podium's Messages table becomes a Message in Unthread:
| Podium Field | Unthread Field | Notes |
|---|---|---|
conversation_item_body |
markdown |
Text content of the message |
conversation_item_inserted_at |
Chronological ordering | Unthread auto-timestamps; original timestamp should be embedded in message body |
conversation_item_sender_name |
onBehalfOf.name |
For email-type conversations |
conversation_item_type |
isPrivateNote |
Internal notes → true |
| Attachment content type | Attachment via multipart form | 20MB max per file, 10 files per message |
Replay history as private notes for closed conversations. This prevents Unthread from accidentally sending historical messages as outbound customer-visible replies.
Tags → Tags
Podium contact tags and campaign opt-in tags map directly to Unthread Tags. Create tags in Unthread first via POST /tags, then associate them with conversations or customers using the link endpoints.
Custom Fields → Ticket Type Fields
Podium supports up to 8 contact custom fields with types including BOOLEAN, DATETIME, FLOAT, INTEGER, STRING, SINGLE_SELECT, and MULTI_SELECT. Unthread Ticket Type Fields support short-answer, long-answer, multi-select, single-select, checkbox, date, and user-select. Type mapping:
| Podium Type | Unthread Type | Notes |
|---|---|---|
STRING |
short-answer |
Direct |
BOOLEAN |
checkbox |
Direct |
DATETIME |
date |
Strip time component if Unthread date field is date-only |
FLOAT, INTEGER |
short-answer |
Convert to string; Unthread has no numeric field type |
SINGLE_SELECT |
single-select |
Normalize picklist values before loading |
MULTI_SELECT |
multi-select |
Normalize picklist values before loading |
Map by type, not by label alone. (docs.podium.com)
Reviews, Payments, and Campaigns
Unthread has no equivalent for these Podium objects. Export them to a separate data store (S3, data warehouse, or spreadsheet archive) before migration. Do not attempt to force this data into Unthread's schema — if reviews, campaigns, or payments still matter to the business, leave them in Podium or another system of record.
Migration Architecture
The architecture follows a standard Extract → Stage → Transform → Load → Validate pipeline. An intermediate database (PostgreSQL recommended) prevents data loss during API timeouts and provides a staging layer for joins and deduplication.
Extraction Layer
Option A: FTP Export
- Request FTP access from your Podium Account Owner (budget 3–5 business days for approval)
- Download all available tables as flat files
- Parse into a staging database
Option B: REST API
- Register an OAuth 2.0 app at
developer.podium.com(requires approval — plan for several business days) - Authenticate via OAuth 2.0 flow to obtain access tokens with refresh logic (set a timer to refresh tokens ~5 minutes before expiry)
- Podium's API returns
429 Too Many Requestswhen rate limits are exceeded but does not publish the exact threshold. Empirically, sustained rates above ~2 requests/second trigger throttling. Build a fixed-delay pacer (500ms between requests) or token bucket into your extraction script rather than reacting to429responses with retry logic. Proactive throttling creates predictable throughput — expect roughly 5,000–7,000 contacts/hour or 3,000–5,000 conversations/hour depending on payload size. (docs.podium.com) - The contacts endpoint supports
updated_atfiltering for delta runs (docs.podium.com)
Critical for both approaches: Download all attachments during extraction. Podium attachment URLs are time-limited signed URLs that expire after 7 days. If you delay, you lose them. (docs.podium.com)
Transformation Layer
- Deduplicate contacts by
contact_uid— Podium contacts can have multiple channel entries (phone, email, secure) for the same person. Without deduplication, you'll create 2–3x the expected Customer/Account count. - Reconstruct conversation threads by grouping
conversation_itemrows byconversation_uid, ordered byconversation_item_inserted_at. - Map statuses from Podium's binary open/closed to Unthread's four-state model (open, in_progress, on_hold, closed).
- Convert Podium Locations to Unthread Accounts.
- Map inbox names to Unthread Ticket Types.
- Strip or convert message formatting — Podium messages are plain text; Unthread expects Slack-compatible markdown (mrkdwn). Convert any HTML entities, strip unsupported formatting.
- Generate placeholder emails for SMS-only contacts to satisfy Unthread's schema requirements (e.g.,
{phone}@podium-archive.local).
Loading Layer
Unthread API endpoints:
| Operation | Endpoint | Method | Required Fields |
|---|---|---|---|
| Create Account | POST /accounts |
POST | name |
| Create Customer | POST /customers |
POST | name or emailDomains |
| Create Tag | POST /tags |
POST | name |
| Create Ticket Type | POST /ticket-types |
POST | name |
| Create Conversation | POST /conversations |
POST | type, markdown |
| Add Message | POST /conversations/:id/messages |
POST | markdown |
| Link Tag to Conversation | POST /tags/:id/conversations/create-links |
POST | conversationIds |
| Link Tag to Customer | POST /tags/:id/customers/create-links |
POST | customerIds |
Authentication: X-Api-Key header with a service account key generated from Unthread's Setup > Service Accounts.
Idempotency: Unthread's create endpoints are not idempotent — calling POST /conversations twice with the same payload creates two conversations. To handle partial failures and script re-runs:
- Maintain a
source_id → target_idcrosswalk table in your staging database - Before each create call, check the crosswalk — if a target ID already exists for that source ID, skip the create
- Wrap each create + crosswalk-insert in a database transaction
- On script restart, resume from the last uncommitted source ID
Unthread API constraints: All list endpoints cap at 100 records per page. File attachments are limited to 20MB per file and 10 files per Slack message. Unthread does not publish write endpoint rate limits in its public docs — start at ~1 request/second and increase gradually, monitoring for 429 responses. Based on migration experience, Unthread's API sustains ~60–90 write operations/minute without throttling. Plan your validation queries and attachment splitting accordingly. (docs.unthread.io)
Error Handling
Both APIs return standard HTTP error codes. Key responses to handle:
| Code | Podium Meaning | Unthread Meaning | Action |
|---|---|---|---|
400 |
Malformed request | Malformed request / validation failure | Log payload, fix schema, retry |
401 |
Token expired | Invalid API key | Refresh OAuth token (Podium) / check key (Unthread) |
404 |
Resource not found | Resource not found | Log and skip — likely deleted |
422 |
Unprocessable entity | Validation error (missing required field) | Log, inspect response body for field-level errors, fix and retry |
429 |
Rate limit exceeded | Rate limit exceeded | Back off exponentially (start 1s, max 60s) |
500/502/503 |
Server error | Server error | Retry with exponential backoff, max 3 attempts |
Both Podium and Unthread return JSON error bodies. Podium's format: {"error": {"code": "...", "message": "..."}}. Log the full response body for every non-2xx response.
Delta Sync
If you are not doing a hard cutover, let Podium webhooks feed a queue and push only new or changed records into Unthread after the historical backfill completes. Note that Podium webhooks time out after 5 seconds and queued events live for around 10 days — if your migration takes longer than 10 days, events from early in the process may be lost. (docs.podium.com)
Step-by-Step Migration Process
Step 1: Extract Data from Podium
For FTP approach: Request FTP access through your Podium Account Owner. Download all tables. Load into a staging database.
For API approach:
import requests
import time
import json
PODIUM_BASE = "https://api.podium.com/v4"
RATE_LIMIT_DELAY = 0.5 # 500ms between requests (~2 req/s)
def get_podium_headers(access_token):
return {"Authorization": f"Bearer {access_token}"}
def extract_contacts(access_token, staging_db):
"""Extract all contacts and persist raw JSON to staging DB."""
url = f"{PODIUM_BASE}/contacts"
page = 0
while url:
resp = requests.get(url, headers=get_podium_headers(access_token))
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", 5))
time.sleep(retry_after)
continue
resp.raise_for_status()
data = resp.json()
for contact in data.get("data", []):
staging_db.upsert("raw_contacts", {
"contact_uid": contact["uid"],
"raw_json": json.dumps(contact),
"extracted_at": "NOW()"
})
page += 1
url = data.get("links", {}).get("next")
time.sleep(RATE_LIMIT_DELAY)
return page
def extract_conversations(access_token, staging_db):
"""Extract conversations with messages. ~3,000-5,000 conversations/hour."""
url = f"{PODIUM_BASE}/conversations"
while url:
resp = requests.get(url, headers=get_podium_headers(access_token))
if resp.status_code == 429:
time.sleep(int(resp.headers.get("Retry-After", 5)))
continue
resp.raise_for_status()
data = resp.json()
for conv in data.get("data", []):
staging_db.upsert("raw_conversations", {
"conversation_uid": conv["uid"],
"raw_json": json.dumps(conv),
"extracted_at": "NOW()"
})
# Download attachments immediately - URLs expire in 7 days
for item in conv.get("items", []):
for attachment in item.get("attachments", []):
download_attachment(attachment["url"], staging_db)
url = data.get("links", {}).get("next")
time.sleep(RATE_LIMIT_DELAY)Do not attempt to transform data on the fly during extraction. Store raw JSON in your staging database and download attachments immediately — attachment URLs expire.
Step 2: Transform Data
def transform_contact_to_account(podium_contact, location_map):
"""Map a Podium contact + location to an Unthread Account."""
location = location_map.get(podium_contact.get("location_uid", ""), {})
email = podium_contact.get("email")
phone = podium_contact.get("phoneNumber", "")
# Generate deterministic placeholder for SMS-only contacts
if not email and phone:
email = f"{phone.replace('+', '')}@podium-archive.local"
return {
"name": location.get("location_name", podium_contact.get("contact_name", "Unknown")),
"emailsAndDomains": [email] if email else [],
"customFields": [
{"key": "podium_org", "value": location.get("organization_name", "")},
{"key": "podium_contact_uid", "value": podium_contact.get("contact_uid", "")},
{"key": "podium_phone", "value": phone}
]
}
def transform_conversation(podium_conv, customer_id_map):
"""Map a Podium conversation to an Unthread conversation."""
status = "closed" if podium_conv.get("conversation_is_closed") else "open"
original_ts = podium_conv.get("conversation_inserted_at", "")
contact_name = podium_conv.get("contact_name", "Unknown")
first_body = podium_conv.get("first_message_body", "(migrated conversation)")
# Embed original timestamp in message body since Unthread
# does not support backdating
markdown = f"**[Migrated from Podium | {original_ts}]**\n\n{first_body}"
return {
"type": "triage", # No live channel for historical imports
"markdown": markdown,
"status": status,
"title": f"[Migrated] {contact_name}",
"customerId": customer_id_map.get(podium_conv.get("contact_channel_uid")),
"metadata": {
"podium_conversation_uid": podium_conv.get("conversation_uid"),
"podium_channel_type": podium_conv.get("conversation_channel_type"),
"podium_inserted_at": original_ts
}
}
def transform_message(podium_item, is_closed_conversation=True):
"""Transform a Podium message to an Unthread message.
Replay as private note if conversation is closed to prevent
accidental outbound delivery."""
original_ts = podium_item.get("conversation_item_inserted_at", "")
sender = podium_item.get("conversation_item_sender_name", "Unknown")
body = podium_item.get("conversation_item_body", "")
is_internal = podium_item.get("conversation_item_type") == "internal_note"
return {
"markdown": f"**[{original_ts} — {sender}]**\n{body}",
"isPrivateNote": is_internal or is_closed_conversation
}Key transformation tasks:
- Cleanse invalid characters and format phone numbers to E.164 standard
- Resolve user mappings (Podium agent IDs → Unthread user IDs)
- Generate placeholder emails for SMS-only contacts
- Normalize picklist and custom field values by type
Step 3: Load into Unthread
Load in strict relational order to maintain referential integrity:
- Accounts
- Customers (linked to Accounts)
- Tags and Ticket Types
- Conversations (with initial message body)
- Messages (replayed via
POST /conversations/:id/messages) - Attachments
import requests
import time
UNTHREAD_BASE = "https://api.unthread.io/api"
WRITE_DELAY = 1.0 # ~1 req/s conservative start; increase to ~60-90/min after testing
def get_unthread_headers(api_key):
return {
"X-Api-Key": api_key,
"Content-Type": "application/json"
}
def create_with_crosswalk(api_key, endpoint, payload, source_id, entity_type, crosswalk_db):
"""Idempotent create: check crosswalk before creating to handle re-runs."""
existing = crosswalk_db.get(entity_type, source_id)
if existing:
return existing # Already created in a previous run
resp = requests.post(
f"{UNTHREAD_BASE}/{endpoint}",
headers=get_unthread_headers(api_key),
json=payload
)
if resp.status_code == 429:
time.sleep(int(resp.headers.get("Retry-After", 5)))
return create_with_crosswalk(api_key, endpoint, payload, source_id, entity_type, crosswalk_db)
if resp.status_code >= 500:
time.sleep(5)
resp = requests.post(f"{UNTHREAD_BASE}/{endpoint}",
headers=get_unthread_headers(api_key), json=payload)
resp.raise_for_status()
result = resp.json()
crosswalk_db.insert(entity_type, source_id, result["id"])
time.sleep(WRITE_DELAY)
return result
def create_account(api_key, account_data, source_id, crosswalk_db):
return create_with_crosswalk(api_key, "accounts", account_data, source_id, "account", crosswalk_db)
def create_conversation(api_key, conv_data, source_id, crosswalk_db):
return create_with_crosswalk(api_key, "conversations", conv_data, source_id, "conversation", crosswalk_db)
def add_message(api_key, conversation_id, message_data):
resp = requests.post(
f"{UNTHREAD_BASE}/conversations/{conversation_id}/messages",
headers=get_unthread_headers(api_key),
json=message_data
)
if resp.status_code == 429:
time.sleep(int(resp.headers.get("Retry-After", 5)))
return add_message(api_key, conversation_id, message_data)
resp.raise_for_status()
time.sleep(WRITE_DELAY)
return resp.json()Replay closed conversations as private notes to prevent Unthread from sending historical messages as outbound replies to customers.
Error handling that matters:
- Checkpoint every page cursor and every created target ID in the crosswalk table
- Retry
429and5xxwith exponential backoff (initial delay 1s, max 60s, max 3 retries for 5xx) - Keep a dead-letter queue for failed records — log the full request payload and response body
- Store source-to-target ID crosswalks in a durable PostgreSQL table, not in-memory
- Checksum or size-check attachments after upload (compare source file size to uploaded file size)
Step 4: Rebuild Relationships
After creating all Accounts, Customers, and Conversations:
- Link Customers to Accounts using
customerIdreferences - Apply Tags to Conversations via
POST /tags/:tagId/conversations/create-links - Apply Tags to Customers via
POST /tags/:tagId/customers/create-links - Set support assignees on Accounts via
PATCH /accounts/:accountId
Step 5: Validate
See the Validation section below.
Step 6: Delta Sync and Cutover
Run a final sync to capture any messages created in Podium during the migration window. Push them to Unthread before switching your routing rules, DNS, and webhooks. If your migration window exceeds 10 days, use the API for delta extraction rather than webhooks (webhook events expire after ~10 days).
Edge Cases and Challenges
Duplicate Contacts
Podium contacts can have multiple channel entries (phone, email, secure) for the same person. Podium can also automatically merge contacts, so merged UIDs need alias handling in your crosswalk — when a contact is merged, the old UID becomes an alias that should resolve to the surviving UID. (docs.podium.com) Deduplicate by matching on contact_uid before creating Unthread records. Without this step, expect 2–3x the expected Customer/Account count.
Multi-Channel Conversations
A single Podium conversation can span SMS and webchat. Unthread conversations are typed (slack, email, triage). You must choose one type per migrated conversation — use triage for historical imports since there is no live channel to connect.
Attachments
Podium's FTP export does not include binary attachments. The API exposes attachment URLs, but these are time-limited signed URLs that expire after 7 days. (docs.podium.com) Your script must download binary files during extraction and re-upload them to Unthread as multipart form data. Unthread limits attachments to 20MB per file and 10 files per Slack message — split oversized payloads accordingly. If a single Podium message has >10 attachments, split across multiple Unthread messages with a [continued] marker.
Conversation Threading
Podium stores messages as flat rows. To reconstruct a threaded conversation in Unthread:
- Group all
conversation_itemrows byconversation_uid - Sort by
conversation_item_inserted_at - Use the first message as the conversation's
markdownbody - Replay subsequent messages via
POST /conversations/:id/messages
Internal Notes vs. Customer Messages
Podium's conversation_item_type distinguishes between customer-facing messages and internal notes. Map internal notes to Unthread's isPrivateNote: true parameter. For historical messages on closed conversations, replay all messages as private notes to prevent accidental outbound delivery.
Agent Attribution
If an agent has left the company and does not exist in Unthread, their historical messages will fail to import unless mapped to a fallback "System User" or "Archived Agent" account. Establish these fallback mappings before starting the load phase. Create a dedicated Unthread user named "Archived Agent" and use it as the default assignedToUserId for any unresolvable Podium agent UIDs.
Deprecated Webhook Fields
Podium marks contact and sender in the message object as deprecated in webhook payloads. Parse stable IDs from the currently documented fields, not fields scheduled for removal. (docs.podium.com)
SMS-Only Contacts
Unthread expects B2B identifiers (email or Slack ID). Use a deterministic email generation pattern for SMS-only contacts: {e164_phone_without_plus}@podium-archive.local (e.g., 14155551234@podium-archive.local). This satisfies schema requirements while remaining clearly distinguishable from real email addresses. Document this convention so agents understand what these addresses mean.
Multi-Level Relationships
The same person can interact across multiple Podium locations and channels. If you key only on phone or only on email domain, you will over-merge contacts. Use contact_uid as the primary deduplication key. (docs.podium.com)
Character Encoding
Podium SMS messages may contain emoji, non-ASCII characters, or special Unicode sequences. Ensure your staging database uses UTF-8 encoding and that your transformation layer handles emoji correctly — Slack's mrkdwn format supports standard Unicode but not all HTML entities.
Limitations and Constraints
Podium-Side
- FTP access requires Account Owner request and Podium approval — budget 3–5 business days
- API rate limits apply — empirically ~2 requests/second sustained;
429responses when exceeded; no published ceiling (docs.podium.com) - Developer account approval takes several business days per Podium's documentation
- No bulk export API — data must be extracted record-by-record or via FTP
- Custom fields limited to 8 per contact
- OAuth tokens expire — build automatic refresh logic
Unthread-Side
- No native historical import from external platforms — all ingestion goes through the API
- Slack channel history backfill covers 6 months of Slack-native messages only — not useful for Podium data
- Pagination capped at 100 records per page on all list endpoints
- File attachments limited to 20MB per file and 10 per Slack message
- Conversation types are fixed (
slack,email,triage) — historical Podium conversations should usetriage - No true custom objects — custom data lives in Ticket Type Fields, not arbitrary entities
- No timestamp override — the public docs do not show fields for backdating imported conversations or messages; original timestamps must be embedded in message body text (docs.unthread.io)
- Email connector is forward-only — Unthread only creates tickets from new inbound email after setup, not historical email
- Write endpoint rate limits not published — empirically sustains ~60–90 writes/minute before throttling
- Create endpoints are not idempotent — duplicate records will be created if a script is re-run without crosswalk checking
- Bulk delete not available via public API — rollback requires record-by-record
DELETEcalls, which at 100 records/page listing + individual deletes can take hours for large datasets
Structural Data Loss
- Reviews, payments, campaign data, and feedback surveys have no Unthread equivalent — structurally lost if not archived separately
- Podium's channel type granularity (phone, email, secure, Apple Chat, Facebook) collapses into Unthread's three conversation types
- Podium's lead-specific fields (
conversation_first_response_sent_at,time_to_convert_seconds) have no mapping in Unthread - Historical messages imported via API are accessible in the Unthread web dashboard but cannot be retroactively injected into Slack channels
- Original message timestamps are lost — Unthread auto-timestamps at import time; original timestamps can only be preserved as text within message bodies
- If using HubSpot or Salesforce sync, account changes can take up to an hour to reflect inside Unthread (docs.unthread.io)
Validation and Testing
Record Count Comparison
After loading, verify counts at each level:
| Entity | Source Count (Podium) | Target Count (Unthread) | Expected Match | Verification Method |
|---|---|---|---|---|
| Accounts/Locations | Count from Locations table | POST /accounts/list (paginate all pages) |
1:1 | Automated |
| Customers/Contacts | Count from Contacts table (deduplicated by contact_uid) |
POST /customers/list (paginate all pages) |
1:1 (after dedup) | Automated |
| Conversations | Distinct conversation_uid count (within date scope) |
POST /conversations/list (paginate all pages) |
1:1 (within scope) | Automated |
| Messages | All conversation_item rows (within scope) |
Sampled via POST /conversations/:id/messages/list |
1:1 per conversation | Sample 5% |
| Tags | Distinct tag values | POST /tags/list |
1:1 | Automated |
| Attachments | Count from staging DB downloads | Size-check uploaded files | 1:1 (file count + total bytes within 1%) | Automated |
Field-Level Validation
Sample 5–10% of records across each entity type (minimum 50 records per entity, maximum 500). For each sampled record:
- Compare contact name, email, and phone against source
- Verify conversation status mapping (open/closed)
- Confirm message body text integrity (character-for-character comparison of first 500 chars)
- Check that tag associations are correct
- Validate custom field values in Ticket Type Fields
- Verify attachment accessibility — open files, check sizes match source within 1%
- Confirm
metadata.podium_conversation_uidmatches the source conversation UID
UAT Process
- Pilot migration: Run the full pipeline against a test Unthread project with 100–500 records. Verify end-to-end before touching production.
- Agent walkthrough: Have 2–3 support agents review 20+ migrated conversations for completeness, readability, and correct attribution.
- Edge case spot-check: Specifically verify multi-message conversations (10+ messages), conversations with attachments, conversations with internal notes, and SMS-only contacts with placeholder emails.
- Rollback test: Verify that migrated records can be deleted via
DELETEendpoints without affecting native Unthread data. Note: Unthread does not offer bulk delete — deletion is record-by-record. For a 10K conversation migration, full rollback via API takes approximately 3–5 hours at sustained delete rates.
For a comprehensive post-migration testing framework, see our Post-Migration QA Checklist.
Post-Migration Tasks
Rebuild Automations
Podium's automations (review invites, campaign triggers, data feed workflows) do not transfer. Rebuild in Unthread using:
- Automations API (
POST /automations) for event-triggered workflows - Ticket Type Fields with conditions for dynamic form behavior
- Support Steps on Customers for escalation paths and SLA enforcement
Configure Slack Channels
Unthread's primary intake is Slack. After migration:
- Connect customer Slack channels to Unthread Customers
- Configure autoresponder settings per customer
- Set up triage channels for internal routing
- Enable AI agents on appropriate channels
Update External Integrations
- Update your website widgets and SMS webhooks to point to Unthread instead of Podium
- Reconnect CRM integrations (HubSpot, Salesforce) and verify account/domain matching — note the documented sync lag of up to one hour
- Revoke Podium API tokens and OAuth credentials to secure your legacy environment
- If using Podium for payments or reviews, confirm those systems remain operational independently
Purge Staging Data
Delete your staging database within 30 days of successful migration completion. If it contains customer PII, treat this as a compliance requirement, not a best practice.
User Training
Podium users are accustomed to a web/mobile inbox for SMS and webchat. Unthread operates primarily in Slack. Plan for:
- Slack workflow training for agents (1–2 hours)
- Ticket type and custom field usage
- SLA and escalation path awareness
- Explanation of migrated data format (embedded timestamps,
[Migrated]prefixes, placeholder emails)
Monitor for Data Inconsistencies
Run validation queries daily for the first two weeks post-migration. Watch for:
- Orphaned conversations (no customer linked)
- Missing messages in long conversation threads (compare message counts per conversation against source)
- Incorrect tag assignments
- Duplicate accounts created by parallel processes
- Placeholder emails being treated as real contact information by agents
Best Practices
- Back up everything. Export Podium's full FTP dataset and store it in a separate archive (S3, GCS) before starting any migration work. This is your rollback insurance.
- Never mutate the source. Treat Podium data as read-only throughout the migration.
- Run a test migration first. Create a separate Unthread project for testing. Migrate 500 records, validate, iterate on your transformation logic, then run the full migration.
- Validate incrementally. Don't wait until the end to check data quality. Validate after each entity type is loaded (Accounts → Customers → Conversations → Messages).
- Preserve source IDs. Store Podium UIDs in Unthread's
metadatafield on conversations andcustomFieldson accounts. This creates an audit trail and simplifies debugging. - Log everything. Maintain a crosswalk table of
Podium_UID→Unthread_IDfor every entity type. If a script fails, you need this table to resume exactly where you left off without creating duplicates. - Handle rate limits by construction. Build a token bucket or fixed-delay pacer into your script. Reacting to
429responses with retry logic creates unpredictable throughput. Start at 500ms delay (Podium) and 1s delay (Unthread), then tune. - Set a date cutoff. Unless there's a compliance reason, migrate only the last 12–24 months of conversations. Older data rarely justifies the engineering cost.
- Make creates idempotent. Since Unthread's API is not natively idempotent, implement crosswalk-based idempotency in your loading script. This is the single most important engineering decision for migration reliability.
- Communicate. Warn your support team that historical tickets will look structurally different — timestamps are embedded in text, conversations are typed as
triage, and placeholder emails replace phone numbers for SMS-only contacts.
Sample Data Mapping Table
| Podium Table | Podium Field | Unthread Object | Unthread Field | Notes |
|---|---|---|---|---|
| Contacts | contact_uid |
Account | customFields [podium_id] |
String, for traceability |
| Contacts | contact_name |
Account | name |
Direct map |
| Contacts | channel_unique_identifier (email) |
Account | emailsAndDomains [] |
Array |
| Contacts | contact.phoneNumber |
Account | customFields [podium_phone] |
No first-class phone field in Unthread customer docs |
| Contacts | contact_opt_in_tags |
Tag | name |
Create via POST /tags, then link |
| Location and Organizations | location_name |
Account | name |
Direct map |
| Location and Organizations | organization_name |
Account | customFields [org] |
String |
| Messages | conversation_uid |
Conversation | metadata.podium_conv_uid |
For traceability |
| Messages | conversation_inserted_at |
Conversation | Embedded in markdown body |
No timestamp override available |
| Messages | conversation_is_closed |
Conversation | status |
true → closed |
| Messages | conversation_item_body |
Message | markdown |
Text content; set isPrivateNote: true for closed conversations |
| Messages | conversation_item_sender_name |
Message | onBehalfOf.name |
For email-type conversations |
| Messages | conversation_assigned_user_uid |
Conversation | assignedToUserId |
Map to Unthread user ID; use fallback for departed agents |
| Messages | inbox_name |
Conversation | ticketTypeId |
Map to Ticket Type via crosswalk |
| Leads | conversation_first_webchat_url |
Conversation | metadata.source_url |
Preserve in metadata |
Making the Right Call
A Podium-to-Unthread migration is a platform architecture change, not a data copy. You are moving from an SMS-and-reviews platform designed for local businesses to a Slack-native ticketing system designed for B2B and internal support.
If your team has fewer than 1,000 conversations and a developer available for a week, the API approach outlined above is viable. For datasets above 10K conversations, multi-location organizations, or teams that cannot dedicate 80–160 engineer-hours, a managed service will get you there faster and with fewer data integrity risks.
If you only need active contacts and a clean cutover, keep it simple: export contacts, rebuild workflow settings in Unthread, and don't pretend you migrated history when you didn't. If you need real history, the safest pattern is API extraction, immediate attachment capture, staged transformation with crosswalk-based idempotency, closed-conversation import as private notes, then a short webhook delta window before final cutover. If reviews, campaigns, or payments still matter to the business, leave them in Podium or another system of record.
For more migration planning resources, see our Help Desk Data Migration Checklist and How We Run Migrations at ClonePartner.
Frequently Asked Questions
- Can I migrate Podium conversation history to Unthread with CSV alone?
- No. Podium's native CSV export covers contacts only — Name, Phone, Email, Contact Status, Address, Tags, and Date Added. Full conversation history, messages, and attachments require API-based extraction or Podium's FTP raw data export combined with Unthread API ingestion.
- What Podium data cannot be migrated to Unthread?
- Reviews, review invites, payment/invoice records, campaign data, and feedback surveys have no Unthread equivalent. These should be archived separately before migration. Podium's lead-specific analytics fields (first response time, conversion time) also have no mapping in Unthread.
- How long does a Podium to Unthread migration take?
- For small datasets under 5K conversations, expect 1–2 weeks including planning, scripting, and validation. For mid-size datasets (10K–50K conversations), budget 2–4 weeks with a dedicated developer. Podium's API rate limits are the primary bottleneck for API-based extraction.
- Will historical Podium messages show up in Slack via Unthread?
- No. Historical messages imported via API are accessible and searchable within the Unthread web dashboard, but they cannot be retroactively injected into Slack channels. Unthread's Slack backfill only covers 6 months of Slack-native messages.
- Can I run Podium and Unthread in parallel during cutover?
- Yes. The common pattern is to complete the historical backfill first via API or FTP, then run a short webhook-based delta sync to capture new records in Podium until you finalize the cutover to Unthread.


