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.

AMPscript only — SSJS quirks live elsewhere

This page tracks AMPscript discrepancies. Server-Side JavaScript engine and runtime quirks are documented separately at ssjs.guide/engine-limitations/differs-from-docs.

Looking for Marketing Cloud Next?

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

High A7 encoding/format/validation semantics Math

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
]%%

Official documentation

FormatDate — mm in the date pattern renders the month, and the day-name tokens are off by one letter

High A7 encoding/format/validation semantics Date and Time

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
]%%

Official documentation

DateAdd — anything outside the five listed units destroys the page instead of returning a value

High A5 undocumented-but-real members Date and Time

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
]%%

Official documentation

DateDiff — one minute either side of midnight on New Year's Eve is one whole year apart

High A5 undocumented-but-real members Date and Time

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
]%%

Official documentation

DatePart — the hour comes back on a 12-hour clock with nothing to tell AM from PM

High A5 undocumented-but-real members Date and Time

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
]%%

Official documentation

DateParse — an unsupported date string aborts the page instead of being rejected gracefully

High A5 undocumented-but-real members Date and Time

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
]%%

Official documentation

DateParse — a day-first date is silently read month-first rather than refused

High A5 undocumented-but-real members Date and Time

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
]%%

Official documentation

Base64Decode — Malformed Base64 takes the whole page down with HTTP 422 instead of returning an empty value

High A5 undocumented-but-real members Encryption and Encoding

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
]%%

Official documentation

IsNull — the reference's own example says an unset variable returns true; on a CloudPage it returns false

High A5 undocumented-but-real members Utility

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
]%%

Official documentation

Format — the documented data-format value Number takes the whole page down

High A5 undocumented-but-real members Utility

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.56
  • Format(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
]%%

Official documentation

IsCHTMLBrowser — an empty user agent aborts the page instead of returning false

High A5 undocumented-but-real members Utility

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
]%%

Official documentation

CloudPagesURL — A page ID that matches no page takes the whole page down instead of returning nothing

High A5 undocumented-but-real members Utility

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
]%%

Official documentation

BuildRowsetFromJSON — The third argument works the opposite way round from the Syntax section

High A1 wrong return type Content

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
]%%

Official documentation

BuildRowSetFromXML — The third argument works the opposite way round from the Syntax section

High A1 wrong return type Content

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
]%%

Official documentation

Divide — a zero divisor silently renders an infinity symbol instead of failing

Medium A5 undocumented-but-real members Math

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
]%%

Official documentation

Mod — a zero divisor returns NaN — inconsistently with its sibling Divide

Medium A5 undocumented-but-real members Math

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
]%%

Official documentation

Length — counts UTF-16 code units, so an emoji measures 2

Medium A7 encoding/format/validation semantics String

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
]%%

Official documentation

Concat — boolean values are swallowed silently — the whole String family does it

Medium A5 undocumented-but-real members String

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
]%%

Official documentation

IndexOf — the search is case-insensitive, which no source mentions

Medium A5 undocumented-but-real members String

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
]%%

Official documentation

ProperCase — capitals inside a word are destroyed, not preserved

Medium A5 undocumented-but-real members String

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
]%%

Official documentation

Substring — the two numeric arguments disagree about negatives

Medium A5 undocumented-but-real members String

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
]%%

Official documentation

Replace — matching ignores case, the replacement argument is optional, and the scan is single-pass

Medium A5 undocumented-but-real members String

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
]%%

Official documentation

ReplaceList — Search values are applied in sequence, so an earlier replacement can be rewritten by a later one

Medium A5 undocumented-but-real members String

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
]%%

Official documentation

RegExMatch — The only String function that matches case-sensitively, and its option list is variadic

Medium A5 undocumented-but-real members String

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
]%%

Official documentation

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

Medium A5 undocumented-but-real members Encryption and Encoding

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
]%%

Official documentation

DateAdd — adding a month to 31 January gives 28 February, and adding it back does not return the 31st

Medium A5 undocumented-but-real members Date and Time

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
]%%

