PPact
Reference

Migration guide

Move your accounts, contacts, and deals into Pact from Salesforce, HubSpot, or Pipedrive — via the CSV import wizard or a native connector.

Bringing your data into Pact takes one of two paths: a one-time CSV import through the import wizard, or a native connector that authenticates against your source CRM and syncs records for you. Most teams do both — connect the CRM for the bulk of records, and use CSV for anything the connector doesn't cover.

Which path to choose

CSV import

The import wizard (/v1/admin/imports, surfaced in the app under Settings → Data import) handles the full lifecycle: draft, upload, map, preview, run. Supported entity types are account, contact, deal, activity, product, ticket, contract, journey, and custom_object (core/imports/schema.py); accounts land in companies, contacts in contacts, and deals in the deals table.

  1. 1

    Export from your current CRM

    In Salesforce, use the Data Export / Reports export to produce a CSV per object (Accounts, Contacts, Opportunities). In HubSpot, use Export under each object's table view. One CSV per entity type.

  2. 2

    Create a draft import

    Pick the target entity type and dedup defaults. This creates a draft you can revisit before anything is written.

  3. 3

    Upload the file

    Multipart upload of your CSV. Pact parses the header row and a sample of values.

  4. 4

    Auto-map columns

    The wizard proposes a column → field mapping using a Claude-assisted + heuristic matcher (/auto-map). Unmatched columns can be turned into custom fields in one step (/custom-fields). Review and confirm the mapping — you always get the final say.

  5. 5

    Preview

    A first-ten-row preview shows exactly how your rows resolve against the mapping and dedup rules before any commit.

  6. 6

    Start the import

    Kick off the run. Progress and outcome are tracked per import; you can list past imports and inspect a single one by its public_id.

A contact import can map one optional column onto Email marketing consent (email_marketing_consent, core/imports/schema.py). The wizard auto-maps headers such as Consent, Email consent, Marketing consent or Subscription status. It never auto-maps an Opted out or Unsubscribed header, because that would invert every row.

Each non-blank cell becomes one event in the consent ledger, written through the consent module's own API (ConsentService.record) under its own validation (core/imports/consent.py):

Cell saysWhat is recorded
granted, opted in, opt-in, subscribed, consentedAn imported (granting) event, email · marketing
withdrawn, opted out, opt-out, unsubscribed, revoked, do not emailA withdrawn event, email · marketing
blankNothing. The person reads No record and cannot be sent marketing.
yes, no, true, false, 1, 0, anything elseRefused. The row is held as a blocking validation error, because a yes/no cell cannot say whether its column recorded an opt-in or an opt-out.

Every event carries source: "import", the time the import ran (Pact records when it learned the decision and does not claim the date your old system held), and proof text naming the import, row, column and cell. Two rules hold regardless of what the file says:

  • An import never overturns a withdrawal. A grant for someone whose current state in Pact is Withdrawn is not recorded, and the import's result says so row by row (kept_existing_withdrawal). A withdrawal always lands.
  • A consent cell needs an email address. A row with a consent value but no email is counted as skipped_no_email. Nothing is guessed.

The import's result carries a consent block with the counts (recorded_granted, recorded_withdrawn, kept_existing_withdrawal, skipped_no_email, failed) and up to 25 per-row notes. A ledger write that fails is counted under failed and logged. It never removes the contacts the import already wrote.

Dedup is a first-class choice, not an afterthought

Every import carries a dedup policy so re-importing an updated export doesn't create duplicates. Confirm the dedup key (typically email for contacts, domain/name for accounts) at the mapping step. Import order matters: bring in accounts first, then contacts (so they link to the right company), then deals.

Native connectors

Connectors live under /v1/integrations and are provider-agnostic by design. Today the wired providers are Salesforce, HubSpot, and Pipedrive (api/routes/integrations.py).

Salesforce

