API Behavior Changes in v5.9.6
This page lists every deliberate change in v5.9.6 that an existing integration can observe, so you can check your code against it before upgrading.
This page is maintained through the release cycle
Entries are added as fixes merge and the list is finalized at release. If you are reading it mid-cycle, treat it as current-but-growing rather than frozen.
Looking for the previous release? See API Behavior Changes in v5.9.5, which repointed the Email Gateway recipient domain report at the send queue, scoped per-domain statistics to the sender domain requested, and allowlisted segment rule fields and operators.
The two entries most likely to surprise you
Segment rules that negate now match subscribers with no value, so existing segments and journey Decision branches select more people than they did. And campaigns now brand with the account's verified sender domain by default, which moves the envelope sender, Message-ID and tracking host off the shared platform domain. Both are described in Tier 2 below and both have an entry in the upgrade checklist.
Tier 1: Calls that used to succeed now return an error
A campaign will no longer send with an invalid
Fromheader. No message leaves the send engine with aFromthat failsFILTER_VALIDATE_EMAIL. The affected queue row is markedFailedwith the reasonInvalid From email address: "..."instead of delivering a malformed message and counting the send as successful.This was reachable in normal operation. Under sender domain management the stored
fromemailholds only the local part, and the domain is recombined at send time. When the sender-domain gate failed, that recombination was skipped and the bare local part went out verbatim asFrom: Cem <cem>, unsigned by DKIM, recorded as a success.Merge tags are unaffected. An address that still contains a
%...%tag at that point is allowed through, because merge tags in the From address are a supported configuration and are expanded on the engine at the last moment (issue #2749).settings.updatenow rejects values that would break the install.DEFAULT_LANGUAGEandUSER_SIGNUP_LANGUAGEmust name an installed language pack,USER_SIGNUP_GROUPIDandUSER_SIGNUP_GROUPIDSmust name existing user groups, andDEFAULT_THEMEIDmust name an existing theme (ErrorCode8, 9 and 10). These values previously succeeded and left the install unusable: an unknown language code makes every page include a missing file. Empty values are unchanged.EnabledPluginsis validated against the plugins present on disk and rejects duplicates (ErrorCode20 and 21), with whitespace around codes trimmed before the column is written. The literal***REDACTED***, whichsettings.getreturns in place of secrets, is refused as a value for any field (ErrorCode13) so a read-modify-write client cannot overwrite a secret with the marker (issue #2782).usergroup.createrefuses to create a second default group for a subscription plan. A payload carryingSubscriptionPlanIsDefault: "Yes"for a plan that already has a default group now answersErrorCode 34, naming the conflicting group inErrorTextand its id inConflictingUserGroupID, and no group is created. This is the refusalusergroup.patchhas answered since #2791 and the rule the admin create screen has always applied, so the three writers now agree.It was reachable and it failed quietly. Nothing rejected a second default, and the lookup that resolves a plan's default group selects with
LIMIT 1and no ordering, so a new signup on that plan landed in whichever of the two rows MySQL happened to return first and inherited that group's sending limits, permissions and delivery-server routing. An explicit error beats an arbitrary choice made at signup.A create that does not set the flag, or sets it for a plan that has no default yet, or leaves the plan empty, is unchanged: an empty plan never conflicts. The flag is still coerced rather than validated on this command, so any value other than the exact string
Yesis stored asNo, as before. The refusal runs before the send-method connectivity test, so a create that is going to be refused no longer sends a live test message through the caller's SMTP server first (issue #2826).users.getwithLimitUtilizationStatuscan answer an error where the same call without it cannot. The parameter is new and additive. An unknown bucket answersErrorCode 1; a bucket theuser_limit_utilizationcron has not written yet answersErrorCode 2rather than an empty list. Calls without the parameter are unchanged (issue #2779).A journey
Decisionwhose rule names a custom field that is not on the entry's list now fails the action instead of routing to No. Custom fields are per-list columns. A Decision rule naming aCustomField<ID>of another list (typically after the journey's trigger was re-pointed to a different list) or a field that no longer exists used to fail its query and silently route every subscriber down the No branch, which in the reported production case drained a journey for 27 hours with no signal anywhere. It is now an action failure: the entry is held in place, retried on theJOURNEY_ACTION_FAILURE_*schedule (by default 15m, 30m, 1h, 2h, 4h) and then dead-ended withErrorCode = decision_structural_errorrecorded onoempro_journeys_action_executions. Fix the rule or the trigger list within that window and the held entries evaluate correctly on their next retry. Transient failures (query builder unreachable, timeout) still take the No branch as before (issue #2716).journey.actions.updaterejects a Decision rule whose custom field cannot be evaluated on the trigger list. Two new errors, each with oneErrors[]entry per problem, returned before any stored action is changed:Code 10when the field exists but belongs to a list that is not a trigger list and is not global, andCode 11when no such field exists on the account. Global custom fields (IsGlobal = Yes) are accepted on any list; when the trigger has no list (Manual, email triggers) only existence is checked. Every payload used to be stored verbatim, so this only refuses input that would have failed at run time. The Journey Builder applies the same rule when saving the canvas (issue #2716).The segment engine refuses a rule naming a custom field that does not exist. Previously the rule was silently dropped and a weaker query was built, so a segment count, a campaign audience or a Decision evaluated fewer conditions than the ones saved.
/system/subscribers_query_buildernow answers HTTP 422 with{"status": false, "errorCode": "unknown_custom_field"}, and a non-global custom field of another list answerserrorCode: custom_field_not_on_listinstead of the raw MySQL "Unknown column" error. This service is internal; callers of the public API see it only as the Decision failure and the save-time rejection above (issue #2716).
Tier 2: Same call, different results
No request change is needed, but the response values, the result set or the delivered message differ.
Segment and journey rules
Negating rule operators now match subscribers with no value. Four operators were rendered as bare SQL negations, and in SQL a comparison against
NULLevaluates toNULLrather than true. A subscriber whose field was never populated therefore failed every one of those rules, which is the opposite of how each one reads in the rule builder.Operator Before Now is not(and the legacyIs not)field != 'x'(field != 'x' OR field IS NULL)does not contain(and the legacyDoes not contain)field NOT LIKE '%x%'(field NOT LIKE '%x%' OR field IS NULL)not betweenfield NOT BETWEEN a AND b(field NOT BETWEEN a AND b OR field IS NULL)not in the last x daysfield < date(field < date OR field IS NULL)An unset field is the normal state, not an edge case. Custom-field columns are created nullable with a null default, so every subscriber that predates the field, or that was imported or created without it, holds
NULL. For a global custom field it is stronger still: an unset field is alwaysNULL.This affects list segments, campaign targeting, journey
Decisionactions, and every API or interface surface that evaluates segment rules. Existing segments using these operators will grow. In a segment the old behavior showed a visibly wrong count. In a journeyDecisionit was worse and silent: the Decision resolved false and routed the subscriber down the No branch, which in the reported production case led straight toExit this journey, ending the journey with no email sent and no signal anywhere in the interface.Empty strings were never affected:
'' != 'energy'was already true and still is. A subscriber whose field equals the compared value is still excluded. TheOR ... IS NULLis always parenthesized inside its own rule, so it cannot widen a neighbouring rule under either the "Match All Rules" or the "Match Any Rule" connector. Anyone who worked around this by pairing the negating rule with anis emptyrule now double-counts harmlessly.Not changed:
is not setandis not emptywere already null-correct. Positive operators (is,contains,between,in the last x daysand the rest) are untouched, because an unset field does not equal, contain or fall inside anything.not in the next x dayscarries the same defect and was deliberately left out pending a separate decision, so note it if you rely on that operator (issue #2715).
Journey statistics
journey.listJourneyStats.AggregatedEmailActionsis now all-time. It was windowed toStatsStartDate/StatsEndDate, which default to the last 30 days, whilejourney.getreturned the same field all-time. The two endpoints disagreed under one key for the same journey, with nothing in either response naming a period.journey.listnow matchesjourney.get.Values increase for any journey with cached data older than the requested window. Nothing is renamed and no field is removed. Callers that wanted the windowed figure should read the new
JourneyStats.WindowedEmailActions, which carries exactly the oldjourney.listsemantics and key set.upsender-frontendis a known consumer of this field (issue #2753).The per-day series now includes the
StartDateday.JourneyStats.AggregatedDaysEmailActionson both endpoints, and each action'sDailyStatsonjourney.get, zero-filled one day short, so theStartDateday was missing unless it happened to carry a real data row. A request whereStartDate == EndDatereturned an empty map.The series now always contains every calendar day in the inclusive range. On a quiet journey the leading day appears as a zero row where it previously vanished, so the series gains one key. On a busy journey where that day already had data the key count is unchanged and the day simply moves from the end of the map into its correct chronological slot. Key order is unchanged: newest first for
AggregatedDaysEmailActions, oldest first forDailyStats. No schema change and no backfill (issue #2754).
Sending identity and headers
Campaigns now brand with the account's verified sender domain by default. A campaign whose From address domain exactly matches one of the account's
Status='Enabled'sender domains is now branded with that domain, even when the user group'sSenderDomainManagementoption is off. The envelope MFROM (return path),Message-ID,List-Unsubscribe,X-Report-Abuse,X-Complaints-Toand the click and open tracking host all move from the shared platform delivery-server domain to the customer domain.The Email Gateway already did this, gating only on the domain being
Enabled. The campaign path additionally required the group flag and an explicit per-content selection, so for one and the same account the gateway mail was customer-branded and the campaigns were platform-branded. Mailbox providers then accumulate a single reputation record across every tenant on that delivery server, and per-customer domain verification bought the customer nothing on the channel that sends the most volume. DKIM signing is done by the MTA off the From and return-path domain, so correcting the campaign MFROM is a precondition for customer-domain DKIM on campaigns rather than a cosmetic change.Who is affected: any install with accounts that hold a verified sender domain matching their campaign From address but whose group does not have
SenderDomainManagementenabled. An account with no matching verified domain sends exactly the headers it sent before.Precedence: an explicit per-content sender-domain selection still wins. The new From-domain match sits between that and the group
DefaultSenderDomainfallback. Matching is exact, so a From onmail.example.comdoes not match a verifiedexample.com.The tracking host is gated separately. It only moves when the domain's verification actually covered that tracking record. If a customer verified their MFROM records but never pointed the tracking CNAME, the MFROM and
Message-IDare branded while tracking stays on the platform host, because pointing links at an unprovisioned host would break every URL in the message.To keep the previous behavior, set
CAMPAIGN_SENDER_DOMAIN_AUTO_BRANDING=falsein.oempro_env. Do that if you run a shared-IP warmup pool that depends on platform-branded campaign headers, since enabling this moves reputation onto a colder customer domain (issue #2750).Campaigns no longer brand off a sender domain that is not
Enabled. The campaign send path accepted any sender-domain row that was notDeleted, includingDisabled,SuspendedandApproval Pending. It now requiresStatus='Enabled', which is what the Email Gateway and theemail.render/email.smtp.renderendpoints already required. A campaign whose selected sender domain has been disabled or suspended now falls back to the groupDefaultSenderDomainif one is configured, and otherwise to platform branding, and the failure is logged. Previously it routed mail through the suspended domain (issue #2750).The Email Gateway SMTP relay tests the
Fromdomain as a suffix, not as a substring. An authenticated relay customer'sFromheader was accepted whenever the domain merely contained.<SenderDomain>anywhere in it, so a customer whose sender domain isexample.comcould sendFrom: bank.example.com.attacker.tldand have that header relayed untouched. The test is now a case-insensitive suffix match, and the domain is taken from the last@rather than the first, so an address containing several@compares the real domain.What changes in delivered mail: a
Fromdomain that is neither the sender domain nor a subdomain of it is now rewritten to<local part>@<SenderDomain>, which is what the relay already did for every other unmatched domain. In the other direction, aFromthat differs only in letter case, such asMAIL.Example.COMagainstmail.example.com, is now recognised and is no longer needlessly rewritten. A malformed address with no@yields an empty domain, fails the test and is rewritten, so the guard fails closed. The envelope sender is unchanged and is still normalised to the sender domain (issue #2746).Auto responder
From:headers now use the sender domain root. Auto responder messages put the sending subdomain in theFrom:header, for examplenewsletter@upm.example.com, while campaigns, previews, test sends and the Email Gateway API all used the root,newsletter@example.com. Auto responders now use the root as well. The envelope sender is unchanged and stays on the sending subdomain, so bounce processing and SPF alignment behave exactly as before.This was a defect rather than a preference. The sending subdomain is a CNAME to shared infrastructure whose inherited MX has no mailbox store, so replies to auto responders were discarded while replies to campaigns arrived normally, and RFC 1034 section 3.6.2 forbids publishing an MX record beside a CNAME, so no customer-side DNS could fix it.
What operators will observe: PowerMTA derives the DKIM
d=from theFrom:header, so the auto responder stream's authenticated domain moves from the subdomain to the root. Google Postmaster Tools tracks reputation per authenticated domain, so auto responder volume now reports under the same domain as campaigns instead of a separate one. Accounts that already send campaigns consolidate onto an established reputation. Accounts that send auto responders and nothing else will see their history restart under the root domain. No action is required, and setting a per-domainOptions.CustomSubdomainis not a way to opt out, because any subdomain is still a CNAME with no mailbox (issue #2745).Journey and Email Gateway tracking links now fall back to the delivery-server domain. Tracking, opt-out, web-version and forward links in journey and Email Gateway mail were built from a per-sender-domain hostname that Octeth composed at send time and never asked anyone to create in DNS. Where that hostname was not provisioned, every link in the email was dead, including the opt-out link.
Those links now use the sender domain's own tracking host only when Sender Domain Management is enabled for the owner's group, and the domain is
Enabled, and its stored DNS record set actually lists that exact tracking host. In every other case they fall back to the delivery-server tracking domain that campaigns, autoresponders and transactional mail already use, which cannot emit a hostname that does not resolve.Two consequences. The shipped
EMAILGATEWAY_DNS_TEMPLATESDefaulttemplate requests no tracking CNAME, so on a default configuration these links move from the sender domain to the delivery-server tracking domain: tracking that was already working keeps working, and tracking that was silently dead starts working. Where the tracking host is requested by a custom DNS template, it is now composed from theEMAILGATEWAY_DNS_*settings rather than theEMAILCAMPAIGN_DNS_*ones, so on an install where those differ the advertised host changes to match the gateway template the operator actually configured (issue #2747).Fresh installs set the Google Analytics source keyword to
newsletter. The installer seededGOOGLE_ANALYTICS_SOURCEwith the literalOcteth, which is the product name rather than a traffic source, and it reaches the recipient: on campaigns with Google Analytics tracking enabled the value is appended to every tracked link asutm_source, so it appears in the browser address bar and in the account owner's Google Analytics reporting. A whitelabelled install was publishing the upstream product name to its customers' recipients.Existing installs are not touched. The value lives in the settings row and only the installer's seed changed, so an upgrade keeps whatever is configured. Change it on Settings, Integration if you want the new default. Only installs created on v5.9.6 or later start on
newsletter(issue #2751).
Journeys
Journey sends no longer consult other accounts' suppression lists. The journey
SendEmailaction checked the email suppression list with no account scope and no list scope, so an address suppressed under one account was silently not mailed from a different account's journeys. The check is now scoped to the sending account plus the platform-global list, which is the rule campaigns, the Email Gateway and transactional delivery already applied.A suppression row now applies to a journey send only when
RelOwnerUserIDis the sending account or0, andRelListIDis the list being mailed or0.RelOwnerUserID = 0is the platform-global list and still blocks every account. Suppression patterns are platform-wide by design and are unchanged.Journeys will send to contacts they previously skipped. The larger the platform-wide suppression table, the more journey sends were being dropped, so on a busy multi-tenant install journey volume can rise noticeably in the first days after upgrading. There is no opt-out: the previous behavior was one tenant's data deciding another tenant's sends, not a configurable policy.
Two corrections ship with it. An account with "Disable suppression check in outgoing emails" set is no longer blocked in journeys, because the old path took no user record and structurally could not honour that option, so the setting worked for the Email Gateway but not for journeys. And the journey SMS action no longer consults the email suppression list at all, since an email opt-out was blocking a text message. SMS suppression is unchanged and is still enforced with its own account and list scope. Subscription status and bounce state continue to gate both channels, and a soft-bounced subscriber is still skipped exactly as before.
Skips are now diagnosable: a journey action execution records a
SkipReasoninExecutionMetadata, one ofNotSubscribed,Bounced,SuppressedorSubscriberNotFound. Previously a skip recorded only a failure with no cause, and the log line said "unsubscribed or hard bounced" even when the real cause was a suppression hit.ExecutionMetadatais not returned by any API command, so no response shape changes (issue #2762).A failed journey action no longer advances the subscriber. A journey action that fails now holds the entry in place, retries it on a backoff, and dead-ends it with a recorded reason once the attempts run out, instead of advancing the subscriber as though the action had succeeded.
Failed runs also stop counting towards the action's
CompletedRuns, so the Journey Builder node counters no longer overstate delivery: a node showing 33 completed used to be able to mean 33 emails or zero. An Email Gateway response that returns HTTP 2xx with noMessageIDis treated as a failed send, because no queue row exists and no email is ever delivered.Failures are recorded on
oempro_journeys_action_executionswithExecutionStatus='Failed', plusErrorMessage,ErrorCode, and for a pending retrySnoozedUntilandSnoozeReason, and in the journey log. The retry schedule is configurable through theJOURNEY_ACTION_FAILURE_*settings in Octeth Configuration (issue #2748).journey.updategains an additiveWarningsarray. Present only when the request changed the trigger and at least one Decision action references a custom field that does not resolve on the new trigger list. Each entry names theActionID, aMessage, and aFieldslist withFieldID,FieldName,FieldListIDand aReasonofforeign_listormissing. The update has already been applied when warnings are returned; they tell you which Decision rules to fix next. Every existing key is unchanged and responses without warnings are byte-identical (issue #2716).
Email content
email.template.updatecan clear a field, andemail.template.createacceptsIsCreatedByAdmin. Every updatable field was guarded by a non-empty test, so nothing could be emptied through the API. The additiveClearFieldsparameter takes a comma list ofTemplateDescription,TemplateSubject,TemplateHTMLContent,TemplatePlainContentandTemplateThumbnail, applied after the value writes, so a field named in both is cleared. An unrecognised entry answersErrorCode 3, and a combination that would leave the template with neither an HTML nor a plain body answersErrorCode 4and writes nothing. Neither code was previously used by this command. Under admin authenticationemail.template.createnow honours an explicitIsCreatedByAdmin; when it is absent the historical derivation is kept, where naming no owner means admin-created. Requests that send neither parameter are unchanged (issue #2787).email.updateno longer wipesOptions.SenderDomainon a partial update.Optionsis now merged onto the stored value rather than replaced wholesale, so an update that omitssenderdomainleaves the stored selection alone.plaincontentautoconvertandsubjectsettotitleelementkeep their previous behavior of clearing when omitted, so no existing caller sees a different result for those two.A read-modify-write round trip on a sender-domain-managed email is also idempotent now. Sending back the stored
fromemail, which under sender domain management holds only the local part, previously derived a garbage domain and returnedErrorCode 17. Relatedly, the domain is no longer stripped offfromemailunless a sender domain actually resolved and is being stored alongside it (issue #2750).
Admin and settings commands
admin.campaigns.searchworks again withoutUserID. After the admin-reach change (#2775) this command answeredErrorCode 5001when called withoutUserID, and withUserIDit was locked to that one account, because it delegates to thecampaigns.gethandler and inherited its new prologue. The cross-tenant browse it exists for was unavailable on develop between the two changes; no tagged release carried it. Restored in #2790: noUserIDis required,FilterByUserIDnarrows to one account again, and restricted sub-admins are scoped to their user groups. Callers that had started passingUserIDas a workaround should switch toFilterByUserID;UserIDis ignored.admin.campaigns.searchaccepts the advanced search syntax and a failed-only filter. The additiveSearchQuerytakes the samefield:valuesyntax as the admin Campaign Report search box, is translated to SQL on the server and is AND-ed with the other filters. The translated SQL is never echoed back, and a translation failure answersErrorCode 5rather than quietly returning an unfiltered result.HasFailedrestricts the listing to campaigns with at least one failed recipient. Both parameters were accepted and ignored before, so a caller that was already sending either one now gets a filtered result where it used to get everything (issue #2790).sso.createreturns the minted key pair. The response gainsKey1andKey2, the two keys the command generates for the new source, so a caller can finish configuring the integration without a second read. Every existing key is unchanged and the error contract is unchanged. Treat the response as credential-bearing: this is the only command that returns these keys (issue #2777).deliveryservers.gettakes filtering, ordering and paging. The additiveDeliveryServerIDnarrows the response to one server,OrderField(Name,DeliveryServerIDorVerificationLastCheckedAt) andOrderTypeorder it, andRecordsFromandRecordsPerRequestpage it, capped at 1000 per request. The response gainsTotalDeliveryServerCount,RecordsFrom,RecordsPerRequest,OrderFieldandOrderType. Every default reproduces the historical response: all servers,Nameascending, unpaged. The one caller that sees a difference without changing its own code is one that was already sendingRecordsFromorRecordsPerRequesthere and having them ignored; those values now take effect (issue #2788).ENABLED_PLUGINSentries written with surrounding whitespace now take effect. The enabled-plugin test compared raw comma-separated fragments, so a value stored asalpha, betaleftbetamatching nothing and that plugin never loaded, while the plugins screen listed it as enabled. Codes are trimmed before comparison now, so the screen and the runtime agree. On an install whose stored value carries spaces after the commas, plugins that were silently inert start loading on upgrade. Check Settings, Plugins after upgrading if you are unsure what the column holds (issue #2783).The Bounce Processing screen no longer loses messages, and manual hard bounces classify correctly. Three defects in that screen are fixed as a side effect of extracting its logic into the class the new
bounce.*commands share. Manual processing left a trailing carriage return on the bounce type for CRLF input, so everyhardline was recorded as a soft bounce; fields are trimmed now and those lines register as hard. Clearing the incoming message spool called the bounce registrar with a one second timeout and then deleted every file whatever the result, so a slow parse silently discarded the message; a file is now unlinked only after it processes successfully, and failures stay in the spool and are reported per message. Saving bounce patterns ran aTRUNCATEfollowed by row-by-row inserts with no transaction, so a mid-way failure left the pattern table empty; it is one transaction now, and a line without==>is reported with its line number instead of being dropped. No API command changes (issue #2781).users.getreports the true total for the blocked-domain filter. WithRelUserGroupID: "ActivationPendingSenderDomains",TotalUsersused to be the number of rows on the requested page, so pagination past page 1 was wrong. It is now the number of matching users (issue #2779).global.customfields.getreturns a realTotalFieldCount. It was computed from the list id and user id of the calling session, neither of which exists under admin authentication, so the value was meaningless. It is now the number of global fields matchingSearchKeyword;TotalCustomFieldscarries the same number andRecordsFromandRecordsPerRequestecho the paging in effect (issue #2788).deliveryservers.deleteresets the user groups that pointed at the deleted server. Every user group whoseTargetDeliveryServerID_Marketing,_Transactionalor_AutoResponderoption referenced the server is reset to0(system default) after the Email Gateway per-user cache for those groups is invalidated. Before, the options kept pointing at a server that no longer existed. The response gainsUserGroupsReset, the list of user group ids that were updated (issue #2788).deliveryserver.createpersistsSenderRotationandSenderRotation_Settings. Both were accepted on create and silently dropped; onlydeliveryserver.updatestored them. Create now stores them the same way (issue #2788).The admin Campaign Report screen and the new
admin.campaigns.*commands share one implementation, and the screen changes as a result. The report now applies the same sub-admin user-group scope as the API (it applied none before), the chart uses the same advanced-search translation as the table instead of a plain campaign-nameLIKE, and the export supports all nine status buckets (it had six) (issue #2790).The admin "Account Activity" chart draws real values. The screen looked its series up by a
date('M j')label againstY-m-dkeys and always drew zeros. It now renders the same seriesadmin.user.activityseries.getreturns (issue #2779).The delivery-server "Test" button can now pass its three CNAME checks. The checks for the sender, link-tracking and open-tracking hosts read a
txtkey from aDNS_CNAMEanswer, whose key istarget, so they could never pass on any install.deliveryserver.verifyand the screen now readtarget(issue #2788).deliveryserver.testresultsnow runs the verification instead of storing what you send. The command used to write thespf,dkim,dmarc,sender_domain,link_domain,open_domainandemail_deliverybooleans from the request, and the request'slast_checked_at, straight into the delivery server row with no check. It now sends a test message through the server's SMTP credentials to the authenticated admin's address, runs the SPF / DKIM / DMARC and CNAME checks, and stores that outcome stamped with the time of the check. The request parameters and theSuccess: trueresponse are unchanged, and the values intest_resultsandlast_checked_atare ignored. The command is now rate limited (10 calls per 300 seconds, the same asdeliveryserver.verify). A caller that relied on this command to mark a server verified without the DNS records in place will now seedeliveryserver.get,deliveryservers.getand the admin Delivery Servers screen report the real state. Usedeliveryserver.verifyto get the results and per-check messages in the response (issue #2769).usergroup.patchwritesSubscriptionPlanIsDefault. Before v5.9.6 the key was silently ignored. A payload carrying it now writes the column, or answersErrorCode 33(a value other thanYesorNo) orErrorCode 34(another group is already the default for that plan, withConflictingUserGroupIDin the response). Payloads without the key are unchanged. The admin user group screen and the API now share the same one-default-per-plan check (issue #2791).usergroup.createvalidatesOptions. It used to JSON-encode the parameter unconditionally, so a JSON string was double-encoded and the stored column held a JSON string rather than an object, which no consumer could read back, including the admin edit screen and the send engine.Optionsis now accepted as an object or as a JSON string of one, the two shapesusergroup.updateandusergroup.patchalready accept, and anything else answersErrorCode 28. Because the old output was unreadable by every consumer, no working integration can have depended on it. Known keys sent as a nested object are additionally remapped onto their canonical spelling, since/api.phplowercases nested request keys and a lowercased key was likewise read by nothing. A create that sends noOptionsat all is unchanged (issue #2791).usergroup.options.patchrefuses an unreadable stored blob. When a group's storedOptionscolumn is non-empty and does not decode to a JSON array or object, the command answersErrorCode 8and writes nothing, naming the group inGroupName. The alternative, treating the column as empty, would have replaced the tenant's stored configuration with only the patched keys. An empty column and the literalnullwritten by an olderusergroup.createare both treated as no options yet and patch normally (issue #2791).The About page's database health panel checks tables in batches. It used to put every Octeth table into a single
CHECK TABLEstatement. On an install with thousands of per-list and per-campaign tables that statement sat in "Opening tables" for over a minute and then failed with "MySQL server has gone away", so the panel never rendered. The result rows and the rendered table are unchanged, except that a batch whose statement fails now shows one error row per table in it instead of nothing. The panel still covers every table, while the newadmin.database.checkcommand defaults to the core schema and takesScope=Allfor the full run, because a fullCHECK TABLEreads every page of every table through the InnoDB buffer pool and was measured exhausting memory on a host whose pool is sized close to its RAM (issue #2784).The admin Dashboard, Live view and System Wide Delivery Metrics screens moved onto a shared class. Restricted sub-admins now see only the accounts of their allowed user groups on all three, where before every tenant was shown regardless of the restriction. The Live view "at a glance" tiles render 0 instead of logging a division-by-zero warning when no campaign falls in the time frame. The Dashboard leaderboards no longer list a user id whose account has been deleted. The forecast chart is rebuilt inline on a cache miss instead of rendering empty until the next cron tick, and a forecast payload cached by a pre-v5.9.6 cron is ignored and rebuilt (issue #2792).
The admin Delivery Servers Reports, List Freshness and Payment Reports screens moved onto a shared class. Rendered HTML is unchanged. Three visible differences: restricted sub-admins now see only the accounts of their allowed user groups on all three screens, as they already did on the Campaign Report since #2790; the Payment Reports screen renders 0% for both the paid and the not-paid ratio when the payment log is empty, where it used to render 0% and 100%, and a month with no revenue renders a 0% difference instead of a PHP warning; and on the Delivery Server Performance report, choosing delivery servers now populates the recipient-domain selector, which a
queue_c<ID>typo had left empty since the feature shipped. The KPI dashboard's Redis payload now stores delivery server ids and names instead of full rows, and the list freshness cache keys moved from_v2to_v3. All of them expire on their own TTL, so nothing needs flushing (issue #2793).Admin global search escapes wildcards and scopes restricted sub-admins. The search box now treats
%and_in the keyword literally, where they used to act as SQL wildcards, and applies the sub-admin user-group restriction in SQL rather than filtering the first twenty rows afterwards, so a restricted sub-admin gets up to twenty in-scope results instead of the in-scope subset of the first twenty (issue #2791).
Tier 3: Security closures
These only affect callers doing something that was never intended to work. Listed for completeness and for anyone auditing.
campaigns.get, and transitivelyadmin.campaigns.search, now allowlistsorderfield. The parameter was passed straight into theORDER BYclause. Anorderfieldthat is not a real, sortable campaign field is now ignored and the endpoint sorts by the documented default,CampaignNameascending, rather than reaching the query unvalidated.Legitimate sort fields are unchanged: any physical
oempro_campaignscolumn, plus the named computed keysDuration,SentRate,DeliveryRate,FailureRate,Velocity,Schedule,sort-by-statusandsort-by-send-date.ordertypeis constrained toASCorDESC, and any other value is treated as the default direction. This is a silent fallback rather than an error, so no working call changes and no client code that already sends a validorderfieldneeds updating. This was the last named sink from the v5.9.5 injection audit (issue #2731).Sub-admin privileges can now be enforced on the API (opt-in). Every admin-capable command declares the sub-admin privilege it needs, matching the screen that owns it. With
ADMIN_API_ENFORCE_PRIVILEGES=truein.oempro_env, a sub-admin calling a command outside its privilege list gets99999(Not enough privileges) instead of succeeding, whether it authenticates with its own API key, aSessionIDfromAdmin.Login, or username and password. The setting is off by default on upgraded installs (absent from an existing.oempro_env), so nothing changes until you enable it; the shipped.oempro_env.examplesets it on for fresh installs. The masterADMIN_API_KEYand admin accounts without restricted access are never affected in either mode. Before enabling it, check that any integration authenticating as a sub-admin holds the privileges it needs (issue #2774).Nine user commands now also accept admin authentication with a
UserIDparameter.lists.get,campaigns.get,segments.get,emailgateway.getdomains,user.senderdomain.list,lists.stats,list.getactivityseries,subscribers.getandmedia.uploadare registereduser,adminwith user first, so a call withoutAccess=adminis still a user call and existing responses are byte-identical. WithAccess=adminandUserIDthe handler runs for that account. New error codes5001,5002,5003exist only on the admin path (issue #2775).Five SMS suppression commands also accept admin authentication.
smssuppression.browse,smssuppression.stats,smssuppression.delete,smssuppression.patterns.browseandsmssuppression.patterns.deleteare now registereduser,adminwith user first, so a call withoutAccess=adminis still a user call and existing responses are byte-identical. WithAccess=adminthey acceptLevel=systemand a caller-suppliedUserID/ListID, the same admin branchsmssuppression.addalready had, and the delete commands can remove system-level rows under admin auth (issue #2786).admin.updatetakes aCurrentPasswordparameter, and can require it. A supplied value is always verified (ErrorCode 10when wrong). Whether omitting it alongsidePasswordis refused (ErrorCode 9) is decided by the newADMIN_UPDATE_REQUIRE_CURRENT_PASSWORDsetting: off by default on upgraded installs, on for fresh installs. The admin Account screen has always demanded the current password; this carries the same check to the API (issue #2776).Saving a sub-admin no longer clears its two-factor authentication. The sub-admin edit screen used to reset
2FA_Enabled, the secret and the recovery key on every save, so renaming a sub-admin silently disabled their 2FA. Both the screen and the newadmin.subadmin.updatecommand now leave 2FA alone; it is only cleared by an explicit disable (issue #2776).Two admin screens are tightened as a side effect of sharing code with the new endpoints. The Settings, Segments screen now rejects a rule whose field or operator is outside the allow-list introduced for issue #2720, and preserves unrelated
Optionskeys on save instead of rewriting the whole blob (issue #2785). The Suppression screen now limits a restricted sub-admin's search and delete-by-id to the user groups it may access; previously it showed and deleted every tenant's rows (issue #2786).AdminAPIKeyalso accepts per-sub-admin keys. Issued on the sub-admin edit screen. A value that is neither the master key nor a sub-admin key still returns99998, so no existing caller sees a different result.Admin.Loginnever returns the key inAdminInfo.Admin-key calls no longer lose
User.Update's admin-only fields when the master admin has 2FA enabled.AccountStatus,AvailableCredits,RelUserGroupID,ReputationLevel,APIKey,UserSinceandSignUpIPAddresswere silently dropped underSuccess: trueon installs where the master administrator had two-factor authentication on, because the key-based login demanded a TOTP it could never receive. The API key is the credential, so the internal login now bypasses the TOTP the same way user API keys already did. Calls that were affected now persist all fields (issue #2774).TemplateThumbnailPathonemail.template.createandemail.template.updateis confined to the temp directory. The value was concatenated onto the temp path with no check and then unlinked, so a relative path could read an arbitrary file into the thumbnail column (readable back throughemail.template.get) and delete it, under user authentication. Both handlers now accept only a bare file name as returned byemail.template.thumbnail.upload; any value containing a path separator or..is ignored.email.template.updatealso now deletes the consumed temp file, as create always did (issue #2787).settings.getnever returns secrets, andsettings.updaterefuses the marker. SMTP passwords, S2S keys, provider API keys and similar values come back as***REDACTED***. Writing that literal back is rejected withErrorCode 13, so a client that reads settings, edits one field and writes the whole set back cannot overwrite a secret with the marker (issue #2782).system.health.checkgains a strict credential mode and a monitor token (opt-in). The endpoint has always required the masterADMIN_API_KEY. WithSYSTEM_HEALTH_CHECK_AUTH_REQUIRED=trueit instead accepts either a valid admin API key (master or per-sub-admin) or the newSYSTEM_HEALTH_CHECK_TOKEN(as theHealthCheckTokenparameter or anAuthorization: Bearerheader), and refuses everything else with HTTP 401 and the standard99998envelope rather than HTTP 500 and100005. The switch is off by default on upgraded installs, so nothing changes until you enable it; the shipped.oempro_env.examplesets it on for fresh installs. TheBearer <AdminAPIKey>form documented since the endpoint shipped keeps working in both modes. The About page's own health panel now runs the check in-process instead of calling the endpoint over HTTP (issue #2767).ADMIN_ALLOWED_IPcan now be enforced on the API (opt-in). The admin-area IP allow-list was checked only when the admin login page rendered;AdminAPIKey, an adminSessionIDand admin username/password all worked from any address. WithADMIN_API_ENFORCE_ALLOWED_IP=trueand a non-empty list, every admin-authenticated API call from an address outside it answers99998. There is no exemption for loopback or private ranges, so an integration that callsapi.phpwith an admin credential from inside the Docker network or from another host needs its address added to the list before you enable this. The legacy admin and user screens are unaffected: they call the API in process, so there is no HTTP request and no address to measure. The new user interface is not. It is a separate application that reachesapi.phpover HTTP from its own container using the master admin key, so its admin-key calls are measured against this list and refused. Staff sign-in, signup, the password reminder, the password reset, profile edits, the password change, the 2FA toggle and impersonation all fail, and because the interface reads the refusal as an expired session it shows a sign-in loop rather than an error. If you run the new interface with this setting on and a non-empty allow-list, add192.168.99.110, the interface's container, to Settings, Security, Authorized IP Addresses, or setADMIN_API_ENFORCE_ALLOWED_IP=false. v6.0.0 exempts that container automatically (issue #2913). Off by default on upgraded installs, on for fresh installs (issue #2770).The admin About page's
downloadaction is confined to the data directory. It served any file under the application root, including.oempro_env, to an admin holding theSystemprivilege. The path is now resolved withrealpath, must be a regular file underdata/, and anything else answers HTTP 403 and is logged. No screen links to this action; it remains for the export-then-download flow only (issue #2766).global.customfields.deleteandcustomfields.deleterefuse ids outside the caller's scope. Both commands handed their id list straight to the deletion routine, whoseALTER TABLE ... DROPkeyed off the field record rather than the caller. An admin call naming a tenant's list-local field id dropped that tenant's subscriber column while the metadata row survived, and a user call naming another account's field did the same to the other account. Each id must now resolve to a system-global field (admin command) or to a field owned by the authenticated account (user command); one bad id refuses the whole call withErrorCode 2and nothing is deleted. The one observable change for a legitimate caller: an id that does not exist, which used to answerSuccess: true, is nowErrorCode 2. The deletion routine itself also refuses any record outside the owner and list scope it was called with, and logs a warning, so no future caller can reach the schema change with a mismatched id (issue #2768).The private
/system/search_translatorservice now requires a shared-secret header. The Laravel service that turns the admin advanced-search syntax into SQL foradmin.campaigns.search(SearchQuery) and the campaign report screens used to be guarded only by a source-address allow-list. It now also requires anX-Octeth-Signatureheader derived fromOEMPRO_PASSWORD_SALTandADMIN_API_KEY, the same value the bounce webhook uses, and answers401with{"error":"unauthorized access"}to anything else. Octeth's own callers send the header, soadmin.campaigns.search, the admin Campaign Report and the user Campaigns search behave exactly as before and nothing needs configuring. This only affects a client that was calling the internal service directly, which was never supported (issue #2771).Disabling administrator two-factor authentication now needs a POST, the page's CSRF token and the current password. The Security screen's "Disable Two Factor Authentication" action used to clear the admin's 2FA on any request, GET included, with no token and no password check, so a logged-in administrator who loaded an attacker-controlled page could have their second factor switched off by an image tag. The action now accepts POST only, requires the session-backed
csrf_tokenthe Security page embeds, and requires the administrator's current password; a request failing any of these redirects back to the Security page with an error and changes nothing. Successful disables are logged with the AdminID. This is a screen-only change:admin.2fa.disableis unaffected (issue #2772).The remaining private
/system/*services require the same shared-secret header (opt-in this release)./system/segment_query_builder,/system/subscribers_query_builder,/system/queue_query_builder,/system/mime_email_parserand/system/email/spamtestwere reachable through HAProxy from the internet with at most a source-address allow-list, and that allow-list included HAProxy's own address, so a public POST reached the segment SQL builder. All of Octeth's own callers now sendX-Octeth-Signature. WithSYSTEM_INTERNAL_SIGNATURE_REQUIRED=truean unsigned request answers401; with it off (the default on upgraded installs) unsigned requests are accepted and logged at WARNING so third-party plugin callers can be found before enforcing. HAProxy's address was removed from the allow-list and theAPP_ENV=localbypass that disabled the guard entirely is gone in both modes.mime_email_parseradditionally confinesRawEmailFilePathto the antivirus spool directory, following symlinks, and the unused/system/campaignpreview route was removed. SeeSYSTEM_INTERNAL_SIGNATURE_REQUIREDin Octeth Configuration (issue #2813).Disabling a user's two-factor authentication now needs a POST, the page's CSRF token and the current password. The user Account screen's "Disable Two Factor Authentication" action used to clear the user's 2FA on any request, GET included, with no token and no password check, the same defect fixed for administrators in #2772. The action now accepts POST only, requires the session-backed token the account page embeds, and requires the user's current password; a request failing any of these redirects back to the account page with an error and changes nothing. Successful disables are logged with the UserID. Screen-only: no API command is affected (issue #2812).
The per-campaign admin commands are scoped to a restricted sub-admin's user groups.
admin.campaign.details,.batches,.queue,.processes,.sending-velocity,.markfailed,.unstuckand.retryfailedtake a campaign id and did not consultOptions.AccessAllowedUserGroupIDsat all, while their listing siblings did. A sub-admin restricted to one set of user groups could therefore read any campaign on the install by naming its id, and the three writing commands could alter another group's live campaign delivery state. All eight now refuse an out-of-reach campaign withErrorCode 11andErrorText"Access denied to this campaign", the code and wordingadmin.subscribers.delete.allalready used for the same refusal.A campaign id that does not exist answers the same refusal, so the commands cannot be used to probe for campaign ids outside the caller's reach. The master
ADMIN_API_KEY, every admin withoutAccessLimited, and any restricted sub-admin whose allowed groups include the campaign owner's group all get byte-identical responses to before (issue #2856).
Upgrade checklist
Do you have segments or journey
Decisionactions usingis not,does not contain,not betweenornot in the last x dayson a field that may be unset? Their audiences will grow, which is the fix, but segment size is sometimes load-bearing. Review sending throttles and per-send limits keyed to an expected audience size, scheduled and recurring campaigns that will now reach more people on their next run, any external reporting or billing that reconciles against a segment count, and every journey whose Yes branch sends mail or changes subscriber state. Where you deliberately want to exclude subscribers with no value, add a companionis not emptyrule to the same group.Do your accounts hold verified sender domains that match their campaign From addresses? Their campaigns now carry customer-domain MFROM,
Message-ID,List-Unsubscribe,X-Report-AbuseandX-Complaints-To, and customer-domain tracking links where the tracking record verified. Confirm that is what you want before upgrading, particularly if you run a shared-IP warmup pool. SetCAMPAIGN_SENDER_DOMAIN_AUTO_BRANDING=falseto keep platform branding.Do you read
JourneyStats.AggregatedEmailActionsfromjourney.list? It is now all-time rather than windowed to the last 30 days, so the values increase. If you wanted the windowed figure, switch toJourneyStats.WindowedEmailActions, which carries the old semantics and key set unchanged.Do you chart
AggregatedDaysEmailActionsor a per-actionDailyStatsseries? Both now always contain every calendar day in the inclusive range, so a quiet series gains one key at theStartDateend. If you index by position rather than by date key, or if you assert a fixed key count, adjust for that.Do you monitor auto responder deliverability separately from campaigns? The authenticated domain moves from the sending subdomain to the sender domain root, so Google Postmaster Tools reports auto responder volume under the same domain as campaigns. If auto responders are the account's only stream, expect its reputation history to restart under the root domain.
Do you depend on journey or Email Gateway tracking links resolving on a sender-domain host? They now fall back to the delivery-server tracking domain unless the domain's stored DNS record set lists that exact tracking host. On a default configuration those links move to the delivery-server domain. Links that were already resolving keep resolving.
Do you treat a journey action's
CompletedRunsas a delivery count? It no longer counts failed runs, so the figure drops to reflect actual sends. A failing action now holds its entry and retries it on a backoff before dead-ending, so entries can sit in a journey longer than they used to. Tune that withJOURNEY_ACTION_FAILURE_MAX_ATTEMPTSand the two retry-interval settings.Do you call
email.updatewith a partialOptionsobject? It now merges rather than replaces, so an update that omitssenderdomainno longer clears the stored sender domain. If your integration relied on omission to clear that field, set it explicitly instead.Do you build
orderfieldforcampaigns.getfrom user input or another system? An unrecognised value is now ignored and the default sort applies, instead of reachingORDER BY. Check that the fields you send are real campaign columns or one of the named computed keys.Do you call
deliveryserver.testresultsfrom an integration? If any integration callsdeliveryserver.testresults, note that it now sends a real test message to the admin's address and performs DNS lookups on every call, and that the storedVerificationResultsare the check's outcome, not the request's. CheckVerificationResultswithdeliveryserver.getafter upgrading for any server that was previously marked verified through this command.Do any of your journeys have Decision rules on a custom field of a list other than the trigger list? This happens when a journey's trigger was re-pointed to another list, or a journey was cloned between lists. Those Decisions used to route everyone to No silently; they now fail, hold the entry and dead-end it after the retry window. Run
journey.geton each journey and check its Decision criteria, or change the trigger list again throughjourney.updateand read theWarningsarray, then re-point the rules to the fields on the current list. New saves of such a rule are refused withjourney.actions.updatecodes 10 and 11.Do you run a third-party plugin or a script that posts to
/system/segment_query_builder,/system/subscribers_query_builder,/system/queue_query_builder,/system/mime_email_parseror/system/email/spamtest? LeaveSYSTEM_INTERNAL_SIGNATURE_REQUIREDoff for a few days after upgrading and grep the Laravel log for[internal.signature]. Every hit is an unsigned caller; update it to sendCore::InternalRequestHeaders()and then enable the flag.Do you run journeys on a multi-tenant install with a large suppression list? Journey sends that were being dropped because a different account had suppressed the address will now go out, so journey volume can rise on the first days after upgrading. Review sending limits and any external reconciliation keyed to journey volume. There is no flag to keep the old behavior. If you relied on the email suppression list to stop journey text messages, add those numbers to SMS suppression instead, because the SMS action no longer reads the email list.
Does anything create user groups through
usergroup.createwithSubscriptionPlanIsDefault? A plan that already has a default group now refuses the create withErrorCode 34instead of quietly producing a second default. If you have plans with two default groups today, clear the flag on the one you do not want before upgrading, because until then new signups on that plan land in an arbitrary one of the two.Do your Email Gateway SMTP relay customers send from a subdomain of their sender domain? The
Fromdomain test is a suffix match now. A genuine subdomain still passes, including one written in mixed case, but a domain that merely contained the sender domain somewhere in it is rewritten to the sender domain root. Check any relay customer whoseFromheader is neither the sender domain nor a real subdomain of it.Do you issue per-sub-admin API keys to accounts restricted by user group? The per-campaign
admin.campaign.*commands now enforce that restriction, so a sub-admin that was reading or unsticking campaigns outside its groups will start gettingErrorCode 11. WidenAccessAllowedUserGroupIDsfor that account if the access was intended.
One general note on error codes
ErrorCode and ErrorText are arrays, not scalars, on most endpoints. A rejection typically returns "ErrorCode": [13], not "ErrorCode": 13. Code written as if (response.ErrorCode === 13) will not match, so use response.ErrorCode.includes(13) or your language's equivalent.
subscribers.get is the exception: it returns a scalar ErrorCode (for example "ErrorCode": 4), consistent with its existing codes 1, 2 and 3. Match it as a scalar.

