Implementing a Simple Honeypot in Dynamics 365 Customer Insights – Journeys Real-time Marketing Forms


While working with Dynamics 365 Customer Insights – Journeys Real-time Marketing Forms, we wanted to explore a simple way of reducing bot submissions.

A common technique used on websites is a honeypot. The idea is straightforward:

  • Add a field that genuine users never see.
  • Hide it using JavaScript.
  • If the field contains a value when the form is submitted, assume it was completed by a bot and cancel the submission.

In this post, we’ll implement this using the supported Client-side Extensibility events provided by Dynamics 365.

Step 1: Add an Unbound Field

Add a new Short Text field to the form.

The field can be Unbound, as it is only used for validation and does not need to be stored in Dataverse.

We used hp_check for the field name as this avoids browser autofill heuristics that may populate well-known fields such as Middle Name, Email, or Phone.

Step 2: Hide the Field

The field should remain in the HTML so that automated bots can still discover it, but it should not be visible to genuine users.

In our implementation, the field is hidden using CSS, which avoids any brief flicker that could occur if it were hidden only after the form loads.

.textFormFieldBlock:has(input[name="hp_check"]) {
    position: absolute !important;
    left: -9999px !important;
    width: 1px !important;
    height: 1px !important;
    overflow: hidden !important;
}

Step 3: Validate During Form Submission

Using the supported d365mkt-afterformload and d365mkt-formsubmit events, configure the field and validate it before submission.

<script>
let honeypotField = null;

document.addEventListener("d365mkt-afterformload", function () {

    honeypotField = document.querySelector('[name="hp_check"]');

    if (!honeypotField) {
        return;
    }

    honeypotField.setAttribute("autocomplete", "off");
    honeypotField.setAttribute("tabindex", "-1");
    honeypotField.setAttribute("aria-hidden", "true");
});

document.addEventListener("d365mkt-formsubmit", function (event) {

    if (honeypotField && honeypotField.value.trim() !== "") {
        event.preventDefault();
    }

});
</script>

The d365mkt-formsubmit event is cancelable, so calling event.preventDefault() prevents the form from being submitted.

Testing

To test the implementation, we opened the browser Developer Tools and executed the following in the Console:

document.querySelector('[name="hp_check"]').value = "I am a bot";

When we clicked Submit, the d365mkt-formsubmit event was triggered and event.preventDefault() successfully cancelled the submission.

Things to consider

While implementing this, there were a few considerations that helped improve the solution:

  • Use a neutral field name such as hp_check instead of generic field name to reduce the chance of browser autofill populating the field.
  • Hide the field using CSS instead of JavaScript to avoid a brief flash of the field while the page is loading.
  • Keep the field unbound, as it is only used for validation.
  • A client-side honeypot helps reduce spam submissions, but it should be considered one layer of protection. For higher security requirements, additional server-side validation can also be implemented. For e.g. if it is bound field on lead than a plugin on pre create operation or if it is unbound we can have plugin against Form Submission table.

Conclusion

A honeypot is a simple but effective technique for reducing automated submissions to Dynamics 365 Customer Insights – Journeys Real-time Marketing Forms.

By using the supported Client-side Extensibility events, the implementation remains lightweight, requires no Dataverse customizations, and can be easily reused across multiple for

References

Real-time Marketing Form Client-side Extensibility

Hope it helps..

Advertisements

Retrieve All One-to-Many Relationships and Cascade Delete Behavior for a Dataverse Table Using C#


While working on a Dynamics 365 Field Service cleanup, we needed to delete a large number of Work Order records.

Before doing so, we wanted to understand exactly what would happen to the related records.

Not every relationship is configured for Cascade Delete. Many use RemoveLink, which doesn’t delete the child records. Instead, Dataverse updates the lookup field to null by removing the relationship.

This behavior is easy to overlook, but it can have unintended consequences. In our case, several integrations were listening for updates on child tables. Deleting a Work Order could therefore generate a large number of update events on related records, potentially triggering unnecessary integration processing.

