Not rated yet
Integrations & C# automation
REST, SQL actions, scripts and practical automation recipes.
Writing your own C# after a submission
MegaForm compiles and runs C# you write, on the server, when a form is submitted.
Why this exists
Most of what a form needs to do after a submission is already configuration. The webhook node posts to an endpoint. The database node mirrors a submission into a table. Workflow sends the email, asks for the approval, creates the user, branches on a value. Start there. Configuration survives an export, needs no one to enable scripting, and cannot be broken by a change to an API you do not own.
What those cannot express is a decision with shape. A node does one thing to every submission. The cases below are the ones that keep arriving, and none of them is a node with more checkboxes:
- The write is more than one row, and the second depends on the first. An order header whose generated key its line items need. No node can hold a value between two writes.
- The destination depends on the answer. Enterprise enquiries to Salesforce, everyone else to the internal queue, and a different payload shape for each.
- The number has to be right, not close. Tax by country and product class, a rate your finance team rounds their own way, a discount table that lives in another system.
- The rule is a lookup, not a value on the form. Whether this class code is still open, whether this account is past due, whether this postcode is in the service area.
- Two systems have to agree. Write locally, call the remote API, and record what the remote one answered so a human can reconcile it later.
The honest test: if you can describe the requirement as "for every submission, do X", a node already does it and you should use the node. If it is "it depends", that is what this section is for.
The trade you are making is real. A script is code your team owns, on a server you own, with no sandbox — Safety and responsibility is the page that spells out what a host is agreeing to before switching it on.
The one idea worth understanding first
ctx is the submission. Everything else is ordinary C#.
There is no mediated object between your script and the platform — no catalog of named actions, no resource broker. You open a SqlConnection, you new HttpClient(), you call UserController. If you can write it in a DNN module, you can write it here.
using System.Net.Http; // everything else: plain C#
using System.Text;
var email = ctx.GetString("email"); // ctx: the submitted values
var total = ctx.GetDecimal("order_total", 0m); // and facts about this submission
ctx.Log($"order for {email}"); // and a line in the run record
using (var http = new HttpClient())
{
var body = new StringContent($"{{\"email\":\"{email}\",\"total\":{total}}}",
Encoding.UTF8, "application/json");
var reply = await http.PostAsync("https://crm.example.com/leads", body, ct);
ctx.Log($"crm status={(int)reply.StatusCode}");
}
A script body may begin with using directives; they are lifted above the generated wrapper for you. The body is async — await works directly, and you are handed a CancellationToken named ct.
Note
If you arrived from an older page describing ctx.Actions, ctx.Api, ctx.Notify,
ctx.Identity or an automation catalog: that layer was removed. Scripts call platform APIs
directly now.
What ctx holds — the whole surface
| Member | What it is |
|---|
ctx.Data | the submitted values, a case-insensitive dictionary |
ctx.GetString / GetDecimal / GetInt / GetBool / GetDate | typed reads with a fallback |
ctx.FormId, ctx.SubmissionId, ctx.PortalId, ctx.FormTitle | which form, which row |
ctx.UserId, ctx.UserName, ctx.UserEmail, ctx.IpAddress, ctx.UtcNow | who and when |
ctx.Log(string) | a line in the run record |
ctx.Fail(string) | records the run as failed |
ctx.SetValue(key, value) | changes a stored value — refused after the commit, see below |
ctx.SetVariable, ctx.Variables | values carried to the run record |
ctx.Response | override the success message or redirect for this submission |
ctx.Stage | which stage is running |
Everything else comes from the platform, with a using. These are the ones you are likely to reach for — a reference list, closed with one line of code so the fence still runs as it stands:
using DotNetNuke.Entities.Users; // UserController.GetUserByEmail, UserController.CreateUser
using DotNetNuke.Security.Roles; // RoleController.Instance.GetRoleByName / AddUserRole
using DotNetNuke.Services.Mail; // Mail.SendEmail(from, to, subject, body) — returns void
using System.Data.SqlClient; // new SqlConnection(Config.GetConnectionString())
using System.Net.Http; // new HttpClient()
// A string literal cannot sit inside an interpolation hole in this language version,
// so build the line with concatenation when the value comes from a call that takes one.
var found = UserController.GetUserByEmail(ctx.PortalId, "jane.carter@contoso.com");
ctx.Log("lookup: " + (found == null ? "no account" : found.UserID.ToString()));
A complete example
This is close to the script that was measured live. It reads two answers, computes a total, writes a row to a table the site owns, and mails the customer.
using System.Data.SqlClient;
using DotNetNuke.Common.Utilities;
using DotNetNuke.Services.Mail;
var email = ctx.GetString("email");
var country = ctx.GetString("country", "GB");
var rate = country == "DE" ? 0.19m : 0.20m;
var total = ctx.GetDecimal("order_total", 0m) * (1m + rate);
ctx.Log($"country={country} rate={rate} total={total:0.00}");
using (var cn = new SqlConnection(Config.GetConnectionString()))
{
await cn.OpenAsync(ct);
using (var cmd = new SqlCommand(
"INSERT INTO Acme_Orders (SubmissionId, Email, Country, Total) " +
"VALUES (@sid, @email, @country, @total)", cn))
{
cmd.Parameters.AddWithValue("@sid", ctx.SubmissionId);
cmd.Parameters.AddWithValue("@email", email);
cmd.Parameters.AddWithValue("@country", country);
cmd.Parameters.AddWithValue("@total", total);
ctx.Log($"insert rowsAffected={await cmd.ExecuteNonQueryAsync(ct)}");
}
}
Mail.SendEmail("orders@example.com", email, "Your order",
$"Thank you. Your total including VAT is {total:0.00}.");
ctx.Log($"mail handed to DNN's sender for {email}");
Parameterise every value that came from ctx.Data. The script runs with the application pool's database access, so string concatenation here is a SQL injection hole in your own tables.
That script — plus an HttpClient.PostAsync to an external endpoint and a UserController.CreateUser followed by RoleController.Instance.AddUserRole — was run on a DNN 10.3 site by submitting the form anonymously. One submission, 927 ms, all four effects real: rows affected 1, HTTP 200 from the external endpoint, the message handed to DNN's sender, an account created and a role granted.
Stages
Four stages exist in the engine. They differ in one respect that decides everything else: where they sit relative to the database commit.
| Stage | Runs | Can refuse the submission? | Can change stored values? | Configurable today? |
|---|
| PreValidate | before validation finishes | yes | yes | no |
| PreInsert | inside the submit transaction | yes — and it rolls back | yes | no |
| PostCommit | after the row is committed | no | no | yes |
| AsyncWorker | later, off a queue | no | no | no |
Important
Only PostCommit can be authored. The script you write and approve is stored as the form's
after-submit hook, and the engine reads that hook as PostCommit. Nothing in the product writes a
script into the other three stages — no editor, no API, no import path.
Two consequences worth being clear about:
- A script cannot refuse a submission today. Refusing needs PreInsert.
ctx.Fail("…") at PostCommit records the run as failed; the row is already stored. To turn away unwelcome-but-valid input, use the anti-spam settings and workflow rules. - A script cannot rewrite a stored value today.
ctx.SetValue is refused at PostCommit rather than quietly ignored, on purpose: a script that believes it rewrote a stored value and did not is a data bug that surfaces months later in a report.
A PostCommit script can still compute a value and send it onward — into your own table, to an API, into an email. It just does not go back into the submission.
The three gates
A script does not run until all three are satisfied, in this order.
- A config file switch. In
web.config appSettings:
``xml <add key="MegaForm:AfterSubmitScriptEnabled" value="true" /> ``
Off on every install. A file rather than a settings screen on purpose: the right bar for "people may run code on this server" is can edit files on this server.
- A host account saves the script. Superuser — not a site administrator, not module-edit rights.
- An approval hash over the source. The stored hash must match the source for the script to run. So a script that arrives inside imported data, a backup or a gallery template is inert until a host on that site opens it and saves it.
Ordinary form saves cannot introduce, alter or enable a script.
Who is responsible
The host owns what their scripts do, exactly as they own a module they install. A script has the application pool's identity: its database access, its file access, its network. Nothing here sandboxes that, and nothing pretends to.
There is an opt-in strict mode that restores an older namespace deny-list. It is off by default.
Everything on these pages is DNN: the gate is a DNN web.config key and the examples call DNN's own APIs.
The recipes
Each page is one job, with the script and what the run record shows afterwards.
| Recipe | Status |
|---|
| Write to your own database, across several tables | runs — SqlConnection, rows affected 1 |
| Push a submission to a CRM, ERP or any REST/SOAP API | runs — HttpClient, HTTP 200 |
| Send email, SMS or Telegram based on what was answered | email runs via Mail.SendEmail; SMS/Telegram are a call to the provider's API and are untested |
| Create a user and grant a role | runs — real account created, role granted |
| Look up tax, exchange rate or shipping in real time | the lookup runs; writing the answer back into the submission needs PreInsert |
| Block a submission with a blacklist or fraud check | not configurable — needs PreInsert |
| Encrypt or normalise a field before it is stored | not configurable — needs PreInsert |
| Start an approval that routes itself | the workflow nodes do this with no code |
| Generate a PDF, Word or Excel document | not implemented |
| Move an uploaded file into a secure folder | not implemented |
| Publish an event to RabbitMQ, Kafka or SQS | not implemented |
Not implemented means MegaForm ships nothing for it and nothing about it has been measured. A script is ordinary C#, so referencing your own library is not blocked — but you are on your own, and the recipe page says what exists instead.
Reference for the script surface itself: Run your own C# after a submission.
Push submissions to a CRM or ERP over HTTP
A Webhook node sends the submission to another system as an HTTP request the moment the form is submitted. It is the right tool when the other system speaks HTTP — Salesforce, HubSpot, Dynamics, Odoo, an internal API gateway, an iPaaS such as Zapier or Make, or a bespoke intake endpoint. When the other system is a database rather than an API, use Write submissions into an existing SQL table instead.
Everything below is configured in the builder — no code, no deployment.
[hinh: Webhook to CRM]
1. Where the node lives
- Open the form in the builder.
- Right rail → BPMN tab. The workflow canvas opens.
- From the palette, under User and Service Tasks, drag Service Task (the one badged
API) onto the canvas and connect it after Form submitted. - Click the node to open its configuration.
The palette label is Service Task, because that is the BPMN name for it. Its internal node type is Webhook — the name that appears in exported JSON and in messages such as "No executor registered for node type 'Webhook'".
The panel is titled "Webhook = send this submission to another system" and is organised as Destination → Payload → Advanced options.
Review payload mapping, timeout and retry settings without sending a request.6 steps · 31.7s · Updated 25 Oct 2026Open video ↗
2. Destination
| Field | Meaning |
|---|
| Webhook URL | Absolute http:// or https:// URL. Supports tokens, so https://crm.example.com/api/leads/{{field.region}} resolves per submission. |
| Method | POST (default), PUT, PATCH, GET, DELETE. A body is only sent for POST/PUT/PATCH. |
Tokens available in the URL, headers, body template and fixed values:
{{field.<key>}} — a form field, by key{{form.id}} — the form id- workflow variables set by earlier nodes
3. Payload — three modes
Send all form fields (default). Every field goes out as one flat JSON object, keyed by field key. Nothing to configure; the panel shows a live preview.
{ "full_name": "Emily Carter", "email": "a@example.com", "phone": "0900000000", "message": "Xin chào" }
Map selected fields. Pick exactly which fields leave the building and what each is called on the other side. The JSON key / path accepts dot notation, which builds nested objects — this is how you match a CRM's expected shape:
| Form field | JSON key / path |
|---|
full_name | customer.name |
email | customer.email |
phone | customer.phone |
{ "customer": { "name": "Emily Carter", "email": "a@example.com", "phone": "0900000000" } }
Three shortcuts sit above the table: Auto-map fields (one row per field, same names), Preset: CRM lead (matches name/email/phone keys into customer.*), and Add row. Fixed value overrides the selected field with a constant or a token — use it to stamp source: "website" or tenant: "{{form.id}}".
Use raw JSON template. Handcraft the body. Anything not valid JSON is sent verbatim as the request body, so this mode also covers XML or form-encoded targets.
{
"event": "megaform.submission",
"formId": "{{form.id}}",
"lead": { "name": "{{field.full_name}}", "email": "{{field.email}}" }
}
Field mappings win over the template when both are present.
4. Authentication
Open Advanced options → Auth type. Four choices, and MegaForm adds the header for you.
No authentication
Leave Auth type = None. The panel confirms "No auth header will be added." Suitable when the endpoint is a signed one-off URL — Zapier catch hooks, Make webhooks, Slack incoming webhooks — where the secret is the URL itself.
Then treat the URL as a credential: anyone who can read the workflow can replay it.
Bearer token
Auth type = BearerToken, paste the token into Bearer token. Sends:
Authorization: Bearer <token>
Auth type = ApiKey, set Header name (default X-Api-Key) and API key value. Sends:
X-Api-Key: <value>
Use this for the many CRMs that want a non-standard header — apikey, X-Auth-Token, X-HubSpot-Signature and friends: just change the header name.
HTTP Basic
Auth type = BasicAuth, fill Username and Password / token. MegaForm base64-encodes the pair and sends:
Authorization: Basic <base64(user:pass)>
Advanced options → Headers → + Add Header takes any number of name/value pairs, and the values accept tokens. This covers OAuth2 where you already hold a long-lived token, tenant headers, X-Correlation-Id, and signature headers computed by an earlier node into a workflow variable:
| Header name | Header value |
|---|
X-Tenant | acme |
X-Correlation-Id | {{form.id}}-{{field.email}} |
Authorization | Bearer {{var.oauthToken}} |
OAuth2 client-credentials: MegaForm does not fetch tokens for you. Put a first Webhook node that POSTs to the token endpoint, store the reply with Response variable (oauthToken), then read {{var.oauthToken}} in the second node's header. Two nodes, no code.
Where the secret is stored
Auth values live in the workflow definition on the server. They are not part of the anonymous form schema the public page downloads. They are, however, readable and exportable by anyone with builder access — so use a dedicated integration account with least privilege, and rotate it like any other stored credential.
5. Reliability — timeout and retry
| Setting | Default | Notes |
|---|
| Timeout (seconds) | 30 | Clamped to 1–120. |
| Retry attempts | 3 | Total attempts, not extra attempts. 1 = try once. |
| Delay seconds | 5 | First wait; each retry doubles it, capped at 60s per wait. |
Retries only fire on HTTP 5xx, timeouts and connection errors. A 4xx is treated as a decision by the other system, not a glitch, and is never retried — so a wrong token or a rejected payload fails immediately rather than four minutes later. Design the endpoint accordingly: return 503 when you want MegaForm to come back, 400 when you do not.
6. Reading the answer back
Response variable stores the raw response body under a name later nodes can read as {{var.<name>}} — the CRM's new record id, for example, which a follow-on Database node can write back into your own table.
Response routes branch the workflow on a value inside the JSON response. Each route is JSON path → operator → value → next node id, evaluated top to bottom; the first match wins and the workflow continues at that node. Nothing matching falls through to the default edge.
| JSON path | Operator | Value | Next node id |
|---|
$.status | Equals | needs_approval | node-approval |
$.status | Equals | approved | node-notify |
The path is simple dot notation ($.status, data.code); array indexing is not supported. Operators: Equals, NotEquals, Contains, GreaterThan, LessThan, Exists, NotExists.
7. Calling a CRM that is on your own network
MegaForm refuses webhook URLs that resolve to loopback, private, link-local, carrier-grade-NAT or cloud-metadata addresses. That guard exists because the URL can contain {{field.*}} tokens filled in by an anonymous visitor, which would otherwise turn a public form into a probe of your internal network. A blocked call fails the node with:
Blocked webhook URL: URL targets a blocked (private/loopback/metadata) address
If the receiving system only answers on an internal address, give it a hostname your DNN server can resolve and reach — normally a reverse proxy or an internal DNS name in front of it — and point the node at that. What the guard inspects is the address the URL resolves to, so a public-looking hostname that resolves to an internal address is still refused.
Whether a server may call private addresses at all is a decision for whoever administers that server, not a per-form setting, and there is deliberately no switch for it in the form builder: a URL assembled from an untrusted field would then be able to reach anything the server can reach.
8. Testing before you trust it
Point the node at a staging endpoint of the receiving system while you are still changing it, never at production. Two things to lean on before the first real submission:
- Test, on the workflow canvas toolbar, runs the graph as a dry run and highlights the nodes it walked — enough to confirm the webhook is on the path and that your response routes branch where you meant them to, without submitting anything.
- Validate BPMN refuses to publish a node that cannot run: a webhook with no URL is reported as "Webhook 'x': URL is required."
Once a call does go out, the node's own error text is the first thing to read — §10 lists the ones that come up most.
9. Node JSON, for reference
The builder writes this; you rarely type it. Useful when reviewing an exported workflow.
{
"type": "webhook",
"config": {
"url": "https://crm.example.com/api/leads",
"method": "POST",
"payloadMode": "mappedFields",
"bodyMappings": [
{ "formFieldKey": "full_name", "bodyPath": "customer.name" },
{ "formFieldKey": "email", "bodyPath": "customer.email" },
{ "formFieldKey": "phone", "bodyPath": "customer.phone" },
{ "bodyPath": "source", "staticValue": "website" }
],
"headers": { "X-Tenant": "acme" },
"auth": { "type": "BearerToken", "value": "<token>" },
"timeoutSeconds": 30,
"retry": { "maxAttempts": 3, "delaySeconds": 5, "backoffMultiplier": 2 },
"responseVariableKey": "crmResult",
"responseRoutes": [
{ "jsonPath": "$.status", "operator": "Equals", "value": "needs_approval", "nextNodeId": "node-approval" }
]
}
}
auth.type is one of None, BearerToken, BasicAuth, ApiKey. For ApiKey add "headerName": "X-Api-Key"; for BasicAuth add "username": "...".
10. Troubleshooting
| Symptom | Cause |
|---|
Blocked webhook URL: … blocked (private/loopback/metadata) address | Target is on a private network — see §7. |
Blocked webhook URL: only http/https URLs are allowed | Scheme is not http/https. |
Webhook HTTP 401 / 403 | Auth type or value wrong. Check §4 against what the endpoint expects — and remember 4xx is never retried. |
Webhook HTTP 0: Request timed out. | Endpoint slower than Timeout; raise it (max 120s) or make the endpoint async. |
Webhook URL is empty after template resolution. | The URL is only tokens and the tokens resolved to nothing — usually a field key that does not exist. |
| Node never runs | It is not connected to the path being taken, or the node is disabled. |
| Body arrives empty | Method is GET or DELETE; MegaForm sends no body on either. |
See also: Workflow · Write submissions into an existing SQL table
Write submissions into an existing SQL table
MegaForm stores every submission in its own tables. This page is about the other copy: writing each submission straight into a table the customer already owns — a CRM's Leads, an ERP's SalesOrders, a reporting staging table — with an INSERT statement and parameters you configure in the builder. No code, no scheduled export.
If the other system speaks HTTP rather than SQL, use Push submissions to a CRM or ERP over HTTP instead.
Map fields and review INSERT SQL. This recording does not execute SQL.6 steps · 37.8s · Updated 25 Oct 2026Open video ↗
1. Register the customer's database as a named connection
MegaForm never takes a connection string from the browser. It resolves one, server-side, from a name.
Admin → MegaForm → Database Settings → Named connections → Add.
| Field | Example |
|---|
| Name | CustomerCrm |
| Provider | SqlServer · PostgreSql · MySql · Sqlite |
| Connection string | Server=…;Database=CRM;User Id=megaform_writer;Password=… |
The name must start with a letter and contain only letters, digits, - or _. These names are reserved and cannot be reused: DashboardDatabase, DefaultConnection, SiteSqlServer, DnnDefault, MegaForm. Connection strings are stored server-side and masked whenever they are echoed back to a browser.
To write into a table that lives in the site's own database — which is the case in the sample below — you do not need a new connection at all: use the built-in DashboardDatabase.
Grant the account only what it needs. INSERT on the one target table is enough.
2. Create the target table
Any table works. The sample schema used throughout these docs ships in the repository as Docs/samples/sql/megaform-demo-crm-erp.sql — run it against the site database and you get CRM_Leads, CRM_Customers, ERP_SalesOrders, ERP_OrderLines and a stored procedure:
sqlcmd -S .\SQLEXPRESS -d <YourDatabase> -E -I -i Docs/samples/sql/megaform-demo-crm-erp.sql
Make every column the form writes nullable, or give it a default. The insert is fail-soft (§6): a NOT NULL column with no default turns one mapping mistake into rows that silently never arrive, while the visitor still sees "thank you".
Once the table exists it shows up in the builder's right rail → DB tab, under Your tables — the group above MegaForm's own tables and the platform's. Clicking a table lists its real columns with their types and marks the primary key, which is the fastest way to get column names right. Tick Show system tables to see MegaForm's and the platform's tables as well; each group collapses and pages, so the list stays short whatever the database holds.
Builder → right rail → Settings (Design) → Database → "Enable database INSERT on submit".
Review all three storage choices and inspect an existing SQL table.8 steps · 41.1s · Updated 25 Oct 2026Open video ↗
| Setting | What to put in it |
|---|
| Connection | The named connection from §1 — picked from a list the server allows, not typed. |
| Table | Pick the table; the panel then loads its real columns. |
| INSERT SQL | A single INSERT statement with :token placeholders. |
| Parameter mapping | :token → form field key. |
A worked example, exactly as shipped in Demo 1 — Contact to CRM (SQL INSERT):
INSERT INTO CRM_Leads (FullName, Email, Phone, Message, Source, SubmissionId)
VALUES (:full_name, :email, :phone, :message, 'Website form', :_submissionId)
{
":full_name": "full_name",
":email": "email",
":phone": "phone",
":message": "message",
":_submissionId": "_submissionId"
}
Rules that decide whether this works:
- A token with no mapping falls back to a field of the same name.
:email finds the email field on its own. Mapping is only needed when the column token and the field key differ. - Tokens are
:name, and MegaForm rewrites them to @name before execution, so the same statement runs on SQL Server, SQLite and PostgreSQL. - Values are always bound as parameters. Nothing a visitor types is ever concatenated into SQL.
- A field left blank sends
NULL for numeric and date columns rather than an empty string, which would otherwise fail with "Error converting data type nvarchar to numeric". - Only one
INSERT is accepted. Anything else — UPDATE, DELETE, DROP, a second statement after a semicolon — is refused with "DatabaseInsert.InsertSql must be a single INSERT statement."
Name them in the statement and they are filled in for you:
| Token | Value |
|---|
:_submissionId | MegaForm's own submission id — the join key back to MF_Submissions |
:_formId | The form id |
:_submittedOnUtc | Submission timestamp, UTC |
Carry :_submissionId into a column of your own. Without it, a row in the customer table cannot be matched to the submission it came from, and a "DB View" tab can only guess by taking the latest row.
Use the panel's Test insert button before publishing: it reports the parameters it found and which ones are unbound.
4. Where the SQL is stored, and who can read it
The statement, the connection alias and the parameter map live in the form's settings on the server. They are stripped out of the schema the public page downloads (FormSchemaSensitivePropertyStripper), so a visitor who reads the anonymous schema endpoint learns neither your table names nor your SQL. The insert itself runs server-side at submit time, from the stored settings — never from anything the request carries.
5. The other route: a Database node in the workflow
The workflow designer also has a Database node, which covers what a plain INSERT cannot: Update, Upsert, and calling a stored procedure. It maps columns to fields rather than taking raw SQL, validates table and column names against an identifier whitelist, and parameterises every value.
| Form Settings → Database | Workflow → Database node |
|---|
| Operations | INSERT only | Insert · Update · Upsert · Stored procedure |
| Configured as | one SQL statement | column → field mappings |
| Runs | on every accepted submission | at its point in the workflow, so it can sit behind a condition or an approval |
| Available on | every host | MegaForm.Web, Umbraco, the ASP.NET Core component |
Not yet enabled on Oqtane. Oqtane registers a reviewed subset of workflow node executors, and the Database node is not in it — a workflow containing one fails at that node with "No executor registered for node type 'Database'". On Oqtane, use Form Settings → Database (§3), which is fully supported there. The sample stored procedure usp_CRM_InsertLead in the demo schema is there for the hosts that do run the node.
6. Fail-soft — read this before you rely on the row being there
If the insert fails, the submission still succeeds. The visitor sees the success message, the submission is saved in MegaForm's own tables, and the failure is written to the host log:
MegaForm DatabaseInsert failed for form 11: <provider message>
This is deliberate — a broken CRM must not cost you the lead — but it means a green form is not proof the row landed. After changing the statement, the table or the connection, check the table itself:
SELECT TOP 5 * FROM CRM_Leads ORDER BY LeadId DESC;
Two consequences worth knowing:
- A submission flagged as spam still writes the row. The anti-spam gate stops the workflow and the notification emails; the database insert runs before it. If your CRM must never see spam, put the write in a workflow Database node behind a condition instead (on the hosts that support it).
- Common causes of a silent miss: a
NOT NULL column with no default and no mapping; a column name that does not exist (Invalid column name); an account without INSERT rights; a connection name that no longer exists.
7. Troubleshooting
| Symptom | Cause |
|---|
| Form succeeds, table stays empty | The insert failed and was swallowed. Check the host log, then §6. |
DatabaseInsert.InsertSql must be a single INSERT statement. | More than one statement, or not an INSERT. |
Invalid column name 'X' | The statement names a column the table does not have. Expand the table in the builder's DB tab to see the real names. |
Connection string 'X' not found. | The named connection was renamed or removed — re-pick it in the panel. |
Every row has NULL in one column | The token does not match a field key and has no mapping entry. |
Error converting data type nvarchar to numeric | An older build; blank numerics are now sent as NULL. |
| Table missing from the DB tab | It may be grouped under MegaForm system or Site platform, or hidden until Show system tables is ticked. |
See also: Push submissions to a CRM or ERP over HTTP · Workflow
Four fields → BPMN → an API service task
A complete walkthrough: build a four-field form, open its BPMN workflow, drop in one API service task, point it at an endpoint, and watch the four answers arrive on the other side. About ten minutes, no code.
In BPMN terms the node is a service task — an automated step that calls out to a system. The palette lists it under User and Service Tasks as Service Task, with an API badge; its internal type is Webhook, which is the name you will see in exported JSON and in error messages. Everything on Push submissions to a CRM or ERP over HTTP applies to it.
[hinh: BPMN API service task]
Builder → New form → add four fields and set their keys. Keys are what the service task maps, so name them deliberately:
| Field | Type | Key |
|---|
| Customer code | Text | customer_code |
| Contact name | Text | contact_name |
| Contact email | Email | contact_email |
| Order amount | Number | amount |
Save the form before opening the workflow. A workflow attaches to a form id, and an unsaved form has none — the designer will say "Save the form first so the workflow can be attached to a real form ID."
2. Open the workflow canvas
Right rail → BPMN tab. The full-screen BPMN 2.0 canvas opens with the palette on the left.
A new workflow starts with a trigger node — Form submitted — which is the BPMN start event. It anchors on the form's first field and never branches on it.
3. Add the API service task
- Drag Service Task (the
API one, under User and Service Tasks) onto the canvas. - Connect Form submitted → Service Task, and Service Task → End Event.
- Click the node to configure it.
The palette also offers Service Task (DB) and Service Task (Sheet) — the Database and Google Sheets nodes. Neither runs on Oqtane yet; see §7.
Destination
| Field | Value |
|---|
| Webhook URL | https://erp.example.com/api/orders |
| Method | POST |
Payload — map the four fields
Set Payload mode to Map selected fields, then Auto-map fields and adjust the JSON paths. Dot notation nests, which is usually what an ERP wants:
| Form field | JSON key / path |
|---|
customer_code | customerCode |
contact_name | contact.name |
contact_email | contact.email |
amount | amount |
The panel previews the exact body:
{
"customerCode": "{{field.customer_code}}",
"contact": { "name": "{{field.contact_name}}", "email": "{{field.contact_email}}" },
"amount": "{{field.amount}}"
}
Leave Fixed value empty on every row. It is only for stamping a constant, and a row with a fixed value ignores the form field you selected.
Authentication
Advanced options → Auth type. Leave None for an open intake endpoint; pick BearerToken, ApiKey or BasicAuth otherwise. See the authentication section for what each one puts on the wire.
Review payload mapping, timeout and retry settings without sending a request.6 steps · 31.7s · Updated 25 Oct 2026Open video ↗
4. Use the reply
An ERP usually answers with an order number and a status. Two settings turn that into workflow state:
Response variable — set it to erpResult. The whole response body is then readable by later nodes as {{var.erpResult}}.
Response routes — branch on a value inside the reply:
| JSON path | Operator | Value | Next node id |
|---|
$.status | Equals | needs_approval | node-approval |
Anything that matches no route continues along the default edge.
5. Apply it
Apply publishes the workflow. Save draft only stores it; a draft never runs, and the log will say so:
Form 13 has a workflow draft but no applied workflow. Submission 9 will use legacy post-submit actions
until the workflow is applied.
Apply-mode validation is strict — a Webhook node with no URL blocks the publish with "Webhook 'x': URL is required.", which is the intended behaviour: a node that cannot run should not be live.
6. Try it end to end
Use Test on the canvas toolbar first. It runs the graph as a dry run and highlights the nodes it walked, which is enough to confirm the API service task is on the path and that the response routes from §4 branch the way you meant.
Then submit the form for real and look for the record in the receiving system. If the reply carries an order number, read it back with {{var.erpResult}} and echo it in the confirmation message — an order number on screen is the quickest end-to-end proof that the task fired and the answer came home.
Keep the task pointed at a staging endpoint until you trust it. If your ERP only answers on an internal address, see Calling a CRM on your own network.
7. When the task does not fire
Work down this list; each item is something that has actually happened.
The submission was scored as spam. The anti-spam gate skips the entire workflow — no webhook, no notification emails — and only says so in the host log:
[SubmissionProcessor] Submission 5 for form 12 marked as spam (score=55). Workflow and notifications skipped.
The threshold is 50. A form of more than two fields completed in under three seconds scores 30, and a bot-like User-Agent another 25 — which is 55, and is why automated smoke tests trip it while humans do not. Sign in as a trusted user, or fill the form at human speed.
The workflow was saved but not applied. See §5.
The node type is not registered on the host. The workflow fails at that node with:
No executor registered for node type 'Database' (node: n-db 'Write order').
Oqtane enables a reviewed subset of node executors. Webhook is enabled (MegaForm 2.0.30 and later); Database and Google Sheets are not — for those, see Write submissions into an existing SQL table.
The body arrived with the right shape but empty values. Fixed in MegaForm 2.0.31. Before that, every "Map selected fields" row was treated as a fixed value, so the payload went out with the keys present and the values blank. Upgrade, or switch the node to Send all form fields as a workaround.
The URL was rejected. Blocked webhook URL: … means the SSRF guard stopped it — §6.
8. What the saved workflow looks like
{
"version": "1.0.0",
"startNodeId": "n-start",
"nodes": [
{ "id": "n-start", "type": "FormField", "label": "Form submitted",
"config": { "FieldKey": "customer_code", "IsPageNode": false } },
{ "id": "n-api", "type": "Webhook", "label": "API service task — create ERP order",
"config": {
"Url": "https://erp.example.com/api/orders",
"Method": "POST",
"Auth": { "Type": "None" },
"BodyMappings": [
{ "FormFieldKey": "customer_code", "BodyPath": "customerCode" },
{ "FormFieldKey": "contact_name", "BodyPath": "contact.name" },
{ "FormFieldKey": "contact_email", "BodyPath": "contact.email" },
{ "FormFieldKey": "amount", "BodyPath": "amount" }
],
"TimeoutSeconds": 15,
"Retry": { "MaxAttempts": 2, "DelaySeconds": 3, "BackoffMultiplier": 2 },
"ResponseVariableKey": "erpResult"
} },
{ "id": "n-end", "type": "End", "label": "Done", "config": { "EndType": "Success" } }
],
"edges": [
{ "id": "e1", "sourceNodeId": "n-start", "targetNodeId": "n-api", "sourceHandle": "default", "targetHandle": "in" },
{ "id": "e2", "sourceNodeId": "n-api", "targetNodeId": "n-end", "sourceHandle": "default", "targetHandle": "in" }
]
}
Config keys are PascalCase. The builder writes them that way, and Apply-mode validation reads them out of a plain dictionary with a case-sensitive lookup — a hand-written workflow using url instead of Url is rejected with "URL is required" even though the URL is right there. This only matters when posting a workflow to the API by hand; the builder never gets it wrong.
9. Importing a real BPMN file
A .bpmn file exported from Camunda or bpmn.io can be imported instead of drawn: service tasks become Webhook nodes, user tasks become Approval nodes, exclusive gateways become Conditions, and sequence-flow conditions are translated into MegaForm expressions. See Workflow for the mapping table and its limits.
See also: Push submissions to a CRM or ERP over HTTP · Write submissions into an existing SQL table · Workflow
Run your own C# after a submission
MegaForm can compile and run C# you write yourself, on the server, immediately after a submission is saved. This page is the reference: when to reach for it, what the script is handed, and what the language and the runtime will and will not accept. The worked recipes live on their own pages and are linked at the end.
Important
If you arrived from an older version of this page describing ctx.Db or ctx.Http: those no
longer exist. A script now calls platform APIs directly — SqlConnection, HttpClient,
UserController — with an ordinary using.
1. Why, and when
Start by not writing a script. Most of what has to happen after a submission is already configuration, and configuration survives an export, needs nobody to enable scripting, and cannot stop compiling after an upgrade.
What no node expresses is a decision with shape. A node does one thing to every submission. These are the shapes that keep arriving, and none of them is a node with more checkboxes:
- The write is more than one row, and the second depends on the first. An order header whose generated key the line items need. No node holds a value between two writes.
- The destination depends on the answer. Enterprise enquiries to one CRM, everyone else to the internal queue, with a different payload shape for each.
- The number has to be right, not close. Tax by country and product class, a rate rounded the way your finance team rounds it, a discount table that lives in another system.
- The rule is a lookup, not a value on the form. Whether this class code is still open, whether this account is past due, whether this postcode is inside the service area.
- Two systems have to agree. Write locally, call the remote API, and record what the remote one answered so a person can reconcile it later.
The honest test: if you can describe the requirement as "for every submission, do X", a node already does it, and the node is the better answer — one fewer thing to read at review time. If the requirement is "it depends", this is the tool.
The trade is real. A script is code, running in your website's process under your website's identity, with no sandbox. Safety and responsibility sets out what a host is agreeing to before switching it on.
2. A first script
You write the body. ctx is the submission; there is no boilerplate to type.
var name = ctx.GetString("full_name");
var country = ctx.GetString("country", "GB");
var rate = country == "DE" ? 0.19m : 0.20m;
var total = ctx.GetDecimal("order_total", 0m) * (1m + rate);
ctx.Log($"country={country} rate={rate} total={total:0.00}");
ctx.SetVariable("totalIncVat", total);
if (total > 1000m)
{
ctx.Response.SuccessMessage = "Thank you " + name + ". An account manager will call you today.";
}
That script reads two answers, computes a third, records it on the run, and changes what this one visitor sees on the thank-you screen. Everything past that — writing a row, calling an API, sending a message, creating an account — is ordinary C# with a using, and is covered by the recipe pages.
Where the editor is
Builder → Design → Form Settings → Server Script. Save the form once first: a script belongs to a form, so there has to be a form to attach it to.
[hinh: The Server Script panel in Form Settings]
The panel lists the current form's field keys above the editor, so you do not have to remember whether it was full_name or fullname. It also holds the enable switch for this form, what happens when a run fails, the run timeout, and Save script, Check syntax and Test run. Compilation happens at Save, so mistakes come back on your own line numbers while you are still looking at the editor rather than on a visitor's submission.
The script is not part of the form's schema and does not travel with it. Saving the form does not save the script, and saving the script does not save the form. They are separate because they carry different authority — see §6.
3. What ctx holds
This is the whole surface. Nothing else exists on it.
Reading the submission
| Member | What it is |
|---|
ctx.Data | IDictionary<string, object> of the submitted values; keys are case-insensitive |
ctx.Has(fieldKey) | bool — whether the field was submitted at all |
ctx.GetString(key, fallback = "") | value as text |
ctx.GetDecimal(key, fallback = 0m) | parsed decimal |
ctx.GetInt(key, fallback = 0) | parsed integer |
ctx.GetBool(key, fallback = false) | parsed boolean |
ctx.GetDate(key) | DateTime? — null when absent or unparseable |
Has and a fallback answer different questions. GetDecimal("discount", 0m) returns 0 both for "the visitor typed 0" and for "the field was never on the form"; Has("discount") tells them apart.
Which submission, and whose
| Member | What it is |
|---|
ctx.FormId, ctx.SubmissionId, ctx.PortalId, ctx.FormTitle | which form, which stored row |
ctx.UserId, ctx.UserName, ctx.UserEmail | the signed-in submitter — 0 and empty strings for an anonymous submission |
ctx.IpAddress | the caller's address |
ctx.UtcNow | the run's timestamp |
Public forms are the normal case, so treat UserId == 0 as expected input rather than as an error.
Recording what happened
| Member | What it is |
|---|
ctx.Log(string) | one line in the run record |
ctx.Logs | the lines logged so far |
ctx.SetVariable(key, value) | a value carried onto the run record |
ctx.Variables | the variables set so far |
ctx.Fail(string) | marks the run failed |
Changing the submission, and the visitor's experience
| Member | What it is |
|---|
ctx.SetValue(key, value) | queues a change to a stored value — refused after the commit, see §5 |
ctx.PendingChanges | what SetValue has queued |
ctx.Response.SuccessMessage | replaces the thank-you text for this submission |
ctx.Response.RedirectUrl | sends this visitor somewhere else |
ctx.Response.CustomData | extra data returned with this submit response |
ctx.Response affects this submission only. It is not a form setting and it does not persist.
Where in the pipeline you are
| Member | What it is |
|---|
ctx.Stage | which stage is running |
ctx.CanAbort | whether the current stage can still refuse the submission |
4. What is supported
The shape your body is compiled into
The body you type is spliced into this method:
// SIGNATURE ONLY — this is generated around your body; you never type it.
async Task RunAsync(SubmissionScriptContext ctx, CancellationToken ct)
So await works directly, ct is in scope, and a bare return; is a valid way to stop early. You never write the signature.
Pass ct to the calls you await. Cancellation is cooperative — a token you do not pass is a timeout that cannot fire.
Language version: C# 7.3
The compiler is pinned to C# 7.3. Modern syntax you may be used to is a compile error at Save:
// FRAGMENT — none of these lines compile in a script.
using var cn = new SqlConnection(cs); // no using declarations; use using (…) { }
List<string> names = new(); // no target-typed new
var text = """raw"""; // no raw string literals
Interpolated strings, nameof, tuples, pattern matching as of 7.3, async/await, expression-bodied members and local functions are all available. Top-level statements are not a thing here — your body already is a method body.
using System.Globalization;
// Local functions are allowed, which is usually enough to keep a longer script readable.
decimal Net(decimal gross, decimal rate)
{
return decimal.Round(gross / (1m + rate), 2);
}
var net = Net(ctx.GetDecimal("order_total", 0m), 0.20m);
ctx.Log("net=" + net.ToString("0.00", CultureInfo.InvariantCulture));
Namespaces already in scope
Do not repeat these — they are imported for you:
System
System.Collections.Generic
System.Linq
System.Text
System.Threading
System.Threading.Tasks
MegaForm.Core.Scripting
A script may begin with its own using directives. They are lifted above the generated wrapper, so they belong at the very top of the body, and a trailing comment after the semicolon is fine:
using System.Net.Http; // the CRM call below
using Newtonsoft.Json;
var payload = JsonConvert.SerializeObject(new
{
name = ctx.GetString("full_name"),
email = ctx.GetString("email")
});
using (var http = new HttpClient())
using (var body = new StringContent(payload, Encoding.UTF8, "application/json"))
{
var reply = await http.PostAsync("https://crm.contoso.com/api/leads", body, ct);
ctx.Log($"crm status={(int)reply.StatusCode}");
if (!reply.IsSuccessStatusCode) ctx.Fail("CRM rejected the lead");
}
Which libraries you can name
The reference set is every assembly the site has loaded. Anything a DNN module can name, a script can name — including:
| Namespace | For |
|---|
DotNetNuke.* | users, roles, mail, config, the platform's own services |
System.Data.SqlClient | your own tables, with parameters |
System.Net.Http | REST, SOAP, anything over HTTP |
Newtonsoft.Json | serialising and parsing payloads |
Two limits sit on top of that:
unsafe code is refused — pointers and stackalloc — in every configuration.- An optional strict mode exists and is off by default. Turned on, it restores an older namespace deny-list that refuses most of the table above. It is a process-wide startup flag, not a per-form setting. Safety and responsibility is where it is documented; do not assume it is on.
Size and time
| Limit | Value |
|---|
| Source length | 64 KB |
| Run timeout | 10 seconds by default, 60 seconds maximum |
Warning
The timeout bounds what the visitor waits for, not what the script does. .NET has no safe way
to abort work already running, so on expiry the request stops waiting and the submission
completes, while the script keeps its thread until it finishes or the application recycles. Treat
the timeout as a seatbelt, not a brake: bound your own loops, and pass ct to every call you
await.
5. Stages, and what they stop you doing
Four stages exist in the engine. They differ in where they sit relative to the database commit, which decides everything else.
| Stage | Runs | Can refuse the submission | Can change stored values | Configurable today |
|---|
| PreValidate | before validation finishes | yes | yes | no |
| PreInsert | inside the submit transaction | yes | yes | no |
| PostCommit | after the row is committed | no | no | yes |
| AsyncWorker | later, off a queue | no | no | no |
Important
Only PostCommit can be configured. The script you write and approve is stored as the form's
after-submit hook, and the engine runs that hook as PostCommit. Nothing in the product writes a
script into the other three stages — no editor, no API, no import path.
Two consequences, stated plainly because they are the biggest constraint on this page:
- A script cannot refuse a submission. Refusing needs PreInsert.
ctx.Fail("…") at PostCommit records the run as failed; the row is already stored. To turn away unwelcome-but-valid input, use the anti-spam settings and workflow rules. - A script cannot rewrite a stored value.
ctx.SetValue is refused after the commit rather than quietly ignored, on purpose: a script that believes it corrected a stored value and did not is a data bug that surfaces months later in a report.
What a PostCommit script can do is compute a value and send it onward — into your own table, to an API, into an email, onto the run record, into ctx.Response. It just does not go back into the submission.
6. The three gates
A script does not run until all three hold. There is no fallback path and no per-site override.
1 — a config file switch. In web.config appSettings:
<appSettings>
<add key="MegaForm:AfterSubmitScriptEnabled" value="true" />
</appSettings>
Off on every install. Anything other than true means off. It is a file rather than a settings screen on purpose: turning this on means people may run code on this server, and the right bar for that is can edit files on this server — a smaller group than knows the superuser password. It also means a site restored from a backup and a fresh config file comes back with the feature off.
2 — a host account saves the script. Host / SuperUser only. Not a site Administrator, not someone with Edit permission on the module. "Edit module" is a content-editor permission several people usually hold, and an Administrator's reach stops at one site while a script runs in the process shared by the whole installation.
3 — an approval hash over the source. Saving stores a hash of the exact source the server accepted, with who approved it and when. Before every run the server re-hashes and compares. Mismatch, no run.
That third gate is what makes every other route into your forms inert. A form export, a template install, a gallery download, a restored backup, a hand-built save request — all of them can carry a script's source, and none of them can carry a valid approval, because an approval is written server-side at the instant a host pressed Save on that site. The ordinary form Save path goes further: it discards the caller's copy of this block and writes back the one the server already had. A content editor saving a form cannot introduce a script, alter an approved one, or switch one off.
7. What is recorded when it runs
Every run is written down, and so is every approval. Between them they answer the two questions that only have answers if something wrote them at the time: what did this script do on that submission, and who put this code on the server.
A run keeps the form, the submission, the stage, the hash of the source that ran, success or failure, the duration, the error if there was one, and everything the script logged. A skipped run is not a failed one — disabled, empty, not approved on this site, or a hash mismatch each record a reason rather than an error.
This is what one measured run looks like from the log side. On a DNN 10.3 site, an anonymous submission ran a PostCommit script that did four things in 927 ms: a parameterised INSERT through SqlConnection, an HttpClient POST to an external endpoint, a message handed to DNN's own sender, and an account created with a role granted.
country=DE rate=0.19 total=892.50
insert rowsAffected=1
crm status=200
mail handed to DNN's sender for jane.carter@example.com
Four ctx.Log lines, one per effect, is a good habit: a script that catches everything and logs nothing will fail for months without anyone noticing. The tables, their columns, and the host-only endpoint that reads them are documented in Safety and responsibility.
8. Where to go next
Writing your own C# after a submission is the branch overview. Each recipe below is one job, with the script and what the run record shows afterwards.
| Recipe | What it covers |
|---|
| Write to your own database | SqlConnection, parameters, several tables in one run |
| CRM, ERP, REST and SOAP | HttpClient, payload shape, what to do with the reply |
| Email and messaging | DNN's own sender, and calling a provider's API |
| Create users and grant roles | UserController, RoleController, and what to check first |
| Safety and responsibility | the gates in detail, strict mode, and reviewing a script before you approve it |
Write a submission into your own tables
Form Settings → Database mirrors each submission into one table with no code, and that covers most forms. This page is for the ones it does not:
- a parent row whose generated key the child rows need
- a write that happens only when the answers say so
- a lookup that decides which table the row belongs in
For those you write C#. There is no catalog and no mediated database object — a script is ordinary C#, so you open a SqlConnection and run a command, the same way you would in a module.
Note
If you arrived from an older page: the capability rail (ctx.Actions and the automation catalog)
was removed. Scripts call platform APIs directly.
Before you start
Three gates have to be open, and they are covered in Run your own C# after a submission:
<add key="MegaForm:AfterSubmitScriptEnabled" value="true" /> in web.config — off on every install.- A host (superuser) account saves the script. A site administrator cannot.
- The stored approval hash must match the source, so a script arriving inside imported data cannot run until a host on that site approves it.
Ordinary form saves cannot introduce, alter or enable a script.
Where in the lifecycle this runs
The only stage you can configure today is PostCommit: the submission row is already written and committed before your first line executes.
That decides how you write the rest:
- A failure here does not lose the submission. If the connection is refused or the INSERT violates a constraint, the answers are still stored and the visitor still gets the success page. What you lose is your copy of the row — which is a repairable problem, not a lost enquiry.
ctx.SetValue is refused. You cannot write a computed value back into the stored submission at this stage. Compute it and send it onward — into your table, an API, an email.- You cannot refuse the submission. That needs PreInsert, which nothing in the product can configure yet.
- Assume someone is waiting. AsyncWorker — the stage that would run this later, off a queue — is not configurable either, so keep the work short and set an explicit
CommandTimeout rather than inheriting the 30 second default on a table that might be locked.
One table, parameterised
The site's own database needs no configuration: Config.GetConnectionString() returns the same connection string DNN itself uses.
using System;
using System.Data;
using System.Data.SqlClient;
using DotNetNuke.Common.Utilities;
var sql =
"INSERT INTO dbo.AcmeEnquiries " +
" (SubmissionId, FullName, Email, Country, Total, CreatedUtc) " +
"VALUES (@submissionId, @fullName, @email, @country, @total, @createdUtc)";
using (var cn = new SqlConnection(Config.GetConnectionString()))
using (var cmd = new SqlCommand(sql, cn))
{
cmd.CommandTimeout = 15;
cmd.Parameters.Add("@submissionId", SqlDbType.Int).Value = ctx.SubmissionId;
cmd.Parameters.Add("@fullName", SqlDbType.NVarChar, 200).Value = ctx.GetString("full_name");
cmd.Parameters.Add("@email", SqlDbType.NVarChar, 256).Value = ctx.GetString("email");
cmd.Parameters.Add("@country", SqlDbType.NVarChar, 2).Value = ctx.GetString("country", "");
cmd.Parameters.Add("@total", SqlDbType.Decimal).Value = ctx.GetDecimal("total", 0m);
cmd.Parameters.Add("@createdUtc", SqlDbType.DateTime2).Value = ctx.UtcNow;
await cn.OpenAsync(ct);
var rows = await cmd.ExecuteNonQueryAsync(ct);
ctx.Log("insert rowsAffected=" + rows);
}
A script body may start with using directives — they are lifted above the generated wrapper. The body is async, so await works directly, and ct is the CancellationToken you were handed; pass it to the ADO calls so a shutdown or an aborted request does not leave a command running.
For a column that may legitimately be empty, send DBNull.Value rather than an empty string:
// continues the example above — inside the same using (var cmd = new SqlCommand(...)) block
var phone = ctx.GetString("phone", "");
cmd.Parameters.Add("@phone", SqlDbType.NVarChar, 40).Value =
string.IsNullOrWhiteSpace(phone) ? (object)DBNull.Value : phone;
Why parameters and not string concatenation
Every value above came from a visitor. Concatenating them into the statement means the visitor is writing part of your SQL:
O'Brien in a name field ends the string literal early and the statement fails to parse. This one is not hypothetical; it is the first support ticket every concatenated insert gets.- A value ending
'; UPDATE dbo.Users SET ... is parsed and executed, because at that point it is the statement. The script runs with the website's own database permissions, which on most DNN installs is db_owner. - Types stop being types. A decimal concatenated into text is formatted with the thread's culture, so
892.50 becomes 892,50 on a server set to a European locale and SQL Server reads it as a different number or refuses it.
A parameter travels beside the statement as data. It is never parsed as SQL, O'Brien needs no escaping, and the decimal and the date arrive as a decimal and a date.
Use Parameters.Add(name, type, length) rather than AddWithValue. AddWithValue infers the type and the length from the value, which is how a 6-character nvarchar(6) parameter ends up unable to use the index on an nvarchar(256) column.
Warning
Write to tables your site owns. MegaForm's own tables — the MF_ prefix — are internal and change
between releases; inserting into them is not supported and will break on upgrade. To read
submissions, use the SDK.
A different database
For a database that is not the DNN one, add a named connection string to web.config and read it by name:
using System.Configuration;
var cs = ConfigurationManager.ConnectionStrings["AcmeCrm"].ConnectionString;
Keeping it in web.config keeps the credentials out of the script, which matters because a script is plain text you may well want to export, diff or paste into a review.
A parent row, its generated key, and child rows
This is the case the no-code insert cannot express: one order row, then one line row per item, each carrying the order's generated Id.
OUTPUT INSERTED.Id returns the key from the INSERT itself, in the same round trip. Prefer it to @@IDENTITY, which returns the wrong value when a trigger on the table inserts elsewhere. SCOPE_IDENTITY() is correct too, but OUTPUT also works when the key comes from a sequence or a newid() default, and it can return keys for a multi-row insert.
Both statements run on one connection inside one transaction, so a failure on the third line does not leave an order with two of its five lines.
using System;
using System.Data;
using System.Data.SqlClient;
using DotNetNuke.Common.Utilities;
var orderSql =
"INSERT INTO dbo.AcmeOrders (SubmissionId, CustomerEmail, Country, Total, CreatedUtc) " +
"OUTPUT INSERTED.Id " +
"VALUES (@submissionId, @email, @country, @total, @createdUtc)";
var lineSql =
"INSERT INTO dbo.AcmeOrderLines (OrderId, Sku, Qty) VALUES (@orderId, @sku, @qty)";
var skus = ctx.GetString("skus", "")
.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
int orderId = 0;
using (var cn = new SqlConnection(Config.GetConnectionString()))
{
await cn.OpenAsync(ct);
using (var tx = cn.BeginTransaction())
{
using (var cmd = new SqlCommand(orderSql, cn, tx))
{
cmd.Parameters.Add("@submissionId", SqlDbType.Int).Value = ctx.SubmissionId;
cmd.Parameters.Add("@email", SqlDbType.NVarChar, 256).Value = ctx.GetString("email");
cmd.Parameters.Add("@country", SqlDbType.NVarChar, 2).Value = ctx.GetString("country", "");
cmd.Parameters.Add("@total", SqlDbType.Decimal).Value = ctx.GetDecimal("total", 0m);
cmd.Parameters.Add("@createdUtc", SqlDbType.DateTime2).Value = ctx.UtcNow;
orderId = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
}
foreach (var raw in skus)
{
var sku = raw.Trim();
if (sku.Length == 0) continue;
using (var cmd = new SqlCommand(lineSql, cn, tx))
{
cmd.Parameters.Add("@orderId", SqlDbType.Int).Value = orderId;
cmd.Parameters.Add("@sku", SqlDbType.NVarChar, 40).Value = sku;
cmd.Parameters.Add("@qty", SqlDbType.Int).Value = 1;
await cmd.ExecuteNonQueryAsync(ct);
}
}
tx.Commit();
ctx.Log("order=" + orderId + " lines=" + skus.Length);
}
}
ctx.SetVariable("orderId", orderId);
Two details that are easy to get wrong:
- Every
SqlCommand in a transaction must be given the transaction (new SqlCommand(sql, cn, tx)). A command created without it throws "ExecuteNonQuery requires the command to have a transaction when the connection assigned to the command is in a pending local transaction" — a clear error, but only at runtime, on the first real submission. ExecuteScalarAsync returns object, and it is null if the INSERT matched no OUTPUT row. Convert.ToInt32(null) returns 0, so if a 0 order id could be silently wrong for you, check for null explicitly before converting.
orderId is declared before the connection block, not inside it, which is the only reason the last line still compiles — a variable declared inside the using is gone once the block closes. It starts at 0 so the compiler can see it assigned on every path; if a 0 order id would be wrong for you, test for it before you use it. ctx.SetVariable puts it in the run record, where it is visible next to the submission.
A write that only happens when the answers say so
Ordinary if. There is no rule syntax to learn.
using System.Data;
using System.Data.SqlClient;
using DotNetNuke.Common.Utilities;
// continues the order example above — orderId is the key its INSERT returned
if (ctx.GetBool("needs_installation", false))
{
var sql = "INSERT INTO dbo.AcmeInstallJobs (OrderId, Postcode, RequestedUtc) " +
"VALUES (@orderId, @postcode, @requestedUtc)";
using (var cn = new SqlConnection(Config.GetConnectionString()))
using (var cmd = new SqlCommand(sql, cn))
{
cmd.Parameters.Add("@orderId", SqlDbType.Int).Value = orderId;
cmd.Parameters.Add("@postcode", SqlDbType.NVarChar, 12).Value = ctx.GetString("postcode", "");
cmd.Parameters.Add("@requestedUtc", SqlDbType.DateTime2).Value = ctx.UtcNow;
await cn.OpenAsync(ct);
await cmd.ExecuteNonQueryAsync(ct);
ctx.Log("install job queued for " + ctx.GetString("postcode", ""));
}
}
else
{
ctx.Log("no installation requested");
}
Log the branch you did not take as well as the one you did. When someone asks in three weeks why an install job is missing, no installation requested in the run record answers it without a debugger.
When it fails
Catch it, say what happened, and mark the run:
using System.Data.SqlClient;
try
{
// … the insert above …
}
catch (SqlException ex)
{
ctx.Fail("enquiry insert failed: " + ex.Number + " " + ex.Message);
}
ctx.Fail records the run as failed with your message. The submission stays exactly where it is — committed, complete, visible in the submissions grid. So the run record becomes the worklist: every failed run names a submission whose row never reached your table, and you can replay it by hand or with a job.
Two things to get right if you plan to replay:
- Put a unique index on
SubmissionId in your table. Then a replay that runs twice cannot double-insert; the second attempt fails on the constraint instead of quietly duplicating an order. - Do not put the exception text into
ctx.Response.SuccessMessage. The visitor gets a database error string, including your schema, on the thank-you page.
An unhandled exception also ends the run, but with the framework's message rather than yours. ctx.Fail with a string you can search for is worth the three lines.
What was measured
On a DNN 10.3 site, one anonymous submission ran a PostCommit script that opened a SqlConnection with Config.GetConnectionString() and ran a parameterised INSERT into a table the site owns:
country=DE rate=0.19 total=892.50
insert rowsAffected=1
The same run also called an external endpoint over HttpClient (HTTP 200), sent mail through the site's SMTP settings, and created a user and granted a role. Total run duration 927 ms.
What this page cannot do for you
- Refuse a submission when the database rejects it. PostCommit is after the commit; refusing needs PreInsert, which is not configurable in this release.
- Write a value back into the stored submission.
ctx.SetValue is refused at PostCommit by design — a script that believes it rewrote a stored value and did not is a data bug that surfaces months later in a report. - Retry for you. There is no queue and no backoff. A failed insert is a line in the run record until someone acts on it.
See also
Push a submission to a CRM, ERP or any REST endpoint
The Webhook node sends every submission to one URL, with one payload shape, with retries, and with no code. When that is the requirement, use it and stop reading.
This page is for the cases it does not cover:
- the payload has to be assembled — a field renamed, a total computed, a lookup value attached
- which system gets told depends on the answers: EU orders to one endpoint, US orders to another
- the reply matters, because the order number it returns has to reach the visitor
- the destination speaks something other than the node's JSON body — SOAP, form-encoded, XML
For those, write C#. A script is ordinary C# — using System.Net.Http; and a HttpClient, the same code you would write in a module on the same site.
Note
If you arrived from an older page describing named endpoints and a catalog (ctx.Api), that layer
was removed. Scripts call platform APIs directly now.
1. Before anything runs: the three gates
<add key="MegaForm:AfterSubmitScriptEnabled" value="true" /> in web.config <appSettings>. Off on every install.- A host (superuser) account saves the script. A site administrator cannot, and neither can module-edit rights.
- An approval hash over the source. The stored hash must match the source, so a script that arrived inside imported data does not run until a host on that site approves it.
See Automation overview for the reasoning.
2. The stage, and what it costs the visitor
Only PostCommit can be configured today. The submission row is already committed when your code starts, which decides three things:
- The call cannot refuse the submission. A 400 from the CRM does not roll anything back.
- The reply cannot be written back into the submission.
ctx.SetValue is refused after the commit. Send the value onward instead — §6. - The visitor is waiting. A PostCommit script runs on the request that renders the thank-you page. Two seconds of CRM latency is two seconds of spinner.
The script's run timeout defaults to 10 seconds and is capped at 60. Set the HttpClient timeout below it, so a slow endpoint produces your own logged failure rather than the engine's generic timeout.
3. A POST, with a timeout, awaiting the token you were given
Script bodies are async and receive a CancellationToken named ct. Pass it to every await: it is cancelled when the run timeout expires, which is what stops a hung request from holding the visitor's thread.
using System.Net.Http;
using System.Text;
using Newtonsoft.Json;
var payload = new
{
email = ctx.GetString("email"),
firstName = ctx.GetString("first_name"),
lastName = ctx.GetString("last_name"),
company = ctx.GetString("company"),
source = "web-form-" + ctx.FormId,
leadStatus = ctx.GetDecimal("budget") >= 50000m ? "OPEN_DEAL" : "NEW"
};
using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) })
using (var body = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"))
{
var reply = await http.PostAsync("https://crm.contoso.com/api/v3/leads", body, ct);
var text = await reply.Content.ReadAsStringAsync();
ctx.Log("crm status=" + (int)reply.StatusCode);
if (!reply.IsSuccessStatusCode)
ctx.Log("crm body=" + (text.Length > 400 ? text.Substring(0, 400) : text));
}
Newtonsoft.Json is referenced by the script compiler, so JsonConvert is available without any setup. So is everything else already loaded in the site's bin.
Verified live, anonymously, on a DNN 10.3 site: this HttpClient.PostAsync returned HTTP 200 inside a run that finished in 927 ms and wrote crm status=200 into the run record.
Reusing the client
The snippet above creates a client per submission. At form-submission rates that is fine. On a form taking sustained traffic, each disposed client leaves a socket in TIME_WAIT; hold one static instance instead by writing the script as a full class:
using System.Net.Http;
public sealed class PushLeadToCrm : ISubmissionAsyncScript
{
static readonly HttpClient Http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
public async Task RunAsync(SubmissionScriptContext ctx, CancellationToken ct)
{
// ... same body, using Http
}
}
A source that declares a type implementing ISubmissionAsyncScript is compiled as written. A bare statement body is spliced into a method, which is why it cannot hold a static field.
using System.Net.Http;
using System.Text;
using DotNetNuke.Entities.Controllers;
using Newtonsoft.Json;
// the token is a host setting, not a literal — §5 explains why
var token = HostController.Instance.GetString("Acme_CrmToken", string.Empty);
var json = JsonConvert.SerializeObject(new { email = ctx.GetString("email") });
using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) })
using (var request = new HttpRequestMessage(HttpMethod.Post, "https://crm.contoso.com/api/v3/leads"))
{
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
// or a vendor-specific key header
request.Headers.Add("X-Api-Key", token);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
var reply = await http.SendAsync(request, ct);
ctx.Log("crm status=" + (int)reply.StatusCode);
}
Basic auth is the same shape:
// continues the example above — inside the same using block, replacing the Bearer line
var user = HostController.Instance.GetString("Acme_CrmUser", string.Empty);
var password = HostController.Instance.GetString("Acme_CrmPassword", string.Empty);
var pair = Convert.ToBase64String(Encoding.UTF8.GetBytes(user + ":" + password));
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", pair);
5. Where the credential lives — read this before you paste a token
Warning
A token typed into a script is stored with the form. It goes into the form's settings JSON, so
it travels in every form export, every template, every database backup, and it is readable by
anyone who can read that record. Rotating it means editing every form that carries it.
Put it in a host setting and read it at run time. Host settings are installation-scoped, are not part of a form, and do not travel with an export.
using DotNetNuke.Entities.Controllers;
var token = HostController.Instance.GetString("Acme_CrmToken", string.Empty);
if (string.IsNullOrEmpty(token))
{
ctx.Log("Acme_CrmToken is not set on this installation — lead not pushed.");
ctx.Fail("CRM credential missing");
return;
}
There is no dictionary accessor to reach for. A script that reads several settings makes the same call once per key:
using DotNetNuke.Entities.Controllers;
var crmToken = HostController.Instance.GetString("Acme_CrmToken", string.Empty);
var erpToken = HostController.Instance.GetString("Acme_ErpToken", string.Empty);
var crmUrl = HostController.Instance.GetString("Acme_CrmUrl", "https://crm.contoso.com/api/v3/leads");
ctx.Log("crm url=" + crmUrl + " token=" + (string.IsNullOrEmpty(crmToken) ? "missing" : "present"));
For a value you would rather not have sitting in plain text in the host settings table, DNN encrypts it with the machine key:
using DotNetNuke.Entities.Controllers;
using DotNetNuke.Common.Utilities;
var token = HostController.Instance.GetEncryptedString("Acme_CrmToken", Config.GetDecryptionkey());
Write the setting once, from a host account — HostController.Instance.Update(...) or UpdateEncryptedString(...) — rather than from a submission script.
The point is not that the host settings table is a vault. It is that the credential stops being part of the form: one place to rotate it, and an exported form is safe to send to a colleague.
Important
Nothing filters the URL when you call HttpClient yourself. The webhook node's outbound guard is
not in this path. Keep the URL a literal or a host setting; never assemble it from a submitted
answer, or a public form becomes a request generator aimed at your own network.
6. When the destination depends on the answers
This is the reason the page exists. The node sends everything to one place; a script chooses.
using System.Net.Http;
using System.Text;
using DotNetNuke.Entities.Controllers;
using Newtonsoft.Json;
var region = ctx.GetString("region"); // "eu" | "us"
var isTrade = ctx.GetBool("trade_account");
var url = isTrade
? "https://erp.contoso.com/api/orders" // trade orders go to the ERP
: (region == "eu" ? "https://crm.contoso.com/eu/api/v3/leads"
: "https://crm.contoso.com/us/api/v3/leads");
object payload = isTrade
? (object)new { account = ctx.GetString("account_code"),
total = ctx.GetDecimal("order_total"),
lines = ctx.GetInt("line_count") }
: (object)new { email = ctx.GetString("email"),
company = ctx.GetString("company") };
var token = HostController.Instance.GetString(isTrade ? "Acme_ErpToken" : "Acme_CrmToken", string.Empty);
using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) })
using (var request = new HttpRequestMessage(HttpMethod.Post, url))
{
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
request.Content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
var reply = await http.SendAsync(request, ct);
var text = await reply.Content.ReadAsStringAsync();
ctx.Log("crm status=" + (int)reply.StatusCode);
if (reply.IsSuccessStatusCode)
{
var orderNumber = (string)JsonConvert.DeserializeObject<Newtonsoft.Json.Linq.JObject>(text)["orderNumber"];
ctx.SetVariable("erpOrderNumber", orderNumber);
ctx.Response.SuccessMessage = "Thank you — your reference is " + orderNumber + ".";
ctx.Response.RedirectUrl = "/order-confirmed?ref=" + Uri.EscapeDataString(orderNumber);
}
}
ctx.SetVariable puts the value on the run record. ctx.Response.SuccessMessage and RedirectUrl override the post-submit experience for this submission only.
What you cannot do is put the order number back into the stored submission: ctx.SetValue is refused at PostCommit, because a script that believes it rewrote a stored value and did not is a data bug that surfaces months later in a report. If the number has to be queryable later, write it to a table you own in the same script — see Write to your own database.
7. Failure, honestly
The row is committed before your code runs, so a dead CRM never loses a submission. It does mean the script is the only thing that knows the push failed.
// continues the example above — http and request are the ones created there,
// and System.Net.Http is already in that fence's usings
try
{
var reply = await http.SendAsync(request, ct);
ctx.Log("crm status=" + (int)reply.StatusCode);
if (!reply.IsSuccessStatusCode)
ctx.Fail("CRM returned " + (int)reply.StatusCode);
}
catch (TaskCanceledException)
{
// HttpClient reports its own timeout and a cancelled ct the same way.
ctx.Log("crm timed out after 8s");
ctx.Fail("CRM timeout");
}
catch (HttpRequestException ex)
{
ctx.Log("crm transport failure: " + ex.Message);
ctx.Fail("CRM unreachable");
}
ctx.Fail records the run as failed. Whether the visitor sees anything depends on the script's failure setting: continue (the default) shows the normal thank-you, report returns the message to the caller. Neither undoes the submission.
There is no retry and no queue. The webhook node retries; a script gets one attempt unless you loop yourself — and the visitor waits for every attempt you make, inside a 10-second run timeout. If the destination is unreliable, the durable pattern is: log the failure, write a row into your own "pending sync" table, and reconcile from a scheduled job. Retrying later from inside the submission request is not available (that would need the AsyncWorker stage, which cannot be configured today).
Note
On .NET Framework, a TLS handshake failure against a modern endpoint surfaces as
HttpRequestException: The request was aborted: Could not create SSL/TLS secure channel. Fix that
at the server — framework/OS TLS configuration — not by setting ServicePointManager.SecurityProtocol
in a script, which mutates process-wide state for every other component on the site.
8. SOAP, and other content types
There is nothing special about SOAP here. It is a POST with an XML body and a SOAPAction header.
using System.Net.Http;
using System.Text;
var envelope =
"<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">" +
"<soap:Body><CreateLead xmlns=\"urn:contoso:crm\">" +
"<Email>" + System.Security.SecurityElement.Escape(ctx.GetString("email")) + "</Email>" +
"<Company>" + System.Security.SecurityElement.Escape(ctx.GetString("company")) + "</Company>" +
"</CreateLead></soap:Body>" +
"</soap:Envelope>";
using (var http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) })
using (var request = new HttpRequestMessage(HttpMethod.Post, "https://legacy.contoso.com/crm.asmx"))
{
request.Headers.Add("SOAPAction", "urn:contoso:crm/CreateLead");
request.Content = new StringContent(envelope, Encoding.UTF8, "text/xml");
var reply = await http.SendAsync(request, ct);
ctx.Log("soap status=" + (int)reply.StatusCode);
}
Escape every submitted value you interpolate into XML. On a public form, treat all of them as hostile. Form-encoded endpoints are the same pattern with FormUrlEncodedContent.
There is no WSDL tooling and no generated proxy: you build the envelope, you read the response.
9. What this page does not give you
| Wanted | Today |
|---|
| Reject the submission when the API says no | Not configurable — needs the PreInsert stage |
| Write the API's answer back into the stored submission | Refused at PostCommit by design (§6) |
| Automatic retry / backoff | Only the webhook node has it |
| Hand a slow call to a background worker | AsyncWorker exists in the engine, not configurable |
| Generate a PDF from the reply, move an uploaded file | Not implemented |
Important
There is an opt-in strict mode that restores an older namespace deny-list. It is off by default; if
a host has turned it on, System.Net.Http is refused at compile time and none of this page applies.
Block a submission with a blacklist or fraud check
Available now. The PreInsert stage aborts the submission and nothing is written — covered by an automated test that asserts the submissions table is still empty afterwards.
Required fields and validation rules stop malformed input. They cannot stop input that is perfectly well-formed and unwelcome: an address on your blocklist, a fourth application from the same person this hour, a card country that does not match the delivery country.
That decision needs data the form does not have, so it needs a lookup — and it has to happen before the row is written, or it is not a decision, it is a cleanup job.
1. Use PreInsert, and know why
| Stage | What ctx.Fail(…) does |
|---|
| PreValidate | refuses the submission; the visitor sees it as a validation failure |
| PreInsert | refuses the submission inside the transaction — nothing is stored |
| PostCommit | records a failure; the row already exists and stays |
ctx.CanAbort is true only for the first two, and a script can assert on it.
2. A blocklist held in your own database
{
"dbActions": [
{
"name": "is-blocked",
"connectionName": "CustomerCrm",
"kind": "scalar",
"sql": "SELECT COUNT(*) FROM dbo.Blocklist WHERE Email = @email OR Domain = @domain",
"parameters": ["email", "domain"]
},
{
"name": "recent-attempts",
"connectionName": "DashboardDatabase",
"kind": "scalar",
"sql": "SELECT COUNT(*) FROM dbo.MF_Submissions WHERE FormId = @formId AND IpAddress = @ip AND SubmittedOnUtc > DATEADD(hour,-1,GETUTCDATE())",
"parameters": ["formId", "ip"]
}
]
}
// Stage: PreInsert
var email = ctx.GetString("email").Trim().ToLowerInvariant();
var domain = email.Contains("@") ? email.Substring(email.IndexOf('@') + 1) : "";
var blocked = ctx.Actions.ExecuteNamedActionAsync("is-blocked", new { email, domain }).Result;
if (Convert.ToInt32(blocked.ScalarValue) > 0)
{
ctx.Log("Blocked: " + email); // the reason goes in the run record …
ctx.Fail("We are unable to accept this application online. Please contact support.");
return; // … not in what the visitor is told
}
var attempts = ctx.Actions.ExecuteNamedActionAsync("recent-attempts",
new { formId = ctx.FormId, ip = ctx.IpAddress }).Result;
if (Convert.ToInt32(attempts.ScalarValue) >= 5)
ctx.Fail("You have submitted this form several times recently. Please try again later.");
The message is the product. Whatever you pass to Fail is shown to an anonymous visitor. Say what they should do next; say nothing about why they were caught. ctx.Log is where the reason belongs — the run record is host-only.
3. A third-party fraud or reputation service
// Stage: PreInsert
var check = ctx.Api.PostJsonAsync("fraud-score", new {
email = ctx.GetString("email"),
ip = ctx.IpAddress,
amount = ctx.GetDecimal("amount")
}).Result;
if (!check.Ok)
{
// Decide deliberately: does an unreachable scorer mean "let it through" or "hold it"?
ctx.Log("Fraud service unavailable (" + check.Status + ") — accepting and flagging for review.");
ctx.SetValue("review_required", true);
}
else
{
var score = (int)Newtonsoft.Json.Linq.JObject.Parse(check.Body)["score"];
ctx.SetVariable("fraudScore", score);
if (score >= 90) ctx.Fail("We could not process this request. Please contact us.");
else if (score >= 60) ctx.SetValue("review_required", true);
}
That fail-open / fail-closed choice is the whole design of this kind of check, and it is worth writing the reasoning into the script as a comment. A payment form probably holds the submission; a newsletter signup probably does not.
4. Rules that need no lookup at all
Not every check needs a database:
// Stage: PreInsert
var start = ctx.GetDate("start_date");
var end = ctx.GetDate("end_date");
if (start.HasValue && end.HasValue && end < start)
ctx.Fail("The end date cannot be before the start date.");
if (start.HasValue && start.Value < ctx.UtcNow.Date.AddDays(2))
ctx.Fail("Bookings need at least two working days' notice.");
Use a script here only when the rule is genuinely cross-field or conditional; a single-field rule belongs in the field's own validation, where the visitor sees it before pressing Submit.
5. What the visitor and the host each see
The visitor gets the message from Fail, in place of the thank-you, with the form still filled in.
The host gets a run record:
Stage PreInsert
Success false
Aborted true ← the submission was refused, not merely logged
Duration 41 ms
Log Blocked: chargeback.regular@example.com
Calls db 'is-blocked' → rows=0 read=0 (18ms)
Aborted is the field that distinguishes "we refused this" from "something broke after we accepted it". They look identical in a log line and mean opposite things to a customer.
6. Practical notes
- Keep it fast. This runs before the visitor gets any response at all. A blocklist lookup on an indexed column is microseconds; a slow third-party scorer is a visible pause on every submission.
- Anti-spam already runs. MegaForm's honeypot, timing and rate-limit checks happen before this stage; you are adding business rules, not bot defence.
- Watch for a script that refuses everything. A bad
WHERE clause turns a form off silently from the outside. The run records make it obvious — a run of Aborted: true rows with no submissions between them.
Encrypt or normalise a field before it is stored
Available now. ctx.SetValue at the PreInsert stage changes what gets written — covered by a test that reads the stored row back and asserts the new value is in it.
Some values should never be stored the way they arrived. A national ID or an account number should be encrypted or masked. A phone number typed as +1 (415) 555-0142 and as 4155550142 is the same customer, and a report that treats them as two is wrong.
Both are the same job: change the value before the row is written.
1. Why the stage matters more than the code
ctx.SetValue works at PreValidate and PreInsert only. At PostCommit it does not quietly do nothing — it throws:
ctx.SetValue is only available before the submission is written (PreValidate / PreInsert). At the PostCommit stage the row already exists — use ctx.Actions to update it, or move this script to the PreInsert stage.
That is deliberate. A script that believes it encrypted a field and did not is a data bug nobody notices until the field is read in anger — during an audit, or a breach. Better a loud error while the host is still looking at the editor.
2. Normalising
// Stage: PreInsert
var phone = ctx.GetString("phone");
if (!string.IsNullOrWhiteSpace(phone))
{
var digits = new string(phone.Where(char.IsDigit).ToArray());
if (digits.Length == 10) digits = "1" + digits; // default country
ctx.SetValue("phone", "+" + digits);
}
ctx.SetValue("email", ctx.GetString("email").Trim().ToLowerInvariant());
ctx.SetValue("country", ctx.GetString("country").Trim().ToUpperInvariant());
var name = System.Text.RegularExpressions.Regex.Replace(
ctx.GetString("full_name").Trim(), @"\s+", " ");
ctx.SetValue("full_name", name);
Every later reader gets the clean version: the submissions grid, exports, the CRM sync, reports. Normalising afterwards means normalising in every one of those places, forever.
3. Masking
Storing the last four digits is often the whole requirement, and it is the option that cannot leak what it does not hold:
// Stage: PreInsert
var id = ctx.GetString("national_id").Replace("-", "").Replace(" ", "");
if (id.Length > 4)
{
ctx.SetValue("national_id", new string('•', id.Length - 4) + id.Substring(id.Length - 4));
ctx.SetVariable("idLastFour", id.Substring(id.Length - 4));
}
Consider this before reaching for encryption. Encrypted data still has to be decrypted by something, and that something is another place the key has to live.
4. Encrypting
System.Security.Cryptography is not available to scripts, and the reason is the key rather than the algorithm: a key pasted into a script travels with an exported form. Encryption therefore goes through something that already holds the key — your database, or a service.
With the database's own encryption:
{
"name": "protect-value",
"connectionName": "CustomerCrm",
"kind": "scalar",
"sql": "SELECT CONVERT(varchar(max), EncryptByKey(Key_GUID('MegaFormKey'), @plain), 2)",
"parameters": ["plain"]
}
// Stage: PreInsert
var plain = ctx.GetString("national_id");
if (!string.IsNullOrWhiteSpace(plain))
{
var protectedValue = ctx.Actions.ExecuteNamedActionAsync(
"protect-value", new { plain }).Result.ScalarValue;
ctx.SetValue("national_id", Convert.ToString(protectedValue));
ctx.Log("national_id stored encrypted"); // never log the value itself
}
With a key service:
var wrapped = ctx.Api.PostJsonAsync("kms-encrypt", new { plaintext = plain }).Result;
if (!wrapped.Ok)
ctx.Fail("We could not process your details securely. Please try again.");
else
ctx.SetValue("national_id",
(string)Newtonsoft.Json.Linq.JObject.Parse(wrapped.Body)["ciphertext"]);
Note the Fail: if the value cannot be protected, refusing the submission is usually right. Storing it in the clear "just this once" is how plaintext ends up in a database nobody thought contained any.
5. Deriving
The same stage is where computed values belong, so they are stored rather than recalculated:
// Stage: PreInsert
var qty = ctx.GetDecimal("quantity");
var unit = ctx.GetDecimal("unit_price");
var net = decimal.Round(qty * unit, 2);
ctx.SetValue("net_total", net);
ctx.SetValue("vat", decimal.Round(net * 0.2m, 2));
ctx.SetValue("gross_total", decimal.Round(net * 1.2m, 2));
Store what the customer agreed to. A total recomputed at report time with next year's VAT rate is a different number from the one on the confirmation email.
6. What is recorded
The run record notes how many values changed — not what they were:
PreInsert automation for form 12 changed 3 field value(s) before the row was written.
Keep it that way. ctx.Log output is stored, and a run record full of national IDs is the problem this page was trying to solve.
7. Practical notes
- Field keys are case-insensitive, and
SetValue on a key the form does not have simply adds it. That is useful for derived values and a silent typo for existing ones — check the key list above the editor. - Encrypting breaks searching and grid display for that field. Decide whether the submissions grid needs to show it before you encrypt it; masking often serves better.
- PreValidate can do this too, and runs earlier. Use PreInsert unless the normalised value is needed by a validation rule.
Look up tax, exchange rate or shipping in real time
Available now. ctx.Api at the PreInsert stage, with the looked-up value written into the row by ctx.SetValue.
A quote calculated in the browser is a guess. The customer's VAT rate depends on where they are and what they bought; the shipping cost depends on weight, destination and the carrier's rates today; the price in their currency depends on this morning's rate.
Ask the authority, and store what it said — at the moment the customer agreed to it.
1. PreInsert, so the stored row carries the number
A rate looked up at PostCommit is a rate the stored submission does not have. Later, someone recalculates from the raw fields, gets a different answer, and the invoice does not match the confirmation email.
Look it up before the write, and store the result:
// Stage: PreInsert
var net = ctx.GetDecimal("net_total");
var country = ctx.GetString("country");
var postcode = ctx.GetString("postcode");
var quote = ctx.Api.PostJsonAsync("tax-quote", new {
amount = net, country, postcode,
productCategory = ctx.GetString("product_category")
}).Result;
if (!quote.Ok)
{
ctx.Log("Tax service returned " + quote.Status + " — falling back to the standard rate.");
ctx.SetValue("tax_rate", 0.20m);
ctx.SetValue("tax_amount", decimal.Round(net * 0.20m, 2));
ctx.SetValue("tax_source", "fallback");
}
else
{
var json = Newtonsoft.Json.Linq.JObject.Parse(quote.Body);
var rate = (decimal)json["rate"];
ctx.SetValue("tax_rate", rate);
ctx.SetValue("tax_amount", decimal.Round(net * rate, 2));
ctx.SetValue("tax_source", (string)json["jurisdiction"]);
}
ctx.SetValue("grand_total",
decimal.Round(net + ctx.GetDecimal("tax_amount") + ctx.GetDecimal("shipping_cost"), 2));
Decide the fallback deliberately and write the reasoning down. Charging a default rate is right for some businesses and unacceptable for others; tax_source is what lets finance tell the two apart later.
2. Currency
// Stage: PreInsert
var currency = ctx.GetString("currency", "USD");
if (currency != "USD")
{
var rates = ctx.Api.PostJsonAsync("fx-rates", new { baseCurrency = "USD" }).Result;
if (rates.Ok)
{
var rate = (decimal)Newtonsoft.Json.Linq.JObject.Parse(rates.Body)["rates"][currency];
ctx.SetValue("fx_rate", rate);
ctx.SetValue("fx_rate_at", ctx.UtcNow);
ctx.SetValue("local_total", decimal.Round(ctx.GetDecimal("grand_total") * rate, 2));
}
else
{
ctx.Fail("We could not confirm today's exchange rate. Please try again in a moment.");
}
}
Store fx_rate and fx_rate_at. "What did we charge and at what rate" is a question that gets asked months later, and the rate is not recoverable afterwards.
For a public form, an FX provider called on every submission is also a cost and a rate limit. Cache it in your own table with a named action and refresh it hourly:
{
"name": "fx-rate-cached",
"connectionName": "DashboardDatabase",
"kind": "scalar",
"sql": "SELECT TOP 1 Rate FROM dbo.FxRates WHERE Currency = @currency AND FetchedOn > DATEADD(hour,-1,GETUTCDATE()) ORDER BY FetchedOn DESC",
"parameters": ["currency"]
}
var cached = ctx.Actions.ExecuteNamedActionAsync("fx-rate-cached", new { currency }).Result;
var rate = cached.ScalarValue != null ? Convert.ToDecimal(cached.ScalarValue) : FetchAndStore(currency);
3. Shipping
// Stage: PreInsert
var parcel = new {
toPostcode = ctx.GetString("postcode"),
toCountry = ctx.GetString("country"),
weightKg = ctx.GetDecimal("total_weight"),
service = ctx.GetString("delivery_speed", "standard")
};
var rates = ctx.Api.PostJsonAsync("carrier-rates", parcel).Result;
if (rates.Ok)
{
var options = Newtonsoft.Json.Linq.JArray.Parse(rates.Body);
var cheapest = options.OrderBy(o => (decimal)o["price"]).First();
ctx.SetValue("shipping_cost", (decimal)cheapest["price"]);
ctx.SetValue("shipping_carrier", (string)cheapest["carrier"]);
ctx.SetValue("shipping_eta_days", (int)cheapest["etaDays"]);
ctx.Response.SuccessMessage =
"Thanks. " + (string)cheapest["carrier"] + " will deliver in about " +
(int)cheapest["etaDays"] + " days.";
}
else if (parcel.toCountry == "US")
{
ctx.SetValue("shipping_cost", 9.95m);
ctx.SetValue("shipping_carrier", "standard");
}
else
{
ctx.Fail("We could not calculate shipping to that address. Please contact us for a quote.");
}
ctx.Response.SuccessMessage puts the answer on the thank-you page, so the customer sees the same number that was stored.
4. Latency is the real constraint
This runs before the visitor gets any response. Every millisecond is on the submit button.
- Set the endpoint's
timeoutSeconds to what you are willing to make a customer wait — 3 to 5 seconds, not the 20-second default. maxAttempts: 1 for anything on this path. A retry doubles the wait for a customer watching a spinner.- Cache what changes slowly. FX rates change by the hour, tax rates by the year.
- If the lookup is genuinely slow, quote a provisional figure now and confirm on AsyncWorker.
{
"name": "tax-quote",
"url": "https://api.taxservice.example.com/v2/quote",
"authType": "header", "authHeaderName": "X-Api-Key", "authValue": "…",
"timeoutSeconds": 4,
"maxAttempts": 1
}
5. What is recorded
endpoint 'tax-quote' → 200 in 310ms
endpoint 'carrier-rates' → 200 in 890ms
PreInsert automation for form 21 changed 6 field value(s) before the row was written.
Which is exactly what a dispute needs: what was asked, what came back, how long it took, and that the stored row is the one the customer agreed to.
Notifications: send a message the answers decide
Most of this needs no code. The workflow Email node sends mail when a form is submitted, holds its own subject and body with {{field:key}} tokens, and a Condition node in front of two Email nodes covers branching:
Form submitted → Condition (total > 5000) ─ yes → Email "Director approval"
└ no → Email "Team lead approval"
Build that first. It is configuration, it survives an export, and nobody has to enable scripting for it.
This page is for the case the node cannot express: when the decision is the hard part — the recipient has to be looked up, the subject changes shape on a value, one message goes to the regional queue and a copy goes to whoever owns that account. Then you write the decision in C# and send with DNN's own mail API.
Note
An earlier version of this page described a ctx.Notify capability with a template catalog. That
was removed. A script is ordinary C# now and calls platform APIs directly.
1. Sending: Mail.SendEmail
// shape only — `from`, `to`, `subject` and `body` come from the sections below
using DotNetNuke.Services.Mail;
Mail.SendEmail(from, to, subject, body);
That is the same code path DNN uses for its own password-reset and notification mail, so it reads the same SMTP configuration — host, port, credentials, SSL — that an administrator already set under Settings → Servers → SMTP Server (with a site-level override on the DNN versions that offer one). There is no second place to configure mail for MegaForm, and no SMTP password in a script.
On this DNN version SendEmail returns nothing. There is no status string to test, so do not write code that reads one — the call either completes or it throws. If the page needs a failure path, put the call in a try/catch and fail the run on the exception:
using DotNetNuke.Services.Mail;
var to = "sales@contoso.com";
var from = "no-reply@contoso.com";
var subject = "Enquiry " + ctx.SubmissionId;
var body = "<p>See the submission for details.</p>";
try
{
Mail.SendEmail(from, to, subject, body);
ctx.Log("mail handed to DNN's sender for " + to);
}
catch (Exception ex)
{
ctx.Log("mail failed for " + to + ": " + ex.Message);
ctx.Fail("Notification not sent");
}
Handing the message to DNN's sender is the boundary the script can see. What happens after that — whether SMTP accepted it, whether the recipient's server took it — is not observable from here.
For cc, bcc, reply-to, an explicit body format or attachments, Mail.SendMail carries longer overloads. Those signatures have changed across DNN versions, so read the one your site's DotNetNuke.dll actually exposes rather than copying a signature from anywhere — including this page. If all you need is a copy to a manager, the reliable version is a second SendEmail call: one message per recipient, one log line per recipient, and no guessing about how a given DNN build splits a recipient list.
Warning
A PostCommit script runs inside the submission request. SMTP is the slowest thing on this page —
an unreachable mail host does not fail fast, it hangs until the connection times out, and the
visitor sits on the form waiting for the thank-you message. Keep it to a couple of sends. If a
notification has to fan out to twenty people, write one row to your own table and let a scheduled
job drain it.
2. The from-address: ask the portal
Do not hard-code a sender. Read the portal's own address:
using DotNetNuke.Entities.Portals;
var portal = PortalController.Instance.GetPortal(ctx.PortalId);
var from = portal.Email;
Two reasons this matters more than it looks. On a multi-portal install the same form template can be running on three sites, and ctx.PortalId is the only thing that knows which one just submitted. And mail sent from an address on a domain whose SPF and DMARC records do not cover your server gets filed as spam or rejected outright — the portal address is the one the administrator already aligned.
3. Building the body, and encoding what came from the submitter
Every value you interpolate into an HTML body arrived from a stranger over the internet. Encode it.
using System;
using System.Net;
using DotNetNuke.Entities.Portals;
using DotNetNuke.Services.Mail;
Func<string, string> h = s => WebUtility.HtmlEncode(s ?? "");
var name = ctx.GetString("full_name", "");
var company = ctx.GetString("company", "");
var message = ctx.GetString("message", "");
var total = ctx.GetDecimal("total_amount", 0m);
var body =
"<p><strong>" + h(name) + "</strong> (" + h(company) + ") submitted "
+ ctx.FormTitle + " #" + ctx.SubmissionId + ".</p>"
+ "<p>Value: " + total.ToString("N2") + "</p>"
+ "<blockquote>" + h(message) + "</blockquote>"
+ "<p>Received " + ctx.UtcNow.ToString("u") + " from " + h(ctx.IpAddress) + "</p>";
total is a decimal — it came out of GetDecimal, it cannot carry markup, so it does not need encoding. Everything that is still a string does.
What goes wrong without it is not theoretical. A company name of Smith & Sons renders as garbage in some clients and correctly in others, which is the confusing case. A message body containing <div style="display:none"> swallows the rest of your email. And an alert that lands in a colleague's inbox, from your site, carrying an attacker's markup, is a phishing email you sent yourself — the reader trusts it precisely because it came from the form they were expecting.
The subject needs different treatment. A carriage return or line feed in a subject is a header injection attempt, and it belongs on one line anyway:
// continues the example above — `company` is the value read there
var safeCompany = company.Replace("\r", " ").Replace("\n", " ").Trim();
if (safeCompany.Length > 60) safeCompany = safeCompany.Substring(0, 60);
var subject = "New enquiry from " + safeCompany;
4. The decision: route, escalate, copy
This is the whole point of doing it in code. Region picks the queue, the value escalates, and a manager gets a copy when the account is one we care about.
using System;
using System.Net;
using DotNetNuke.Entities.Portals;
using DotNetNuke.Services.Mail;
Func<string, string> h = s => WebUtility.HtmlEncode(s ?? "");
var portal = PortalController.Instance.GetPortal(ctx.PortalId);
var from = portal.Email;
var region = ctx.GetString("region", "").Trim().ToUpperInvariant();
var total = ctx.GetDecimal("total_amount", 0m);
var name = ctx.GetString("full_name", "");
// 1. route
string to;
if (region == "EMEA") to = "emea.sales@contoso.com";
else if (region == "APAC") to = "apac.sales@contoso.com";
else if (region == "AMER") to = "amer.sales@contoso.com";
else
{
to = "sales@contoso.com";
ctx.Log("unmapped region '" + region + "' — sent to the central queue");
}
// 2. escalate
var urgent = total >= 25000m;
var subject = (urgent ? "[Escalation] " : "") + "Enquiry " + ctx.SubmissionId
+ " — " + total.ToString("N0");
var body = "<p>" + h(name) + " — " + h(region) + " — " + total.ToString("N2") + "</p>"
+ "<blockquote>" + h(ctx.GetString("message", "")) + "</blockquote>";
try
{
Mail.SendEmail(from, to, subject, body);
ctx.Log("primary=" + to + " urgent=" + urgent + " handed to DNN's sender");
}
catch (Exception ex)
{
ctx.Log("primary=" + to + " FAILED: " + ex.Message);
ctx.Fail("Primary notification not sent");
return;
}
// 3. copy the manager, as a separate message
if (urgent)
{
try
{
Mail.SendEmail(from, "sales.director@contoso.com", subject, body);
ctx.Log("manager copy handed to DNN's sender");
}
catch (Exception ex)
{
ctx.Log("manager copy FAILED: " + ex.Message);
}
}
ctx.SetVariable("notifiedQueue", to);
ctx.Log lines land in the run record, in order, with the run's duration. That is what answers "was the director told about #4187" three weeks later, so log the address you actually used, not the branch you think you took.
ctx.SetVariable puts a value on the run record too. Use it when a later reader — a workflow node, a report — needs to know which way the decision went.
Note
ctx.Fail records the run as failed. It does not undo the submission: at PostCommit the row is
already committed and the visitor already has their thank-you message. Refusing a submission needs
PreInsert, which is not configurable today. A failed notification is a failed notification, not a
lost enquiry — and the run record is where you find it.
5. When the recipient has to be looked up
The address often is not in the form at all. It is in a table: account owners, cost-centre approvers, the on-call rota. That is a plain SqlConnection against the site's own database — the write to your own database page covers the connection and the parameterisation; the only new part here is the fallback.
using System.Data;
using System.Data.SqlClient;
using DotNetNuke.Common.Utilities;
using DotNetNuke.Entities.Portals;
using DotNetNuke.Services.Mail;
// the lookup, as a local function — replace the table and column with your own
string LookupOwner(string code)
{
if (string.IsNullOrEmpty(code)) return null;
using (var cn = new SqlConnection(Config.GetConnectionString()))
using (var cmd = new SqlCommand(
"SELECT TOP 1 OwnerEmail FROM dbo.AccountOwners WHERE AccountCode = @code", cn))
{
cmd.Parameters.Add("@code", SqlDbType.NVarChar, 32).Value = code;
cn.Open();
return cmd.ExecuteScalar() as string;
}
}
var portal = PortalController.Instance.GetPortal(ctx.PortalId);
var from = portal.Email;
var subject = "Enquiry " + ctx.SubmissionId;
var body = "<p>See submission #" + ctx.SubmissionId + ".</p>";
var accountCode = ctx.GetString("account_code", "");
var owner = LookupOwner(accountCode);
if (string.IsNullOrEmpty(owner))
{
owner = "sales@contoso.com";
ctx.Log("no owner for account " + accountCode + " — central queue");
}
try
{
Mail.SendEmail(from, owner, subject, body);
ctx.Log("mail handed to DNN's sender for " + owner);
}
catch (Exception ex)
{
ctx.Log("mail failed for " + owner + ": " + ex.Message);
ctx.Fail("Notification not sent");
}
Always have the else branch. A lookup that returns nothing and a script that then sends nothing is the failure mode nobody notices, because the form still says thank you.
6. SMS, Telegram, Slack, Teams
There is no separate mechanism for these. Every one of them is an HTTP POST to the provider's API with a token in a header, which means the REST integration page is the page you want — same HttpClient, same handling of a non-2xx response.
Two practical notes carried over from mail. Encode or escape anything the submitter typed before it goes into a chat message, because chat clients render markup too. And keep the token out of the script body where you can: the URL and the token belong in the same kind of server-side configuration as everything else you would not put in a form export.
7. What this cannot do
- Refuse the submission because the notification failed. That needs PreInsert; nothing in the product writes a script into that stage today.
- Write the chosen recipient back into the stored submission.
ctx.SetValue is refused at PostCommit, deliberately — a script that believes it changed a stored value and did not is a data bug that surfaces months later in a report. Send the value onward, or write it to your own table. - Attach a generated PDF. Document generation is not implemented.
Before any script runs
Three gates, in order:
<add key="MegaForm:AfterSubmitScriptEnabled" value="true" /> in the server's web.config appSettings. Off on every install, and a file rather than a settings screen on purpose: the right bar for people may run code on this server is can edit files on this server.- A host (superuser) account saves the script. Not a site administrator, not module-edit rights.
- An approval hash over the source. The stored hash must match the source, so a script that arrives inside an imported form or a gallery template is inert until a host on that site approves it.
Ordinary form saves cannot introduce, alter or enable a script.
Measured
On a DNN 10.3 site, one anonymous submission ran a single PostCommit script that sent mail through the site's own SMTP settings alongside a parameterised INSERT, an outbound HTTP POST and a user creation. Total run duration 927 ms. The mail line in the run log:
mail handed to DNN's sender for jane.carter@example.com
"Handed to DNN's sender" is the honest boundary, and it is as far as the log line can go. The call returns nothing, so the script knows only that it completed without throwing. Whether SMTP delivered the message, and whether the recipient's server accepted it, is between the two mail servers — the answer to that is in your SMTP logs, not in the run record.
Start an approval that routes itself
Mostly available now. Approval routing works today with no code, through the workflow Condition and Approval nodes — §1 and §2. Starting or signalling a workflow from inside a script (ctx.Workflow) is planned.
An expense claim under £500 goes to the team lead. Over £5,000 it goes to the director. Over £20,000 it needs both, in order. That is the archetypal after-submit automation, and on DNN it is what people reach for "Execute C# Code" to build.
In MegaForm most of it is a diagram.
1. What works today: Condition + Approval nodes
Builder → Workflow.
Form submitted
└─ Condition amount > 20000 ──── yes ──→ Approval: Director ──→ Approval: Finance ──→ Done
└── no ──→ Condition amount > 5000 ── yes ─→ Approval: Manager ──→ Done
└── no ─→ Approval: Team lead ──→ Done
The Approval node creates a task in the assignee's inbox, emails them, and waits. Approve or reject decides which edge the flow takes next. Nothing about the shape above needs code, and the diagram is the documentation.
Assignees can be a role, a named user, or {{field:manager_email}} from the submission itself — so "route to the manager the claimant selected" is already configuration rather than code.
See Approval workflows & inbox and Workflow library for reusing one flow across many forms.
2. Where a script earns its place
When the approver cannot be expressed as a rule on the form's own fields — because it lives in another system.
// PreInsert or PostCommit. Works today: the lookup, and handing the answer to the workflow.
var amount = ctx.GetDecimal("total_amount");
var costCode = ctx.GetString("cost_centre");
var lookup = ctx.Actions.ExecuteNamedActionAsync("finance-approver-for",
new { costCode, amount }).Result;
var approver = lookup.First;
if (approver == null)
{
ctx.SetVariable("approverEmail", "finance@example.com");
ctx.SetVariable("routingReason", "no approver mapped for " + costCode);
}
else
{
ctx.SetVariable("approverEmail", approver.Str("Email"));
ctx.SetVariable("approverName", approver.Str("DisplayName"));
ctx.SetVariable("routingReason", "cost centre " + costCode + ", limit " + approver.Num("Limit"));
}
ctx.SetVariable values are stored on the run record, and a workflow Approval node can address {{var.approverEmail}}. So today the split is: the script decides who, the workflow does the asking and the waiting. That division is worth keeping even after ctx.Workflow ships — waiting for a human is exactly what a workflow engine is for and exactly what a script is not.
3. The planned interface
Task<string> StartAsync (string workflowName, object model, CancellationToken ct = default);
Task SignalAsync(string executionId, string signalName, object payload, CancellationToken ct = default);
What it adds over configuration:
- Choose the flow, not just the branch: a purchase over a threshold starts the capital-expenditure workflow rather than a longer branch of the same one.
- Start a flow for a different record — a submission that creates three orders can start an approval per order.
- Signal a waiting flow when something outside MegaForm happens: a script on a second form, or an AsyncWorker stage polling an external system, releases a flow that is waiting.
// PLANNED
var flow = amount >= 20000 ? "capex-approval" : "standard-approval";
var executionId = await ctx.Workflow.StartAsync(flow, new {
ctx.SubmissionId,
amount,
approver = ctx.GetString("approverEmail")
});
ctx.SetVariable("approvalExecutionId", executionId);
4. What a script must not do here
Do not implement the waiting. A script runs inside a submit request and finishes in milliseconds; an approval takes days, survives restarts, needs reminders, reassignment and an audit trail. That is the workflow engine's job, and every one of those properties is lost by a script that tries to own it.
The same applies to sleeping, polling, or spawning background work — Task.Run, Thread and Task.Factory are refused at compile time for this reason.
5. Which stage
| Want | Stage |
|---|
| Decide the route, then let the workflow ask | PostCommit — the claim exists and can be approved |
| Refuse a claim that breaks a hard limit before it is filed | PreInsert — see fraud checks |
6. What is recorded
The routing decision is on the run record, and the approval itself has its own history in the workflow inbox:
Stage PostCommit
Variables approverEmail = director@example.com
routingReason = cost centre CC-4410, limit 25000
Calls db 'finance-approver-for' → rows=0 read=1 (12ms)
"Why did this go to the director" is answerable a year later, which for an approval trail is the entire point.
Create a user and grant a role
A student enters a class code on a form and should end up with an account that can open the course materials. A partner completes a registration form and should land in the Resellers role.
There are two routes. The workflow nodes cover the plain case with no code. A script covers the case where eligibility is a lookup, or the role depends on data the form does not carry.
Note
If you arrived from an older page: scripts no longer go through a mediated identity object. A
script is ordinary C# and calls the platform's own membership API directly.
1. No code: the three identity nodes
Builder → Workflow.
| Node | Does |
|---|
| Add User | creates an account from submitted fields |
| Add Role | creates a role if the site does not already have it |
| Add User To Role | puts a user into a role |
The class-code scenario is then configuration, not code:
Form submitted
└─ Condition class_code == "IT-2026" ── yes ─→ Add User ─→ Add User To Role (IT_Student) ─→ Done
└─ no ─→ End: "That class code was not recognised."
Add User takes UserName, Email, DisplayName, FirstName, LastName and Password, and three switches that matter more than they look:
GeneratePasswordIfEmpty (default on) — leave Password blank and the node makes one.ApproveUser (default on) — the account can sign in immediately rather than waiting for an administrator.UpdateIfExists (default on) — a second submission from the same person updates the existing account instead of failing. Turn it off if you would rather the run fail loudly on a duplicate.
The node writes its results into workflow variables — provisioned.userId, provisioned.userName, provisioned.email, provisioned.password — so a later node can read {{var.provisioned.userId}} or mail the generated password out. Add User To Role takes UserIdentifier, RoleName, a LookupMode (auto, by id, by name, by email) and AutoCreateRole, which is on by default: a typo in RoleName creates a role rather than reporting one missing, so treat the role name as a value you check, not one you type twice.
There is no membership expiry date on the node. If you need one, use a script.
The nodes are configuration, so a form that uses them carries its identity behaviour with it when you export it. The measurements further down this page are from the script route.
2. Where a script earns its place
When the answer to "is this person allowed, and as what" lives somewhere the form cannot see — an enrolment table, a licence server, a list of class codes with expiry dates.
This script runs at PostCommit: the submission row is already stored when it starts.
using DotNetNuke.Entities.Users;
using DotNetNuke.Security.Roles;
using DotNetNuke.Security.Membership;
using DotNetNuke.Services.Mail;
var email = ctx.GetString("email").Trim();
var name = ctx.GetString("full_name").Trim();
var code = ctx.GetString("class_code").Trim().ToUpperInvariant();
// The role this submission earns. In a real install this is a lookup —
// a SELECT against your own enrolment table, keyed on the class code.
var roleName = code == "IT-2026" ? "IT_Student" : null;
if (roleName == null)
{
ctx.Log("class code not recognised: " + code);
ctx.Response.SuccessMessage =
"Thanks. We could not match that class code, so a coordinator will contact you.";
return;
}
// Check first. A second submission from the same student is normal, not an error.
var user = UserController.GetUserByEmail(ctx.PortalId, email);
string tempPassword = null;
if (user == null)
{
tempPassword = System.Guid.NewGuid().ToString("N").Substring(0, 12) + "aA1!";
user = new UserInfo();
user.PortalID = ctx.PortalId;
user.Email = email;
user.Username = email;
user.DisplayName = string.IsNullOrEmpty(name) ? email : name;
user.FirstName = name;
user.Membership.Password = tempPassword;
user.Membership.Approved = true;
user.Membership.UpdatePassword = true; // force a change at first sign-in
var status = UserController.CreateUser(ref user);
ctx.Log("CreateUser -> " + status + " id=" + user.UserID);
if (status != UserCreateStatus.Success)
{
ctx.Fail("Could not create the account for " + email + ": " + status);
return;
}
}
else
{
ctx.Log("account already exists, id=" + user.UserID);
}
var role = RoleController.Instance.GetRoleByName(ctx.PortalId, roleName);
if (role == null)
{
ctx.Fail("Role not found on this site: " + roleName);
return;
}
RoleController.Instance.AddUserRole(ctx.PortalId, user.UserID, role.RoleID,
RoleStatus.Approved, false, System.DateTime.MinValue, System.DateTime.MinValue);
ctx.Log("granted " + role.RoleName + " to " + user.UserID);
if (tempPassword != null)
{
var body = "<p>Hello " + user.DisplayName + ",</p>" +
"<p>Your account is ready. Sign in with <strong>" + email + "</strong> " +
"and the temporary password <strong>" + tempPassword + "</strong>. " +
"You will be asked to change it.</p>";
Mail.SendEmail("registrar@contoso.com", email, "Your course account", body);
ctx.Log("mail handed to DNN's sender for " + email);
}
ctx.SetVariable("provisionedUserId", user.UserID);
ctx.Response.RedirectUrl = "/course-materials";
using directives at the top of a script body are lifted above the generated wrapper for you. Script bodies are async, so you can await directly, and a CancellationToken named ct is in scope for anything that takes one.
Read the status, do not assume success
UserController.CreateUser returns a UserCreateStatus and does not throw when it refuses. The returns you will actually hit on a live site:
| Status | Cause |
|---|
Success | account created; user.UserID is now set |
DuplicateEmail | the site forbids duplicate emails and one exists |
UsernameAlreadyExists | the username is taken, often by a user in another portal |
InvalidPassword | your generated password does not meet the site's password policy |
BannedPasswordUsed | the password is on the site's banned list |
InvalidQuestion / InvalidAnswer | the site requires a password question and answer |
The enum has more members than these. Treat anything that is not Success as a failure worth recording — a script that ignores the return value silently produces submissions with no account behind them, and nobody notices until a student cannot sign in.
Note the shape of the code above: check, then create. GetUserByEmail returning null is the only reliable way to tell "new student" from "student submitting the form twice", and getting that wrong turns a duplicate submission into a failed run.
What the run record shows
A single script doing this work on a DNN 10.3 site, from an anonymous submission, logged:
class code IT-2026 accepted
CreateUser -> Success id=1042
granted IT_Student to 1042
mail handed to DNN's sender for jane.carter@example.com
The same run also wrote a row into a table the site owns with SqlConnection and a parameterised INSERT, and posted the submission to an external endpoint with HttpClient (HTTP 200). Total run duration 927 ms.
3. The judgement the host is making
This is the script surface with the widest consequences, and it is worth being blunt about it.
A script that can call RoleController.AddUserRole can add anybody to Administrators. Nothing in MegaForm inspects the role name and refuses. Neither route constrains it — the Add User To Role node will grant Administrators too, and will create the role if it is misspelled. The protection is not a filter on the API; it is who is allowed to put the code there in the first place.
Three gates stand in front of a script, in this order:
MegaForm:AfterSubmitScriptEnabled in web.config. Off on every install. A file rather than a settings screen on purpose: the right bar for "people may run code on this server" is can edit files on this server.- A host (superuser) account saves it. Not a site administrator, not module-edit rights.
- An approval hash over the source. The stored hash must match the source for the script to run, so a script that arrives inside an imported form, a backup or a gallery template is inert until a host on that site opens it and saves it. Ordinary form saves cannot introduce, alter or enable a script.
So the security question is not "what can the script reach" — it is what did you approve. That is the same trust you extend to any third-party module you install on a DNN site: once it is in the bin folder it runs as your application. Read the role names in a provisioning script the way you would read a module's installer, and reject one whose role names are assembled from submitted data rather than written out in the source.
Important
A role name built from ctx.GetString(...) means the visitor picks their own role. If you need the
role to depend on the submission, resolve it through your own lookup — a table you control, keyed
on the submitted value — and fail closed when the lookup returns nothing, as the script above does.
4. What this cannot do yet
Four stages exist in the engine — PreValidate, PreInsert, PostCommit, AsyncWorker — but only PostCommit can be configured today. Nothing in the product writes a script into the other three: no editor, no API, no import path. Two consequences apply here:
- A script cannot refuse the submission. You cannot reject an unrecognised class code before the row is stored; you can only decline to provision an account and change the message the visitor sees. Refusing needs PreInsert.
- A script cannot write the new user id back into the stored submission.
ctx.SetValue is refused at PostCommit rather than quietly ignored, because a script that believes it rewrote a stored value and did not is a data bug that surfaces months later in a report. Use ctx.SetVariable to carry the id into the run record, or write it into your own table.
Generate a PDF, Word or Excel document
Partly available now. MegaForm already renders a submission to a printable/PDF document with no code — §1. Generating a designed document from a template inside a script (ctx.Documents) is planned.
Someone finishes a course and should get a certificate with their name on it. An order form should produce a PDF invoice the customer can download immediately. On DNN this is usually done by dropping iTextSharp into /bin and writing a file path — which works until the next person needs a different template, or the path turns out to be user-controlled.
1. What works today
Per-submission print / PDF. Every submission has a print view that renders its answers using the form's own layout and theme, reachable from the submissions grid and from a link you can email. For "a tidy record of what was submitted", that is the whole feature and it needs no code.
See Submissions grid.
Confirmation content. The post-submit experience can show a formatted summary with {{field:key}} tokens, which the visitor can print from the browser.
What is missing is a designed artefact: a certificate with a border and a signature, an invoice on company letterhead, a spreadsheet with three sheets.
2. The planned interface
Task<AutomationDocumentResult> CreatePdfAsync(string templateName, object model,
CancellationToken ct = default);
Task<AutomationDocumentResult> CreateFromTemplateAsync(string templateName, string format, object model,
CancellationToken ct = default);
// PLANNED
var cert = await ctx.Documents.CreatePdfAsync("course-certificate", new {
name = ctx.GetString("full_name"),
course = ctx.GetString("course_title"),
completedOn = ctx.UtcNow,
reference = "CERT-" + ctx.SubmissionId
});
ctx.SetVariable("certificateUrl", cert.DownloadUrl);
ctx.Response.SuccessMessage = "Congratulations — your certificate is ready.";
ctx.Response.CustomData["downloadUrl"] = cert.DownloadUrl;
AutomationDocumentResult returns FileName, DownloadUrl and SizeBytes.
3. Three deliberate constraints
A template name, not a document builder. The layout is a registered template maintained by whoever owns the branding. A script supplies the model. Change the certificate design and no script changes.
The script does not choose the path. CreatePdfAsync returns a DownloadUrl — a tokenised link to secure storage — not a filesystem path. Compare the usual DNN recipe:
// The pattern this capability exists to replace:
var path = HostingEnvironment.MapPath("~/Portals/0/Certificates/") + fullName + "_Cert.pdf";
fullName comes from a public form. A name containing ..\ writes outside the folder; a name containing .aspx writes something the server will execute. Both are one submitted string away, and neither is obvious while writing the line.
System.IO stays closed, so the pattern above does not compile. That is the point: the capability is not a convenience wrapper, it is the reason the dangerous version is unavailable.
4. Where this should run
Document generation is slow — hundreds of milliseconds for a simple PDF, seconds for a large spreadsheet — and it happens after the submission is safe.
Put it on AsyncWorker (planned) rather than PostCommit, and tell the visitor it is coming:
// PLANNED
ctx.Response.SuccessMessage = "Thanks — your certificate will arrive by email shortly.";
If the customer must have it on the thank-you page, PostCommit works, and the cost is that every submission waits for the renderer.
5. Until it ships
- Print view for a record of the answers — no code.
- A named endpoint to a document service works today, and keeps the guard and the audit trail:
// Works today.
var doc = ctx.Api.PostJsonAsync("doc-service-certificate", new {
name = ctx.GetString("full_name"),
course = ctx.GetString("course_title"),
reference = "CERT-" + ctx.SubmissionId
}).Result;
if (doc.Ok)
{
var url = (string)Newtonsoft.Json.Linq.JObject.Parse(doc.Body)["url"];
ctx.SetVariable("certificateUrl", url);
ctx.Response.SuccessMessage = "Your certificate is ready.";
ctx.Response.CustomData["downloadUrl"] = url;
}
else
{
ctx.Log("Document service returned " + doc.Status + "; the nightly job will retry.");
}
The service holds the renderer and the template. The script holds neither, which is where this ends up anyway.
Move an uploaded file into a secure folder
Partly available now. Mirroring uploads to cloud storage works today with no code, through Form Settings → Cloud Storage — §1. Moving or classifying a file from inside a script (ctx.Files) is planned.
A supplier uploads a signed contract; it belongs in the legal folder, not the general uploads directory. A claim form takes three photos and a receipt; the receipt goes to finance and the photos go to the assessor. Same upload field, different destinations, decided by what was submitted.
1. What works today
Form Settings → Cloud Storage pushes uploaded files to Google Drive or Amazon S3 after each submission — per provider, per connection, per target folder, optionally one sub-folder per submission. No code.
The connection and its credentials live in the site's connection catalog, never in the form. See Storage & integrations.
What that does not do is choose between destinations based on the answers.
2. The planned interface
Task<AutomationDocumentResult> MoveUploadAsync(string fieldKey, string targetFolderName,
CancellationToken ct = default);
IList<string> FolderNames();
// PLANNED
var kind = ctx.GetString("document_type");
var folder = kind == "contract" ? "legal-signed"
: kind == "invoice" ? "finance-inbox"
: "general-uploads";
var moved = await ctx.Files.MoveUploadAsync("attachment", folder);
ctx.SetVariable("filedTo", folder);
ctx.SetVariable("fileUrl", moved.DownloadUrl);
ctx.Log("Filed " + moved.FileName + " (" + moved.SizeBytes + " bytes) into " + folder);
3. Folder names, not paths — and why that is the whole design
targetFolderName is a catalog entry:
{
"folders": [
{ "name": "legal-signed", "relativePath": "Documents/Legal/Signed" },
{ "name": "finance-inbox", "relativePath": "Documents/Finance/Inbox" },
{ "name": "general-uploads","relativePath": "Documents/General" }
]
}
A script cannot express a path, so it cannot express .., cannot express a UNC share, and cannot express ~/Portals/0/. The set of places any form on the site can put a file is a list an administrator wrote and a reviewer can read in ten seconds.
This is the same reason System.IO is refused at compile time. The usual DNN recipe —
var path = HostingEnvironment.MapPath("~/Portals/0/Uploads/") + ctx.GetString("customer_name") + ".pdf";
— is one submitted string away from writing outside the folder or writing something executable, and the danger is invisible in the line as written.
The capability will also keep the file-type rules that apply to uploads: an extension allow-list, and Content-Disposition: attachment plus nosniff for anything renderable.
4. Until it ships
Cloud Storage mappings cover the fixed-destination case, which is most of them.
Conditional filing without moving files: record the classification and let the destination system fetch it. Nothing moves, nothing can escape a folder, and the decision is still automated:
// Works today.
var kind = ctx.GetString("document_type");
var queue = kind == "contract" ? "legal" : kind == "invoice" ? "finance" : "general";
ctx.Actions.ExecuteNamedActionAsync("file-routing-add", new {
submissionId = ctx.SubmissionId,
queue,
fileField = "attachment"
}).Wait();
ctx.Api.PostJsonAsync("dms-notify", new {
submissionId = ctx.SubmissionId, queue, uploadedBy = ctx.UserEmail
}).Wait();
ctx.SetVariable("routedTo", queue);
A document management system with an API is usually the better answer for anything legal or financial anyway: it has retention, versioning and access control that a folder does not.
5. Which stage
PostCommit. The upload is stored with the submission by then, and a filing decision is not a reason to refuse a submission — if the classification is wrong, the file is still safe where it is.
Publish an event to RabbitMQ, Kafka or SQS
Planned. ctx.Queue is declared and a script calling it today gets "ctx.Queue is not available on this installation yet". §4 shows what works now.
Once more than two systems care about a submission, calling each one from the form stops scaling. The CRM, the warehouse, the analytics pipeline and the fraud team all want to know that an order arrived, they each have their own downtime, and none of them should be able to make a customer's submit button spin.
The answer is to publish one event and let the consumers be somebody else's problem.
1. The planned interface
Task PublishAsync(string topicName, object payload, CancellationToken ct = default);
IList<string> TopicNames();
// PLANNED
await ctx.Queue.PublishAsync("crm.leads", new {
eventType = "lead.created",
submissionId = ctx.SubmissionId,
formId = ctx.FormId,
occurredAt = ctx.UtcNow,
email = ctx.GetString("email"),
company = ctx.GetString("company"),
score = ctx.GetInt("lead_score")
});
topicName is a catalog entry — broker, exchange/topic, routing key, credentials — so the script names a topic and never a connection string. Switching from RabbitMQ to SQS is an administrator's edit, not a change to anybody's code.
2. The outbox, which is the part that matters
Publishing directly from a submit request has a failure mode that is easy to miss: the row is committed, the broker is briefly unreachable, the event is lost, and the CRM never learns about a lead that MegaForm is quite sure it received. Nothing looks broken anywhere.
So PublishAsync will write to MF_AutomationOutbox in the same breath as recording the run, and a background worker drains it with retries and a dead-letter state.
| Column | Purpose |
|---|
PayloadJson | the event |
Status | pending / sent / failed |
Attempts, NextRunUtc | back-off |
LastError | why the last attempt failed |
That gives at-least-once delivery, survives a broker outage and an application recycle, and turns "did this event go out" into a query. Consumers should be idempotent — include submissionId as the deduplication key, as above.
The table exists already; the worker and the broker adapters are the planned part.
3. Which stage
AsyncWorker when it ships. PostCommit is acceptable because a queue publish is milliseconds — but only with the outbox in place, so that a slow broker cannot become a slow thank-you page.
Never PreInsert: publishing "order created" before the order is written is an event that can be a lie.
4. What works today
A named HTTP endpoint covers every broker with an HTTP front door — SQS, EventBridge, Azure Service Bus, Kafka via a REST proxy, RabbitMQ's management API — with the guard, the retry policy and the audit trail already attached:
// Works today.
var evt = ctx.Api.PostJsonAsync("event-bus-publish", new {
topic = "crm.leads",
eventType = "lead.created",
submissionId = ctx.SubmissionId,
email = ctx.GetString("email")
}).Result;
if (!evt.Ok)
{
// Do the outbox by hand until ctx.Queue ships: record it and let a scheduled job drain it.
ctx.Actions.ExecuteNamedActionAsync("outbox-add", new {
topic = "crm.leads",
payload = Newtonsoft.Json.JsonConvert.SerializeObject(new {
submissionId = ctx.SubmissionId, email = ctx.GetString("email")
})
}).Wait();
ctx.Log("Event bus returned " + evt.Status + " — queued for retry.");
}
That is the same design in miniature, and the code you write for it survives the migration: the if (!evt.Ok) branch disappears and the endpoint call becomes ctx.Queue.PublishAsync.
5. Design notes worth keeping
- Publish facts, not commands.
lead.created with the data, not send-welcome-email. Consumers decide what to do; you do not have to redeploy the form when a new one appears. - Include enough to act on. A consumer that has to call back for the details is a consumer that breaks when MegaForm is down.
- But not everything. A national ID in a message on four brokers is four copies to protect — see field encryption.
- Version the event.
eventType = "lead.created.v2" costs nothing now and saves a coordinated release later.
Scripting: what the host is agreeing to
A MegaForm server script is ordinary C# running inside your website's own process, under your website's own identity. It can open your database with your connection string, call out to the internet, read anything the worker process can read, and create user accounts. There is no sandbox and no reduced privilege set.
That is the same bargain you already accept every time you install a third-party module. A module you install gets the process; a script a host saves gets the process. The difference is only that a script is smaller, is written by you, and takes effect without a restart.
This page is about the three things that stand between "a form exists" and "code runs on your server", what each one is actually for, and how to keep the arrangement sane once more than one person is involved.
Important
If you are arriving from an older page that described a mediated capability object — a catalog of
named actions and endpoints a script asked for by name — that layer has been removed. Scripts now
call platform APIs directly.
1. The three gates, in order
All three must hold. There is no fallback path, no per-site override, and no way for a request to talk the server into skipping one.
Gate 1 — the installation switch
Off on every install. A host turns it on by editing the config file on the server:
<appSettings>
<add key="MegaForm:AfterSubmitScriptEnabled" value="true" />
</appSettings>
Anything other than true (case-insensitive) — including a missing or unreadable entry — means off.
It is a config file rather than a settings screen on purpose. Turning this on means people may run code on this server, and the right bar for that is can edit files on this server — a meaningfully smaller group than knows the superuser password. Editing the file also recycles the application, so the change lands at a moment you picked rather than mid-afternoon on a Tuesday.
There is a second, quieter benefit. Any recovery procedure that restores your site from a backup and a fresh config file brings the feature back off. A settings-table switch would come back on.
Gate 2 — a host account saves the script
Only a Host / SuperUser can open the editor, change a script, or save one. Not a site administrator. Not someone with Edit permission on the module.
That distinction matters more than it looks. "Edit module" is a content-editor permission several people usually hold, and a site Administrator's reach stops at their own portal — while a script runs in the process shared by every portal on the installation. Anyone below Host gets a plain refusal rather than an editor that looks enabled and then does nothing.
Gate 3 — the approval hash
When a host saves a script, the server computes a SHA-256 of the exact source it accepted — after its own normalisation, from the copy the server holds, not from anything the client sent alongside it — and stores that hash with the approver's user id, name and UTC timestamp.
Before every run, the server re-hashes the stored source and compares. Mismatch, no run.
That makes every other route into your forms inert by construction. A form export, a template install, a gallery download, a restored backup, a hand-built save request — all of them can carry a source string. None of them can carry a valid approval, because an approval is written server-side at the instant a host pressed Save on that site.
The messages an administrator sees when this bites are deliberately specific, because "nothing happened" is the failure mode this feature had to avoid:
Script has no host approval on this site. A host/superuser must open it and press Save.
Script source does not match the approved version — it was changed outside the host-only editor.
After-submit script is disabled for this form.
The first one is the import case. It is not a bug report — it is the gate doing its job. Someone on your site has to read the script and approve it before it becomes code.
The normal form Save path does not merge the client's version of the script block. It discards it and writes back the copy the server already had. A content editor saving a form cannot introduce a script, edit an approved one, or switch one off.
Switching one off is included on purpose. It sounds harmless, and it is not: anyone who can toggle Enabled off can toggle it back on after a host disabled it.
2. What the gates do not do
Being straight about the limits is the point of having them:
- A script is not sandboxed. Once approved, it has the process. The gates control who can put code there, not what the code may reach.
- The run timeout bounds the visitor, not the server. The run is awaited with a timeout (1–60 seconds, default 10). .NET has no safe way to abort a foreign thread, so on expiry the request stops waiting and the submission completes, with
Script did not finish within 10s. Cancellation was requested and the run was abandoned. in the run record. A script stuck in a tight loop keeps a thread-pool thread until the process recycles. Cancellation is cooperative: your script receives a CancellationToken named ct, and passing it to your await calls is what makes the timeout real. - Script source lives in the form record. It is in your database, in every backup, and in any export taken by someone with export rights. A connection string or an API key pasted into a script is a credential in all of those places.
- The audit write is fail-soft. An audit failure never fails a submission — losing an audit row is bad, losing a customer's submission is worse. So an empty run list is weak evidence. The same facts also go to the platform event log.
3. Strict mode
The product ships with an older enforcement pass still in the box, switched off.
Turned on, it runs a semantic check over the compiled script and refuses whole namespaces: System.IO, System.Net, System.Data, System.Reflection, System.Diagnostics, System.Threading (except Tasks), System.Security, System.Xml, Microsoft.Win32, Microsoft.CodeAnalysis, and the platform's own namespace, DotNetNuke. Individual types in otherwise-open namespaces go too: System.Type, System.Activator, System.Environment, System.AppDomain, System.Console, dynamic. Violations come back as MF1001 at save time, on the author's own line numbers:
MF1001 Namespace 'System.IO' is not available to scripts (used here: System.IO.File).
The check works on bound symbols, not on text, so it is not fooled by using F = System.IO.File;, by fully-qualified names, by global::, by generic arguments, or by an inferred var.
It is off by default because the reasoning behind it assumed something that is no longer true here: that the list protects the site from the script's author. It does not. The only account that can save a script is a superuser who can already install a module, edit config and run SQL. There is no privilege left to hold back.
Warning
Strict mode denies DotNetNuke, System.Data and System.Net outright. Every example in the
scripting documentation — UserController, RoleController, Mail.SendEmail, SqlConnection,
HttpClient — stops compiling. Turning it on is not a hardening tweak you apply to a working
site; it is a decision to write a different, much smaller kind of script.
When it is the right call: an installation where the person writing scripts and the person answering for the server are not the same person, and the scripts are genuinely only computation — scoring, rounding, formatting, deriving a value to log. If your scripts write to a database or call an API, strict mode is not for you.
The switch is a process-wide flag set at startup, not a per-form or per-site setting, and there is no screen for it. Unsafe code — pointers and stackalloc — is refused in both modes.
4. Reviewing a script before you approve it
You are the third gate. If the script came from a template, a partner, a contractor or an older site, read it as code you are about to install, because that is what it is.
What is worth the minutes:
Credentials. Any literal connection string, bearer token, API key or password. On DNN, Config.GetConnectionString() already gives you the site's own connection — a script that carries its own is either talking to something else (fine, but you should know about it) or duplicating a secret into your form table.
SQL built by concatenation. "... WHERE Email = '" + ctx.GetString("email", "") + "'" is an injection hole with a submitted value on the wrong side of it. Parameters, always.
Outbound URLs built from submitted data. A script's HttpClient is a plain HttpClient. There is no guard between it and your internal network, so a URL that comes even partly from ctx.Data is a request an anonymous visitor gets to aim. Hard-code the host; take only the path or the payload from the form.
What it does with identity. UserController.CreateUser and RoleController calls on a public form mean an anonymous submission creates accounts and grants roles. Check which role, and check the conditions under which it happens.
Work inside a loop. Every HTTP call and every query is paid inside the submit request, with the visitor watching a spinner. Count the calls in the worst case, not the typical one.
Silence. A script that catches everything and logs nothing will fail for months without anyone noticing. Look for a ctx.Log line on the paths that matter and a ctx.Fail on the ones that should count as failures.
Anything that reads like infrastructure. Reading or writing files, starting processes, touching the registry, loading assemblies, compiling more code. A form hook has no business doing any of it. If you see one and cannot explain why it is there, do not approve it.
Two habits that cost nothing: keep a copy of what you approved outside the site, and re-read the diff — not the whole script — every time someone asks you to press Save again. The approval hash tells you the source changed; it cannot tell you whether the change was the one you were promised.
5. The run record
Every attempted run is written down, including the ones that did not happen.
Runs capture the form, the submission, the stage, the hash of the source that ran, success or failure, whether the run aborted, the duration in milliseconds, the acting user id, the error message, the full ctx.Log output as text, and the UTC timestamp. On SQL Server these are the MF_AutomationRuns rows, indexed by form and date.
Approvals capture the form, the stage, the hash, the action, the user id and name, the source length and the UTC timestamp — MF_AutomationScriptApprovals. This is the table that answers "who put code on this server, and when" after an incident. It is only able to answer because the endpoint wrote it at the moment it happened, from the server's own idea of who the caller was.
Read both through the host-only endpoint, newest first:
GET /DesktopModules/MegaForm/API/FormScript/Runs?formId=12&take=25
take defaults to 25 and is clamped to 200. The response carries runs and approvals side by side, which is usually how you want them: a run that failed right after an approval row is a different story from one that has been failing since last quarter.
There is no runs screen in the builder today. The endpoint and the tables are the interface.
A useful detail when reading them: a skipped run is not a failed one. Disabled, empty, not approved on this site, hash mismatch, or the scripting assembly missing from bin all record a reason rather than an error, so "did not run" and "ran and broke" stay tellable apart.
6. One more thing about stages
Four stages exist in the engine — PreValidate, PreInsert, PostCommit, AsyncWorker — and only PostCommit can be configured. Nothing in the product writes a script into the other three: no editor, no API, no import path.
For this page that has one consequence worth stating plainly. A script cannot refuse a submission, and it cannot rewrite a stored value; ctx.SetValue is refused after the commit by design. So the review question is never "could this script silently corrupt what we stored" — it is "what does this script reach outside the submission", which is exactly the list in §4.