Skip to main content

Migration from Legacy

Migrate your contact data into Benchmark Email using the API. This guide walks through the complete end-to-end workflow: reviewing your contact structure, creating lists, importing contacts, and verifying the results.

Goal

By the end of this guide you will have migrated your contact data into Benchmark Email by adding custom fields to your contact structure, creating lists, creating contacts with all their field values, assigning contacts to lists, and verifying the migration was successful.

Prerequisites

  • An API key with contacts:write scope (includes read access)
  • Your API base URL (found on the Settings > API Keys page)
  • Your source data (contacts, field definitions, list assignments) exported from your legacy system
  • Familiarity with API authentication

Overview

The migration follows this order:
1. Review and update contact structure (custom fields)
       |
2. Create lists
       |
3. Create contacts (with field values and list assignments)
       |
4. Verify the migration
Each step depends on the previous one: contacts reference field IDs from the structure, and list IDs for assignments. Plan your migration before writing code.

Steps

Step 1: Review and update your contact structure

First, retrieve your existing contact structure to see what fields are already defined.
curl https://api-us-west-2-c1.benchmarkemail.com/api/contact-structure \
  -H "X-API-Key: bme_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
Your account has a default contact structure. If you need to add custom fields to match your legacy data, use PUT to update the structure. For example, to add Company, Phone, City, State, and Signup Source fields:
curl -X PUT https://api-us-west-2-c1.benchmarkemail.com/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1 \
  -H "X-API-Key: bme_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Contacts",
    "keyName": "Email",
    "fields": [
      { "_id": "64a1b2c3d4e5f6a7b8c9d100", "label": "First Name", "dataType": "text", "required": false, "predefinedField": "firstName" },
      { "_id": "64a1b2c3d4e5f6a7b8c9d101", "label": "Last Name", "dataType": "text", "required": false, "predefinedField": "lastName" },
      { "label": "Company", "dataType": "text", "required": false },
      { "label": "Phone", "dataType": "text", "required": false },
      { "label": "City", "dataType": "text", "required": false },
      { "label": "State", "dataType": "text", "required": false },
      { "label": "Signup Source", "dataType": "text", "required": false }
    ],
    "tags": [
      { "label": "Customer" },
      { "label": "Prospect" },
      { "label": "VIP" }
    ],
    "__v": 0
  }'
Save the response. You will need:
  • The contact structure _id (e.g., 64a1b2c3d4e5f6a7b8c9d0e1)
  • Each field’s _id to map your data to the correct fields
  • Each tag’s _id if you plan to assign tags
See Manage Custom Fields for details.

Step 2: Create lists

Create lists that match your legacy system’s segmentation. You will need the contact structure ID from Step 1.
# Create first list
curl -X POST https://api-us-west-2-c1.benchmarkemail.com/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists \
  -H "X-API-Key: bme_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Newsletter Subscribers" }'

# Create second list
curl -X POST https://api-us-west-2-c1.benchmarkemail.com/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists \
  -H "X-API-Key: bme_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Product Updates" }'

# Create third list
curl -X POST https://api-us-west-2-c1.benchmarkemail.com/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists \
  -H "X-API-Key: bme_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" \
  -H "Content-Type: application/json" \
  -d '{ "name": "VIP Customers" }'
Save each list’s _id from the responses. You will need these when creating contacts. See Manage Lists for details.

Step 3: Create contacts

Now create contacts one at a time, mapping your legacy data to the field IDs from your contact structure and assigning list memberships.
curl -X POST https://api-us-west-2-c1.benchmarkemail.com/api/contact \
  -H "X-API-Key: bme_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "jane.smith@example.com",
    "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1",
    "fields": [
      { "_id": "64a1b2c3d4e5f6a7b8c9d100", "value": "Jane" },
      { "_id": "64a1b2c3d4e5f6a7b8c9d101", "value": "Smith" },
      { "_id": "64a1b2c3d4e5f6a7b8c9d102", "value": "Acme Corp" },
      { "_id": "64a1b2c3d4e5f6a7b8c9d103", "value": "555-0123" },
      { "_id": "64a1b2c3d4e5f6a7b8c9d104", "value": "San Francisco" },
      { "_id": "64a1b2c3d4e5f6a7b8c9d105", "value": "CA" },
      { "_id": "64a1b2c3d4e5f6a7b8c9d106", "value": "website-form" }
    ],
    "lists": [
      { "_id": "66f1a6e4698f1bca60425001" },
      { "_id": "66f1a6e4698f1bca60425003" }
    ],
    "tags": [
      { "_id": "64a1b2c3d4e5f6a7b8c9d300" },
      { "_id": "64a1b2c3d4e5f6a7b8c9d302" }
    ]
  }'