To identify every table referencing msdyn_workorder along with its delete behavior, I wrote a small console application that retrieves the relationship metadata and exports it to a CSV file.

The application uses the RetrieveEntityRequest message to retrieve all one-to-many relationships for a Dataverse table along with their cascade settings.

static void Main(string[] args)
{
    Console.WriteLine("Connecting...");

    string connectionString =
        ConfigurationManager.AppSettings["ConnectionString"];

    var service = new ServiceClient(connectionString);

    if (!service.IsReady)
    {
        Console.WriteLine("Connection Failed");
        Console.WriteLine(service.LastError);
        Console.ReadLine();
        return;
    }

    Console.WriteLine("Connected Successfully");

    var request = new RetrieveEntityRequest
    {
        LogicalName = "msdyn_workorder",
        EntityFilters = EntityFilters.Relationships
    };

    var response =
        (RetrieveEntityResponse)service.Execute(request);

    var relationships = new List<RelationshipInfo>();

    foreach (var relationship in response.EntityMetadata.OneToManyRelationships)
    {
        relationships.Add(new RelationshipInfo
        {
            SchemaName = relationship.SchemaName,
            ParentTable = relationship.ReferencedEntity,
            ChildTable = relationship.ReferencingEntity,
            LookupAttribute = relationship.ReferencingAttribute,
            DeleteCascade = relationship.CascadeConfiguration.Delete.ToString(),
            AssignCascade = relationship.CascadeConfiguration.Assign.ToString(),
            ShareCascade = relationship.CascadeConfiguration.Share.ToString(),
            ReparentCascade = relationship.CascadeConfiguration.Reparent.ToString()
        });
    }

    Console.WriteLine($"Relationships Found: {relationships.Count}");

    foreach (var relationship in relationships)
    {
        Console.WriteLine(
            $"{relationship.ChildTable} | " +
            $"{relationship.LookupAttribute} | " +
            $"{relationship.DeleteCascade}");
    }

    using (var writer = new StreamWriter("WorkOrderRelationships.csv"))
    using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
    {
        csv.WriteRecords(relationships);
    }

    Console.WriteLine("CSV exported successfully.");
}

RelationshipInfo Class

public class RelationshipInfo
{
    public string SchemaName { get; set; }
    public string ParentTable { get; set; }
    public string ChildTable { get; set; }
    public string LookupAttribute { get; set; }
    public string DeleteCascade { get; set; }
    public string AssignCascade { get; set; }
    public string ShareCascade { get; set; }
    public string ReparentCascade { get; set; }
}

The output :

Why is this useful?

This utility can be useful when we need to:

  • Review cascade behavior before performing bulk deletes.
  • Understand which child tables will be deleted, updated, or left unchanged.
  • Identify RemoveLink relationships that generate automatic update events on child records.
  • Validate plugin and integration behavior before data cleanup activities.
  • Export relationship metadata for documentation or analysis.

The code works for any Dataverse table. Simply replace:

LogicalName = “msdyn_workorder”

with the logical name of the table you want to analyze.

In our case, this quick analysis helped us identify child tables that would receive automatic updates due to RemoveLink relationships, allowing us to ensure those updates did not unnecessarily trigger downstream integrations during the cleanup process.

Hope it helps..

Advertisements

Why SQL4CDS Record Counts May Not Match Advanced Find for Date Filters (Dataverse / Dynamics 365)


While validating some Dynamics 365 Field Service data recently, we came across an interesting scenario where SQL4CDS and Advanced Find returned different record counts even though the date filters appeared to be identical.

At first glance it was surprising to see different record counts being returned despite using what appeared to be the same date range. After investigating further, we found that the difference was related to time zone handling and the behavior of User Local date fields.

In this post, we’ll walk through the issue, explain why it happens, and show how to get matching results between Advanced Find and SQL4CDS.

The Scenario

We had a user in Auckland, New Zealand running the following Advanced Find query against Work Orders.

Date Window Start

  • On or After 01/01/2026
  • On or Before 02/01/2026

Advanced Find returned:

5,755 records

The generated FetchXML looked like this:


To validate the result, we ran the following query in SQL4CDS:

SELECT COUNT(*)
FROM msdyn_workorder
WHERE msdyn_datewindowstart >= ‘2026-01-01 00:00:00’
  AND msdyn_datewindowstart <= ‘2026-01-02 00:00:00’;

The results were unexpected.

Query MethodTime Zone UsedResult
Advanced Find (Auckland User)Auckland (NZDT)5,755
SQL4CDSUTC Mode3,027
SQL4CDSLocal Mode (India)3,026

At this point, it was clear that Advanced Find and SQL4CDS were evaluating different date boundaries, even though the filters appeared very similar. The next step was to understand why.

Understanding the Date Window Start Field

The key detail was the configuration of the Date Window Start field.

The Date Window Start field is configured as a Date Only field with User Local behavior.

Although users only see a date value, Dataverse stores an underlying UTC datetime value and performs time zone conversion based on the user’s personal settings.

To better understand what was happening, we queried some of the underlying values directly.

SELECT msdyn_workorderid,
       msdyn_datewindowstart
FROM msdyn_workorder
WHERE msdyn_datewindowstart >= ‘2026-01-01 00:00:00’
  AND msdyn_datewindowstart <= ‘2026-01-02 00:00:00’;

When running SQL4CDS in UTC mode, many records had values such as:

2026-01-01 11:00:00

This initially looked unusual because users only see a date value in the application.

However, the explanation becomes clear when we consider the Auckland user’s time zone.

In January, Auckland operates on New Zealand Daylight Time (NZDT), which is UTC+13.

For a User Local Date Only field, Dataverse converts the user’s local date into UTC before storing it.

Date Seen by Auckland UserStored UTC Value
01-Jan-202631-Dec-2025 11:00 UTC
02-Jan-202601-Jan-2026 11:00 UTC
03-Jan-202602-Jan-2026 11:00 UTC

This explains why so many records appear with a value of 11:00 UTC when viewed in SQL4CDS running in UTC mode.

Why Advanced Find Returned More Records

When the Auckland user enters:

01/01/2026
to
02/01/2026

Advanced Find interprets those dates using the user’s personal time zone.

The actual UTC boundaries become:

>= 2025-12-31 11:00:00 UTC
<  2026-01-02 11:00:00 UTC

This represents two complete calendar days for the Auckland user.

Our original SQL4CDS query was searching a different range entirely:

>= 2026-01-01 00:00:00 UTC
<= 2026-01-02 00:00:00 UTC

Although the dates appear similar, the actual UTC boundaries are very different.

Finding the Correct SQL4CDS Query in UTC Mode

To reproduce the Advanced Find results, we converted the Auckland user’s date range into UTC and updated the SQL4CDS query accordingly.

SELECT COUNT(*)
FROM msdyn_workorder
WHERE msdyn_datewindowstart >= ‘2025-12-31 11:00:00’
  AND msdyn_datewindowstart <  ‘2026-01-02 11:00:00’;

This returned:

5,755 records

which matched Advanced Find exactly.

What If SQL4CDS Is Running in Local Mode?

The example above used SQL4CDS running in UTC mode. However, SQL4CDS can also be configured to use Local Time mode.

In our scenario, SQL4CDS was running on a machine configured for India Standard Time (IST), which is UTC+5:30.

To match the Advanced Find results in Local Mode, we need to convert the Auckland UTC boundaries into the local time zone used by SQL4CDS.

Earlier we determined that the Auckland user’s date range:

01-Jan-2026 to 02-Jan-2026

corresponds to the following UTC boundaries:

31-Dec-2025 11:00 UTC
to
02-Jan-2026 11:00 UTC

When SQL4CDS is running in Local Mode on an India machine, those UTC values need to be converted to IST.

UTC BoundaryIST Boundary
31-Dec-2025 11:00 UTC31-Dec-2025 16:30 IST
02-Jan-2026 11:00 UTC02-Jan-2026 16:30 IST

The equivalent SQL4CDS query becomes:

SELECT COUNT(*)
FROM msdyn_workorder
WHERE msdyn_datewindowstart >= ‘2025-12-31 16:30:00’
  AND msdyn_datewindowstart <  ‘2026-01-02 16:30:00’;

This query also returned:

5,755 records

matching Advanced Find exactly.

The results can now be summarized as follows:

Validation MethodQuery BoundaryResult
Advanced Find (Auckland User)User Time Zone5,755
SQL4CDS UTC Mode31-Dec-2025 11:00 UTC → 02-Jan-2026 11:00 UTC5,755
SQL4CDS Local Mode (India)31-Dec-2025 16:30 IST → 02-Jan-2026 16:30 IST5,755

References

For a deeper understanding of how SQL4CDS handles date and time values, I highly recommend Mark Carrington’s article:

https://markcarrington.dev/2021/05/21/date-time-handling-in-sql-4-cds

This article explains how SQL4CDS interprets date and time values in both UTC and Local Time modes and was a useful reference while investigating this scenario.

Key Takeaways

The investigation highlighted that there may be three different time zones involved when validating results:

  • The Dataverse user’s personal time zone used by Advanced Find.
  • The SQL4CDS Local Time setting.
  • UTC when SQL4CDS is configured to use UTC mode.

Even when the same date values are entered, the actual UTC range being queried may be different.

For the most reliable comparison:

  1. Identify the time zone of the user who ran Advanced Find.
  2. Convert the date boundaries to UTC.
  3. Run SQL4CDS in UTC mode.
  4. Use explicit UTC values in your query.

We also recommend using an exclusive upper boundary:

WHERE Field >= StartBoundaryUTC
  AND Field < EndBoundaryUTC

instead of:

WHERE Field <= EndOfDay

This avoids potential issues with milliseconds and provides more predictable results.

SQL4CDS can match Advanced Find in either UTC Mode or Local Mode. The important requirement is that the date boundaries represent the same moment in time. We generally prefer UTC Mode because the query behaves consistently regardless of the machine or user executing it.

Hope it helps..

Advertisements

Unable to Delete Work Order Due to “The Time To Promised Must Be Later Than The Time From Promised” exception – Dynamics 365 Field Service


While attempting to delete a historical Dynamics 365 Field Service Work Order, we encountered the following error:

Exception Message: The time to promised must be later than the time from promised.

ErrorCode: -2147220891

HexErrorCode: 0x80040265

