DataVerse

Seeding a Dynamics 365 Sales Instance with Realistic Dummy Data Using Python

Setting up a Dynamics 365 Sales instance for development, demos, or training is painful when it’s empty. You need accounts, contacts, opportunities, quotes, orders — the full sales pipeline — with realistic relationships between them. Clicking through forms to create 262 records by hand? No thanks.

In this post, I’ll walk through a Python-based data seeding system I built with Claude Code that populates a vanilla Dynamics 365 Sales instance with a complete, interconnected dataset in minutes.

What Gets Created

The seeding script creates 262 records across the entire sales pipeline:

Entity Count Notes
Business Units 4 Sales UK, Sales Europe, Marketing, Operations
Accounts 25 Companies across UK, Europe, Australia
Contacts 25 Linked to accounts with job titles
Leads 25 Unqualified prospects from various companies
Products 25 Cloud licences, analytics, support plans ($500–$96K)
Price List + Items 26 Standard pricing with currency amounts
Opportunities 25 At various pipeline stages (Qualify→Close)
Opportunity Products 25 One product line per opportunity
Quotes 25 Linked to opportunities
Quote Details 25 Product lines on quotes
Orders 15 Top probability deals converted to orders
Order Details 15 Product lines on orders
Invoices 10 Subset of orders invoiced
Invoice Details 10 Product lines on invoices
Competitors 10 Linked to opportunities via N:N
Activities 75 Tasks, phone calls, emails, appointments, letters

Crucially, the data isn’t random noise. The accounts are named things like “Northwind Trading Ltd” and “Alpine Software GmbH” with appropriate cities and industries. The opportunities have names like “Woodgrove – Digital Banking Platform” with realistic budgets and win probabilities. Activities reference actual contacts and opportunities. It feels like a real CRM.

Architecture

The system is split into five Python files and a data/ directory of JSON files:

seed.py          # Orchestrates the 20-step seeding process
cleanup.py       # Deletes everything in reverse dependency order
auth.py          # OAuth2 client credentials with token caching
api.py           # HTTP wrappers for the Dynamics Web API
config.py        # Environment variable loading (.env)
data/
  accounts.json
  contacts.json
  leads.json
  products.json
  business_units.json
  opportunities.json
  competitors.json
  activities.json

This separation keeps things manageable. Want to change the test data? Edit the JSON files. Want to change how records are created? Edit api.py. The seeding logic in seed.py only cares about what to create and in what order.

The Data Files

Each JSON file contains an array of objects with the fields needed for that entity. Here’s a snippet from accounts.json:

[
  {
    "name": "Northwind Trading Ltd",
    "city": "London",
    "country": "United Kingdom",
    "industry": 9,
    "revenue": 5200000,
    "phone": "+44 20 7946 0958",
    "website": "https://northwindtrading.example.com"
  },
  {
    "name": "Alpine Software GmbH",
    "city": "Munich",
    "country": "Germany",
    "industry": 6,
    "revenue": 12000000,
    "phone": "+49 89 123 4567",
    "website": "https://alpinesoftware.example.com"
  }
]

The activities file is the most complex, containing five different activity types (tasks, phone calls, emails, appointments, and letters) each with type-specific fields like direction, duration, and activity party references.

Authentication

The system uses OAuth2 client credentials flow — no interactive login needed. An Azure AD App Registration provides the client ID and secret, and the token is cached in memory so we’re not hitting the token endpoint on every API call:

_token_cache = {"access_token": None, "expires_at": 0}

def get_token():
    if _token_cache["access_token"] and time.time() < _token_cache["expires_at"] - 60:
        return _token_cache["access_token"]

    resp = requests.post(TOKEN_URL, data={
        "grant_type": "client_credentials",
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "scope": f"{DYNAMICS_URL}/.default",
    })
    resp.raise_for_status()
    data = resp.json()
    _token_cache["access_token"] = data["access_token"]
    _token_cache["expires_at"] = time.time() + data["expires_in"]
    return _token_cache["access_token"]

Configuration lives in a .env file, loaded via python-dotenv. No credentials in source code.

The 20-Step Seeding Process

Order matters enormously when seeding Dynamics data. You can't create an opportunity product without first having the opportunity, the product, the price list, and the unit of measure. The script executes 20 steps in strict sequence:

  1. Step 0 — Query the root business unit and base currency (these already exist)
  2. Step 1 — Create child business units
  3. Step 2 — Query default teams (auto-created with BUs)
  4. Steps 3–5 — Create accounts, contacts, and leads
  5. Steps 6–8 — Create unit groups, products, and publish them
  6. Steps 9–10 — Create price list and price list items
  7. Steps 11–12 — Create opportunities and add product lines
  8. Steps 13–14 — Create quotes with details from opportunities
  9. Steps 15–16 — Create orders from high-probability deals
  10. Steps 17–18 — Create invoices from a subset of orders
  11. Step 19 — Create competitors and link to opportunities (N:N)
  12. Step 20 — Create all activities with activity parties

Each step builds on the previous ones using in-memory lookup maps. When we create an account, we store its GUID in account_map. When we later create a contact, we use account_map to set the parentcustomerid relationship.

The API Wrapper

Every record creation goes through a single create_record() function that handles the HTTP POST, extracts the GUID from the OData-EntityId response header, and tracks the record for cleanup:

def create_record(entity_set, data, label=""):
    url = f"{API_URL}/{entity_set}"
    resp = requests.post(url, headers=get_headers(), json=data)
    if resp.status_code not in (200, 201, 204):
        resp.raise_for_status()

    # Extract ID from OData-EntityId header
    entity_id_header = resp.headers.get("OData-EntityId", "")
    match = re.search(r"\(([0-9a-fA-F-]{36})\)", entity_id_header)
    record_id = match.group(1) if match else None

    track_record(entity_set, record_id, label)
    time.sleep(0.1)  # Rate limiting
    return record_id

The track_record() function appends every created record to created_records.json. This file is the cleanup script's manifest — it knows exactly what to delete.

Handling Activities and Activity Parties

Activities were the trickiest part. Unlike simple entities, phone calls, emails, and appointments require activity parties — the From, To, CC, BCC, Organizer, and Attendee participants. These are set as nested objects in the create payload:

# Phone call with activity parties
payload = {
    "subject": "Discovery call with Northwind CTO",
    "directioncode": True,  # Outgoing
    "phonecall_activity_parties": [
        {
            "partyid_systemuser@odata.bind": f"/systemusers()",
            "participationtypemask": 1,  # From
        },
        {
            "partyid_contact@odata.bind": f"/contacts({contact_id})",
            "participationtypemask": 2,  # To
        }
    ]
}

The participation type mask values are: 1=From, 2=To, 3=CC, 4=BCC, 5=Required Attendee, 6=Optional Attendee, 7=Organizer. Getting these wrong produces cryptic errors.

One gotcha: when creating activities with parties, you need to omit the Prefer: return=representation header that we normally include. Otherwise Dynamics returns a 500 error. The script uses a separate _make_headers_no_prefer() function for activity creation.

Marking Activities as Completed

Some activities should appear as completed in the timeline. You can't set statecode during creation — you have to create the activity first, then PATCH it:

# After creating all activities, mark some as completed
for entity_set, rec_id, state, status in tasks_to_complete:
    requests.patch(
        f"{API_URL}/{entity_set}({rec_id})",
        headers=headers,
        json={"statecode": state, "statuscode": status}
    )

The status codes vary by activity type — for tasks it's statecode=1, statuscode=5 (Completed), for emails it's statecode=1, statuscode=4 (Sent).

Products: The Publish Step

Products in Dynamics 365 have a lifecycle. When you create a product via the API, it's in a Draft state and can't be added to price lists or opportunities. You must publish it first by calling the PublishProductHierarchy action:

def step8_publish_products():
    for name, prod_id in product_map.items():
        call_action("products", prod_id, "PublishProductHierarchy")
        print(f"  Published: {name}")

This is a bound action, so the URL looks like: POST /products({id})/Microsoft.Dynamics.CRM.PublishProductHierarchy. Skip this step and every subsequent step that tries to add a product line will fail.

Competitors and N:N Relationships

Competitors are linked to opportunities through a many-to-many relationship. Unlike lookup fields (which are set with @odata.bind), N:N associations require a separate POST to the relationship endpoint:

# Link competitor to opportunity
url = f"{API_URL}/competitors({comp_id})/opportunitycompetitors_association/$ref"
ref_payload = {"@odata.id": f"{API_URL}/opportunities({opp_id})"}
requests.post(url, headers=headers, json=ref_payload)

The relationship name (opportunitycompetitors_association) is specific to the Dynamics schema. Getting the wrong name gives a 404.

The Cleanup Script

What makes the seeding system truly useful for development is the cleanup script. It reads created_records.json and deletes everything in reverse dependency order:

DELETE_ORDER = [
    "tasks", "phonecalls", "emails", "appointments", "letters",
    "invoicedetails", "invoices",
    "salesorderdetails", "salesorders",
    "quotedetails", "quotes",
    "opportunityproducts", "opportunities",
    "competitors",
    "productpricelevels", "pricelevels", "products",
    "leads", "contacts", "accounts",
    "businessunits",
]

Business units get special treatment — they must be disabled before they can be deleted. The script handles this automatically with a PATCH to set isdisabled: true before the DELETE.

This means you can seed, test, clean up, and reseed as many times as you need. Perfect for iterative development.

Running It

Setup is straightforward:

# Install dependencies
pip install requests python-dotenv

# Create .env file
CLIENT_ID=your-app-client-id
CLIENT_SECRET=your-app-client-secret
TENANT_ID=your-azure-tenant-id
DYNAMICS_URL=https://yourorg.crm.dynamics.com

# Seed the data
python seed.py

# Verify counts
python seed.py --verify

# Clean up when done
python cleanup.py

The script prints progress for every step and runs a verification pass at the end, checking that record counts match expectations.

Key Takeaways

  • Dependency order is everything — Create parents before children, seed before cleanup in reverse. The 20-step sequence isn't arbitrary.
  • Separate data from logic — JSON data files mean you can change what gets created without touching the seeding code
  • Track what you create — The created_records.json manifest makes cleanup reliable and repeatable
  • Products need publishing — Draft products can't be used in price lists, opportunities, quotes, or orders
  • Activity parties are special — They require nested objects with participation type masks and different HTTP headers
  • Rate limit yourself — A 100ms delay between API calls avoids throttling and keeps the Dynamics instance responsive
  • Business units need disabling — You can't delete a BU directly; disable it first

Wrapping Up

Building this seeding system with Claude Code took the pain out of test data management. Instead of spending an afternoon clicking through Dynamics forms, I run one command and have a fully populated sales instance in about two minutes. When I'm done testing, another command wipes it clean.

The full source code — all five Python files plus the JSON data — is compact enough to drop into any Dynamics 365 project. Swap out the JSON files with your own test scenarios and you've got a repeatable, scriptable way to set up any Dynamics 365 Sales environment.

Leave a Comment

Your email address will not be published. Required fields are marked *