Bulk migration script pattern (pseudocode):
const API_BASE = "https://api-us-west-2-c1.benchmarkemail.com";
const API_KEY = "bme_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6";
const STRUCTURE_ID = "64a1b2c3d4e5f6a7b8c9d0e1";

// Map your legacy field names to Benchmark field IDs
const fieldMap = {
  "first_name": "64a1b2c3d4e5f6a7b8c9d100",
  "last_name":  "64a1b2c3d4e5f6a7b8c9d101",
  "company":    "64a1b2c3d4e5f6a7b8c9d102",
  "phone":      "64a1b2c3d4e5f6a7b8c9d103",
  "city":       "64a1b2c3d4e5f6a7b8c9d104",
  "state":      "64a1b2c3d4e5f6a7b8c9d105",
  "source":     "64a1b2c3d4e5f6a7b8c9d106",
};

// Map your legacy list names to Benchmark list IDs
const listMap = {
  "newsletter":      "66f1a6e4698f1bca60425001",
  "product_updates": "66f1a6e4698f1bca60425002",
  "vip":             "66f1a6e4698f1bca60425003",
};

async function migrateContact(legacyContact) {
  const fields = Object.entries(fieldMap)
    .filter(([legacyName]) => legacyContact[legacyName])
    .map(([legacyName, bmeFieldId]) => ({
      _id: bmeFieldId,
      value: legacyContact[legacyName],
    }));

  const lists = (legacyContact.lists || [])
    .filter((name) => listMap[name])
    .map((name) => ({ _id: listMap[name] }));

  const response = await fetch(`${API_BASE}/api/contact`, {
    method: "POST",
    headers: {
      "X-API-Key": API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      key: legacyContact.email,
      contactStructureId: STRUCTURE_ID,
      fields,
      lists,
    }),
  });

  return { email: legacyContact.email, status: response.status };
}

Step 4: Handle errors and retries

Bulk migrations will encounter transient errors. Implement retry logic with exponential backoff. Rate limit handling: The API allows 3,600 requests per hour (~1 request/second sustained). For large migrations, pace your requests accordingly.
async function migrateWithRetry(contact, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    const result = await migrateContact(contact);

    if (result.status === 200) {
      return { success: true, email: contact.email };
    }

    if (result.status === 429) {
      // Rate limited — wait for the Retry-After period
      const retryAfter = parseInt(result.headers?.get("Retry-After") || "60", 10);
      console.log(`Rate limited. Waiting ${retryAfter}s before retry...`);
      await sleep(retryAfter * 1000);
      continue;
    }

    if (result.status === 400) {
      // Could be a duplicate contact — check the error type
      const body = await result.json();
      const errorType = body?.errors?.[0]?.errorType;
      if (errorType === "DuplicateFieldError") {
        console.log(`Duplicate: ${contact.email} — skipping`);
        return { success: true, email: contact.email, skipped: true };
      }
    }

    if (result.status >= 500) {
      // Server error — retry with backoff
      const backoff = Math.pow(2, attempt) * 1000;
      console.log(`Server error (${result.status}). Retrying in ${backoff}ms...`);
      await sleep(backoff);
      continue;
    }

    // Client error (403, etc.) — do not retry
    console.error(`Failed: ${contact.email}${result.status}`);
    return { success: false, email: contact.email, status: result.status };
  }

  return { success: false, email: contact.email, error: "max retries exceeded" };
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
Dedup before creating: To avoid duplicate errors for contacts that already exist, search before creating:
async function dedup(email, structureId) {
  const response = await fetch(`${API_BASE}/api/contact/search`, {
    method: "POST",
    headers: {
      "X-API-Key": API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      contactStructureId: structureId,
      page: 1,
      pageSize: 1,
      source: ["_id", "key"],
      contactSpecification: {
        filters: [
          {
            criterias: [
              { columnToFilter: "KEY", operator: "EQ", values: [email] },
            ],
          },
        ],
      },
    }),
  });

  const data = await response.json();
  return data.totalRecords > 0 ? data.contacts[0] : null;
}
See Search Contacts for more search patterns.