Official documentation

DatePart — month and day are zero-padded, hour and minute are not

Medium A5 undocumented-but-real members Date and Time

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
]%%

Official documentation

SystemDateToLocalDate — the shift is seasonal, not a fixed offset — a summer instant moves one hour further than a winter one

Medium A5 undocumented-but-real members Date and Time

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
]%%

Official documentation

LocalDateToSystemDate — a date with no time part is pushed into the previous day

Medium A5 undocumented-but-real members Date and Time

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
]%%

Official documentation

GetSendTime — Outside a send it silently becomes Now(), with no signal that the send semantics never applied

Medium A5 undocumented-but-real members Date and Time

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
]%%

Official documentation

Base64Encode — The encoding argument accepts six names, two of which silently mangle the input

Medium A5 undocumented-but-real members Encryption and Encoding

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
]%%

Official documentation

StringToHex — charSet accepts four names beyond the documented pair, and two of them substitute 3f silently

Medium A5 undocumented-but-real members Encryption and Encoding

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 636166e93f3f 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
]%%

Official documentation

GetJWT — the payload is never parsed as JSON, so a malformed payload is signed and shipped silently

Medium A5 undocumented-but-real members Encryption and Encoding

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
]%%

Official documentation

IsEmailAddress — an address whose domain has no top-level domain is rejected, though the reference lists it as valid

Medium A7 encoding/format/validation semantics Utility

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
]%%

Official documentation

Output — a literal argument renders nothing instead of the documented error

Medium A2 null/empty on absence Utility

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>"))
]%%

Official documentation

AttributeValue — undocumented: resolves system attributes on a page with no subscriber, ignores case, but aborts on an empty name

Medium A5 undocumented-but-real members Utility

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
]%%

Official documentation

RequestParameter — undocumented: names ignore case, a repeated parameter comes back comma-joined, and the value is handed over raw

Medium A5 undocumented-but-real members Utility

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
]%%

Official documentation

WrapLongURL — the documented shortening never happens outside a send — a page gets the input back unchanged

Medium A6 context/availability differs Utility

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
]%%

Official documentation

AuthenticatedEmployeeID — Returns a non-empty user ID on a public CloudPage where nobody is signed in

Medium A5 undocumented-but-real members Utility

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, "")), "]"))
]%%

Official documentation

AuthenticatedEmployeeUserName — Returns a non-empty username on a public CloudPage where nobody is signed in

Medium A5 undocumented-but-real members Utility

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"), "]"))
]%%

Official documentation

AuthenticatedEmployeeNotificationAddress — Returns a real mailbox address on a public CloudPage, and it is not the username

Medium A6 context/availability differs Utility

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"), "]"))
]%%

Official documentation

AuthenticatedEnterpriseID — Returns the parent MID on a public CloudPage, even when the page runs on a child business unit

Medium A5 undocumented-but-real members Utility

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), "]"))
]%%

Official documentation

AuthenticatedMemberID — Returns the running business unit's own MID on a public CloudPage — the child, not the parent

Medium A5 undocumented-but-real members Utility

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), "]"))
]%%

Official documentation

AuthenticatedMemberName — Returns the business unit's display name on a public CloudPage — not a person, and not the login username

Medium A5 undocumented-but-real members Utility

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"), "]"))
]%%

Official documentation

BuildRowsetFromJSON — Nested objects and arrays render empty, not as a placeholder label

Medium A1 wrong return type Content

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
]%%

Official documentation

BarcodeURL — An empty value to encode aborts the whole page instead of returning empty

Medium A5 undocumented-but-real members Content

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
]%%

Official documentation

HTTPPost — The fourth argument receives the response body, not the request status the docs describe

Medium A5 undocumented-but-real members HTTP

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
]%%

Official documentation

HTTPPost2 — The response argument holds the body while the rowset argument holds the headers

Medium A5 undocumented-but-real members HTTP

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
]%%

Official documentation

HTTPPostWithRetry — The responseStatus argument holds the body, not the status the docs describe

