HarborCRM Skill
Environment
Base URL: http://34.46.77.124:8001 (no auth required for public API). All GET.
Endpoints by Task Type
Event Reconciliation (train_001, train_004)
| Endpoint |
Returns |
GET /api/events/{event_id} |
Event metadata, dates, campaign_code, followup_days, lead_opportunity_amount |
GET /api/events/{event_id}/orders |
Sponsor orders (same as sponsor_packages) |
GET /api/events/{event_id}/sponsor_packages |
Same as orders |
GET /api/events/{event_id}/badges |
Badge scans with contact info, badge_type, scan_score |
GET /api/finance/invoices?event_id={event_id} |
Invoices with paid_amount, deferred_amount, status |
GET /api/crm/accounts |
All CRM accounts (status, disqualified_reason) |
GET /api/crm/contacts |
All CRM contacts (opted_out, email, phone) |
GET /api/crm/opportunities |
All opportunities across events |
GET /api/crm/campaign_members?event_id={event_id} |
Existing campaign members |
GET /api/policies |
sponsor_handoff status_enums, contact_hygiene note |
Trade-show Prospecting (train_002, train_005)
| Endpoint |
Returns |
GET /api/tradeshows |
All trade shows |
GET /api/tradeshows/{show_id}/exhibitors |
Exhibitor list with company_id, description, crm_account_id |
GET /api/tradeshows/{show_id}/meeting_interest |
Interest scores, demo requests per company |
GET /api/crm/accounts |
CRM accounts for overlap detection |
GET /api/crm/contacts |
CRM contacts |
GET /api/policies |
prospecting platform_enums, qualification_note |
Batch Import (train_003)
| Endpoint |
Returns |
GET /api/import_batches |
All batches |
GET /api/import_batches/{batch_id}/raw_contacts |
Raw import rows with row_id, source_name, captured_at |
GET /api/import_batches/{batch_id}/suppression |
Suppression list (email, phone, reason) |
GET /api/crm/accounts |
CRM accounts for matching |
GET /api/crm/contacts |
CRM contacts for matching |
GET /api/policies |
contact_hygiene policy |
Data Models & Field Conventions
CRM Account
account_id (string), name (string), status ("customer"|"prospect"|"disqualified")
disqualified_reason (string|null) — non-null means disqualified
domain, industry, owner_region
CRM Contact
contact_id, account_id, name, email, phone, opted_out (bool)
- An opted_out contact's email is treated as suppressed for new imports.
Orders / Sponsor Packages
order_status: "confirmed", "proposal_sent", "canceled"
ticket_contacts: array of contact name strings — these are the sponsor attendees
Invoices
status: "paid_deferred" (fully paid), "open" (has unpaid balance)
paid_amount, amount, deferred_amount — all integers (USD)
payment_date: null if unpaid
Badges
badge_type: "sponsor", "attendee", "student", "press", "exhibitor"
- Business types: sponsor, attendee, exhibitor. Non-business: student, press.
email, phone — raw format, needs normalization
Campaign Members
status: "attended_sponsor", "registered_sponsor", "attended"
last_activity_date
Exhibitors (trade shows)
company_id, company_name, description, crm_account_id (string|null)
booth, country, website
Meeting Interest
company_name, interest_score (int), requested_demo (bool), notes
Import Raw Contacts
row_id, company_name, contact_name, email, phone
captured_at (ISO timestamp), source_name (controlled enum)
source_name enum: "badge_scan", "sponsor_form", "partner_upload", "webinar_form", "exhibitor_form", "manual_upload"
Suppression List
email, phone, reason ("global_opt_out", "privacy_request", "role_account")
Core Business Rules
1. Sponsor Status Determination
For each sponsor order for the event:
| Condition |
Status |
| Invoice exists AND invoice.status == "paid_deferred" |
paid_deferred |
| Invoice exists AND invoice.status == "open" |
open_invoice |
| Order exists (confirmed/proposal_sent) AND no invoice |
proposal_only |
| Order status == "canceled" |
not_sponsor (inactive — exclude entirely) |
package_amount = order.amount (integer USD)
- For paid_deferred:
paid_amount = invoice.paid_amount, open_balance = 0
- For open_invoice:
paid_amount = invoice.paid_amount, open_balance = invoice.amount - invoice.paid_amount
- For proposal_only:
paid_amount = 0, open_balance = 0, invoice_id = null
2. Sponsor Revenue Totals
Sum package_amount (order.amount) by status:
paid_deferred: sum of orders with paid_deferred status
open_invoice: sum of orders with open_invoice status
proposal_only: sum of orders with proposal_only status
open_invoice_balance: sum of (invoice.amount - invoice.paid_amount) for open_invoice sponsors
All amounts in integer USD (no decimals).
3. Qualified Lead Identification (Event Tasks)
A badge scan is a qualified non-sponsor lead when ALL of:
badge_type is a business badge (attendee, exhibitor — NOT student, press, sponsor)
- Company is NOT an active sponsor for this event (not in orders with confirmed/proposal_sent status)
- Company is NOT a canceled/inactive sponsor
- CRM account (matched by company_name) is NOT disqualified (status != "disqualified" and disqualified_reason == null)
Each qualified lead gets opportunity_amount = event's lead_opportunity_amount.
4. Exclusion Reasons (Event Tasks)
| Reason |
Applies when |
sponsor_attendee |
Badge contact is listed in an active sponsor's ticket_contacts |
existing_disqualified |
CRM account matched by company_name has status="disqualified" or non-null disqualified_reason |
inactive_sponsor_record |
Company had a sponsor order for this event with order_status="canceled" |
non_business_badge |
badge_type is student, press, or other non-business types |
Excluded records sorted by company_name ascending, then contact_name ascending.
5. Follow-up Due Dates
lead_due_date: event.end_date + event.followup_days_after_end days
sponsor_finance_due_date: event.end_date + event.sponsor_followup_days_after_end days
- Date format:
YYYY-MM-DD
lead_task_count: number of qualified lead accounts
sponsor_finance_task_count: number of sponsors needing finance follow-up (open_invoice + proposal_only)
sponsor_finance_accounts: list of account names needing sponsor finance follow-up, sorted ascending
6. CRM Action Counts (Event Tasks)
Count distinct operations needed:
accounts_create: qualified leads where company has no matching CRM account
accounts_update: qualified leads where company matches an existing CRM account
contacts_create: qualified leads where contact email does NOT match any existing CRM contact
contacts_update: qualified leads where contact email matches an existing CRM contact
campaign_members_create: all qualified leads (each needs a campaign member)
campaign_members_update: existing campaign members whose status needs updating
7. Trade-show Exhibitor Qualification
An exhibitor is qualified when its description indicates the company builds, manufactures, or OEM-integrates target platforms (AUV, ROV, Underwater Camera).
An exhibitor is excluded when:
- distributor_only: reseller/dealer only, does not manufacture
- service_only: consulting/services only, no hardware manufacturing
- sensor_vendor_only / sensor_only: makes sensors but not platforms
- research_only: analytics/software only, no platform hardware
- not_target_market: doesn't fit any qualifying category
Platform derivation: Read the exhibitor description text to determine which platforms they build:
- AUV: autonomous underwater vehicles, AUV scouts, autonomous subs
- ROV: remotely operated vehicles, inspection ROVs, pen-cleaning ROVs
- Underwater Camera: camera modules, underwater cameras, camera arrays, imaging systems
Platform arrays sorted in enum order: ["AUV", "ROV", "Underwater Camera"].
8. Trade-show Priority Tiers
For tasks that require priority tier assignment:
| Tier |
Criteria |
Opportunity (USD) |
| A |
requested_demo=true AND interest_score ≥ 90 |
120000 |
| B |
requested_demo=true AND interest_score ≥ 80 |
90000 |
| C |
All other qualified leads |
50000 |
Only qualified exhibitors with meeting_interest records get tiers.
9. Lead Ranking (train_005)
Sort qualified leads by these criteria in order:
requested_demo = true first
interest_score descending
- Number of platforms covered (broader coverage first)
company_name ascending (alphabetical)
Assign 1-based contiguous rank after sorting.
10. CRM Overlap (Trade-show Tasks)
- If exhibitor
crm_account_id is non-null → account exists in CRM → crm_action: "update_existing"
- If exhibitor
crm_account_id is null → no CRM account → crm_action: "create_account"
- For excluded exhibitors:
crm_action: "no_import"
11. Batch Import Processing Pipeline
Step 1: Deduplicate by normalized email
- Normalize email: trim whitespace, lowercase
- Group raw contacts by normalized email
- For each group with >1 row, determine winner:
- Row whose normalized email matches an existing CRM contact email (lowercase) wins
- Otherwise, earliest
captured_at wins
- Tiebreak: lowest
row_id (lexicographic string comparison)
- Remove non-winner duplicates; record in
duplicate_summary
Step 2: Remove unusable rows
missing_contact: normalized email is empty/whitespace-only AND normalized phone is empty/whitespace-only
- These go to
removal_summary with reason "missing_contact"
Step 3: Apply suppression
- Match each surviving row against the suppression list:
- Row's normalized email matches suppression email (case-insensitive) → suppressed
- Row's normalized phone (digits only) matches suppression phone → suppressed
- CRM contact with matching email has
opted_out: true → suppressed
- Suppressed rows go to
removal_summary with reason "suppressed"
Step 4: Determine CRM actions for clean contacts
- Match company to CRM account by
company_name (case-insensitive exact match, or substring match of company names):
- Match found →
crm_action: "update_existing", set existing_account_id
- No match →
crm_action: "create_account", existing_account_id: null
- Match contact to CRM contact by normalized email:
- Match found → set
existing_contact_id
- No match →
existing_contact_id: null
12. Contact Normalization
- Email: trim leading/trailing whitespace, convert to lowercase. Empty string
"" if none.
- Phone: strip ALL non-digit characters (spaces, parens, dashes, dots, plus signs). Empty string
"" if none.
- Normalize BEFORE dedup and CRM matching.
13. Badge-level Processing (train_004)
For each badge, classify and determine CRM action:
Classification:
sponsor_attendee: badge_type="sponsor" OR contact in active sponsor ticket_contacts
qualified_non_sponsor_lead: business badge, non-sponsor, not disqualified
excluded: non-business badge, disqualified account, or missing contact
CRM action per badge:
- Match badge company to CRM account, badge contact to CRM contact, then check campaign members
create_account_contact_campaign_member: new account + new contact
create_contact_campaign_member: existing account + new contact
add_campaign_member: existing account and contact, no campaign member yet
update_campaign_member: existing campaign member whose status needs changing
no_action: existing campaign member already in correct state
no_import: excluded badges
14. Campaign Member Status Mapping
- Sponsor attendees → target_status: "attended_sponsor" (if they attended) or "registered_sponsor"
- Non-sponsor attendees → target_status: "attended"
- Excluded → target_status: "excluded"
Existing campaign members whose status already matches the target_status → action: "no_action".
Sorting Rules Summary
| Output list |
Sort key(s) |
| sponsor_statuses |
account_name ascending |
| qualified_lead_accounts |
account_name ascending |
| excluded_records |
company_name ascending, then contact_name ascending |
| qualified_exhibitors (train_002) |
company_name ascending |
| excluded_near_misses |
company_name ascending |
| ranked_leads (train_005) |
rank ascending (1-based) |
| excluded_exhibitors (train_005) |
company_name ascending |
| clean_contacts (train_003) |
clean_contact_id ascending |
| duplicate_keys |
key (normalized email) ascending |
| removed_rows |
row_id ascending |
| badge_decisions (train_004) |
badge_id ascending |
| campaign_member_actions |
subject_key ascending |
| badge_only_contacts |
company_name ascending |
| platforms array |
enum order: AUV, ROV, Underwater Camera |
| sponsor_finance_accounts |
account_name ascending |
Common Pitfalls
- Phone normalization: Strip ALL non-digit characters including
+, (, ), -, ., and spaces. Leading country code digits are kept.
- Email normalization: Only trim + lowercase. Do NOT remove dots or plus-address parts.
- Sponsor status from invoices, not orders: Always check invoices for paid_deferred vs open_invoice. Use proposal_only only when NO invoice exists.
- Canceled orders = not a sponsor: Exclude canceled orders from sponsor_statuses and revenue totals entirely. They are not "proposal_only".
- Disqualified accounts: Check
disqualified_reason != null, not just status == "disqualified". An account can have a non-null disqualified_reason even if status says otherwise (defense in depth).
- Sponsor attendee exclusion: Match badge contacts against
ticket_contacts array in orders/sponsor_packages. Only active (non-canceled) sponsors count.
- Dedup winner selection: CRM match first, then earliest captured_at, then lowest row_id. Check CRM contact match by normalized email before falling back to timestamp.
- Integer USD: All monetary values are integers. Don't include decimal points.
- Boolean fields: Use JSON
true/false, not strings.
- null vs "":
invoice_id and existing_*_id fields use null when absent. email/phone use "" when empty.
- Platform derivation from descriptions: Read the exhibitor description text to infer platforms. "Builds AUVs" → AUV, "camera modules/manufacturer" → Underwater Camera, "ROVs" → ROV. A company can have multiple platforms.
- Ranking tiebreakers: When scores are equal, broader platform coverage wins. When coverage is equal, company_name ascending wins.
- Campaign member existing status check: Don't create when an existing campaign member already has the correct target_status — that's a no_action.
- Suppression matching: Check BOTH the /suppression endpoint AND CRM contacts with opted_out=true. The suppression list may duplicate opted-out CRM contacts — deduplicate removal reasons.
1---2name: self-attempt-02-423description: HarborCRM Skill4---5# HarborCRM Skill67## Environment89Base URL: `http://34.46.77.124:8001` (no auth required for public API). All `GET`.1011## Endpoints by Task Type1213### Event Reconciliation (train_001, train_004)14| Endpoint | Returns |15|---|---|16| `GET /api/events/{event_id}` | Event metadata, dates, campaign_code, followup_days, lead_opportunity_amount |17| `GET /api/events/{event_id}/orders` | Sponsor orders (same as sponsor_packages) |18| `GET /api/events/{event_id}/sponsor_packages` | Same as orders |19| `GET /api/events/{event_id}/badges` | Badge scans with contact info, badge_type, scan_score |20| `GET /api/finance/invoices?event_id={event_id}` | Invoices with paid_amount, deferred_amount, status |21| `GET /api/crm/accounts` | All CRM accounts (status, disqualified_reason) |22| `GET /api/crm/contacts` | All CRM contacts (opted_out, email, phone) |23| `GET /api/crm/opportunities` | All opportunities across events |24| `GET /api/crm/campaign_members?event_id={event_id}` | Existing campaign members |25| `GET /api/policies` | sponsor_handoff status_enums, contact_hygiene note |2627### Trade-show Prospecting (train_002, train_005)28| Endpoint | Returns |29|---|---|30| `GET /api/tradeshows` | All trade shows |31| `GET /api/tradeshows/{show_id}/exhibitors` | Exhibitor list with company_id, description, crm_account_id |32| `GET /api/tradeshows/{show_id}/meeting_interest` | Interest scores, demo requests per company |33| `GET /api/crm/accounts` | CRM accounts for overlap detection |34| `GET /api/crm/contacts` | CRM contacts |35| `GET /api/policies` | prospecting platform_enums, qualification_note |3637### Batch Import (train_003)38| Endpoint | Returns |39|---|---|40| `GET /api/import_batches` | All batches |41| `GET /api/import_batches/{batch_id}/raw_contacts` | Raw import rows with row_id, source_name, captured_at |42| `GET /api/import_batches/{batch_id}/suppression` | Suppression list (email, phone, reason) |43| `GET /api/crm/accounts` | CRM accounts for matching |44| `GET /api/crm/contacts` | CRM contacts for matching |45| `GET /api/policies` | contact_hygiene policy |4647---4849## Data Models & Field Conventions5051### CRM Account52- `account_id` (string), `name` (string), `status` ("customer"|"prospect"|"disqualified")53- `disqualified_reason` (string|null) — non-null means disqualified54- `domain`, `industry`, `owner_region`5556### CRM Contact57- `contact_id`, `account_id`, `name`, `email`, `phone`, `opted_out` (bool)58- An opted_out contact's email is treated as suppressed for new imports.5960### Orders / Sponsor Packages61- `order_status`: "confirmed", "proposal_sent", "canceled"62- `ticket_contacts`: array of contact name strings — these are the sponsor attendees6364### Invoices65- `status`: "paid_deferred" (fully paid), "open" (has unpaid balance)66- `paid_amount`, `amount`, `deferred_amount` — all integers (USD)67- `payment_date`: null if unpaid6869### Badges70- `badge_type`: "sponsor", "attendee", "student", "press", "exhibitor"71- Business types: sponsor, attendee, exhibitor. Non-business: student, press.72- `email`, `phone` — raw format, needs normalization7374### Campaign Members75- `status`: "attended_sponsor", "registered_sponsor", "attended"76- `last_activity_date`7778### Exhibitors (trade shows)79- `company_id`, `company_name`, `description`, `crm_account_id` (string|null)80- `booth`, `country`, `website`8182### Meeting Interest83- `company_name`, `interest_score` (int), `requested_demo` (bool), `notes`8485### Import Raw Contacts86- `row_id`, `company_name`, `contact_name`, `email`, `phone`87- `captured_at` (ISO timestamp), `source_name` (controlled enum)88- `source_name` enum: "badge_scan", "sponsor_form", "partner_upload", "webinar_form", "exhibitor_form", "manual_upload"8990### Suppression List91- `email`, `phone`, `reason` ("global_opt_out", "privacy_request", "role_account")9293---9495## Core Business Rules9697### 1. Sponsor Status Determination9899For each sponsor order for the event:100101| Condition | Status |102|---|---|103| Invoice exists AND invoice.status == "paid_deferred" | **paid_deferred** |104| Invoice exists AND invoice.status == "open" | **open_invoice** |105| Order exists (confirmed/proposal_sent) AND no invoice | **proposal_only** |106| Order status == "canceled" | **not_sponsor** (inactive — exclude entirely) |107108- `package_amount` = order.amount (integer USD)109- For paid_deferred: `paid_amount` = invoice.paid_amount, `open_balance` = 0110- For open_invoice: `paid_amount` = invoice.paid_amount, `open_balance` = invoice.amount - invoice.paid_amount111- For proposal_only: `paid_amount` = 0, `open_balance` = 0, `invoice_id` = null112113### 2. Sponsor Revenue Totals114115Sum `package_amount` (order.amount) by status:116- `paid_deferred`: sum of orders with paid_deferred status117- `open_invoice`: sum of orders with open_invoice status118- `proposal_only`: sum of orders with proposal_only status119- `open_invoice_balance`: sum of (invoice.amount - invoice.paid_amount) for open_invoice sponsors120121All amounts in **integer USD** (no decimals).122123### 3. Qualified Lead Identification (Event Tasks)124125A badge scan is a **qualified non-sponsor lead** when ALL of:126- `badge_type` is a business badge (attendee, exhibitor — NOT student, press, sponsor)127- Company is NOT an active sponsor for this event (not in orders with confirmed/proposal_sent status)128- Company is NOT a canceled/inactive sponsor129- CRM account (matched by company_name) is NOT disqualified (status != "disqualified" and disqualified_reason == null)130131Each qualified lead gets `opportunity_amount` = event's `lead_opportunity_amount`.132133### 4. Exclusion Reasons (Event Tasks)134135| Reason | Applies when |136|---|---|137| `sponsor_attendee` | Badge contact is listed in an active sponsor's `ticket_contacts` |138| `existing_disqualified` | CRM account matched by company_name has status="disqualified" or non-null disqualified_reason |139| `inactive_sponsor_record` | Company had a sponsor order for this event with order_status="canceled" |140| `non_business_badge` | badge_type is student, press, or other non-business types |141142Excluded records sorted by `company_name` ascending, then `contact_name` ascending.143144### 5. Follow-up Due Dates145146- `lead_due_date`: event.end_date + event.followup_days_after_end days147- `sponsor_finance_due_date`: event.end_date + event.sponsor_followup_days_after_end days148- Date format: `YYYY-MM-DD`149- `lead_task_count`: number of qualified lead accounts150- `sponsor_finance_task_count`: number of sponsors needing finance follow-up (open_invoice + proposal_only)151- `sponsor_finance_accounts`: list of account names needing sponsor finance follow-up, sorted ascending152153### 6. CRM Action Counts (Event Tasks)154155Count distinct operations needed:156- `accounts_create`: qualified leads where company has no matching CRM account157- `accounts_update`: qualified leads where company matches an existing CRM account158- `contacts_create`: qualified leads where contact email does NOT match any existing CRM contact159- `contacts_update`: qualified leads where contact email matches an existing CRM contact160- `campaign_members_create`: all qualified leads (each needs a campaign member)161- `campaign_members_update`: existing campaign members whose status needs updating162163### 7. Trade-show Exhibitor Qualification164165An exhibitor is **qualified** when its `description` indicates the company **builds, manufactures, or OEM-integrates** target platforms (AUV, ROV, Underwater Camera).166167An exhibitor is **excluded** when:168- **distributor_only**: reseller/dealer only, does not manufacture169- **service_only**: consulting/services only, no hardware manufacturing170- **sensor_vendor_only / sensor_only**: makes sensors but not platforms171- **research_only**: analytics/software only, no platform hardware172- **not_target_market**: doesn't fit any qualifying category173174**Platform derivation**: Read the exhibitor `description` text to determine which platforms they build:175- **AUV**: autonomous underwater vehicles, AUV scouts, autonomous subs176- **ROV**: remotely operated vehicles, inspection ROVs, pen-cleaning ROVs177- **Underwater Camera**: camera modules, underwater cameras, camera arrays, imaging systems178179Platform arrays sorted in enum order: `["AUV", "ROV", "Underwater Camera"]`.180181### 8. Trade-show Priority Tiers182183For tasks that require priority tier assignment:184185| Tier | Criteria | Opportunity (USD) |186|---|---|---|187| A | requested_demo=true AND interest_score ≥ 90 | 120000 |188| B | requested_demo=true AND interest_score ≥ 80 | 90000 |189| C | All other qualified leads | 50000 |190191Only qualified exhibitors with meeting_interest records get tiers.192193### 9. Lead Ranking (train_005)194195Sort qualified leads by these criteria in order:1961. `requested_demo` = true first1972. `interest_score` descending1983. Number of platforms covered (broader coverage first)1994. `company_name` ascending (alphabetical)200201Assign 1-based contiguous `rank` after sorting.202203### 10. CRM Overlap (Trade-show Tasks)204205- If exhibitor `crm_account_id` is non-null → account exists in CRM → `crm_action`: "update_existing"206- If exhibitor `crm_account_id` is null → no CRM account → `crm_action`: "create_account"207- For excluded exhibitors: `crm_action`: "no_import"208209### 11. Batch Import Processing Pipeline210211**Step 1: Deduplicate by normalized email**212- Normalize email: trim whitespace, lowercase213- Group raw contacts by normalized email214- For each group with >1 row, determine winner:215 1. Row whose normalized email matches an existing CRM contact email (lowercase) wins216 2. Otherwise, earliest `captured_at` wins217 3. Tiebreak: lowest `row_id` (lexicographic string comparison)218- Remove non-winner duplicates; record in `duplicate_summary`219220**Step 2: Remove unusable rows**221- `missing_contact`: normalized email is empty/whitespace-only AND normalized phone is empty/whitespace-only222- These go to `removal_summary` with reason "missing_contact"223224**Step 3: Apply suppression**225- Match each surviving row against the suppression list:226 - Row's normalized email matches suppression email (case-insensitive) → suppressed227 - Row's normalized phone (digits only) matches suppression phone → suppressed228 - CRM contact with matching email has `opted_out: true` → suppressed229- Suppressed rows go to `removal_summary` with reason "suppressed"230231**Step 4: Determine CRM actions for clean contacts**232- Match company to CRM account by `company_name` (case-insensitive exact match, or substring match of company names):233 - Match found → `crm_action`: "update_existing", set `existing_account_id`234 - No match → `crm_action`: "create_account", `existing_account_id`: null235- Match contact to CRM contact by normalized email:236 - Match found → set `existing_contact_id`237 - No match → `existing_contact_id`: null238239### 12. Contact Normalization240241- **Email**: trim leading/trailing whitespace, convert to lowercase. Empty string `""` if none.242- **Phone**: strip ALL non-digit characters (spaces, parens, dashes, dots, plus signs). Empty string `""` if none.243- Normalize BEFORE dedup and CRM matching.244245### 13. Badge-level Processing (train_004)246247For each badge, classify and determine CRM action:248249**Classification:**250- `sponsor_attendee`: badge_type="sponsor" OR contact in active sponsor ticket_contacts251- `qualified_non_sponsor_lead`: business badge, non-sponsor, not disqualified252- `excluded`: non-business badge, disqualified account, or missing contact253254**CRM action per badge:**255- Match badge company to CRM account, badge contact to CRM contact, then check campaign members256- `create_account_contact_campaign_member`: new account + new contact257- `create_contact_campaign_member`: existing account + new contact258- `add_campaign_member`: existing account and contact, no campaign member yet259- `update_campaign_member`: existing campaign member whose status needs changing260- `no_action`: existing campaign member already in correct state261- `no_import`: excluded badges262263### 14. Campaign Member Status Mapping264265- Sponsor attendees → target_status: "attended_sponsor" (if they attended) or "registered_sponsor"266- Non-sponsor attendees → target_status: "attended"267- Excluded → target_status: "excluded"268269Existing campaign members whose `status` already matches the `target_status` → action: "no_action".270271---272273## Sorting Rules Summary274275| Output list | Sort key(s) |276|---|---|277| sponsor_statuses | account_name ascending |278| qualified_lead_accounts | account_name ascending |279| excluded_records | company_name ascending, then contact_name ascending |280| qualified_exhibitors (train_002) | company_name ascending |281| excluded_near_misses | company_name ascending |282| ranked_leads (train_005) | rank ascending (1-based) |283| excluded_exhibitors (train_005) | company_name ascending |284| clean_contacts (train_003) | clean_contact_id ascending |285| duplicate_keys | key (normalized email) ascending |286| removed_rows | row_id ascending |287| badge_decisions (train_004) | badge_id ascending |288| campaign_member_actions | subject_key ascending |289| badge_only_contacts | company_name ascending |290| platforms array | enum order: AUV, ROV, Underwater Camera |291| sponsor_finance_accounts | account_name ascending |292293---294295## Common Pitfalls2962971. **Phone normalization**: Strip ALL non-digit characters including `+`, `(`, `)`, `-`, `.`, and spaces. Leading country code digits are kept.2982. **Email normalization**: Only trim + lowercase. Do NOT remove dots or plus-address parts.2993. **Sponsor status from invoices, not orders**: Always check invoices for paid_deferred vs open_invoice. Use proposal_only only when NO invoice exists.3004. **Canceled orders = not a sponsor**: Exclude canceled orders from sponsor_statuses and revenue totals entirely. They are not "proposal_only".3015. **Disqualified accounts**: Check `disqualified_reason != null`, not just `status == "disqualified"`. An account can have a non-null disqualified_reason even if status says otherwise (defense in depth).3026. **Sponsor attendee exclusion**: Match badge contacts against `ticket_contacts` array in orders/sponsor_packages. Only active (non-canceled) sponsors count.3037. **Dedup winner selection**: CRM match first, then earliest captured_at, then lowest row_id. Check CRM contact match by normalized email before falling back to timestamp.3048. **Integer USD**: All monetary values are integers. Don't include decimal points.3059. **Boolean fields**: Use JSON `true`/`false`, not strings.30610. **null vs ""**: `invoice_id` and `existing_*_id` fields use `null` when absent. `email`/`phone` use `""` when empty.30711. **Platform derivation from descriptions**: Read the exhibitor description text to infer platforms. "Builds AUVs" → AUV, "camera modules/manufacturer" → Underwater Camera, "ROVs" → ROV. A company can have multiple platforms.30812. **Ranking tiebreakers**: When scores are equal, broader platform coverage wins. When coverage is equal, company_name ascending wins.30913. **Campaign member existing status check**: Don't create when an existing campaign member already has the correct target_status — that's a no_action.31014. **Suppression matching**: Check BOTH the /suppression endpoint AND CRM contacts with opted_out=true. The suppression list may duplicate opted-out CRM contacts — deduplicate removal reasons.