Step 5: Verify the migration

After all contacts are created, verify the migration by checking counts and spot-checking individual contacts. Check total contact count:
curl -X POST https://api-us-west-2-c1.benchmarkemail.com/api/contact/search \
  -H "X-API-Key: bme_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" \
  -H "Content-Type: application/json" \
  -d '{
    "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1",
    "page": 1,
    "pageSize": 1,
    "source": ["_id"],
    "contactSpecification": {
      "filters": [
        {
          "criterias": [
            { "columnToFilter": "CONTACT_STATUS", "operator": "EQ", "values": ["active"] }
          ]
        }
      ]
    }
  }'
Check the totalRecords value in the response against your expected count. Check list membership counts:
curl "https://api-us-west-2-c1.benchmarkemail.com/api/contact-structure/64a1b2c3d4e5f6a7b8c9d0e1/lists?page=1&size=100" \
  -H "X-API-Key: bme_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
Each list in the response includes a totalContacts count. Spot-check a specific contact:
curl -X POST https://api-us-west-2-c1.benchmarkemail.com/api/contact/search \
  -H "X-API-Key: bme_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" \
  -H "Content-Type: application/json" \
  -d '{
    "contactStructureId": "64a1b2c3d4e5f6a7b8c9d0e1",
    "page": 1,
    "pageSize": 1,
    "source": ["_id", "key", "fields", "tags", "lists"],
    "contactSpecification": {
      "filters": [
        {
          "criterias": [
            { "columnToFilter": "KEY", "operator": "EQ", "values": ["jane.smith@example.com"] }
          ]
        }
      ]
    }
  }'
Verify that the returned fields, tags, and list assignments match your source data.

Migration Checklist

  • Export all contacts from your legacy system
  • Map legacy fields to Benchmark field types
  • Update the contact structure with all required fields
  • Record the field ID mapping (legacy field name to Benchmark _id)
  • Create all lists and record their IDs
  • Run a small test batch (10-50 contacts) to validate the mapping
  • Run the full migration with retry logic
  • Verify total contact count matches expectations
  • Verify list membership counts
  • Spot-check 5-10 individual contacts for field accuracy

Tips for Large Migrations

  1. Pace your requests. The API rate limit is 3,600 requests/hour. For 10,000 contacts, expect the migration to take approximately 3 hours.
  2. Log everything. Keep a log of each contact created (email, status code, contact ID) so you can identify and retry failures.
  3. Use dedup checks sparingly. Each dedup search counts against your rate limit. If you are confident your source data has no duplicates, skip the dedup step and handle duplicate errors (400 DuplicateFieldError) instead.
  4. Batch your field mapping once. Fetch the contact structure once at the start, build your field map, and reuse it for every contact. Do not fetch the structure for each contact.
  5. Monitor your monthly quota. Each API request counts toward your monthly quota (contactLimit x 10). Check remaining quota via the X-Monthly-Remaining response header.

Common Errors

StatusErrorCauseFix
401UnauthorizedErrorInvalid or inactive API keyVerify your key in Settings > API Keys
403ForbiddenErrorKey lacks contacts:write scopeMigration requires contacts:write
400ValidationErrorMissing key or contactStructureId, or invalid field IDVerify your field mapping matches the contact structure
400DuplicateFieldErrorContact with this email already existsUse search to dedup, or update the existing contact instead
429TooManyRequestsErrorRate limit or monthly quota exceededImplement backoff and respect the Retry-After header. See Rate Limits

Next Steps