Pushing engagement data into Dynamics 365
Your Dynamics connection already keeps contacts and leads in sync. Data push adds the other direction of the story: the emails you sent those people, how they engaged, the texts you sent, and the forms they filled in — written into your own Dataverse environment, attached to the Contact or Lead records your sales team already works in.
This page covers what gets created, what it costs, the permissions involved, and how to read the diagnostics when something looks wrong.
Data push is available for Dynamics 365 connections only, and it is off until you turn it on. Nothing is created in your environment before that.
What your environment needs
Dynamics 365 Sales — or any Dynamics app that includes the Lead table.
Every table we create except Bulk Email links to both Contact and Lead, so that a send or a form submission shows up against whichever record your team actually works in. Those links are part of the table definition, which means the Lead table has to exist in your environment before we can create them.
A plain Power Platform environment — Dataverse without a Dynamics app — has Contact but not Lead. Data push cannot be set up there. If you are not sure which you have, look for Leads in your app navigation, or ask whoever administers the environment whether a Dynamics 365 app is installed.
We check this before creating anything and tell you plainly if the table is missing, rather than failing partway through.
What gets created in your environment
We create our own publisher and solution, then the tables below inside it. Nothing is added to any Microsoft solution, and no existing table is modified.
In your solutions list the solution is named for your deployment type, so you can tell them apart if you end up with more than one:
| Deployment type | Solution |
|---|---|
| Stored | Thinking Streams Marketing Sync — Stored |
| Activities | Thinking Streams Marketing Sync — Activities |
| Elastic | Thinking Streams Marketing Sync — Elastic |
| Virtual | Thinking Streams Marketing Sync — Virtual |
An update we release keeps the same solution and raises its version, so it appears as an upgrade rather than a second entry in the list.
| Table | Holds |
|---|---|
| Bulk Email | one row per email you sent, with its totals and rates |
| Email Send | one row per recipient per email, with that person's own engagement |
| Email Event | one row per open, click, bounce, unsubscribe, and so on |
| SMS Send | one row per recipient per text, with its delivery outcome |
| Form Submission | one row per web-form submission |
Email Send and SMS Send are created as activities, which is why they appear on a contact's timeline next to phone calls and appointments rather than only in a separate tab.
A view, a form, and an icon for each table
Dataverse builds a new table a grid showing only its name and Created On, and a form with none of your data on it. So alongside each table we also create:
- a view named "Thinking Streams — <Table>" with the columns worth scanning, newest first. For Email Send that is the recipient, when it went, whether it was delivered or bounced, and the open and click counts;
- a form named "Thinking Streams", opening with Related — the contact or lead the row belongs to, so you can click straight through to them — then grouping the rest into Message, Delivery and Engagement, and ending with an Integration section holding the identifiers our support team would ask you for;
- an icon, so the five tables are distinguishable in a list.
We never modify your views or your forms. Dataverse's own All … and
My Open … views and its stock Information form are left exactly as it made
them, and ours sit beside them — which is why they carry our name. Installing
again does not touch them either: if ours is already there, nothing is created,
and if you have edited ours, your edit stays.
Our view is not made the default. Which view opens first is your choice, and taking it would change what your team sees without asking.
If a view, form or icon cannot be created, the install still succeeds — the table and your data are the point, and the failure is reported on the settings screen rather than stopping the rows arriving.
An app named for your deployment type
We also create one model-driven app, with a navigation area listing each table we provisioned. Without it the tables are reachable only through Advanced Find or a table picker — the data arrives, and finding it is a chore nobody repeats. The app carries its own tile image, which is stored as a web resource alongside the table icons.
The app is named after the deployment type it belongs to, so that if you ever have more than one installed you can tell them apart at a glance:
| Deployment type | App |
|---|---|
| Standard | Thinking Streams — Stored |
| Standard + Activities | Thinking Streams — Activities |
| Elastic | Thinking Streams — Elastic |
| Virtual | Thinking Streams — Virtual |
Each carries a one-line description of what it does with your data, for the same reason.
We ship our own app rather than editing one of yours. Your Sales Hub is yours, and the same rule that protects your views protects it — so removing us later means deleting one app, not unpicking edits from yours.
If you turn on another record type afterwards, its table is added to the existing app, in the existing solution — we do not create a second app for your current deployment type. Switching deployment type is the one thing that adds another app and another solution, and that is covered below.
Unless you have rearranged the app's navigation yourself — renamed the area, retitled a tile, added your own link or group. Then we leave it exactly as you built it and say so on the settings screen, because rewriting it would delete work we cannot see. The new table is still added to the app and is reachable; you add its tile where you want it.
Deployment types, and changing yours
Data push has one deployment type for the whole connection — you no longer choose a storage kind per table. Pick it once and it decides where all five tables live:
- Virtual (recommended) — nothing is stored in Dataverse at all; your environment reads the data from us on demand. It is the only type with neither storage cost nor API-entitlement cost, at any volume. It needs your workspace to be reachable from the internet — see "Virtual: reading live, storing nothing" below.
- Activity — Email Send and SMS Send appear on the contact timeline. Only those two tables can ever be activities; Bulk Email, Email Event and Form Submission are stored the same as Standard under this type too. Choose this if timeline placement is what you are here for — it is the one thing Virtual cannot do.
- Standard (the plain default) — ordinary Dataverse tables: charts, rollups, Power BI, the lot.
- Elastic — built for very high volume, billed against log capacity rather than database capacity. Gives up cascade behaviour, charts and rollups.
If you would rather keep a copy of the data inside Dataverse, the stored types divide by volume: Standard or Activity up to about 10,000 emails a month, Elastic beyond it.
Choosing a deployment type installs a managed Dataverse solution built for it, with all five tables inside — one action, not five, and every table ships regardless of which type you pick (see "What gets created in your environment" above). Turning a record type's row off further down the settings screen does not remove its table; it only stops data flowing into it.
Switching your deployment type installs a different managed solution rather than converting the one you have. Dataverse cannot change a solution's type in place, so switching adds the new one alongside the old — your existing tables and their rows are left exactly as they are, because they are your data and we do not delete them just because you changed your mind. There is no confirmation dialog for this: unlike the old per-table switch, choosing wrong costs you an unused solution, not lost data.
New versions ship automatically, unless you turn that off. By default, when we release a change to the managed solution, your environment is upgraded the moment it's needed — in front of the next batch of rows, not on a fixed schedule — so nothing you already have stops working. Turn auto-upgrade off on the settings screen if you would rather control the timing yourself; pushing then pauses the first time a new column would be needed, and the deployment card reads Upgrade required and says so — until you upgrade from that same screen.
You can reinstall the version you already have. Once a deployment is installed the card offers Reinstall beside it. This imports the same version again — useful if the tables, views or forms were edited in Dynamics and you want them back as we ship them. Nothing you have pushed is lost: reinstalling replaces the solution's own components, not your rows.
On the Virtual deployment type it does one thing more. Virtual tables hold no data; they read live from us, through an address and a key stored in your environment. Reinstalling refreshes both. That is the fix if your virtual tables suddenly show an error instead of rows — most often after the key has been rotated, or after the address we read through has changed.
What we can and cannot tell you about a text message
Delivery status is per person. Each SMS Send row carries that recipient's own outcome — queued, sent, delivered or failed — along with the delivery time and, when the carrier gives one, the failure reason.
Replies and opt-outs are matched by timing, not by message. Text messaging has no threading: when someone replies, the carrier tells us who replied, not which message they were replying to. We attach a reply or an opt-out to the most recent message you sent that person before it, which is almost always the right answer — but if you sent the same person two messages an hour apart, a reply can only be attributed to the later one.
The message body is the one you composed. It is stored with its merge fields as written, rather than the personalised version each recipient received — we do not keep a per-recipient copy of the text after sending.
Finding a contact's sends from the contact
Open a contact or lead in Dynamics and their engagement is on the Related tab: Email Sends, SMS Sends, Email Events and Form Submissions, each listing only that person's rows. This works on every deployment type, including Elastic and Virtual.
Two details worth knowing:
- On Activity, look at the timeline instead. That type puts Email Sends and SMS Sends on the contact's timeline, which is the more natural place to read them, and they are not duplicated onto a separate Related entry.
- The grid you get is ours. Each table ships a Thinking Streams — … view, and it is the default the Related tab opens. If you ever see an empty grid there with rows you know exist, check the view selector: Dynamics generates its own associated view per table, and that one shows none of our columns.
Sorting or filtering a view by one of these lookups is the one thing Dataverse does not allow on Elastic and Virtual tables. Reading them, which is what the Related tab does, is unaffected.
Opening the record back in Thinking Streams
Bulk Email, SMS Send and Form Submission rows each carry a link back here. It is a field on the record called Open in Thinking Streams — click it and the thing that produced the row opens in your workspace.
Where it lands depends on what exists to land on. A form submission opens its web artifact, with the form already in view. A bulk email or an SMS send opens the session that produced it, falling back to the campaign — neither is an artifact in its own right, so the session is the closest thing to the record itself.
The link is absent rather than wrong when we cannot build a good one. It needs your workspace's public address, and a row pushed before that address was known carries no link instead of one that leads nowhere. A link that fails is worse than none, because there is no way to tell the two apart until you click.
The same applies if you later change your workspace address: rows already in Dataverse keep the link they were written with. Only Virtual tables rebuild it on every read, because they hold no stored copy.
What a form submission row contains
One row per submission, with the form's identifier, when it was submitted and when we received it, and the submitted fields themselves.
The fields come through in whichever form is more useful. If you have mapped the form's fields to contact fields, the row carries the mapped version; otherwise it carries exactly what was submitted. Either way it is stored as structured data, so Dataverse renders it readably rather than as one long line.
We also record the page the form was submitted from, the submitter's IP address and their browser's user agent, where those are available. Please check this against your own privacy notice before turning this record type on — an IP address is personal data in most jurisdictions, and turning the record type on copies it into your Dataverse environment, where your retention rules apply, not ours. The parsed fields described in the next section are derived from that same user agent — nothing additional is collected — but they are stored as separate columns, so include them when you check.
What the user agent tells you, in plain words
A user agent is a long technical string, so alongside it we record what can be read out of it: Browser, Operating System and Device Type. These appear next to the raw User Agent on both Email Event and Form Submission, and they are derived from the string we already collect rather than newly gathered.
Email Event carries a fourth, Email Client — which app opened the message, such as Gmail, Apple Mail or Outlook. Form Submission does not: a form is submitted from a web browser, so there is no mail client to name.
Any of them is left blank when we cannot read it from the user agent, and we would rather leave a field empty than guess: you cannot tell an invented value from a measured one once it is in a report.
Blank means we could not tell — never "none of the above". Those are different answers and the fields keep them apart. Device Type is Desktop, Mobile, Tablet, or Other, and Other is a real answer: we read the user agent and it was none of the three, which is what an open from a smart TV, a script or a crawler looks like. A blank Device Type means there was nothing to read.
The blanks matter most for opens. Gmail and Yahoo fetch images through their own servers, so their user agent describes that server and not the person reading. For those we name the client and leave the browser, operating system and device empty, because the alternative is reporting a browser nobody used — one well-known proxy reports itself as Firefox 11 on Windows XP.
Email Client reads one of three things. A named application — Gmail, Yahoo Mail, Apple Mail, Outlook or Thunderbird — when the user agent identifies one. Webmail / Browser when the message was opened in a web browser on a real phone, tablet or computer, which is what webmail looks like and is by far the most common case. Or blank, when there was nothing readable at all.
So a blank Email Client now means one thing only: we could not read the user agent. If the field says Webmail / Browser, look at Browser and Operating System beside it — those tell you what was used.
Two honest limits. Webmail / Browser is inferred, not stated: the user agent says "a real browser on a real device and no mail application named", which is also what a link scanner or a security proxy looks like. The raw User Agent is kept next to it so you can judge for yourself. And naming depends on the client announcing itself: Apple Mail on a Mac is named, while the same client on an iPhone is not and reads as Webmail / Browser instead. Treat the named values as "clients that identified themselves", not a complete breakdown of your audience.
A form submission is the opposite case: the user agent is the submitter's real browser, so Browser, Operating System and Device Type are usually all filled. That is also why Form Submission has no Email Client field — there is no mail client in the picture, and a column that could only ever be blank would just raise the question of what you were missing.
Apple Mail Privacy Protection is worth knowing about separately. When a recipient turns it on, Apple pre-fetches images whether or not the person opened the message, so some opens are the proxy rather than a reader. That already affects your open counts today; these fields make it visible rather than causing it.
The cost of event rows — please read this one
Every row we write counts against your environment's Power Platform Request entitlement — but how much depends on the deployment type, by a wide margin:
| Deployment type | What one request buys | Cost per 1,000 rows |
|---|---|---|
| Standard, Activity | one row | 1,006 requests (measured) |
| Elastic | a batch of up to 100 rows | 10 requests (measured) |
| Virtual | nothing is written at all | none — nothing is sent |
Those are measured numbers, not estimates. On 15 September 2026 we pushed 10,000 rows of each kind into a real Dataverse environment and counted every request that left our side:
| Deployment type | Rows | Requests | Per 1,000 rows | Throughput |
|---|---|---|---|---|
| Standard | 10,000 | 10,056 | 1,005.6 | 625 rows/minute |
| Elastic | 10,000 | 100 | 10.0 | 29,435 rows/minute |
So Elastic is about 100 times cheaper and 47 times faster for the same 10,000 rows. Two details worth knowing:
- The Standard figure is slightly above one request per row — 10,056 rather than 10,000. The extra 56 are retries: at this rate Dataverse throttles, and a throttled request still counts against your entitlement. Expect a few percent over the row count rather than exactly the row count.
- Activity tables are not in the measured table above, but they take the same write path as Standard — addressed by a key we attach to each row — so they cost the same one request per row.
That difference is a property of Dataverse, not a setting we can change. Standard and Activity tables are addressed by a key we attach to each row, and Dataverse's bulk operations cannot create rows that way — so those tables are written one row per request. Elastic tables are addressed by a key we derive ourselves, which the bulk path does accept.
Opens and clicks typically run 10 to 50 times your send volume. A 200,000-email campaign can generate around a million event rows — at the figures above that is just over a million requests on a Standard table, ten thousand on an Elastic one, and none at all on a Virtual one. At the measured Standard throughput, a million rows is also about 27 hours of pushing; on Elastic, about 34 minutes.
So by default, summaries are on and individual event rows are off. You turn on exactly the event types you want, one at a time, and the settings screen shows this warning next to those toggles.
If you send at high volume and still want event-level detail, the Virtual deployment type is the right answer: your environment reads events from us on demand, so there is no storage and no request cost at all.
Turning on individual event rows
Event rows are off by default, and you turn on exactly the types you want — opens, clicks, bounces and so on — one at a time. Only the types you enable are ever pushed.
Turning a type on is not retroactive. New events of that type start flowing from the moment you enable it; events that already happened are not swept up. If you want the history too, use Re-push everything on that record type's line — see Re-pushing a record type.
Changing your deployment type
Dataverse cannot convert a solution from one deployment type to another, so changing your type installs a second managed solution and starts writing to its tables instead. Your previous tables and their rows are left exactly as they are — they are your data.
If you switch back to a type you used before, we reuse the tables we already made rather than installing a third solution. That is why we never delete the record of what we installed for you.
The push simply resumes where that type's own tables last stopped — automatically, with nothing for you to confirm. That resume is not a backfill: whatever happened while a different deployment type was active is not swept up into the tables you are returning to. If you want that history, use Re-push everything on the record types you care about — see Re-pushing a record type.
Virtual: reading live, storing nothing
Virtual is the one deployment type where nothing is copied into your environment. Dataverse calls our service at the moment someone opens the table, so there is no storage to pay for, no API-entitlement cost at all, and the data is never out of date.
That makes it the natural choice for the high-volume record type — Email Event — where the request cost of pushing individual opens and clicks is the thing that would otherwise stop you.
It needs your workspace to be reachable from the internet. Dataverse fetches these rows from us from Microsoft's cloud, on demand, whenever someone opens the table — so unlike every other deployment type, Virtual only works if your workspace has a public address. If it does not, Install declines Virtual and says so, rather than creating tables that would come back empty forever. Choose a stored type in that case.
The per-table switches still matter — for a different reason. On a stored type, switching a record type off stops data flowing into its table. Nothing flows on Virtual, so the switch decides whether the table answers at all: off means it serves no rows. Every table is installed either way, so this is the only way to keep one out of Dynamics — and it is worth knowing that the usual reason to switch a table off does not apply here, because Virtual has no API-entitlement cost. Visibility is the remaining reason.
The push figures — rows pushed, lag, last pushed, dead letters — are not shown for a Virtual record type. There is no push to measure, so they would read zero for ever.
New fields appear when you install the new solution, and nothing breaks while you wait. Because Virtual tables are read live, we serve whatever fields the current version of our service knows about — which can be more than the version of the solution installed in your environment has columns for. We checked what Dynamics does with the difference rather than assuming: a table with 22 columns reading a feed offering 26 returned its rows normally, and the four it had no column for were simply ignored. So your tables keep working, you just will not see a newly added field until you install the solution version that adds it. This is worth stating because the failure it would otherwise cause is a silent one — an empty table, with the real reason written only to your environment's plug-in trace log.
How to turn it on. Choose Virtual as your connection's deployment type and press Install. That creates the data source and imports all five virtual tables together, and issues the credential Dataverse uses to read from us — a key scoped to this connection, read-only, which we store only as a hash and never log. Re-running Install leaves an existing data source and its credential alone.
What you give up. The provider imposes these, and they are not settings we can change:
- Choice columns become plain text. There is no way to map a Choice, so
values arrive as words (
opened) rather than picklist entries. - The row identity is a GUID we derive, not your own id. It lives in
tstr_rowid, because Dataverse requires a virtual table's key to be a GUID. Your own id is still there, intstr_appid, so a row stays traceable back to the message or event it came from. - Organization-owned only. Per-user security filtering and field-level security are unavailable on a virtual table.
- It cannot be an activity, so Email Send and SMS Send lose timeline placement if you switch them to Virtual — the same trade as Elastic.
- Filtering and sorting work on the mapped columns only. Ask Dataverse to filter on anything else and the request is refused rather than answered approximately — a filter we cannot push down would have to be applied after paging, which would return short pages and quietly omit rows.
- No auditing, Dataverse search, charts, dashboards, queues, offline use, business process flows, or calculated and rollup columns. You also cannot filter or sort a view by one of these lookups — though reading them works, which is what the contact's Related tab does.
A credential lives in your environment. The connection Dataverse uses to reach us authenticates with a key stored in the data source record — the OData standard offers no sign-in option for this. We mint it scoped to that one environment's read and nothing else: it cannot write, and it cannot read any other part of your account. You can rotate or revoke it from this screen at any time, and rotating updates your data source in the same action so it never half-applies.
If the table looks empty, turn on plug-in tracing. Errors from a virtual table are written to your environment's plug-in trace log and appear nowhere else — not in the grid, and not as an error message. The diagnostics check says so too, because an empty grid with no explanation is the single most confusing way this can fail.
Permissions
Two separate things, and it is worth keeping them separate.
To create the tables (one-off). The Application User behind your Dynamics connection needs permission to customise the environment — the System Customizer role is the simple way to grant it. Assign it, press Install, and then you can remove it again: after the install we only ever write rows, never schema.
For your team to see the rows (ongoing). The tables are user-owned, and the rows are owned by the Application User. That means your users will not see any of it unless their security role has Organization-level Read on the new tables. This is the single most common way data push looks broken when it is working perfectly — the data is there, the grid is empty. The diagnostics check calls it out explicitly.
See CRM permissions for the full picture of what the Dynamics connection uses.
If the solution is removed from your environment
Every table now belongs to one managed solution, so there is no longer a single table to delete on its own — removing any of it means removing the whole solution. That happens in the maker portal; we have no button for it, deliberately, since it is your environment to manage.
We notice on our own, rather than trusting our own record of having installed it. The next time we check, we recognise that the solution is gone and show the connection as Not installed rather than retrying against tables that no longer exist. Press Install to put everything back — all five tables, their views, forms, icons and the app — exactly as they were the first time.
This matters because the failure is otherwise silent: rows simply stop arriving, and our side would otherwise keep retrying tables that are not there.
If we cannot reach your environment at that moment, the deployment card says so and asks you to check again, rather than reporting the solution as removed — we would rather tell you nothing than send you to reinstall a solution that is fine.
Rows we could not write
A row we cannot write after retrying is set aside rather than dropped, and the count appears on that record type's line as Dead letters. Select the count to see which rows failed and the error Dynamics returned for each — that message is the thing worth sending us if you need help.
Two actions, and the difference matters:
- Retry sends the selected rows again and clears only the ones that land. A row that fails again stays listed, carrying the error from this attempt rather than the original — after a retry, what went wrong this time is the useful message.
- Dismiss clears the record without sending anything. Use it for rows you have decided not to chase.
We keep failed rows indefinitely — the housekeeping pass never tidies them away — so a row that failed on a permission you fix next month is still there to retry.
Re-pushing a record type
Sometimes the rows in Dynamics are correct but out of date: you fixed a field mapping, or we shipped a fix that fills a column we used to leave empty. The rows already in your environment do not change on their own — nothing re-reads them.
Re-push everything, on each record type's line, queues every row of that type to be written again. What to expect:
- Nothing is duplicated. Every row is matched on its Thinking Streams id, so a row already in your environment is updated in place.
- It is not instant. The request is queued, and the push works through it on its normal passes. The line tells you what was queued.
- It costs API requests. Each re-pushed row is one Power Platform request, the same as the first time — which is why it is one record type at a time, and why we ask you to confirm.
- The record type has to be on. A re-push for a record type you have switched off would sit in the queue and never move, so the button waits for you to enable it.
If a record type is in the middle of a push, the re-push is declined and tells you so. Nothing is changed — wait for that pass to finish and press it again.
Reading the diagnostics
Run checks produces a checklist, and every line that is not a pass tells you what to do about it. The ones worth understanding:
- Can create tables — often shows as could not be determined. That is honest, not a fault: Dataverse does not let us ask what an Application User is allowed to do. Pressing Install is the real answer.
- Users can see pushed rows — the invisibility trap above.
- Schema current — we expect a column your table does not have, because our build has moved ahead of the solution installed in your environment. With auto-upgrade left on you will not see this: the upgrade runs in front of the next batch, before the column is needed. With it off, that record type parks rather than writing incomplete rows, the deployment card reads Upgrade required and tells you pushing is paused, and the button beside it upgrades you to the version it names.
- Supports bulk writes — informational. Some tables do not accept Dataverse's bulk write messages, so we fall back to a slower path. Correctness is unaffected.
- Can link to Leads — if your environment does not allow activities to be linked to leads, lead-linked rows would be skipped. This can only affect the Activity deployment type, the one where Email Send and SMS Send are activities; under every other type they are ordinary tables and the question does not arise. There is no per-table setting for this — it follows the connection's deployment type.
- Failed rows — rows Dataverse rejected individually, with its error per row. Fix the cause, then Retry. One bad row never blocks the others.
When Dynamics is unavailable
Outages, throttling, an exhausted API entitlement, expired credentials, a security role that was changed — none of these lose data and none of them require you to restart anything.
We retry with increasing gaps and then settle into retrying every hour, for as long as it takes. Nothing is skipped in the meantime: we track exactly how far we got, so when the connection comes back the push carries on from there. If the cause was a permission you fixed, it simply resumes — there is no button to press.
During a large send it is normal to be behind for a while. The screen shows how many rows are outstanding and an estimated time to catch up, so you can tell "working through a backlog" apart from "stuck".
Housekeeping on our side
We keep a small amount of bookkeeping so the push can resume exactly where it left off. A slow background pass tidies the parts of it that grow — finished work units, and failure records you have already dealt with (kept for 30 days by default).
Two things are never tidied away: the record of which tables we installed for you, and how far through your data we have pushed per deployment type. That is what makes switching your deployment type — and switching back — reuse the tables we already made instead of installing a third solution, and it is what stops a switch from re-pushing your entire history and billing you for it a second time.
Failures waiting for you are never removed, however old. If you fix the underlying cause a year later, Retry will still find them.
What we do not do
- We never delete anything in your Dataverse. Once a row is pushed it is your data in your environment, and removing it is your call. That includes rows belonging to someone who later asks to be erased — we stop sending anything new about them, but the rows already delivered are yours to manage under your own retention and erasure processes.
- We never modify tables we did not create, and never touch a Microsoft solution.