Medium A5 undocumented-but-real members HTTP

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
]%%

Official documentation

ClaimRow — an exhausted data extension returns an empty row instead of raising an exception

Medium A2 null/empty on absence Data Extension

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
]%%

Official documentation

Output — email/send context: Output is rejected in sendable email content as an unrecognized function — it is CloudPage-only in practice

Medium A6 context/availability differs Utility

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. -->

Official documentation

IsCHTMLBrowser — email/send context: not valid in sendable content — the send parser rejects it as non-sendable-only

Medium A6 context/availability differs Utility

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. -->

Official documentation

CreateObject — email/send context: the whole WSProxy object model (CreateObject, SetObjectProperty, AddObjectArrayItem, InvokeCreate/Retrieve/Update/Delete/Execute/Perform) is rejected in sendable email content

Medium A6 context/availability differs API Object

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. -->

Official documentation

ExecuteFilter — email/send context: not valid in sendable content — the send parser rejects it as non-sendable-only

Medium A6 context/availability differs Data Extension

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. -->

Official documentation

ExecuteFilterOrderedRows — email/send context: not valid in sendable content — the send parser rejects it as non-sendable-only

Medium A6 context/availability differs Data Extension

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. -->

Official documentation

HTTPRequestHeader — email/send context: rejected in sendable content — only valid where an inbound HTTP request exists (CloudPages/landing pages)

Medium A6 context/availability differs HTTP

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. -->

Official documentation

Redirect — email/send context: rejected in sendable content — it emits an HTTP 302, which only exists in an HTTP-response (CloudPage/landing page) context

Medium A6 context/availability differs Utility

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. -->

Official documentation

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

Medium A6 context/availability differs Data Extension

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. -->

Official documentation

GetPublishedSocialContent — email/send context: explicitly rejected as social-sharing/landing-page-only — a hard context ban, unlike CloudPage where the same call reaches runtime

Medium A6 context/availability differs Social

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"). -->

Official documentation

Mod — the sign of the result follows the dividend, not the divisor

Low A5 undocumented-but-real members Math

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
]%%

Official documentation

Uppercase — the German sharp s is left alone instead of expanding to SS

Low A7 encoding/format/validation semantics String

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
]%%

Official documentation

Concat — a single argument is accepted, although two are presented as the minimum

Low A3 wrong arity/optionality String

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
]%%

Official documentation

Char — character codes are not capped at 255 - values beyond extended ASCII resolve to their Unicode character

Low A5 undocumented-but-real members String

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
]%%

Official documentation

IndexOf — an undocumented third argument picks which occurrence to locate

Low A5 undocumented-but-real members String

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
]%%

Official documentation

StringToDate — Indistinguishable from DateParse at runtime, minus DateParse's UTC switch

Low A5 undocumented-but-real members Date and Time

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
]%%

Official documentation

GetJWT — the algorithm name is matched case-insensitively, which no source states

Low A5 undocumented-but-real members Encryption and Encoding

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
]%%

Official documentation

Domain — a multi-level domain comes back whole and keeps its original casing

Low A5 undocumented-but-real members Utility

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
]%%

Official documentation

OutputLine — undocumented: no argument at all is legal, and several are written in turn

Low A5 undocumented-but-real members Utility

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>"))
]%%

Official documentation

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

Low A5 undocumented-but-real members Utility

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 acceptedQueryParameter("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
]%%

Official documentation

v — undocumented: it outputs literals and nested function results too, not only variables

Low A5 undocumented-but-real members Utility

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
]%%

Official documentation

BuildRowSetFromString — A trailing separator adds an empty row, and an empty separator splits nothing

Low A5 undocumented-but-real members Content

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
]%%

Official documentation

HTTPRequestHeader — Custom non-RFC 7231 headers are returned, though the docs say only standard headers can be

Low A5 undocumented-but-real members HTTP

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
]%%

Official documentation

LongSFID — a non-15-character input is passed through unchanged instead of validated

Low A5 undocumented-but-real members Sales and Service Cloud

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
]%%

Official documentation