AMPscript Differs from Official Docs (Engagement)
AMPscript functions whose real behaviour on Marketing Cloud Engagement contradicts the official Salesforce documentation — wrong return types, wrong argument rules, undocumented behaviour. Every entry is proven on a live Engagement CloudPage.
The official AMPscript documentation is not always right. Return types can be wrong, arguments described as optional turn out to be mandatory, and some functions behave differently than the reference page claims. This page collects those cases for Marketing Cloud Engagement — every entry here is proven by running the function on a live Engagement CloudPage, not inferred from reading the docs.
This page tracks AMPscript discrepancies. Server-Side JavaScript engine and runtime quirks are documented separately at ssjs.guide/engine-limitations/differs-from-docs.
Function availability and behaviour diverge between the two platforms — Char and RegExMatch, for example, are Engagement-only. Next findings are tracked on AMPscript Differs from Official Docs (Next).
How to read an entry
Each finding is a card with a severity (high / medium / low), a discrepancy-type code, the affected function category, a description of what the docs claim versus what actually happens, and — where one exists — a copy-paste AMPscript snippet that reproduces it plus a link to the official page that is inaccurate.
| Code | Discrepancy type |
|---|---|
| A1 | Wrong return type |
| A2 | Null / empty result on absence |
| A3 | Wrong argument count or optionality |
| A4 | Wrong, renamed, or relocated members |
| A5 | Undocumented but real behaviour |
| A6 | Context or availability differs |
| A7 | Encoding, format, or validation semantics |
Findings
Discrepancy type
Random — decimal bounds are documented as allowed but abort the page
The official reference presents the two bounds as numbers that may carry a
decimal part. At runtime only whole numbers are usable: every decimal bound
aborted the CloudPage with HTTP 422, discarding all output rendered before
it. Random(1.2, 1.8), Random(1, 2.5), Random(1.0, 3.0) and the quoted
form Random("1.5", "3.5") all failed, while the equivalent whole-number
calls and whole-number strings such as Random("1", "2") returned values
normally.
The same decimal calls were redeployed to the parent business unit and aborted there too, so this is not a limitation of the child QA account. Treat the bounds as integers only — round or truncate before the call.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* whole-number bounds: renders a value */
IF @b == "ok" THEN
OutputLine(Concat("Random(1,3)=[", Random(1,3), "]"))
ENDIF
/* decimal bounds: page aborts with HTTP 422, nothing renders */
IF @b == "dec" THEN
OutputLine(Concat("Random(1.2,1.8)=[", Random(1.2,1.8), "]"))
ENDIF
]%%
FormatDate — mm in the date pattern renders the month, and the day-name tokens are off by one letter
The two pattern arguments are separate dialects, and both ignore case.
The official reference prints one custom-pattern table and demonstrates it on
the second argument, which reads as though a single set of tokens applies
everywhere. It does not. In the second argument (the date pattern) mm and
MM both render the month; minutes are simply not reachable from there.
Move the same pattern into the third argument (the time pattern) and mm
means minutes again — there, MM means minutes too.
The consequence bites the most common call there is. For the instant
2026-03-04 13:52:07, the documented pattern
FormatDate(@d, "yyyy-MM-dd HH:mm:ss") rendered 2026-03-04 13:03:07 — the
minutes position printed 03, the month. Splitting the pattern across the two
arguments as FormatDate(@d, "yyyy-MM-dd", "HH:mm:ss") rendered
2026-03-04 13:52:07 correctly. Case makes no difference anywhere: YYYY
matched yyyy, mmmm matched MMMM, and DDDD matched dddd.
Single-letter tokens select a standard format instead of an unpadded
number. The doc lists d as the day without a leading zero and M as the
month without one; d actually rendered the whole short date 3/4/2026 and
M rendered March 4. In the time pattern, h or H on its own does not
render an hour at all — it aborts the page with HTTP 422 and discards
everything already written. Use the doubled forms.
The day-name tokens are off by one repetition, and one of them is
corrupted. The doc promises dddd for the full day name and ddd for the
abbreviation. dddd rendered Wed, and the full name needed a fifth d:
ddddd rendered Wednesday. ddd rendered neither — it produced
We4ne74a26, in which digits taken from the date have replaced letters of the
day name, and the same input on a December date produced ri25a26. Never use
ddd.
Show test script
%%[ VAR @b, @d
SET @b = RequestParameter("b")
SET @d = "2026-03-04 13:52:07"
/* the minutes position renders the month - fetch ?b=repro */
IF @b == "repro" THEN
OutputLine(Concat("ONEARG=[", FormatDate(@d, "yyyy-MM-dd HH:mm:ss"), "]"))
OutputLine(Concat("SPLIT=[", FormatDate(@d, "yyyy-MM-dd", "HH:mm:ss"), "]"))
OutputLine(Concat("DATEmm=[", FormatDate(@d, "mm"), "]"))
OutputLine(Concat("TIMEmm=[", FormatDate(@d, "", "mm"), "]"))
ENDIF
/* the day-name tokens are shifted, and ddd is corrupted - fetch ?b=daynames */
IF @b == "daynames" THEN
OutputLine(Concat("d4=[", FormatDate(@d, "dddd"), "]"))
OutputLine(Concat("d5=[", FormatDate(@d, "ddddd"), "]"))
OutputLine(Concat("d3=[", FormatDate(@d, "ddd"), "]"))
OutputLine(Concat("SINGLEd=[", FormatDate(@d, "d"), "]"))
OutputLine(Concat("SINGLEM=[", FormatDate(@d, "M"), "]"))
ENDIF
]%%
DateAdd — anything outside the five listed units destroys the page instead of returning a value
The unit list is exhaustive, and the penalty for leaving it is the whole
page. The reference lists Y, M, D, H and MI without saying what
happens to anything else. What happens is an HTTP 422 that discards every
byte the page had already written — there is no fallback unit, no ignored
argument and no empty-string result to test for.
A seconds unit, a milliseconds unit, a weeks unit, a quarter unit, the
spelled-out words for a day and a minute, an unknown two-letter token and an
empty string were each fetched in their own single-call branch and each
returned 422 with no output, while a control branch calling DateAdd in its
plainest form rendered normally in the same deployment.
The amount argument is just as strict about shape. A whole number works in
either sign, and so does the same value written as a string — useful, since a
value read out of a data extension arrives as one. A decimal does not: 1.5
and "1.5" both abort, as does a word. Nothing is rounded or truncated.
The date argument behaves the same way, which is worth knowing because the
neighbouring FormatDate does
the opposite: where FormatDate quietly returns an empty string for input it
cannot read, DateAdd aborts. An unparseable string, an empty string and a
plain number each cost the page.
The one piece of good news is that the unit token ignores case — mi, Mi
and mI all produced the same minute as MI.
Show test script
%%[ VAR @b, @d
SET @b = RequestParameter("b")
SET @d = "2026-03-04 13:52:07"
/* the control - this one renders. Fetch ?b=ctrl */
IF @b == "ctrl" THEN
OutputLine(Concat("CTRL=[", DateAdd(@d, 1, "D"), "]"))
OutputLine(Concat("LOWER=[", DateAdd(@d, 1, "mi"), "]"))
OutputLine(Concat("STRAMT=[", DateAdd(@d, "1", "D"), "]"))
ENDIF
/* each of these renders NOTHING - fetch one at a time */
IF @b == "sec" THEN
OutputLine(Concat("S=[", DateAdd(@d, 30, "S"), "]"))
ENDIF
IF @b == "wk" THEN
OutputLine(Concat("W=[", DateAdd(@d, 1, "W"), "]"))
ENDIF
IF @b == "frac" THEN
OutputLine(Concat("FRAC=[", DateAdd(@d, 1.5, "D"), "]"))
ENDIF
IF @b == "baddate" THEN
OutputLine(Concat("BAD=[", DateAdd("not a date at all", 1, "D"), "]"))
ENDIF
]%%
DateDiff — one minute either side of midnight on New Year's Eve is one whole year apart
This function does not measure elapsed time — it counts boundaries. Both dates are truncated to the requested unit and then subtracted, so what you get back is how many unit boundaries lie between them, not how much time passed. The reference is silent on this, and its own worked example (a whole day, measured in minutes) is the one case where the two readings agree.
The extreme case makes it obvious. From 2026-12-31 23:59:00 to
2027-01-01 00:00:00 — sixty seconds — the answer is 1 for Y, 1 for
M, 1 for D and 1 for MI, all at once, because that single minute
crosses all four boundaries. Run it the other way and a gap of almost a full
year, 2026-01-01 00:00:00 to 2026-12-31 23:59:00, returns 0 for Y.
The everyday version is the one that bites: 2026-01-10 08:00:00 to
2026-01-11 07:00:00 is 23 hours and returns 1 day, while 23 hours that
stay inside one date return 0. Anything finer than the requested unit is
discarded outright — 59 seconds measured in MI is 0, but one second
across a minute boundary is 1.
So DateDiff(a, b, "D") == 1 does not mean “a day has passed”; it means
“the date changed once”. If you need elapsed time, ask for the smallest unit
available and divide — and note there is no seconds unit, so MI is as fine
as it gets.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* fetch ?b=bound */
IF @b == "bound" THEN
OutputLine(Concat("ONEMIN_Y=[", DateDiff("2026-12-31 23:59:00", "2027-01-01 00:00:00", "Y"), "]"))
OutputLine(Concat("ONEMIN_D=[", DateDiff("2026-12-31 23:59:00", "2027-01-01 00:00:00", "D"), "]"))
OutputLine(Concat("ALMOSTYEAR_Y=[", DateDiff("2026-01-01 00:00:00", "2026-12-31 23:59:00", "Y"), "]"))
OutputLine(Concat("ACROSS23H_D=[", DateDiff("2026-01-10 08:00:00", "2026-01-11 07:00:00", "D"), "]"))
OutputLine(Concat("WITHIN23H_D=[", DateDiff("2026-01-10 00:00:00", "2026-01-10 23:59:00", "D"), "]"))
OutputLine(Concat("ONESEC_MI=[", DateDiff("2026-01-10 08:00:59", "2026-01-10 08:01:00", "MI"), "]"))
ENDIF
]%%
DatePart — the hour comes back on a 12-hour clock with nothing to tell AM from PM
Asking for the hour does not give you the hour of the day. The value is
read off a 12-hour clock and no AM/PM indicator comes with it, so the answer
is ambiguous by construction. 19:35:47 returns 7, and 09:05:07 also
returns 9 — the two are indistinguishable from the result alone. The
reference says only that H extracts hours.
Midnight is the trap underneath the trap. 00:30 returns 12, not 0, and
a date string carrying no time part at all — 2026-03-04 — also returns
12, because midnight on a 12-hour clock is twelve o’clock. So a missing
time is not distinguishable from noon or from half past midnight either.
Anything that compares hours, buckets a send into a time of day, or feeds the
number back into a date will be wrong for half the day. Pull the hour with
FormatDate and an HH pattern
when you need a 24-hour value, and treat DatePart(..., "H") as display-only.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* fetch ?b=hour */
IF @b == "hour" THEN
OutputLine(Concat("EVENING=[", DatePart("2026-11-23 19:35:47", "H"), "]"))
OutputLine(Concat("MORNING=[", DatePart("2026-03-04 09:05:07", "H"), "]"))
OutputLine(Concat("AFTERMIDNIGHT=[", DatePart("2026-03-04 00:30:00", "H"), "]"))
OutputLine(Concat("NOON=[", DatePart("2026-03-04 12:30:00", "H"), "]"))
OutputLine(Concat("NOTIMEPART=[", DatePart("2026-03-04", "H"), "]"))
ENDIF
]%%
DateParse — an unsupported date string aborts the page instead of being rejected gracefully
The reference lists the formats that are not supported, but never says what
happens when you pass one. The answer is that the whole render is thrown
away. March 4th, 2026 with an ordinal suffix, 4 mai 2026 with a
non-English month, an empty string, free text and a bare number each returned
HTTP 422 with no output at all — not an empty value, not an error token, and
not the partial page written above the call.
An epoch-style number is worth calling out separately, because it looks like
it ought to work: 1772614800 passed as a string aborts exactly the same way,
and so does the unquoted number 20260304. There is no numeric input path.
This matters most when the string comes from a data extension field or a query string, where a single malformed row takes the page down. Validate the value before it reaches the function — there is no return value you can test for afterwards, because there is no “afterwards”. This is the same unforgiving failure mode as DateAdd and DatePart, and the opposite of FormatDate, which returns an empty string.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* fetch one at a time: ?b=ordinal, ?b=frmonth, ?b=epoch, ?b=empty */
IF @b == "ordinal" THEN
OutputLine(Concat("ORDINAL=[", DateParse("March 4th, 2026"), "]"))
ENDIF
IF @b == "frmonth" THEN
OutputLine(Concat("FRMONTH=[", DateParse("4 mai 2026"), "]"))
ENDIF
IF @b == "epoch" THEN
OutputLine(Concat("EPOCH=[", DateParse("1772614800"), "]"))
ENDIF
IF @b == "empty" THEN
OutputLine(Concat("EMPTY=[", DateParse(""), "]"))
ENDIF
]%%
DateParse — a day-first date is silently read month-first rather than refused
The reference calls little-endian notation unsupported, which reads like it
will be rejected. It is not — it is quietly misread. 5/8/2026, meant as
the 5th of August, parsed without complaint and came back as
5/8/2026 12:00:00 AM, i.e. the 8th of May. No abort, no empty value, nothing
to test for: the page renders a plausible date that is four months wrong.
That makes it more dangerous than the formats that abort, because those at least announce themselves. Any European-formatted string arriving from an import, a form post or a partner feed is at risk, and roughly a third of day-first dates in a year are also valid month-first dates, so the failure is intermittent rather than reproducible on the first row you check.
Normalise to an ISO yyyy-MM-dd string before parsing, and never rely on the
function to notice that a date is the wrong way round.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* fetch ?b=little */
IF @b == "little" THEN
OutputLine(Concat("MEANT_5_AUGUST=[", DateParse("5/8/2026"), "]"))
OutputLine(Concat("ISO_CONTROL=[", DateParse("2026-08-05"), "]"))
ENDIF
]%%
Base64Decode — Malformed Base64 takes the whole page down with HTTP 422 instead of returning an empty value
A value that is not well-formed Base64 aborts the entire page. No empty string, no partial result, no error token — HTTP 422 and everything already rendered is discarded.
Three malformed shapes were each proven on their own gate:
Base64Decode("not!base64###") (characters outside the alphabet),
Base64Decode("SGVsbG8") (a length that is not a multiple of four) and
Base64Decode("SGVsbG8=====") (over-padding). All three returned HTTP 422
with not even the block’s own start marker rendered, while an ungated
control block in the same deployment kept returning HTTP 200 — so the
aborts belong to the function, not to the harness.
The middle case is the one that bites in production: SGVsbG8 is the
correct payload for Hello with its single = stripped, so a token that
merely lost its padding somewhere in transit is fatal. Since AMPscript has
no try/catch, there is no way to recover after the fact. Validate any
externally supplied value before it reaches the function — a non-empty
check plus Mod(Length(@raw), 4) == 0 catches both realistic cases.
The empty string is the one exception: it decodes to the empty string at HTTP 200.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* each gate below aborts the page - fetch them one at a time */
IF @b == "badchars" THEN
OutputLine(Concat("--- badchars start ---"))
OutputLine(Concat("B1=[", Base64Decode("not!base64###"), "]"))
ENDIF
IF @b == "badpad" THEN
OutputLine(Concat("--- badpad start ---"))
OutputLine(Concat("B2=[", Base64Decode("SGVsbG8"), "]"))
ENDIF
IF @b == "overpad" THEN
OutputLine(Concat("--- overpad start ---"))
OutputLine(Concat("B3=[", Base64Decode("SGVsbG8====="), "]"))
ENDIF
/* fetch ?b=empty - the one malformed-looking input that is accepted */
IF @b == "empty" THEN
OutputLine(Concat("BDE=[", Base64Decode(""), "]"))
ENDIF
]%%
IsNull — the reference's own example says an unset variable returns true; on a CloudPage it returns false
The usage example on the official page declares a variable with VAR,
never assigns it, and states the result is true. That shape returned
False on a live CloudPage.
The gate printed its own start and done markers at HTTP 200 next to a known-good control block, so the page ran to completion — this is a result rather than a swallowed abort.
Sixteen further inputs gave the same answer: an undeclared variable, the
empty string, whitespace, 0, the string "0", the string "false", an
ordinary string, a date, an attribute that does not exist, a request
parameter that was not supplied, _subscriberkey, firstname,
_messagecontext, jobid, and an unset variable routed through both v()
and Concat(). Not one produced the true token, while Empty() probed in
the same run rendered True readily — so the engine does render the token,
this function just never reached it.
In practice the function answers a narrower question than its name
suggests: a genuine database null in a data extension field. For “is this
value missing or blank”, use Empty() instead — writing IsNull() there
silently takes the false branch for every input a page variable can hold.
Show test script
%%[ VAR @b, @unset
SET @b = RequestParameter("b")
/* fetch ?b=unset - the official example's shape, rendered as False */
IF @b == "unset" THEN
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
OutputLine(Concat("N_UNSET=[", IsNull(@unset), "]"))
OutputLine(Concat("E_UNSET=[", Empty(@unset), "]"))
ENDIF
]%%
Format — the documented data-format value Number takes the whole page down
The official reference names two values for the third parameter, Date
and Number. Passing the literal Number aborts the page with HTTP 422
and discards every byte of output.
Both Format(1234.555, "C2", "Number") and the four-argument
Format(1234.555, "C2", "Number", "de-DE") failed the same way, in
single-call gates, next to a known-good control block that rendered on its
own request — so this is the argument value being rejected, not a broken
harness and not an argument-count problem.
An invented value such as Banana produced exactly the same abort, which
is the tell: the runtime recognises only Date (in any capitalisation)
and the empty string, and treats everything else as unusable.
The parameter is not merely optional for numbers — it is unusable for them. Number formatting works with the third parameter left out entirely, and when a locale is needed the empty string keeps the fourth slot reachable:
Format(1234.555, "C2")→$1,234.56Format(1234.555, "C2", "", "de-DE")→ the German form with the euro sign at the end
Anyone following the reference literally and writing "Number" gets a
blank page rather than a formatted number.
Show test script
%%[ VAR @b, @n
SET @b = RequestParameter("b")
SET @n = 1234.555
/* fetch ?b=numflag - aborts with HTTP 422, no output at all */
IF @b == "numflag" THEN
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
OutputLine(Concat("N=[", Format(@n, "C2", "Number"), "]"))
ENDIF
/* fetch ?b=emptyflag - renders, and the locale still applies */
IF @b == "emptyflag" THEN
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
OutputLine(Concat("E=[", Format(@n, "C2", "", "de-DE"), "]"))
ENDIF
]%%
IsCHTMLBrowser — an empty user agent aborts the page instead of returning false
Passing an empty value aborts the request with HTTP 422 and discards everything the page had already rendered.
The gate whose only content was IsCHTMLBrowser("") failed, while three
sibling gates on the same deploy — the empty string put through
IsEmailAddress, IsPhoneNumber and Domain — each rendered normally
at HTTP 200, as did the plain request. So the page compiled and only this
branch died.
The realistic way to meet it is the documented usage itself: the
reference suggests passing HTTPRequestHeader("user-agent") straight in.
A request that sends no user-agent header leaves that empty, and the page
goes blank for the visitor. Read the header into a variable, check it
with Empty, and only then call:
IF NOT Empty(@ua) THEN
SET @chtml = IsCHTMLBrowser(@ua)
ENDIF
Composing the two calls inline also aborts, independently of whether a header was sent — the variable is not optional styling.
The docs are silent about the empty case rather than wrong about it.
Show test script
%%[ VAR @b, @ua
SET @b = RequestParameter("b")
SET @ua = HTTPRequestHeader("user-agent")
/* fetch ?b=safe - guarded, renders for every visitor */
IF @b == "safe" THEN
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
IF NOT Empty(@ua) THEN
OutputLine(Concat("LIVE=[", IsCHTMLBrowser(@ua), "]"))
ELSE
OutputLine(Concat("LIVE=[no-user-agent-sent]"))
ENDIF
ENDIF
/* fetch ?b=empty - aborts with HTTP 422, no output at all */
IF @b == "empty" THEN
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
OutputLine(Concat("E=[", IsCHTMLBrowser(""), "]"))
ENDIF
]%%
CloudPagesURL — A page ID that matches no page takes the whole page down instead of returning nothing
The official reference says nothing about what happens when the page ID does not identify a landing page. The answer is the worst one available: the request aborts with HTTP 422 and discards everything already rendered. There is no empty string, no error value and no sentinel to test for — a single mistyped digit turns a working page into a blank failure, and the output that would have told you why is thrown away with it.
This is doubly worth knowing because the sibling MicrositeURL behaves the opposite way: an ID matching no asset there still returns a well-formed URL. Two functions with the same signature shape, and one of them is a landmine.
Validate the ID before you build the link, and never take one from unvalidated input.
The same abort applies to an even total argument count — the extra name-value pairs are accepted only in pairs, so a name supplied without its value fails the request rather than being ignored.
Show test script
%%[ VAR @b, @pid
SET @b = RequestParameter("b")
SET @pid = 39412
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
/* fetch ?b=good — renders the page URL */
IF @b == "good" THEN
OutputLine(Concat("R=[", CloudPagesURL(@pid), "]"))
ENDIF
/* fetch ?b=bad — HTTP 422, and the control line above is lost too */
IF @b == "bad" THEN
OutputLine(Concat("R=[", CloudPagesURL(987654321), "]"))
ENDIF
/* fetch ?b=odd — a name without its value, also HTTP 422 */
IF @b == "odd" THEN
OutputLine(Concat("R=[", CloudPagesURL(@pid, "k1"), "]"))
ENDIF
]%%
BuildRowsetFromJSON — The third argument works the opposite way round from the Syntax section
The reference page’s Syntax section says a false third argument returns
an empty rowset and a true one raises an exception. Runtime does the
reverse: an unparsable payload passed with 1 rendered a rowset of zero
rows, while the identical payload passed with 0 aborted the whole page.
The same page’s Errors section actually describes the runtime ordering, so
it contradicts its own Syntax bullet. Treat the flag as return empty on
error: pass 1 (or true) whenever the input can be untrusted, and check
RowCount before reading anything.
The XML sibling behaves identically — see BuildRowSetFromXML.
Show test script
%%[ VAR @b, @rows
SET @b = RequestParameter("b")
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
/* fetch ?b=safe - renders rc=[0] */
IF @b == "safe" THEN
SET @rows = BuildRowsetFromJSON("{ not json ", "$.Flights[*]", 1)
OutputLine(Concat("rc=[", RowCount(@rows), "]"))
ENDIF
/* fetch ?b=abort - HTTP 422, the control line above is lost too */
IF @b == "abort" THEN
SET @rows = BuildRowsetFromJSON("{ not json ", "$.Flights[*]", 0)
OutputLine(Concat("rc=[", RowCount(@rows), "]"))
ENDIF
]%%
BuildRowSetFromXML — The third argument works the opposite way round from the Syntax section
As with the JSON sibling, the Syntax section’s description of the third
argument is reversed. Unclosed markup parsed with 1 produced a rowset of
zero rows; the same markup parsed with 0 aborted the page and discarded
everything already rendered.
Pass 1 (or true) for untrusted input and branch on RowCount. See
BuildRowsetFromJSON for the
identical finding on the JSON builder.
Show test script
%%[ VAR @b, @rows
SET @b = RequestParameter("b")
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
/* fetch ?b=safe - renders rc=[0] */
IF @b == "safe" THEN
SET @rows = BuildRowSetFromXML("<root><a>1</a>", "//a", 1)
OutputLine(Concat("rc=[", RowCount(@rows), "]"))
ENDIF
/* fetch ?b=abort - HTTP 422, the control line above is lost too */
IF @b == "abort" THEN
SET @rows = BuildRowSetFromXML("<root><a>1</a>", "//a", 0)
OutputLine(Concat("rc=[", RowCount(@rows), "]"))
ENDIF
]%%
Divide — a zero divisor silently renders an infinity symbol instead of failing
The official page says nothing at all about a divisor of zero — this is an
undocumented behaviour, not a contradiction of the docs. What actually
happens is that the call does not raise an error and does not abort
the page: with a non-zero dividend it renders the infinity symbol ∞
(U+221E), and Divide(0, 0) renders NaN. A negative dividend renders
-∞. A zero divisor passed as a numeric string behaves identically.
That matters because the value flows straight into the rendered message, so
an unguarded division by zero ships ∞ to the recipient rather than
failing loudly. Guard the divisor yourself before dividing.
Note the glyph really is U+221E — a console that is not reading the response
as UTF-8 can display it as the digit 8. Verify the codepoint before
concluding anything about the rendered literal.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
IF @b == "d0" THEN
OutputLine(Concat("Divide(100,0)=[", Divide(100,0), "]"))
OutputLine(Concat("Divide(-100,0)=[", Divide(-100,0), "]"))
OutputLine(Concat("Divide(0,0)=[", Divide(0,0), "]"))
OutputLine(Concat("Divide(100,'0')=[", Divide(100,"0"), "]"))
ENDIF
]%%
Mod — a zero divisor returns NaN — inconsistently with its sibling Divide
As with Divide, the official page is silent on a divisor of zero, so this
is undocumented behaviour rather than a documented claim being wrong. The
call does not abort the page: Mod(10, 0), Mod(-10, 0) and Mod(0, 0)
all render the three ASCII characters NaN.
The interesting part is the inconsistency between the two sibling
functions. Divide renders the infinity symbol ∞ for a non-zero dividend
and only falls back to NaN for 0 / 0, whereas Mod renders NaN in
every zero-divisor case including a non-zero dividend — see
Divide. Code that tests for one sentinel
will not catch the other, so guard the divisor instead of pattern-matching
the result.
The responses were fetched as UTF-8 so a non-ASCII glyph could not be
mistaken for NaN.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
IF @b == "m0" THEN
OutputLine(Concat("Mod(10,0)=[", Mod(10,0), "]"))
OutputLine(Concat("Mod(-10,0)=[", Mod(-10,0), "]"))
OutputLine(Concat("Mod(0,0)=[", Mod(0,0), "]"))
/* contrast: Divide renders the infinity symbol here */
OutputLine(Concat("Divide(10,0)=[", Divide(10,0), "]"))
ENDIF
]%%
Length — counts UTF-16 code units, so an emoji measures 2
The official page speaks of the number of characters without ever saying
what a character is, so nothing it claims is contradicted — this is simply
undocumented. What the runtime actually counts is UTF-16 code units. A
single emoji from outside the Basic Multilingual Plane returns 2, because
it is stored as a surrogate pair, while a precomposed accented letter and
the German sharp s each return 1.
That was settled by dumping the codepoints of the echoed input next to the
returned count, so a mis-decoded console could not have invented the result:
the emoji echoed as the pair 55357/56832 and still measured 2, the
accented letter echoed as the single codepoint 233 and measured 1.
The practical consequence is truncation and validation logic. A field
limited by Length will accept a string that is one user-visible character
shorter than expected as soon as an emoji is involved, and cutting a string
at a code-unit position can split a surrogate pair in half.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
IF @b == "nal" THEN
/* plain ASCII: one unit per character */
OutputLine(Concat("NAL_ASCII4=[", Length("abcd"), "]"))
/* precomposed accented letter: still counts as one */
OutputLine(Concat("NAL_CAFE=[", Length("café"), "]"))
/* astral emoji: counts 2, one per UTF-16 code unit */
OutputLine(Concat("NAL_EMO=[", Length("😀"), "]"))
ENDIF
]%%
Concat — boolean values are swallowed silently — the whole String family does it
None of the String reference pages say what happens when a boolean reaches
them, so this is undocumented behaviour and not a contradiction of anything
the docs state. The runtime neither converts the boolean nor rejects it: the
page returns HTTP 200 and the value simply disappears. Concat(true, false)
rendered an empty result, and so did Lowercase(true) and
Uppercase(false). Length(true) is the only one that renders anything at
all, and it renders 0.
This is worse than an abort would be, because booleans arrive in string
contexts by accident all the time — the result of IsNull, Empty or an
address check dropped straight into a Concat for a rendered message. The
send succeeds, the recipient gets a sentence with a hole in it, and nothing
in the response signals a problem.
Because nothing coherent about the boolean survives, this is treated as a
rejection for typing purposes: the parameter types stay string, number and
date. Convert booleans to text yourself — an IF that writes "yes" or
"no" into a variable — before passing them to a string function.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* all three return HTTP 200 with nothing between the brackets */
IF @b == "cb" THEN
OutputLine(Concat("CB=[", Concat(true, false), "]"))
ENDIF
IF @b == "lob" THEN
OutputLine(Concat("LOB=[", Lowercase(true), "]"))
ENDIF
IF @b == "ub" THEN
OutputLine(Concat("UB=[", Uppercase(false), "]"))
ENDIF
/* Length does render a number, but it is always 0 */
IF @b == "lb" THEN
OutputLine(Concat("LB=[", Length(true), "]"))
ENDIF
]%%
IndexOf — the search is case-insensitive, which no source mentions
Neither the official reference nor the community guide says anything about letter case, and every published example happens to search with matching case, so the behaviour is invisible from the documentation. The search is in fact case-insensitive: searching “Hello World” for an all-caps WORLD returns 7, searching it for an all-lowercase world returns the same 7, and searching for a lowercase h against the leading capital H returns 1. A caller who expects a case-sensitive match will silently get hits they never intended.
There is no flag to turn this off. When case matters, compare the located text yourself - pull it out with Substring and test it - rather than relying on IndexOf returning 0 for a case mismatch.
The docs are silent here rather than wrong, so the catalog entry is not flagged as contradicting them.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
IF @b == "case" THEN
OutputLine(Concat("UPPERNEEDLE=[", IndexOf("Hello World", "WORLD"), "]"))
OutputLine(Concat("LOWERNEEDLE=[", IndexOf("Hello World", "world"), "]"))
OutputLine(Concat("LOWERH=[", IndexOf("Hello World", "h"), "]"))
ENDIF
]%%
ProperCase — capitals inside a word are destroyed, not preserved
The official page describes the capitalising half of the conversion and says nothing about what happens to the remaining letters, so this is undocumented behaviour rather than a documented claim being wrong. At runtime the conversion is destructive: after the first letter of a word is raised, every other letter in that word is forced down.
ProperCase("iPhone") renders Iphone, ProperCase("McDONALD") renders
Mcdonald, and ProperCase("HTML and CSS") renders Html And Css. The
obvious use — tidying an all-caps name field — is unaffected, but running
it over mixed-case display text silently damages brand names and acronyms.
Word boundaries are also narrower than they look. Only whitespace and
digits start a new word for capitalisation purposes: a letter directly
after an apostrophe, hyphen, full stop or comma stays lower case, so
o'neill mcdonald-smith becomes O'neill Mcdonald-smith rather than the
O'Neill McDonald-Smith a name formatter would want.
Whitespace itself is untouched — padding and inner runs of spaces survive exactly — so this function never doubles as a trim.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
IF @b == "destructive" THEN
/* the rest of each word is forced to lower case */
OutputLine(Concat("PM1=[", ProperCase("iPhone"), "]"))
OutputLine(Concat("P3=[", ProperCase("McDONALD"), "]"))
OutputLine(Concat("PW3=[", ProperCase("HTML and CSS"), "]"))
/* punctuation does not start a new word, a digit does */
OutputLine(Concat("PW1=[", ProperCase("o'neill mcdonald-smith"), "]"))
OutputLine(Concat("PM3=[", ProperCase("a1b c2d"), "]"))
/* padding and inner spacing survive */
OutputLine(Concat("PW4=[", ProperCase(" spaced out "), "]"))
ENDIF
]%%
Substring — the two numeric arguments disagree about negatives
Neither the official page nor the community guide states a lower bound for either numeric argument, so this is undocumented behaviour rather than a documented claim being wrong — but the two arguments behave differently enough that a caller can take a page down without realising it.
A start position below 1 is silently clamped to the first character.
Substring("Hello World", 0, 5) and Substring("Hello World", -3, 5) both
render Hello, exactly like a start of 1. Nothing is shifted and nothing
is dropped.
A negative length, by contrast, aborts the whole page with HTTP 422 and discards everything rendered before the call. That matters because the idiomatic way to size a substring is to subtract two positions, and a missing separator makes that arithmetic go negative. Guard the computed length before passing it, rather than trusting the clamping that the start position gets.
An over-long length is safe: asking for more characters than remain returns what is left instead of erroring.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* a start below 1 is clamped - fetch ?b=clamped */
IF @b == "clamped" THEN
OutputLine(Concat("START1=[", Substring("Hello World", 1, 5), "]"))
OutputLine(Concat("START0=[", Substring("Hello World", 0, 5), "]"))
OutputLine(Concat("STARTNEG=[", Substring("Hello World", -3, 5), "]"))
OutputLine(Concat("LENOVER=[", Substring("Hello World", 7, 99), "]"))
ENDIF
/* a negative length aborts - fetch ?b=lenneg, the marker never renders */
IF @b == "lenneg" THEN
OutputLine(Concat("--- lenneg start ---"))
OutputLine(Concat("LENNEG=[", Substring("Hello World", 3, -2), "]"))
ENDIF
]%%
Replace — matching ignores case, the replacement argument is optional, and the scan is single-pass
Three behaviours no source describes, all of which change how the function should be called.
Casing is ignored when matching. Searching for WORLD, world or
hELLO all hit the differently-cased text in Hello World, and a source
holding Cat cat CAT comes back as three replacements, not one. Every
example in both sources happens to use matching case, so the behaviour is
invisible from the documentation - and there is no case-sensitive variant to
fall back on. The sibling IndexOf
matches the same way.
The third argument is optional. Both sources mark all three parameters required, but a two-argument call runs fine and deletes the search text, so removing a fragment needs no empty-string placeholder.
The source is scanned once. Replacing a doubled letter with a single one
inside aaa renders aa, not a: the pair the replacement helped form
is never revisited. Text you insert is likewise never re-matched, so a
replacement containing the search string is safe rather than looping.
An empty search string is a no-op that returns the source untouched, rather than inserting the replacement between every character.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* casing is ignored - fetch ?b=case */
IF @b == "case" THEN
OutputLine(Concat("UPPERNEEDLE=[", Replace("Hello World", "WORLD", "There"), "]"))
OutputLine(Concat("MIXEDNEEDLE=[", Replace("Hello World", "hELLO", "Howdy"), "]"))
OutputLine(Concat("MULTICASE=[", Replace("Cat cat CAT", "cat", "dog"), "]"))
ENDIF
/* two arguments delete, and the scan is single-pass - fetch ?b=shape */
IF @b == "shape" THEN
OutputLine(Concat("DROP=[", Replace("abc", "b"), "]"))
OutputLine(Concat("RECURSE=[", Replace("aaa", "aa", "a"), "]"))
OutputLine(Concat("SELFREF=[", Replace("cat", "cat", "cat dog"), "]"))
OutputLine(Concat("EMPTYNEEDLE=[", Replace("abc", "", "X"), "]"))
ENDIF
]%%
ReplaceList — Search values are applied in sequence, so an earlier replacement can be rewritten by a later one
Both sources present the search values as a flat set of delimiters to strip, which hides the two properties that decide whether a call is correct.
The search values run one after another, and each one sees the previous
result. ReplaceList("a", "XY", "a", "X") renders XYY: the first pass
turns the source into XY, and the second pass then finds the X that pass
one had just inserted. Order therefore matters -
ReplaceList("abc", "-", "ab", "bc") gives -c while swapping the two
search values gives a-. Put a search value that could match your own
replacement text first, or pick a replacement none of them can match.
Within a single search value the scan is still one pass, exactly like
Replace: replacing aa with a
single a inside aaa leaves aa rather than collapsing further.
Matching ignores case. A lowercase search value rewrites capitalised and
all-caps text alike, so ReplaceList("Red BLUE green", "-", "red", "blue", "GREEN")
blanks all three colours. There is no case-sensitive variant.
An empty search value is a no-op that returns the source untouched, and an empty replacement simply deletes every match.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* order and cascading - fetch ?b=order */
IF @b == "order" THEN
OutputLine(Concat("CASCADE=[", ReplaceList("a", "XY", "a", "X"), "]"))
OutputLine(Concat("ORDER=[", ReplaceList("abc", "-", "ab", "bc"), "]"))
OutputLine(Concat("ORDERREV=[", ReplaceList("abc", "-", "bc", "ab"), "]"))
OutputLine(Concat("SINGLEPASS=[", ReplaceList("aaa", "a", "aa"), "]"))
ENDIF
/* casing is ignored - fetch ?b=case */
IF @b == "case" THEN
OutputLine(Concat("UPPERNEEDLE=[", ReplaceList("Hello World", "There", "WORLD"), "]"))
OutputLine(Concat("MIXEDLIST=[", ReplaceList("Red BLUE green", "-", "red", "blue", "GREEN"), "]"))
ENDIF
]%%
RegExMatch — The only String function that matches case-sensitively, and its option list is variadic
Neither source states how RegExMatch treats casing, and the answer is the
opposite of every other String function.
Matching is case-sensitive by default.
RegExMatch("ORDER-4821", "order", 0) renders nothing at all, while the same
call with "IgnoreCase" appended renders ORDER. Its siblings
IndexOf,
Replace and
ReplaceList all match
regardless of case, so a pattern ported from one of them silently stops
matching. Pass the option, or write the pattern to cover both casings.
The option list has no upper bound. The syntax block shows a single trailing option, but five option names on one call were all accepted.
An empty result is ambiguous. No match, a capture-group index past the last group, and a group name that the pattern never declares all return the same empty string at HTTP 200. Only a malformed pattern or an option name that is not a real RegexOptions member behaves differently - those abort the page with HTTP 422 and discard everything rendered before them.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* casing and the option list - fetch ?b=case */
IF @b == "case" THEN
OutputLine(Concat("CASEDEF=[", RegExMatch("ORDER-4821", "order", 0), "]"))
OutputLine(Concat("CASEOPT=[", RegExMatch("ORDER-4821", "order", 0, "IgnoreCase"), "]"))
OutputLine(Concat("MANYOPTS=[", RegExMatch("ORDER-4821", "order.4821", 0, "IgnoreCase", "Singleline", "Multiline"), "]"))
ENDIF
/* the three ways to get an empty string - fetch ?b=empty */
IF @b == "empty" THEN
OutputLine(Concat("NOMATCH=[", RegExMatch("order-4821-eu", "[0-9][0-9][0-9][0-9][0-9][0-9]", 0), "]"))
OutputLine(Concat("BADGROUP=[", RegExMatch("order-4821-eu", "order-([0-9]+)-", 5), "]"))
OutputLine(Concat("BADNAME=[", RegExMatch("order-4821-eu", "order-([0-9]+)-", "nope"), "]"))
ENDIF
]%%
URLEncode — The two flags accept 0/1, true/false and the quoted spellings interchangeably - and by default a non-URL value is not encoded at all
The two optional flags have four accepted spellings, all equivalent. The
official reference describes them as integers while the community reference
and our own catalog described them as booleans. Both are right: 0/1,
false/true, and those same four words quoted as strings all reach the
same code path. Encoding spring sale with the second flag on gave
spring%20sale whether it was written 0, 1, false, true, or "0", "1",
and the fully encoded spring+sale for 1, 1, true, true and "1", "1".
Neither spelling aborts the page and neither is a silent no-op.
An out-of-domain integer is not rejected either. Passing 2 for both
flags rendered the input untouched at HTTP 200, i.e. it behaved like the off
state rather than erroring.
With no flags, a value that is not a URL is returned completely
unchanged. This is the trap the name hides: encoding only applies to the
part after a question mark unless the third argument switches it on. The
literal spring sale came back as spring sale, while the same text inside
a query string came back as spring%20sale. A caller encoding a bare field
value for a link therefore needs URLEncode(@value, 1, 1), not
URLEncode(@value).
Show test script
%%[ VAR @b, @plain, @url
SET @b = RequestParameter("b")
SET @plain = "spring sale"
SET @url = Concat("https://example.org/go?promo=spring sale&tags=a,b")
/* the four spellings of the same two flags - fetch ?b=spellings */
IF @b == "spellings" THEN
OutputLine(Concat("N01=[", URLEncode(@plain, 0, 1), "]"))
OutputLine(Concat("B01=[", URLEncode(@plain, false, true), "]"))
OutputLine(Concat("S01=[", URLEncode(@plain, "0", "1"), "]"))
OutputLine(Concat("N11=[", URLEncode(@plain, 1, 1), "]"))
OutputLine(Concat("B11=[", URLEncode(@plain, true, true), "]"))
OutputLine(Concat("S11=[", URLEncode(@plain, "1", "1"), "]"))
ENDIF
/* the default only touches a query string - fetch ?b=default */
IF @b == "default" THEN
OutputLine(Concat("PLAIN=[", URLEncode(@plain), "]"))
OutputLine(Concat("URL=[", URLEncode(@url), "]"))
OutputLine(Concat("OUTOFDOMAIN=[", URLEncode(@plain, 2, 2), "]"))
ENDIF
]%%
DateAdd — adding a month to 31 January gives 28 February, and adding it back does not return the 31st
Month arithmetic clamps to the last valid day, and the clamp loses
information. The reference is silent on what happens when the target month
is too short for the day you started on. The answer is that the day is pulled
back rather than spilling forward: one month after 2026-01-31 is
2/28/2026, not 3/3/2026.
That much is reasonable. The part that surprises callers is that the
operation does not undo itself — subtracting a month from that result gives
1/28/2026, three days away from where you started. Any code that steps a
date forward and back through month boundaries drifts.
The clamp applies to the result rather than sticking to the value: two months
from the same base rendered 3/31/2026, so the 31st is restored as soon as
the target month is long enough. Year arithmetic clamps identically — one
year after 2028-02-29 gave 2/28/2029.
If you need month-end semantics, compute the day of month yourself rather
than relying on a round trip through DateAdd.
Show test script
%%[ VAR @b, @j
SET @b = RequestParameter("b")
SET @j = "2026-01-31 13:52:07"
/* fetch ?b=roll */
IF @b == "roll" THEN
OutputLine(Concat("PLUS1M=[", DateAdd(@j, 1, "M"), "]"))
OutputLine(Concat("PLUS2M=[", DateAdd(@j, 2, "M"), "]"))
OutputLine(Concat("THEREANDBACK=[", DateAdd(DateAdd(@j, 1, "M"), -1, "M"), "]"))
OutputLine(Concat("LEAP=[", DateAdd("2028-02-29 09:00:00", 1, "Y"), "]"))
ENDIF
]%%
DatePart — month and day are zero-padded, hour and minute are not
The padding is not uniform across the parts. From
2026-03-04 09:05:07, the month renders as 03 and the day as 04 — two
characters each — while the hour renders as 9 and the minute as 5, one
character each. Length() over the same two calls confirms it: 2 for the
month, 1 for the hour.
Concatenating the parts to build a timestamp therefore produces a ragged
string rather than a fixed-width one, and comparing the text of two parts
compares padded values against unpadded ones. The numbers themselves are
fine — Add(DatePart(@d, "Y"), 1) and Multiply(DatePart(@d, "M"), 10) both
work without conversion, so the padding only matters when the value is
treated as text.
Use FormatDate when the width has to be predictable.
Show test script
%%[ VAR @b, @d
SET @b = RequestParameter("b")
SET @d = "2026-03-04 09:05:07"
/* fetch ?b=pad */
IF @b == "pad" THEN
OutputLine(Concat("MONTH=[", DatePart(@d, "M"), "]"))
OutputLine(Concat("DAY=[", DatePart(@d, "D"), "]"))
OutputLine(Concat("HOUR=[", DatePart(@d, "H"), "]"))
OutputLine(Concat("MINUTE=[", DatePart(@d, "MI"), "]"))
OutputLine(Concat("LEN_MONTH=[", Length(DatePart(@d, "M")), "]"))
OutputLine(Concat("LEN_HOUR=[", Length(DatePart(@d, "H")), "]"))
ENDIF
]%%
SystemDateToLocalDate — the shift is seasonal, not a fixed offset — a summer instant moves one hour further than a winter one
The reference explains that system time is Central Standard Time with no daylight-saving adjustment, and that local time comes from the account configuration — but it never says what that combination does to the size of the shift. It varies by season, because only one side of the conversion is frozen.
Two fixed inputs, same wall-clock time, six months apart, measured with
DateDiff in minutes on the account this was proven on:
2026-01-15 09:30:00 moved forward 420 minutes, while
2026-07-15 09:30:00 moved forward 480. Exactly one hour more in summer.
LocalDateToSystemDate mirrors it with
-420 and -480.
So never hard-code the offset, and never derive it once and reuse it: call the function for each instant you convert. The literal minute counts above belong to one account’s configured time zone — on another account the two numbers differ, but the one-hour seasonal gap remains as long as the configured zone observes daylight saving.
Show test script
%%[ VAR @b, @w, @s
SET @b = RequestParameter("b")
SET @w = "2026-01-15 09:30:00"
SET @s = "2026-07-15 09:30:00"
/* fetch ?b=offset */
IF @b == "offset" THEN
OutputLine(Concat("WIN_S2L_MIN=[", DateDiff(DateParse(@w), SystemDateToLocalDate(@w), "MI"), "]"))
OutputLine(Concat("SUM_S2L_MIN=[", DateDiff(DateParse(@s), SystemDateToLocalDate(@s), "MI"), "]"))
ENDIF
]%%
LocalDateToSystemDate — a date with no time part is pushed into the previous day
The same seasonal shift as
SystemDateToLocalDate, with the sign
reversed — -420 minutes in January and -480 in July on the account this
was proven on — plus one consequence the reference does not mention at all.
A string carrying no time part is treated as midnight, and subtracting the
offset from midnight lands on the day before. LocalDateToSystemDate("2026-01-15")
rendered 1/14/2026 5:00:00 PM. Anything that then formats only the date part
reports the 14th for a value the caller supplied as the 15th, silently and with
nothing to test for.
Convert timestamps, not bare dates. If a date-only value has to be converted, decide explicitly which wall-clock time it stands for and pass that instead of letting midnight decide for you.
Show test script
%%[ VAR @b, @w, @s
SET @b = RequestParameter("b")
SET @w = "2026-01-15 09:30:00"
SET @s = "2026-07-15 09:30:00"
/* fetch ?b=offset */
IF @b == "offset" THEN
OutputLine(Concat("WIN_L2S_MIN=[", DateDiff(DateParse(@w), LocalDateToSystemDate(@w), "MI"), "]"))
OutputLine(Concat("SUM_L2S_MIN=[", DateDiff(DateParse(@s), LocalDateToSystemDate(@s), "MI"), "]"))
OutputLine(Concat("DATEONLY=[", LocalDateToSystemDate("2026-01-15"), "]"))
ENDIF
]%%
GetSendTime — Outside a send it silently becomes Now(), with no signal that the send semantics never applied
On a CloudPage this function neither fails nor returns an empty value — it
returns the current system time, indistinguishable from Now().
The reference describes three send situations and what each returns; a
CloudPage is none of them, and the page is silent about what happens there.
Both calls were rendered side by side in a single render and matched to the
microsecond, not merely to the second: FormatDate(..., "ffffff") read
507042 for GetSendTime() and 507042 for Now(), while DateDiff
between the two returned 0. Passing the job-level argument changes nothing
either — 1, 0, true, false, all four of those spellings quoted, and
even a word that is not a flag at all were each accepted and returned that
same instant.
The result is a genuine date value the other date functions consume
directly, so nothing downstream misbehaves — which is exactly what makes it
risky. A page that calls it gets a plausible timestamp and no indication
that no send time was ever involved. Use
Now explicitly outside a send, and
treat a GetSendTime() value in CloudPage code as a bug rather than a
fallback.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* fetch ?b=same - the two values match to the microsecond */
IF @b == "same" THEN
OutputLine(Concat("GS=[", GetSendTime(), "] NOW=[", Now(), "]"))
OutputLine(Concat("GSTICK=[", FormatDate(GetSendTime(), "ffffff"), "]"))
OutputLine(Concat("NOWTICK=[", FormatDate(Now(), "ffffff"), "]"))
OutputLine(Concat("DIFFMI=[", DateDiff(GetSendTime(), Now(), "MI"), "]"))
ENDIF
/* fetch ?b=flags - no spelling of the argument changes the value */
IF @b == "flags" THEN
OutputLine(Concat("G1=[", GetSendTime(1), "] GT=[", GetSendTime(true), "]"))
OutputLine(Concat("S1=[", GetSendTime("1"), "] BAD=[", GetSendTime("spring"), "]"))
ENDIF
]%%
Base64Encode — The encoding argument accepts six names, two of which silently mangle the input
The optional second argument accepts a wider set of encoding names than any source lists, and two of them are lossy without saying so.
UTF-8, UTF-16 (little-endian), UTF-16BE, UTF-32, ASCII and
ISO-8859-1 were all accepted, and each produced the Base64 of exactly
those bytes — every value matched an independent implementation character
for character. That is the same accepted domain
StringToHex and the hash family show,
which suggests one shared encoding lookup behind all of them.
The trap is that ASCII and ISO-8859-1 replace anything they cannot
represent with a question mark and still return HTTP 200. A string
containing é and € came back as Y2FmPz8= under ASCII — the
encoding of caf?? — and as Y2Fm6T8= under ISO-8859-1, which keeps
é and loses only €. The result looks perfectly valid and decodes
cleanly; the characters are simply gone.
An unrecognised name is not tolerated at all: banana, and an empty
encoding name, each aborted the page with HTTP 422 rather than falling
back to the default.
Show test script
%%[ VAR @b, @nb
SET @b = RequestParameter("b")
SET @nb = Concat("caf", Char(233), Char(8364))
/* fetch ?b=enc - all six names are honoured, two of them lossily */
IF @b == "enc" THEN
OutputLine(Concat("NBIN=[", @nb, "]"))
OutputLine(Concat("BE8=[", Base64Encode(@nb, "UTF-8"), "]"))
OutputLine(Concat("BE16=[", Base64Encode(@nb, "UTF-16"), "]"))
OutputLine(Concat("BE16BE=[", Base64Encode(@nb, "UTF-16BE"), "]"))
OutputLine(Concat("BE32=[", Base64Encode(@nb, "UTF-32"), "]"))
OutputLine(Concat("BEASC=[", Base64Encode(@nb, "ASCII"), "]"))
OutputLine(Concat("BEISO=[", Base64Encode(@nb, "ISO-8859-1"), "]"))
ENDIF
/* fetch ?b=badenc alone - it aborts the page */
IF @b == "badenc" THEN
OutputLine(Concat("--- badenc start ---"))
OutputLine(Concat("BEBAD=[", Base64Encode("probe", "banana"), "]"))
ENDIF
]%%
StringToHex — charSet accepts four names beyond the documented pair, and two of them substitute 3f silently
The reference lists UTF-8 and UTF-16. Four more names work, and two
of them quietly change the input first.
UTF-16BE, UTF-32, ASCII and ISO-8859-1 were each accepted and each
rendered the hex of precisely those bytes, verified against an independent
implementation. On a string containing é and €: 636166c3a9e282ac
under UTF-8, 630061006600e900ac20 under UTF-16 (so UTF-16 means
little-endian), 00630061006600e920ac under UTF-16BE, and
630000006100000066000000e9000000ac200000 under UTF-32.
ASCII gave 6361663f3f and ISO-8859-1 gave 636166e93f — 3f is a
question mark, substituted for every character the encoding cannot carry.
ISO-8859-1 keeps é as the single byte e9 and loses only €. Both
return HTTP 200 with no signal that anything was replaced.
The same domain appears on Base64Encode and on the hash family, so it is a platform-wide encoding lookup rather than a quirk of one function. An unrecognised name still aborts the page with HTTP 422.
Show test script
%%[ VAR @b, @nb
SET @b = RequestParameter("b")
SET @nb = Concat("caf", Char(233), Char(8364))
/* fetch ?b=enc - all six names are honoured, two of them lossily */
IF @b == "enc" THEN
OutputLine(Concat("NBIN=[", @nb, "]"))
OutputLine(Concat("SH8=[", StringToHex(@nb, "UTF-8"), "]"))
OutputLine(Concat("SH16=[", StringToHex(@nb, "UTF-16"), "]"))
OutputLine(Concat("SH16BE=[", StringToHex(@nb, "UTF-16BE"), "]"))
OutputLine(Concat("SH32=[", StringToHex(@nb, "UTF-32"), "]"))
OutputLine(Concat("SHASC=[", StringToHex(@nb, "ASCII"), "]"))
OutputLine(Concat("SHISO=[", StringToHex(@nb, "ISO-8859-1"), "]"))
ENDIF
/* fetch ?b=badenc alone - it aborts the page */
IF @b == "badenc" THEN
OutputLine(Concat("--- badenc start ---"))
OutputLine(Concat("SHBAD=[", StringToHex("probe", "banana"), "]"))
ENDIF
]%%
GetJWT — the payload is never parsed as JSON, so a malformed payload is signed and shipped silently
The parameter is named for JSON and the reference calls a JSON object typical, but nothing checks it.
Passing the plain string not json at all returned a perfectly valid
token whose middle segment is simply the Base64url encoding of that
string. No error, no empty result, HTTP 200. The payload is copied into
the token untouched, and the only thing the function guarantees is that it
was not altered afterwards.
The consequence is that a payload built by string concatenation — an unescaped quote in a name, a missing comma, an empty personalisation string — produces a token that looks correct on the page and fails only when the receiving system tries to parse it. Validate the payload before signing; the token is not the place where the mistake will surface.
The companion GetJWTByKeyName() documents a FunctionExecutionException
for a malformed payload, which makes the silence of the inline form the
more surprising half of the pair.
Show test script
%%[ VAR @b, @sec
SET @b = RequestParameter("b")
SET @sec = "sfmc-probe-secret-2026"
/* fetch ?b=nonjson - the plain string is signed as readily */
IF @b == "nonjson" THEN
OutputLine(Concat("T=[", GetJWT(@sec, "HS256", "not json at all"), "]"))
ENDIF
]%%
IsEmailAddress — an address whose domain has no top-level domain is rejected, though the reference lists it as valid
The official result table lists an address whose domain is a single
label — no dot, no .com — as valid, with a note that such domains are
rare but real. The engine returns False for that shape.
IsEmailAddress("tomas.q@example") rendered False in a block that
printed its own start and done markers at HTTP 200, next to a known-good
control that rendered in the same response.
Every other row of the same table matched what the engine did: the
missing at sign, the double at sign, the missing local part and the
missing second-level domain all returned False, and a well-formed
address returned True. The single-label domain is the one row that does
not hold.
In practice this only bites when validating addresses at intranet-style hosts. It is worth knowing before writing a workaround for a case the docs promise works.
A separate, undocumented quirk is worth pairing with it: a leading or trailing space is enough to fail the check, so an untrimmed form field reads as invalid. Trim before validating.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* fetch ?b=tld - the docs say True, the engine renders False */
IF @b == "tld" THEN
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
OutputLine(Concat("NO_TLD=[", IsEmailAddress("tomas.q@example"), "]"))
OutputLine(Concat("WITH_TLD=[", IsEmailAddress("tomas.q@example.com"), "]"))
ENDIF
/* fetch ?b=space - undocumented: whitespace is not trimmed */
IF @b == "space" THEN
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
OutputLine(Concat("LEAD=[", IsEmailAddress(" tomas.q@example.com"), "]"))
OutputLine(Concat("TRAIL=[", IsEmailAddress("tomas.q@example.com "), "]"))
ENDIF
]%%
Output — a literal argument renders nothing instead of the documented error
The official reference states that the argument must be a function call, and that any other value — a string literal is the example it gives — makes the function return an error.
On a live Engagement CloudPage nothing of the sort happens. A literal, a bare variable and a bare number each render nothing at all, at HTTP 200, with every surrounding marker printing normally. There is no error text, no status, no abort — the argument is simply dropped.
That silence is the reason a debugging line can vanish without a trace.
Wrap every value in a function call, even a single-argument Concat, and
the value appears:
Output(Concat("checkpoint reached"))
The same applies to OutputLine, where a dropped literal still leaves its line break behind — see the arity card.
Show test script
%%[ VAR @v
SET @v = "vee"
/* each pair is delimited so a dropped value is visible as a position */
Output(Concat("<function>"))
Output(Concat("shown"))
Output(Concat("</function>"))
OutputLine(Concat(""))
Output(Concat("<literal>"))
Output("dropped")
Output(Concat("</literal>"))
OutputLine(Concat(""))
Output(Concat("<variable>"))
Output(@v)
Output(Concat("</variable>"))
OutputLine(Concat(""))
Output(Concat("<number>"))
Output(123)
Output(Concat("</number>"))
]%%
AttributeValue — undocumented: resolves system attributes on a page with no subscriber, ignores case, but aborts on an empty name
The reference describes this function purely in send terms — subscriber profiles, sendable data extension fields, journey entry attributes. It says nothing about a CloudPage, where there is no subscriber at all.
Three things turn out to be true there, none of them documented.
System attributes still resolve. AttributeValue("_messagecontext")
gives LANDINGPAGE, AttributeValue("memberid") gives the business unit
MID and AttributeValue("jobid") gives 0. Subscriber-scoped names such
as the email address are empty, as you would expect.
The name is matched without regard to case. _messagecontext and
_MessageContext return the same value.
An empty name aborts the page. Every unknown name answers empty, which is the whole point of the function, but an empty name returns HTTP 422 and takes the surrounding block down with it. If the name comes from a variable, check it first:
IF NOT Empty(@attrName) THEN
SET @value = AttributeValue(@attrName)
ENDIF
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* fetch ?b=safe - the documented and the undocumented resolutions */
IF @b == "safe" THEN
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
OutputLine(Concat("MISSING=[", AttributeValue("NoSuchAttributeZq7"), "]"))
OutputLine(Concat("MSGCTX=[", AttributeValue("_messagecontext"), "]"))
OutputLine(Concat("MIXEDCASE=[", AttributeValue("_MessageContext"), "]"))
OutputLine(Concat("MID=[", AttributeValue("memberid"), "]"))
ENDIF
/* fetch ?b=empty - aborts with HTTP 422, no output at all */
IF @b == "empty" THEN
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
OutputLine(Concat("E=[", AttributeValue(""), "]"))
ENDIF
]%%
RequestParameter — undocumented: names ignore case, a repeated parameter comes back comma-joined, and the value is handed over raw
The official page describes one thing only: pass a parameter name, get its value. Everything below is behaviour it is silent about — none of it contradicts the page, but all of it will surprise a caller.
The name ignores case. A request carrying ?Foo=1 answers 1 for
RequestParameter("foo"), RequestParameter("FOO") and
RequestParameter("Foo") alike. QueryParameter
behaves identically.
A repeated name is joined, not resolved. ?x=1&x=2 returns the single
string 1,2 — three characters, not 1 and not 2. Code that expects a
scalar quietly receives a comma-separated list, so a numeric comparison or
a lookup key built from it silently goes wrong.
The value arrives decoded and unescaped. %20 becomes a space, +
becomes a space, %25 becomes %, %26 becomes & — and whatever the
caller sent is returned verbatim. That means anything you render into
markup must be escaped by you; the function does no escaping of its own.
An absent parameter is an empty string, never a failure. Empty()
answers true on it and the page still returns HTTP 200, so a guard is
cheap:
IF Empty(RequestParameter("id")) THEN
SET @id = "unknown"
ENDIF
One thing that is not a property of the function: a query string
containing an unencoded-looking HTML tag, e.g. ?ht=%3Cb%3Ex%3C%2Fb%3E,
makes the request itself return HTTP 422 — the same URL fails even when
no AMPscript on the page reads that parameter at all.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* fetch ?b=case&Foo=1 - all three spellings answer 1 */
IF @b == "case" THEN
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
OutputLine(Concat("LOW=[", RequestParameter("foo"), "]"))
OutputLine(Concat("UPP=[", RequestParameter("FOO"), "]"))
OutputLine(Concat("MIX=[", RequestParameter("Foo"), "]"))
ENDIF
/* fetch ?b=dup&x=1&x=2 - one value, comma-joined, three characters long */
IF @b == "dup" THEN
OutputLine(Concat("DUP=[", RequestParameter("x"), "]"))
OutputLine(Concat("LEN=[", Length(RequestParameter("x")), "]"))
ENDIF
/* fetch ?b=enc&sp=a%20b - the value arrives decoded */
IF @b == "enc" THEN
OutputLine(Concat("SP=[", RequestParameter("sp"), "]"))
OutputLine(Concat("LEN=[", Length(RequestParameter("sp")), "]"))
ENDIF
]%%
WrapLongURL — the documented shortening never happens outside a send — a page gets the input back unchanged
The official reference states the shortening unconditionally: a URL longer than 975 characters comes back as a short link that redirects to the original address. No page render ever produced one.
A URL assembled in-page to 1048 characters was returned at exactly 1048 characters, character for character the input, and the two calls in the same render compared identical. A 27-character URL, an empty string, a word that is not a URL and a bare number likewise came straight back, the number as its decimal digits.
The community guide supplies the condition the official page omits — shortening is applied when the message is sent — which explains the gap. The consequence for testing is the part that matters: a CloudPage is not a valid place to check whether shortening works, and a caller cannot tell from the return value alone whether it happened.
The email preview context behaves the same way: rendered through the
Email Preview API against a seeded sendable row, WrapLongURL returned the
long URL unchanged, exactly as on a CloudPage. So neither a CloudPage nor an
email preview exercises the shortening — only a live send does.
Show test script
%%[ VAR @b, @s, @long, @u1, @u2
SET @b = RequestParameter("b")
/* fetch ?b=long - the input comes back at its original length */
IF @b == "long" THEN
SET @s = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz01"
SET @s = Concat(@s, @s)
SET @s = Concat(@s, @s)
SET @s = Concat(@s, @s)
SET @s = Concat(@s, @s)
SET @long = Concat("https://example.com/p?q=", @s)
SET @u1 = WrapLongURL(@long)
SET @u2 = WrapLongURL(@long)
OutputLine(Concat("IN_LEN=[", Length(@long), "] OUT_LEN=[", Length(@u1), "]"))
OutputLine(Concat("UNCHANGED=[", IIf(@u1 == @long, "unchanged", "changed"), "]"))
OutputLine(Concat("SAME=[", IIf(@u1 == @u2, "identical", "different"), "]"))
ENDIF
]%%
AuthenticatedEmployeeID — Returns a non-empty user ID on a public CloudPage where nobody is signed in
The official page scopes this function to microsites with sender authenticated redirection and says it is not for CloudPages, so it makes no claim at all about what a CloudPage does. It turns out to be neither empty nor an abort: an anonymous request to a public CloudPage rendered a nine-digit ID at HTTP 200.
The value behaves like any other string. It compared as not equal to the
empty string, Empty() on it answered false, Length() on it gave nine and
it rendered unchanged when nested inside a Concat, so it is a plain value
and not a call that ends the page.
The consequence for a caller is the trap: because a value always comes back, a page cannot use a non-empty result to decide that its visitor is a signed-in Marketing Cloud user. Anything gating content on that check would open the content to everybody. Use a real authentication mechanism instead.
Show test script
%%[ VAR @aeid
SET @aeid = AuthenticatedEmployeeID()
OutputLine(Concat("AEID=[", @aeid, "]"))
OutputLine(Concat("AEID_EMPTY=[", IIf(Empty(@aeid), "empty", "not-empty"), "]"))
OutputLine(Concat("AEID_IN_CONCAT=[", Concat("<", AuthenticatedEmployeeID(), ">"), "]"))
OutputLine(Concat("AEID_LEN=[", Length(Concat(@aeid, "")), "]"))
]%%
AuthenticatedEmployeeUserName — Returns a non-empty username on a public CloudPage where nobody is signed in
The official page scopes this function to microsites with sender authenticated redirection and says it is not for CloudPages, so it says nothing about what a CloudPage produces. What it produces is a real username: an anonymous request to a public CloudPage rendered a non-empty string at HTTP 200, not an empty value and not an abort.
The shape is worth knowing before you print it. On the business unit tested
the value looked like an email address — a local part, an @, then a
dotted domain-like suffix — and IsEmailAddress() accepted it. It is
therefore account-identifying and should not be rendered into public
content. It is also unrelated in form to the numeric ID from
AuthenticatedEmployeeID:
the two values are not equal and the ID does not appear anywhere inside the
username, so neither can be derived from the other.
Same trap as its sibling: because a value always comes back, a non-empty result cannot be used to decide that the visitor is a signed-in Marketing Cloud user. Gate on a real authentication mechanism instead.
Show test script
%%[ VAR @aeun
SET @aeun = AuthenticatedEmployeeUserName()
OutputLine(Concat("AEUN_EMPTY=[", IIf(Empty(@aeun), "empty", "not-empty"), "]"))
OutputLine(Concat("AEUN_LEN=[", Length(Concat(@aeun, "")), "]"))
OutputLine(Concat("AT_POS=[", IndexOf(@aeun, "@"), "]"))
OutputLine(Concat("IS_EMAIL=[", IIf(IsEmailAddress(@aeun), "yes", "no"), "]"))
OutputLine(Concat("SAME_AS_ID=[", IIf(@aeun == AuthenticatedEmployeeID(), "identical", "different"), "]"))
]%%
AuthenticatedEmployeeNotificationAddress — Returns a real mailbox address on a public CloudPage, and it is not the username
The official page scopes this function to microsites with sender authenticated redirection and explicitly rules out use on CloudPages. The runtime contradicts that scoping: an anonymous request to a public CloudPage rendered a non-empty email address at HTTP 200 — no empty value, no abort.
The name-alike trap is the interesting part. It is easy to assume this returns the same string as AuthenticatedEmployeeUserName, which is itself email-shaped. Called side by side in one render they came back different: the notification address was a real mailbox on a registered corporate domain, while the username carried a longer, account-scoped suffix that is not a mail domain. It is equally unrelated to the numeric ID from AuthenticatedEmployeeID. Pick the function whose value you actually want; none of the three is a substitute for another.
Same trap as both siblings: a value always comes back, so a non-empty result says nothing about whether the visitor is a signed-in Marketing Cloud user. And because this one is a deliverable mailbox, do not render it into public content.
Show test script
%%[ VAR @aena
SET @aena = AuthenticatedEmployeeNotificationAddress()
OutputLine(Concat("AENA_EMPTY=[", IIf(Empty(@aena), "empty", "not-empty"), "]"))
OutputLine(Concat("AENA_LEN=[", Length(Concat(@aena, "")), "]"))
OutputLine(Concat("IS_EMAIL=[", IIf(IsEmailAddress(@aena), "yes", "no"), "]"))
OutputLine(Concat("SAME_AS_UNAME=[", IIf(@aena == AuthenticatedEmployeeUserName(), "identical", "different"), "]"))
OutputLine(Concat("SAME_AS_ID=[", IIf(@aena == AuthenticatedEmployeeID(), "identical", "different"), "]"))
]%%
AuthenticatedEnterpriseID — Returns the parent MID on a public CloudPage, even when the page runs on a child business unit
The official page scopes this function to microsites with sender authenticated redirection and says it is not for CloudPages, so it makes no claim about a CloudPage at all. An anonymous request to a public CloudPage rendered a non-empty, digits-only account ID at HTTP 200 — no empty value, no abort.
The question worth settling is which MID comes back. The page was published on a child business unit, yet the value matched the account’s parent (enterprise) MID exactly and did not match the child’s own MID. So this is an account-level identifier, not the identifier of the business unit the code happens to run in — reach for a business-unit MID elsewhere if that is what you need.
It is also not the employee identifier. Read in the same render, AuthenticatedEmployeeID came back a different, longer digit string, and neither value occurs inside the other.
Same trap as its siblings: a value always comes back, so a non-empty result says nothing about whether the visitor is a signed-in Marketing Cloud user.
Show test script
%%[ VAR @eid
SET @eid = AuthenticatedEnterpriseID()
OutputLine(Concat("AEID_EMPTY=[", IIf(Empty(@eid), "empty", "not-empty"), "]"))
OutputLine(Concat("AEID_LEN=[", Length(Concat(@eid, "")), "]"))
OutputLine(Concat("SAME_AS_EMPID=[", IIf(@eid == AuthenticatedEmployeeID(), "identical", "different"), "]"))
OutputLine(Concat("PLUS1=[", Add(@eid, 1), "]"))
]%%
AuthenticatedMemberID — Returns the running business unit's own MID on a public CloudPage — the child, not the parent
The official page scopes this function to microsites with sender authenticated redirection and says it is not for CloudPages, so it makes no claim about a CloudPage at all. An anonymous request to a public CloudPage rendered a non-empty, digits-only MID at HTTP 200 — no empty value, no abort.
The pairing with its sibling is the finding. The page was published on a child business unit. In one and the same render this function returned the child’s own MID, while AuthenticatedEnterpriseID returned the account’s parent MID. Both comparisons ran against the two real MIDs configured for the account, so the split is real and not an artefact of an invented identifier. Reach for this function when you want the business unit the code is executing in, and for the enterprise function when you want the account.
It is neither of the employee values either: called alongside
AuthenticatedEmployeeID
it came back a different digit string of the same length, with neither value
occurring inside the other. The digits are genuinely numeric — Add()
applied straight to the result returned it incremented by one.
Same trap as its siblings: a value always comes back, so a non-empty result says nothing about whether the visitor is a signed-in Marketing Cloud user.
Show test script
%%[ VAR @mid
SET @mid = AuthenticatedMemberID()
OutputLine(Concat("AMID_EMPTY=[", IIf(Empty(@mid), "empty", "not-empty"), "]"))
OutputLine(Concat("AMID_LEN=[", Length(Concat(@mid, "")), "]"))
OutputLine(Concat("SAME_AS_EID=[", IIf(@mid == AuthenticatedEnterpriseID(), "identical", "different"), "]"))
OutputLine(Concat("SAME_AS_EMPID=[", IIf(@mid == AuthenticatedEmployeeID(), "identical", "different"), "]"))
OutputLine(Concat("PLUS1=[", Add(@mid, 1), "]"))
]%%
AuthenticatedMemberName — Returns the business unit's display name on a public CloudPage — not a person, and not the login username
The official page scopes this function to microsites with sender authenticated redirection and says it is not for CloudPages, so it makes no claim about a CloudPage. An anonymous request to a public CloudPage rendered a non-empty name at HTTP 200 — no empty value, no abort.
What comes back is the business unit’s display name, the human-readable
label you see in the Marketing Cloud UI, not an identity. It is free text:
the value observed contained spaces and a punctuation separator, so treat it
as prose rather than as a key — IsEmailAddress() rejected it and it
contained no @.
It is emphatically not a person. Called in the same render, AuthenticatedEmployeeUserName returned a completely different, much longer email-shaped login string, with neither value occurring inside the other. Nor is it the technical key: it did not equal the business unit’s configured name as used by tooling and deployment configuration, and neither string contained the other — the UI display name and the configuration name differ in separator and spacing. If you need to branch on the business unit, match the MID from AuthenticatedMemberID rather than string-matching this name.
Same trap as its siblings: a value always comes back, so a non-empty result says nothing about whether the visitor is a signed-in Marketing Cloud user.
Show test script
%%[ VAR @amn
SET @amn = AuthenticatedMemberName()
OutputLine(Concat("AMN_EMPTY=[", IIf(Empty(@amn), "empty", "not-empty"), "]"))
OutputLine(Concat("AMN_LEN=[", Length(Concat(@amn, "")), "]"))
OutputLine(Concat("SPACE_POS=[", IndexOf(@amn, " "), "]"))
OutputLine(Concat("IS_EMAIL=[", IIf(IsEmailAddress(@amn), "yes", "no"), "]"))
OutputLine(Concat("SAME_AS_AEUN=[", IIf(@amn == AuthenticatedEmployeeUserName(), "identical", "different"), "]"))
]%%
BuildRowsetFromJSON — Nested objects and arrays render empty, not as a placeholder label
The reference page states that when a selected value is itself an object or
an array, the rowset carries a descriptive placeholder in that cell. It does
not. Selecting the keys of {"a":{"b":1},"c":[1,2],"d":"plain"} with a
wildcard path gave three rows in which the object-valued and array-valued
keys both rendered an empty value, and only the plain scalar came back
with content.
So you cannot detect a structured value by testing for a marker string. Select the nested path you actually want with a second call instead.
Show test script
%%[ VAR @b, @rows
SET @b = RequestParameter("b")
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
/* fetch ?b=nested - renders rc=[3] r1=[1] r2=[] r3=[] */
IF @b == "nested" THEN
SET @rows = BuildRowsetFromJSON('{"a":{"b":1},"c":[1,2],"d":"plain"}', "$.*", 1)
OutputLine(Concat("rc=[", RowCount(@rows), "] r1=[", Field(Row(@rows, 1), 1), "] r2=[", Field(Row(@rows, 2), 1), "] r3=[", Field(Row(@rows, 3), 1), "]"))
ENDIF
]%%
BarcodeURL — An empty value to encode aborts the whole page instead of returning empty
The reference page says nothing about what happens when the value to encode is empty. Runtime settles it: passing an empty string as the first argument aborts the page with an HTTP 422 and discards everything rendered before it, rather than returning an empty string or a URL that renders a blank barcode.
Guard the value before calling — check it is non-empty first — because an empty value takes the whole page down, not just the one image.
Show test script
%%[ VAR @b, @bc
SET @b = RequestParameter("b")
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
/* fetch ?b=good - renders a bare LiveContent URL at HTTP 200 */
IF @b == "good" THEN
SET @bc = BarcodeURL("12345678901", "code128auto", 150, 50)
OutputLine(Concat("bc=[", @bc, "]"))
ENDIF
/* fetch ?b=empty - the whole page aborts with HTTP 422, no output */
IF @b == "empty" THEN
SET @bc = BarcodeURL("", "code128auto", 150, 50)
OutputLine(Concat("bc=[", @bc, "]"))
ENDIF
]%%
HTTPPost — The fourth argument receives the response body, not the request status the docs describe
The official reference calls the fourth argument an output parameter that holds the “status” of the request. At runtime that variable receives the response body instead — a POST to the echo endpoint filled it with the full echoed JSON (the reflected headers and payload), 299 characters, while the numeric HTTP status code came back as the function’s own return value.
A non-2xx response never surfaces as a status here: a POST that answered
404, and a POST with an empty URL, each aborted the whole page with
HTTP 422 rather than returning a status you could branch on. So the return
value is only ever a success status — read the body from the fourth
argument, and treat any failure as an aborted page.
Show test script
%%[ VAR @b, @st, @resp
SET @b = RequestParameter("b")
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
/* fetch ?b=post1 - status 200, @resp holds the echoed body */
IF @b == "post1" THEN
SET @st = HTTPPost("https://postman-echo.com/post", "application/json", '{"marker":"amp-post-zz9","n":7}', @resp)
OutputLine(Concat("status=[", @st, "] resplen=[", Length(@resp), "]"))
ENDIF
]%%
HTTPPost2 — The response argument holds the body while the rowset argument holds the headers
The official reference labels the fifth argument (response) as storing
the request “status”. At runtime it receives the response body, and
the sixth argument (responseRowSet) receives the response headers as
a rowset. A POST to the echo endpoint filled the body variable with 289
characters of echoed JSON and the rowset with 11 header rows, while the
numeric status came back as the function’s return value.
This split — body in one out-variable, headers in a rowset — is what HTTPPost2 adds over plain HTTPPost, whose single out-variable also holds the body.
Show test script
%%[ VAR @b, @s2, @body2, @hdr2
SET @b = RequestParameter("b")
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
/* fetch ?b=post2 - status 200, body in @body2, headers in @hdr2 */
IF @b == "post2" THEN
SET @s2 = HTTPPost2("https://postman-echo.com/post", "application/json", '{"marker":"amp-post2-yy8"}', true, @body2, @hdr2)
OutputLine(Concat("status=[", @s2, "] bodylen=[", Length(@body2), "] hdrRows=[", RowCount(@hdr2), "]"))
ENDIF
]%%
HTTPPostWithRetry — The responseStatus argument holds the body, not the status the docs describe
The official reference calls the responseStatus argument a variable that
stores the request “status”. At runtime it receives the response body
instead, and responseContentRowset receives the response headers as a
rowset — the same layout as
HTTPPost2. A POST to the echo endpoint
filled the body variable with 289 characters and the rowset with 11 header
rows, while the numeric status came back as the function’s return value.
The extra retry controls — numRetries, reschedule and
returnExceptionOnError — are all accepted at runtime. Retry-on-failure is
documented but hard to observe against a healthy endpoint, so a normal
success is what a probe can confirm.
Show test script
%%[ VAR @b, @s3, @body3, @rr3
SET @b = RequestParameter("b")
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
/* fetch ?b=retry - status 200, body in @body3, headers in @rr3 */
IF @b == "retry" THEN
SET @s3 = HTTPPostWithRetry("https://postman-echo.com/post", "application/json", '{"marker":"amp-retry-xx7"}', 2, false, true, @body3, @rr3)
OutputLine(Concat("status=[", @s3, "] bodylen=[", Length(@body3), "] rrRows=[", RowCount(@rr3), "]"))
ENDIF
]%%
ClaimRow — an exhausted data extension returns an empty row instead of raising an exception
The official reference states that ClaimRow returns an exception when there
are no unclaimed rows left in the data extension. At runtime it does not raise:
it returns an empty row and the page keeps rendering, so Empty() on the
result is true and no error is thrown.
Proven on a CloudPage against a claimable data extension seeded with four
unclaimed rows (C1..C4). Four distinct claimants advanced through C1, C2, C3
and C4; a fifth distinct claimant — with every row now claimed — received an
empty row rather than aborting the page. A caller must therefore guard the
result with Empty rather than
relying on an exception to signal exhaustion. The scalar twin
ClaimRowValue instead
returns its documented fallback value when exhausted, matching its own docs.
Show test script
%%[ VAR @b, @em, @cr
SET @b = RequestParameter("b")
SET @em = RequestParameter("em")
/* claim the next unclaimed row for a distinct claimant; an exhausted DE
returns an EMPTY row (Empty() true) rather than raising */
IF @b == "claim" THEN
SET @cr = ClaimRow("AMP_VERIFY_CLAIM", "IsClaimed", "EmailAddress", @em)
IF Empty(@cr) THEN
OutputLine(Concat("claim em=[", @em, "] -> EMPTY row"))
ELSE
OutputLine(Concat("claim em=[", @em, "] -> CouponCode=[", Field(@cr, "CouponCode"), "]"))
ENDIF
ENDIF
]%%
Output — email/send context: Output is rejected in sendable email content as an unrecognized function — it is CloudPage-only in practice
This finding originates in the email/send context. The official
reference lists Output as a general AMPscript utility without noting any
context restriction. Rendered through the Email Preview API against a seeded
sendable row, an isolated %%=Output(Concat("O","K"))=%% was rejected with
HTTP 400, errorcode 19691: “The function call uses an unrecognized function
name. Function Name: Output”.
The rejection is the function itself, not the harness: OutputLine — with
the identical CAN-SPAM footer, in the same run — rendered normally. So
Output works on a CloudPage (where it is verified) but is not available
in sendable email content. Use OutputLine to write into an email, or
keep Output to landing pages.
Show test script
%%=Output(Concat("O","K"))=%%
<!-- In sendable email content this aborts the render with errorcode 19691
(unrecognized function name). OutputLine works in email; Output does not. -->
IsCHTMLBrowser — email/send context: not valid in sendable content — the send parser rejects it as non-sendable-only
This finding originates in the email/send context. The official
reference documents IsCHTMLBrowser as an AMPscript function without noting
that it is restricted to non-sendable content. Rendered through the Email
Preview API against a seeded sendable row, an isolated
%%=IsCHTMLBrowser("DoCoMo/2.0 N905i")=%% was rejected with HTTP 400,
errorcode 10004: “IsCHTMLBrowser Function is not valid in content. This
function is only allowed in non sendable content.”
So the function is a CloudPage / landing-page feature (non-sendable content), where it evaluates the user-agent string it is given. It cannot be used inside a sendable email — which also fits its purpose, since a user-agent is a request-time value that does not exist at send time.
Show test script
%%=IsCHTMLBrowser("DoCoMo/2.0 N905i")=%%
<!-- In sendable email content this aborts the render with errorcode 10004
("only allowed in non sendable content"). Use it on CloudPages only. -->
CreateObject — email/send context: the whole WSProxy object model (CreateObject, SetObjectProperty, AddObjectArrayItem, InvokeCreate/Retrieve/Update/Delete/Execute/Perform) is rejected in sendable email content
This finding originates in the email/send context. The official
reference documents the WSProxy AMPscript object model — CreateObject,
SetObjectProperty, AddObjectArrayItem and the Invoke* verbs
(InvokeCreate, InvokeRetrieve, InvokeUpdate, InvokeDelete,
InvokeExecute, InvokePerform) — without noting that it is restricted to
non-sendable content. Rendered through the Email Preview API against a
seeded sendable row, an isolated CreateObject("DataExtension") (and
CreateObject("QueryDefinition")) was rejected with HTTP 400, errorcode
10004: “CreateObject Function is not valid in content. This function is
only allowed in non sendable content.”
Because every Invoke* verb and every SetObjectProperty /
AddObjectArrayItem call operates on a handle returned by CreateObject,
the entire object model is a CloudPage / landing-page (non-sendable
content) feature — it cannot run inside a sendable email. For
data-extension reads inside an email use the Lookup* / LookupRows* /
Field / Row family instead, which do render in sendable content.
Show test script
%%[ VAR @de SET @de = CreateObject("DataExtension") ]%%
<!-- In sendable email content this aborts the render with errorcode 10004
("only allowed in non sendable content"). The whole WSProxy family
(SetObjectProperty, AddObjectArrayItem, Invoke*) is CloudPage-only. -->
ExecuteFilter — email/send context: not valid in sendable content — the send parser rejects it as non-sendable-only
This finding originates in the email/send context. The official
reference documents ExecuteFilter without noting a context restriction.
Rendered through the Email Preview API against a seeded sendable row, an
isolated %%[ SET @rs = ExecuteFilter("<filter>") ]%% was rejected with
HTTP 400, errorcode 10004: “ExecuteFilter Function is not valid in content.
This function is only allowed in non sendable content.”
So ExecuteFilter is a CloudPage / landing-page (non-sendable content)
feature and cannot be used inside a sendable email. To read filtered data in
an email, resolve the rows another way (e.g. LookupRows against the target
DE), which does render in sendable content.
Show test script
%%[ SET @rs = ExecuteFilter("AMP_VERIFY_FILTER") ]%%
<!-- In sendable email content this aborts the render with errorcode 10004
("only allowed in non sendable content"). Use it on CloudPages only. -->
ExecuteFilterOrderedRows — email/send context: not valid in sendable content — the send parser rejects it as non-sendable-only
This finding originates in the email/send context. The official
reference documents ExecuteFilterOrderedRows without noting a context
restriction. Rendered through the Email Preview API against a seeded
sendable row, an isolated
%%[ SET @rs = ExecuteFilterOrderedRows("<filter>", 1, "Score desc") ]%%
was rejected with HTTP 400, errorcode 10004: “ExecuteFilterOrderedRows
Function is not valid in content. This function is only allowed in non
sendable content.”
So it is a CloudPage / landing-page (non-sendable content) feature and
cannot be used inside a sendable email. Use LookupOrderedRows against the
target DE for ordered reads in an email, which does render in sendable
content.
Show test script
%%[ SET @rs = ExecuteFilterOrderedRows("AMP_VERIFY_FILTER", 1, "Score desc") ]%%
<!-- In sendable email content this aborts the render with errorcode 10004
("only allowed in non sendable content"). Use it on CloudPages only. -->
HTTPRequestHeader — email/send context: rejected in sendable content — only valid where an inbound HTTP request exists (CloudPages/landing pages)
This finding originates in the email/send context. The official
reference documents HTTPRequestHeader without noting a context
restriction. Rendered through the Email Preview API against a seeded
sendable row, an isolated %%=HTTPRequestHeader("User-Agent")=%% was
rejected with HTTP 400, errorcode 10004: “HTTPRequestHeader Function is
not valid in content. This function is only allowed in content with an
HTTP context.”
So the function works only where an inbound HTTP request exists — CloudPages and landing pages — and cannot be used inside a sendable email. This fits its purpose, since request headers are a request-time value that does not exist at send time.
Show test script
%%=HTTPRequestHeader("User-Agent")=%%
<!-- In sendable email content this aborts the render with errorcode 10004
("only allowed in content with an HTTP context"). CloudPages only. -->
Redirect — email/send context: rejected in sendable content — it emits an HTTP 302, which only exists in an HTTP-response (CloudPage/landing page) context
This finding originates in the email/send context. The official
reference documents Redirect as landing-page-only but the constraint is
not surfaced as a docs discrepancy. Rendered through the Email Preview API
against a seeded sendable row, an isolated
%%[ Redirect("https://sfmc.guide/robots.txt") ]%% was rejected with HTTP
400, errorcode 10005: “Redirect Function is not valid in content. This
function is only allowed in in content with an HTTP context.”
Redirect emits an HTTP 302 whose Location is the supplied value; a
sendable email has no HTTP response to redirect, so it is a CloudPage /
landing-page (HTTP-context) feature only. Note RedirectTo is not
rejected in email content — it renders as a no-op there, since it emits no
redirect at all.
Show test script
%%[ Redirect("https://sfmc.guide/robots.txt") ]%%
<!-- In sendable email content this aborts the render with errorcode 10005
("only allowed in content with an HTTP context"). CloudPages only. -->
InsertData — email/send context: the *Data DE-write family (InsertData, UpdateData, UpsertData, DeleteData) is rejected in sendable email content — a send is a batch context. Use the *DE variants instead
This finding originates in the email/send context. The official
reference documents the *Data data-extension write family — InsertData,
UpdateData, UpsertData, DeleteData — without noting a context
restriction. Rendered through the Email Preview API against a seeded
sendable row, an isolated InsertData("<de>", ...) (and each of
UpdateData, UpsertData, DeleteData) was rejected with HTTP 400,
errorcode 10005: “InsertData Function is not valid in content. This
function is only allowed in a non batch context.”
A send is a batch operation, so the entire *Data family cannot run
inside a sendable email — it is a CloudPage / landing-page (non-batch)
feature. The sibling *DE functions (InsertDE, UpdateDE, UpsertDE,
DeleteDE) are accepted in sendable email content and render without
error, so use those to write to a data extension from inside an email.
Show test script
%%[ VAR @r SET @r = InsertData("AMP_VERIFY_SCRATCH", "Id", "X", "FirstName", "Y") ]%%
<!-- In sendable (batch) content this aborts with errorcode 10005
("only allowed in a non batch context"). Use InsertDE in an email. -->
GetPublishedSocialContent — email/send context: explicitly rejected as social-sharing/landing-page-only — a hard context ban, unlike CloudPage where the same call reaches runtime
This finding originates in the email/send context. Rendered through the
Email Preview API against a seeded sendable row, an isolated
GetPublishedSocialContent("SocialRegion") was rejected with HTTP 400,
errorcode 10004: “GetPublishedSocialContent Function is only allowed to be
called from social sharing pages or landing pages.”
This is a hard context restriction enforced by the send parser and is stronger than the CloudPage behaviour: on a CloudPage the same call reaches runtime and aborts only because no Classic Content social region resolves on a modern tenant. In the email/send context it is not permitted at all — the function is scoped to social sharing pages and landing pages.
Show test script
%%[ VAR @c SET @c = GetPublishedSocialContent("SocialRegion") ]%%
<!-- In sendable email content this aborts with errorcode 10004
("only allowed ... from social sharing pages or landing pages"). -->
Mod — the sign of the result follows the dividend, not the divisor
The official page does not state which operand decides the sign of the
remainder, and languages disagree on this. Proven at runtime: the sign
always follows the dividend (the first argument). Mod(-10, 3) gives
-1, Mod(10, -3) gives 1, and Mod(-10, -3) gives -1.
That matches C-style truncated remainder semantics and JavaScript’s %,
but it is the opposite of the floored-modulo convention used by languages
such as Python, where -10 % 3 is 2. Porting a modulo expression into
AMPscript from a floored-modulo language changes the result for negative
dividends — normalise explicitly if you need a non-negative remainder.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
IF @b == "mods" THEN
OutputLine(Concat("Mod(10,3)=[", Mod(10,3), "]"))
OutputLine(Concat("Mod(-10,3)=[", Mod(-10,3), "]"))
OutputLine(Concat("Mod(10,-3)=[", Mod(10,-3), "]"))
OutputLine(Concat("Mod(-10,-3)=[", Mod(-10,-3), "]"))
ENDIF
]%%
Uppercase — the German sharp s is left alone instead of expanding to SS
The official page makes no statement about the German sharp s, so this is
undocumented behaviour rather than a documented claim being wrong. At
runtime the sharp s is returned untouched: uppercasing a six-letter word
containing it yielded the codepoints 83, 84, 82, 65, 223, 69 — the 223 sat
unchanged between the surrounding capitals, so the result is still six
characters rather than the seven a caller expecting an SS expansion would
get.
Other accented letters are mapped: the lowercase accented vowels 224, 233, 238, 245 and 252 came back as 192, 201, 206, 213 and 220. So the sharp s is a specific gap, not a sign that non-ASCII input is ignored wholesale.
Casing is also culture-invariant. The dotless i (codepoint 305) uppercases
to plain ASCII I (73), not to the dotted capital a Turkish locale would
produce. Do not rely on uppercasing to normalise text for comparison across
locales — compare on a value you normalised yourself.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
IF @b == "nau" THEN
/* the sharp s survives uppercasing verbatim */
OutputLine(Concat("NAU_UP_SS=[", Uppercase("Straße"), "]"))
/* accented lowercase letters do map to accented capitals */
OutputLine(Concat("NAU_UP_CAFE=[", Uppercase("café"), "]"))
ENDIF
IF @b == "tr" THEN
/* invariant, not Turkish: the dotless i becomes a plain ASCII I */
OutputLine(Concat("TR_UP_DOTLESS=[", Uppercase("ı"), "]"))
ENDIF
]%%
Concat — a single argument is accepted, although two are presented as the minimum
The official reference presents the function as joining two or more values,
and our own catalog encoded that as a minimum of two. At runtime one
argument is enough: Concat("only") returned HTTP 200 and echoed only
back unchanged. Passing no argument at all is rejected — that call aborted
the page with HTTP 422 before the surrounding marker line rendered — so the
real minimum is one, not zero and not two.
Treat this as an accepted form the documentation never mentions rather than as the documentation being wrong: an argument-count disagreement on its own is recorded in the catalog, not flagged as a doc contradiction. It is listed here because callers can genuinely write the one-argument form and it will work, which is not something the reference lets you predict.
The catalog was corrected accordingly during this verification run: the minimum argument count moved from two to one and the second parameter is now marked optional, so editor diagnostics no longer flag a valid call.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* one argument: HTTP 200, the value comes back unchanged */
IF @b == "c1" THEN
OutputLine(Concat("--- c1 start ---"))
OutputLine(Concat("C1=[", Concat("only"), "]"))
OutputLine(Concat("--- c1 done ---"))
ENDIF
/* zero arguments: page aborts with HTTP 422, no marker renders */
IF @b == "c0" THEN
OutputLine(Concat("--- c0 start ---"))
OutputLine(Concat("C0=[", Concat(), "]"))
ENDIF
]%%
Char — character codes are not capped at 255 - values beyond extended ASCII resolve to their Unicode character
The official reference frames the accepted domain as the extended ASCII set, codes 0 through 255, and says nothing about what a larger number does. A reader would reasonably assume it is rejected. It is not: passing 256 rendered the Latin letter A with a macron (U+0100), and passing 8364 rendered the euro sign (U+20AC). The codes are being treated as UTF-16 code unit values, not as byte values.
Both results were read from a raw byte dump of the response rather than from a console, because an extended-ASCII character and a mis-decoded multi-byte sequence look identical otherwise. The euro sign came back as the three bytes of its UTF-8 encoding, which is what a genuine U+20AC looks like.
The documentation is silent here rather than wrong, so this is published as an undocumented capability and the entry is not flagged as contradicting the docs. Still, do not lean on it for portability: a number outside 0-255 has no documented contract, and a decimal code is rejected outright with HTTP 422.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* beyond extended ASCII: both render their Unicode character */
IF @b == "c256" THEN
OutputLine(Concat("--- c256 start ---"))
OutputLine(Concat("C256=[", Char(256), "]"))
OutputLine(Concat("--- c256 done ---"))
ENDIF
IF @b == "ceuro" THEN
OutputLine(Concat("--- ceuro start ---"))
OutputLine(Concat("CEURO=[", Char(8364), "]"))
OutputLine(Concat("--- ceuro done ---"))
ENDIF
/* a decimal code aborts the page with HTTP 422, no marker renders */
IF @b == "cdec" THEN
OutputLine(Concat("--- cdec start ---"))
OutputLine(Concat("CDEC=[", Char(65.7), "]"))
ENDIF
]%%
IndexOf — an undocumented third argument picks which occurrence to locate
Every source describes exactly two parameters, so a third argument looks like it should abort the page the way a fourth one does. It does not: the call succeeds and the extra number selects which match to report. Searching “Hello World” for the letter o returns 5 with an occurrence of 1 and 8 with an occurrence of 2, and asking for a third o - there are only two - returns 0 rather than aborting.
Two details are worth knowing before leaning on it. Overlapping matches are not counted separately, so asking for the second “aa” inside “aaaa” returns 3, not 2. And a negative occurrence resolves to the LAST match regardless of magnitude: on a source with three matches, -1, -2 and -9 all returned the third match’s position.
The argument is real but undocumented, so it has no compatibility guarantee. A decimal, a boolean or a non-numeric string in that position aborts the page with HTTP 422; a numeric string works.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
IF @b == "occurrence" THEN
OutputLine(Concat("OCC1=[", IndexOf("Hello World", "o", 1), "]"))
OutputLine(Concat("OCC2=[", IndexOf("Hello World", "o", 2), "]"))
OutputLine(Concat("OCC3=[", IndexOf("Hello World", "o", 3), "]"))
OutputLine(Concat("OCCOVERLAP=[", IndexOf("aaaa", "aa", 2), "]"))
OutputLine(Concat("NEG1_ABC=[", IndexOf("abcabcabc", "abc", -1), "]"))
ENDIF
/* aborts the page: the start marker never renders */
IF @b == "occbool" THEN
OutputLine(Concat("--- occbool start ---"))
OutputLine(Concat("OCCBOOL=[", IndexOf("banana", "na", true), "]"))
ENDIF
]%%
StringToDate — Indistinguishable from DateParse at runtime, minus DateParse's UTC switch
Neither reference mentions the other, yet the two functions produced character-for-character identical output for every input tried.
Nine input shapes were rendered side by side in a single render — the ISO
date, the ISO date-time, the T-separated timestamp, an offset timestamp, a
Zulu timestamp, an RFC-style GMT string, the US slash form and both
spelled-out month orderings. Every pair matched exactly, Length() over both
results returned 20, and a DateDiff between the two results over the same
fixed input was 0. The failure side matched too: free text, an empty string
and a bare number abort the page with HTTP 422 under either name, and a
day-first 5/8/2026 is silently read as the 8th of May by both.
The one real difference is arity. DateParse takes an optional second
argument that returns the instant in UTC; StringToDate accepts exactly one
argument, and a two-argument call aborts the page — including the
"UTF-8" encoding argument the community guide still lists. Use
DateParse when a UTC conversion
might ever be needed; otherwise the choice is cosmetic.
Show test script
%%[ VAR @b, @s
SET @b = RequestParameter("b")
SET @s = "2026-03-04 09:05:07"
/* fetch ?b=same */
IF @b == "same" THEN
OutputLine(Concat("SD=[", StringToDate(@s), "] DP=[", DateParse(@s), "]"))
OutputLine(Concat("XDIFF=[", DateDiff(StringToDate(@s), DateParse(@s), "H"), "]"))
ENDIF
/* fetch ?b=twoargs - expect HTTP 422 with no output */
IF @b == "twoargs" THEN
OutputLine(Concat("A2=[", StringToDate(@s, "UTF-8"), "]"))
ENDIF
]%%
GetJWT — the algorithm name is matched case-insensitively, which no source states
The reference lists the three algorithm names in upper case and says nothing about spelling. Lower case works identically.
hs256 produced exactly the token HS256 produces — the same signature,
and a header that still decodes to {"alg":"HS256","typ":"JWT"}. The
engine normalises the name before it reaches the header, so a lower-case
call is indistinguishable from an upper-case one at the receiving end.
The docs are silent rather than wrong here, but the tolerance is worth
knowing because the surrounding behaviour is unforgiving: anything outside
the three HMAC names aborts the page with HTTP 422 rather than falling back
to a default. RS256, which the companion GetJWTByKeyName() documents,
is rejected by this function, and so is an empty secret.
Show test script
%%[ VAR @b, @sec, @pl
SET @b = RequestParameter("b")
SET @sec = "sfmc-probe-secret-2026"
SET @pl = '{"sub":"probe","n":7}'
/* fetch ?b=lower - the token is identical to the HS256 one */
IF @b == "lower" THEN
OutputLine(Concat("UPPER=[", GetJWT(@sec, "HS256", @pl), "]"))
OutputLine(Concat("LOWER=[", GetJWT(@sec, "hs256", @pl), "]"))
ENDIF
/* fetch ?b=rs256 alone - it aborts the page */
IF @b == "rs256" THEN
OutputLine(Concat("--- rs256 start ---"))
OutputLine(Concat("T=[", GetJWT(@sec, "RS256", @pl), "]"))
ENDIF
]%%
Domain — a multi-level domain comes back whole and keeps its original casing
The docs are silent on multi-level domains and on casing. Both matter when the extracted value is compared against something.
Domain("tomas.q@a.b.example.co.uk") renders a.b.example.co.uk — every
label, with no reduction to a registrable domain — and a five-label
domain likewise came back in full.
Domain("Tomas.Q@Example.COM") renders Example.COM. A comparison
against a lowercase allow-list therefore needs Lowercase around the
call, which is easy to omit because the usual test address is already
lowercase.
Neither behaviour contradicts anything the reference claims — it simply does not address either case. The function is a split on the first at sign and nothing more: values that IsEmailAddress rejects still produce a domain.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* fetch ?b=dom */
IF @b == "dom" THEN
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
OutputLine(Concat("MULTI=[", Domain("tomas.q@a.b.example.co.uk"), "]"))
OutputLine(Concat("CASE=[", Domain("Tomas.Q@Example.COM"), "]"))
OutputLine(Concat("TWO_AT=[", Domain("a@b@example.com"), "]"))
OutputLine(Concat("NO_AT=[", Domain("example.com"), "]"))
ENDIF
]%%
OutputLine — undocumented: no argument at all is legal, and several are written in turn
Both output functions are documented with exactly one parameter. The runtime is looser than that, and the docs are silent rather than wrong.
A call with no argument renders happily: Output() writes nothing and
OutputLine() writes just the line break. A call with two or three
arguments writes each value in turn with no separator between them, and
the line-ending form still emits a single break after the last one — so
three arguments give one line, not three.
An argument that produces no text behaves the same way: the line-ending form still emits its break, which is why an empty call is the idiomatic way to end a line in a probe harness.
None of this belongs in production code — the single-argument form is the one the docs support — but it does mean a stray extra argument fails silently rather than announcing itself. A wrong argument count in a different function aborts the page; here it does not.
Show test script
%%[
Output(Concat("<none>"))
Output()
Output(Concat("</none>"))
OutputLine(Concat(""))
Output(Concat("<three>"))
Output(Concat("a"), Concat("b"), Concat("c"))
Output(Concat("</three>"))
OutputLine(Concat(""))
Output(Concat("<threeline>"))
OutputLine(Concat("a"), Concat("b"), Concat("c"))
Output(Concat("</threeline>"))
]%%
QueryParameter — the official page's claim that it behaves like RequestParameter holds byte-for-byte on a GET, and it too takes only one argument
The official page says the two functions behave the same way and exist only for backward compatibility. On a live CloudPage GET that is exactly what happens: reading the same parameter with both functions in one block gives identical strings of identical length, for a plain value, a decoded space, a decoded ampersand, a percent sign, a missing parameter, a repeated parameter and a name spelled in the wrong case. There is no GET input that separates them.
Two things the page does not say. A second argument is not accepted —
QueryParameter("p", 1) aborts with HTTP 422, as does the same shape on
RequestParameter, so neither has a hidden decode-or-not flag. And a
numeric argument is accepted without failing, but never matches a
parameter, so it always answers empty.
A POST body was not exercised, so the equivalence above is stated for GET only. See RequestParameter for the shared behaviour of both.
Show test script
%%[ VAR @b
SET @b = RequestParameter("b")
/* fetch ?b=cmp&p=hello - both functions, same parameter, same block */
IF @b == "cmp" THEN
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
OutputLine(Concat("RP=[", RequestParameter("p"), "]"))
OutputLine(Concat("QP=[", QueryParameter("p"), "]"))
IF RequestParameter("p") == QueryParameter("p") THEN
OutputLine(Concat("EQ=[same]"))
ELSE
OutputLine(Concat("EQ=[diff]"))
ENDIF
ENDIF
/* fetch ?b=two&p=hello - HTTP 422, a second argument is not accepted */
IF @b == "two" THEN
OutputLine(Concat("TWO=[", QueryParameter("p", 1), "]"))
ENDIF
]%%
v — undocumented: it outputs literals and nested function results too, not only variables
Both the official page and the community guide describe a single job: output the value of a variable. The runtime accepts rather more than that, and nothing warns you about the one case that bites.
A string literal is output as itself. v("p") renders p. That is the
trap: pass a parameter name by mistake — v("id") where you meant
v(RequestParameter("id")) — and the page renders the word id instead of
failing, so the bug ships silently.
A number literal is output as itself, and a nested function call is evaluated: wrapping a request-parameter read renders the parameter’s value, identical to reading it into a variable first.
Exactly one argument. Zero arguments and two arguments each abort with HTTP 422.
Show test script
%%[ VAR @b, @word
SET @b = RequestParameter("b")
SET @word = "zeta"
/* fetch ?b=safe&p=hello */
IF @b == "safe" THEN
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
OutputLine(Concat("VAR=[", v(@word), "]"))
OutputLine(Concat("LIT=[", v("p"), "]"))
OutputLine(Concat("NUM=[", v(5), "]"))
OutputLine(Concat("NEST=[", v(RequestParameter("p")), "]"))
ENDIF
/* fetch ?b=zero - HTTP 422 */
IF @b == "zero" THEN
OutputLine(Concat("ZERO=[", v(), "]"))
ENDIF
/* fetch ?b=two - HTTP 422 */
IF @b == "two" THEN
OutputLine(Concat("TWO=[", v(@word, @word), "]"))
ENDIF
]%%
BuildRowSetFromString — A trailing separator adds an empty row, and an empty separator splits nothing
The reference page is silent about the edges of the split. Runtime settles
them: a trailing separator is not ignored — "a,b," split on a comma
gives three rows whose last one is empty — and an empty separator does
not split per character, it returns the whole input as a single row.
An empty or unset source gives a rowset of zero rows rather than aborting,
so RowCount is a safe guard here.
Show test script
%%[ VAR @b, @rows
SET @b = RequestParameter("b")
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
/* fetch ?b=trailing - renders rc=[3] r3=[] */
IF @b == "trailing" THEN
SET @rows = BuildRowSetFromString("a,b,", ",")
OutputLine(Concat("rc=[", RowCount(@rows), "] r3=[", Field(Row(@rows, 3), 1), "]"))
ENDIF
/* fetch ?b=emptydelim - renders rc=[1] */
IF @b == "emptydelim" THEN
SET @rows = BuildRowSetFromString("a,b,c", "")
OutputLine(Concat("rc=[", RowCount(@rows), "]"))
ENDIF
/* fetch ?b=emptysrc - renders rc=[0] */
IF @b == "emptysrc" THEN
SET @rows = BuildRowSetFromString("", ",")
OutputLine(Concat("rc=[", RowCount(@rows), "]"))
ENDIF
]%%
HTTPRequestHeader — Custom non-RFC 7231 headers are returned, though the docs say only standard headers can be
The official reference states that this function can only retrieve the
standard HTTP headers defined in RFC 7231. Runtime does not enforce that:
a CloudPage request sent with a custom X-Amp-Probe header returned that
header’s value verbatim, exactly as it does for a standard header such as
User-Agent or Host. So you can read arbitrary custom request headers,
not just the RFC 7231 set.
A header that is genuinely absent from the request still returns the empty
string — reading Referer on a request that sent none rendered empty and
Empty() reported true — which matches the documented note about missing
headers.
Show test script
%%[ VAR @b, @v
SET @b = RequestParameter("b")
OutputLine(Concat("CTRL=[", Uppercase("ok"), "]"))
/* send a custom header X-Amp-Probe: probe-value-42, fetch ?b=custom */
IF @b == "custom" THEN
SET @v = HTTPRequestHeader("X-Amp-Probe")
OutputLine(Concat("custom=[", @v, "]"))
ENDIF
/* fetch ?b=absent with no Referer sent - renders empty */
IF @b == "absent" THEN
SET @v = HTTPRequestHeader("Referer")
OutputLine(Concat("absent=[", @v, "] empty=[", Empty(@v), "]"))
ENDIF
]%%
LongSFID — a non-15-character input is passed through unchanged instead of validated
Both the official reference and ampscript.guide describe LongSFID only for a
15-character Salesforce ID, and neither says what happens for any other input.
At runtime the function does not validate the length: it appends the
3-character checksum only to a genuine 15-character ID and otherwise returns
the argument unchanged, with no error.
Proven on a CloudPage: LongSFID("0036000000QKv5TAAT") (already 18 characters)
returned the same 18-character string — it was not transformed a second time.
LongSFID("ABC") returned ABC (3 characters) and LongSFID("") returned an
empty string. A caller cannot rely on the result being 18 characters, nor on
an invalid ID being rejected — guard the input length yourself if that matters.
Show test script
%%[ VAR @b, @a, @s, @e
SET @b = RequestParameter("b")
IF @b == "already18" THEN
SET @a = LongSFID("0036000000QKv5TAAT")
OutputLine(Concat("already18=[", @a, "] len=[", Length(@a), "]"))
ENDIF
IF @b == "short" THEN
SET @s = LongSFID("ABC")
OutputLine(Concat("short=[", @s, "] len=[", Length(@s), "]"))
ENDIF
IF @b == "empty" THEN
SET @e = LongSFID("")
OutputLine(Concat("empty=[", @e, "] len=[", Length(@e), "]"))
ENDIF
]%%