Salesforce is the most complete connector — OAuth connect, scheduled + manual sync, and writeback:

  • POST /v1/integrations/salesforce/start returns the authorize URL (supports sandbox orgs via a sandbox flag).
  • GET /v1/integrations/salesforce/callback handles the OAuth redirect; the state parameter is single-use and tenant-scoped so a leaked link can't be replayed cross-tenant.
  • GET /v1/integrations/salesforce/status shows connection state, sync stats, recent runs, and your org's daily-API-call headroom (scraped from Sforce-Limit-Info).
  • POST /v1/integrations/salesforce/sync triggers a manual "Sync now".
  • DELETE /v1/integrations/salesforce disconnects and clears stored tokens.
bash
# 1. Begin the OAuth handshake
curl -X POST https://api.pact.place/v1/integrations/salesforce/start \
  -H "Authorization: Bearer $PACT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sandbox": false}'
# → { "authorize_url": "https://login.salesforce.com/...", "state": "..." }

# 2. Open authorize_url in a browser, approve, land back on the callback.
# 3. Trigger the first pull:
curl -X POST https://api.pact.place/v1/integrations/salesforce/sync \
  -H "Authorization: Bearer $PACT_TOKEN"

Token encryption is required

Connector OAuth tokens are stored encrypted with Fernet. The connect flow requires the platform's encryption key to be configured (AUTH_TOKEN_ENCRYPTION_KEY or JWT_SECRET); without it the connect step is refused rather than storing tokens in the clear.

HubSpot & Pipedrive

HubSpot and Pipedrive share the same integration store, status surface, and run history as Salesforce, and their sync clients (core/integrations/hubspot, core/integrations/pipedrive) map source objects onto the same account/contact tables the CSV wizard targets. Their authentication and sync model differs from Salesforce, though:

  • Auth is an API token, not three-legged OAuth. HubSpot connects with a Private App access token and Pipedrive with an API token, supplied through the in-app BYOK/settings flow. There is no /start + /callback OAuth handshake for these two — full user-consent OAuth is a follow-up (core/integrations/hubspot/__init__.py notes the three-legged flow as a layered PR).
  • Sync is manual. POST /v1/integrations/hubspot/sync and POST /v1/integrations/pipedrive/sync are "Sync now" triggers with run history. The 30-minute background scheduler (core/integrations/salesforce/sync.py run_incremental) is Salesforce-only today.

Sync is import-oriented today

The connectors are built primarily for pulling data in (and, for Salesforce, writeback of specific fields). They are not a full bidirectional replication of every object and custom field. For objects a connector doesn't cover, export to CSV and use the import wizard.

Deal records land in a different table depending on the path

All three connectors (Salesforce, HubSpot, Pipedrive) write synced deal records into the opportunities table. The CSV import wizard's deal entity type writes into a separate deals table. If you use both paths for the same workspace, deal records from a connector sync and deal records from a CSV import will not merge or dedupe against each other — pick one path per deal record, or plan for a manual reconciliation.

After the migration

  • Consent is carried only where the source actually recorded a decision. A contact with no consent record reads No record on every person surface and is blocked from marketing sends until consent is captured. What sets a record:

    • CSV wizard: the optional consent column described in Carrying consent in a CSV.
    • CRM migration (/v1/admin/migrations, core/migration/importer.py): records a granted or withdrawn event with source crm_import only when the provider returns a decision. Salesforce reads HasOptedOutOfEmail: true is recorded as withdrawn and false as granted. HubSpot reads hs_email_optout: true is withdrawn, false is granted, and an absent flag records nothing. Pipedrive reads marketing_status: subscribed is granted, unsubscribed or no_consent is withdrawn, and anything else records nothing. Zoho has no consent flag on a contact, so it records nothing. The source's own value is always kept verbatim with the migration record.
    • Native sync connectors (/v1/integrations/*) do not write consent.

    If a Salesforce or HubSpot "not opted out" is not consent under the regime you operate in, record the decision yourself through the consent API after the migration.

  • Verify record linkage. Spot-check that contacts resolved to the right accounts and deals to the right pipeline stages before you switch off the old system.

  • Public IDs, not integer IDs. Pact exposes UUID public_id values in URLs and API responses; the integer primary keys from your old CRM aren't reused. Bookmark and integrate against public_id.