Skip to content

Import & Export

Miranda supports three import pathways: CSV file upload, Stripe customer import, and direct JSON API ingest. All pathways handle deduplication, relationship assignment, tag application, and activity logging.

The CSV import is a two-step process: upload (parse and preview) then confirm (execute the import).

POST /api/import/upload
Content-Type: multipart/form-data
file: contacts.csv

Response:

{
"headers": ["first_name", "last_name", "email", "phone"],
"preview": [
{"first_name": "Jane", "last_name": "Doe", "email": "[email protected]", "phone": "555-1234"}
],
"file_path": "imports/abc123.csv",
"total_rows": 150
}

The parser handles:

  • BOM stripping — Removes UTF-8 BOM from files exported by Excel
  • Delimiter detection — Auto-detects comma, semicolon, or tab delimiters
  • Header normalization — Lowercases and trims all header names
POST /api/import/confirm
Content-Type: application/json
{
"file_path": "imports/abc123.csv",
"column_mapping": {
"first_name": "first_name",
"last_name": "last_name",
"email": "email",
"phone": "phone"
},
"options": {
"relationship_type": "donor",
"tags": ["imported-2026", "csv-upload"]
}
}
Miranda FieldDescription
first_nameContact first name
last_nameContact last name
emailEmail address (used for deduplication)
phonePhone number
address_streetStreet address
address_cityCity
address_stateState
address_zipZIP/postal code
tagsComma-separated tag names
OptionTypeDefaultDescription
relationship_typestringgeneralContactRelationshipType to assign (donor, applicant, subscriber, general)
tagsstring[][]Global tags applied to all imported contacts

Email is the deduplication key. For each row:

  1. If a contact with the same email exists in the foundation, update their fields (name, phone, address)
  2. If no match exists, create a new contact
  3. Rows without a first name AND email are skipped
{
"status": "completed",
"counts": {
"created": 120,
"updated": 25,
"skipped": 3,
"errors": 2
}
}

ImportService::importFromStripe() pulls customer and charge data from a connected Stripe account:

$counts = $importService->importFromStripe($foundationId, $stripeAccountId);

For each Stripe customer with an email address:

  1. Parses the customer name into first/last name
  2. Retrieves all successful charges to calculate giving metrics
  3. Creates or updates the contact record
  4. Assigns the donor relationship with metadata:
{
"lifetime_giving": 1250.00,
"gift_count": 5,
"first_gift_date": "2025-06-15",
"last_gift_date": "2026-03-01",
"stripe_customer_id": "cus_abc123"
}

Customers without an email are skipped.

For programmatic imports, the JSON ingest endpoint accepts an array of contact objects directly:

POST /api/import/ingest
Content-Type: application/json
{
"contacts": [
{
"first_name": "Jane",
"last_name": "Doe",
"email": "[email protected]",
"phone": "555-1234",
"tags": "donor,board-member"
}
],
"options": {
"relationship_type": "donor",
"tags": ["api-import"]
}
}

Validation rules:

  • contacts — Required, array, minimum 1 item
  • contacts.*.first_name — Required, string, max 255
  • contacts.*.last_name — Nullable, string, max 255
  • contacts.*.email — Nullable, valid email, max 255
  • contacts.*.phone — Nullable, string, max 50
  • contacts.*.tags — Nullable, comma-separated string

For every contact imported (regardless of pathway):

  1. Contact created or updated (deduplicated by email)
  2. Relationship assigned via addRelationship() (idempotent)
  3. Tags applied (from CSV column values + global tags, auto-created if new)
  4. Activity logged with ActivityType::Imported and source metadata
MethodPathDescription
POST/api/import/uploadUpload CSV, get headers and preview
POST/api/import/confirmExecute import with column mapping
GET/api/import/status/{importId}Check import status (placeholder for future async)
POST/api/import/ingestDirect JSON import