Error Details: {“errorCode”:2147746405,”message”:”The time to promised must be later than the time from promised.”,”code”:2147746405,”raw”:”{\”_errorCode\”:2147746405,\”_errorFault\”:{\”_responseXml\”:null,\”_errorCode\”:2147746405,\”_innerFault\”:{\”_responseXml\”:null,\”_errorCode\”:0,\”_innerFault\”:null,\”_callStack\”:null,\”_responseText\”:null,\”_annotations\”:null,\”_hasCustomerInfo\”:false,\”_messages\”:[\”The time to promised must be later than the time from promised.\”]},\”_callStack\”:null,\”_responseText\”:\”{\\\”error\\\”:{\\\”code\\\”:\\\”0x80040265\\\”,\\\”message\\\”:\\\”The time to promised must be later than the time from promised.\\\”,\\\”@Microsoft.PowerApps.CDS.ErrorDetails.ApiExceptionSourceKey\\\”:\\\”Plugin/Microsoft.Dynamics.FieldService.FieldServicePlugin\\\”,\\\”@Microsoft.PowerApps.CDS.ErrorDetails.ApiStepKey\\\”:\\\”919f17c2-2931-4b27-b6b2-daaf91aaaaf8\\\”,\\\”@Microsoft.PowerApps.CDS.ErrorDetails.ApiDepthKey\\\”:\\\”1\\\”,\\\”@Microsoft.PowerApps.CDS.ErrorDetails.ApiActivityIdKey\\\”:\\\”1642f2af-356e-45c3-b971-42b11e9e91d9\\\”,\\\”@Microsoft.PowerApps.CDS.ErrorDetails.ApiPluginSolutionNameKey\\\”:\\\”

At first, the error suggested that the Work Order contained invalid promise dates. We reviewed the values stored on the record and found that Time To Promised was already later than Time From Promised.

Since the values appeared valid, we attempted to clear both fields using SQL 4 CDS:

The update completed successfully and both fields were set to NULL.

However, deleting the Work Order still resulted in the same error.

Changing the Work Order Status to Cancelled also didn’t help.

After further testing, we changed the Record Status from Active to Inactive. Once the record was inactive, the Work Order could be deleted successfully.

Based on the plugin trace, deleting the Work Order triggered an internal update before the delete operation was executed. It appears that when the Work Order was Active, additional Field Service validations were performed, resulting in the promised date error even after the fields were cleared.

After changing the record to Inactive, the delete operation likely followed a different validation path, allowing the Work Order to be deleted successfully.

Hope it helps..

Advertisements

Testing the New RunJobForSandbox Option in Bulk Delete Jobs (Preview) – Dataverse / Dynamics 365


“Edit – 25 – June – 2026 – This only applies to sandbox environments not production. Can see Microsoft updating the details.

Details

Microsoft recently introduced a preview feature for Dataverse Bulk Delete Jobs that provides additional control over bulk delete processing. One of the new options available when creating a bulk delete job through the API is RunJobForSandbox.

According to the documentation, this option is intended to control sandbox processing during bulk delete operations, which could be particularly useful in environments where delete plugins or custom workflows impact large-scale data cleanup activities.

For our testing, we created a Bulk Delete Job using Postman and included the following option in the request payload:

{
  "QuerySet": [
    {
      "EntityName": "contact",
      "Criteria": {
        "FilterOperator": "And",
        "Conditions": [
                   {
            "AttributeName": "createdon",
            "Operator": "OnOrBefore",
            "Values": [
              {
                "Value": "2026-09-07T23:59:59Z",
                "Type": "System.DateTime"
              }              
            ]
          }
        ]
      }
    }
  ],
  "JobName": "Sample Bulk Delete Job with Run Job For Sandbox True",
  "SendEmailNotification": false,
  "RecurrencePattern": "",
  "StartDateTime": "2026-05-18T00:00:00Z",
  "ToRecipients": [],
  "CCRecipients": [],
  "Options": {
    "CanRecoverDeletedRecords": false,
    "RunJobForSandbox": true
  }
}

The complete job targeted Contact records based on their Created On date and was created successfully.

To understand how this option behaves, we registered a simple plugin on the Delete message of the Contact table. The plugin was intentionally designed to throw an InvalidPluginExecutionException whenever a record deletion was attempted.

Our expectation was that enabling RunJobForSandbox would prevent the sandbox plugin from executing during the bulk delete process, allowing the records to be deleted successfully.

However, the results were different from what we anticipated.

When the bulk delete job was executed, the delete plugin was still triggered. Because the plugin threw an exception, all targeted records failed to delete. The Bulk Delete Job completed with failures and reported errors indicating that the deletion operation had been aborted by a plugin or custom workflow.

Since this capability is currently in Preview, it is possible that the feature is still evolving, has limitations that are not yet documented, or requires additional configuration. To better understand the observed behavior, we have raised a Microsoft Support ticket and are awaiting clarification from the team.

Even though our initial test did not produce the expected result, this is still a very promising feature. Once fully implemented and generally available, the ability to control sandbox processing during bulk delete operations could make large-scale data cleanup significantly easier, especially in environments where plugins and custom workflows frequently interfere with bulk deletion activities.

We’d update this post once we receive additional information from Microsoft regarding the current behavior and intended functionality of RunJobForSandbox.

The feature is documented here:

Control Bulk Delete Processing (Preview)

Hope it helps..

Advertisements

Using RetrieveDependenciesForDeleteRequest to find and delete hidden dependencies (Dataverse/ Dynamics 365)


Please refer to the post below, which provides a clear explanation of how RetrieveDependenciesForDeleteRequest works and how it can be used to identify dependencies.