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