Last updated: September 19, 2026
CustomerGate 365: web service reference
For developers who build a website on CustomerGate 365. It covers authentication, the shape of the endpoints, every service and procedure, the JSON keys and fixed values, and the security rules behind them. It describes app version 1.0. Names are case-sensitive, and the AL source of the app is the authority.
The services
CustomerGate 365 publishes eight web services. The app publishes them itself on installation and on every update: a missing service is created, and a service that already exists under that name is never changed, even if someone unpublished or edited it by hand. The three iFacto services exist only where Steel 365 is installed.
| Service | Kind | Purpose | Called by |
|---|---|---|---|
voaItems | page, read-only | Items for the catalog | synchronization application |
voaVariants | page, read-only | Item variants (sizes) | synchronization application |
voaCategories | page, read-only | Item categories | synchronization application |
voaProfiles | page (iFacto DXSteel) | Steel profiles | synchronization application |
voaGrades | page (iFacto DXSteel) | Steel grades | synchronization application |
voaItemTypes | page (iFacto DXSteel) | Steel item types | synchronization application |
voaRequests | codeunit, unbound actions | Accept a quote request or an account request | website application |
voaPortal | codeunit, unbound actions | Sign-in and everything a signed-in customer sees or does | website application |
Authentication
The website talks to Business Central as two Microsoft Entra applications. Their client IDs go into CustomerGate Setup, and Repair Permissions there registers them in Business Central and assigns their permission sets.
| Setup field | Used for | May call |
|---|---|---|
| Website Client ID | Quote and account requests, the customer portal | voaRequests, voaPortal |
| Synchronization Client ID | The catalog synchronization. Leave blank if the catalog is not synchronized. | voaItems, voaVariants, voaCategories, voaProfiles, voaGrades, voaItemTypes |
Neither application can use the other's services: the website application has no execute right on the catalog pages, and the synchronization application has none on voaPortal or voaRequests.
Permission sets
| Set | Assigned to | What it allows |
|---|---|---|
VOA WEB API | website, all companies | Execute voaRequests. Indirect insert only on the request tables, no read. |
VOA WEB PORTAL | website, all companies | Execute voaPortal and the codeunits it calls. Only indirect table rights. |
VOA WEB BASELINE | website, all companies | The floor a web service session needs, instead of D365 BASIC, which would expose customer and sales data. |
VOA CG EXECUTE | website, tenant set | Execute rights on the objects the mail, write and PDF paths reach. |
VOA CG MAIL | website, tenant set | What the e-mail connectors need to send mail, such as the sign-in code. |
VOA CG S365 ARCHIVE | website, only with DXSteel Doc Archive | Read the archive tables, with a security filter on the allowed archive categories. |
VOA CG DOCUMENTS | website, only without that archive and with Portal Documents from Reports on | Read what the standard sales reports read, and run them, so Document can print PDFs. |
CDC BASIC | website, only with Continia Document Capture | Continia hooks the triggers of the tables the website writes to. |
VOA WEB SYNC | synchronization, per company | The three catalog pages and their source tables, plus read on item ledger entries for the remaining inventory. |
VOA CG S365 SYNC | synchronization, only with Steel 365 | The three DXSteel pages and the tables behind them. |
Getting a token
Both applications use the OAuth 2.0 client credentials flow:
POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
grant_type=client_credentials
client_id={client id}
client_secret={secret}
scope=https://api.businesscentral.dynamics.com/.defaultSend the access token as Authorization: Bearer {token} on every call.
Endpoints and conventions
All services are standard Business Central OData v4 endpoints under https://api.businesscentral.dynamics.com/v2.0/{tenant}/{environment}/ODataV4.
Page services
The catalog pages are read with GET. The reference website addresses the company by name:
GET .../ODataV4/Company('{company name}')/voaItems?$select=No,Description&$filter=...A company name with an apostrophe has it doubled (O''Brien), as OData requires. Large results are paged: follow @odata.nextLink until it is absent. The records are in value.
Codeunit services
voaRequests and voaPortal expose each public procedure as an unbound action named {service}_{Procedure}. Call it with POST and pass the parameters by name in a JSON body. The reference website addresses the company with its GUID, the id of the company in the standard companies API:
POST https://api.businesscentral.dynamics.com/v2.0/{tenant}/{environment}/ODataV4/voaPortal_ReadSession?company={company id}
Authorization: Bearer {access token}
Content-Type: application/json
Accept: application/json
Accept-Language: en-US
{"token": "<session token>"}The return value comes back in the OData envelope under value:
{"@odata.context": "…", "value": "{\"email\":\"buyer@example.com\",\"customerNo\":\"C00010\", …}"}Conventions
- Names are case-sensitive and bound by name.
voaPortal_readSessiondoes not exist, and a parameter calledemailAddressinstead ofemailgets a 400 with no further explanation. - Every parameter is a string: all procedure parameters are AL
Text. - A JSON result comes back as a string inside
value, because an unbound action cannot return a nested object. Parsevaluea second time. A Boolean result is a JSONtrueorfalseinvalue. - An empty string is an answer. For most
voaPortalprocedures,""means no valid session, or nothing for you here. - Optional keys are left out, not sent as null. Treat a missing key as null.
- Dates are
YYYY-MM-DD, timestamps are ISO 8601 in UTC (2026-09-19T14:30:00.000Z). An empty date or timestamp is"". - Numbers and Booleans are JSON numbers and JSON Booleans.
Accept-Languagesets the language of the Business Central session. It affects error messages and the option captions in the catalog, never the fixed values below. The reference website sendsnl-NL.
Status codes and retries
| Status | Meaning here |
|---|---|
| 200 | The procedure ran. Check value for the outcome. |
| 400 | An AL error was raised, for example a refused request (the message is in the body), or the parameter names do not match. |
| 401 / 403 | Token or permission sets are wrong. The body names the object that is missing. |
| 404 | The service is not published under that name, or the company is wrong. |
| 409 | Concurrent calls with the same session token tried to update the same session row. |
| 429 | Business Central is throttling. Nothing was processed; honour Retry-After. |
| 5xx | Server error. For a state-changing call you do not know whether it ran. |
Retrying after a 429 is always safe. After a network error, a time-out, a 409 or a 5xx, retry only the reading procedures (ReadSession, MyDetails, MyDeliveryAddresses, MyRequests, MyQuotes, MyOrders, MyShipments, DocumentLines, MyDocuments, Document). Do not retry RequestCode, SignIn, SignOut, LinkRequest or AcceptQuote on your own; for CreateQuoteRequest and CreateAccountRequest, a retry is safe only with the same requestId.
Security model
There are two layers. The Entra application proves that the caller is the website; it never identifies a customer. The portal session token identifies the signed-in person, and Business Central derives the customer from it. The customer number never travels from the website to Business Central: no procedure takes a customer number, an e-mail filter or any other parameter that decides whose data is returned.
Portal users
- A person may use the portal once inside sales has approved their account request. That creates a portal user: an e-mail address linked to one customer, with a role. The website cannot create portal users.
- E-mail addresses are compared in lowercase, with surrounding spaces removed.
- If one address is linked to several customers, the session gets the lowest customer number that is allowed in the portal. The interface has no way to list or switch customers.
- The portal user must be active and not anonymized, and the customer must exist, must not be privacy blocked and must not be blocked for all. Customers blocked for ship or invoice keep portal access.
Sign-in with a one-time code
- The visitor enters their e-mail address; the website calls
RequestCode(email, ipHash). - Business Central creates a code, stores only a salted hash of it, and e-mails the code to the address if it belongs to an active portal user.
- The visitor enters the code; the website calls
SignIn(email, code, ipHash)and gets a session token, or"". - The website keeps the token, for example in an HttpOnly cookie, and passes it as
tokento every othervoaPortalprocedure. SignOut(token)ends the session.
The code is ten characters from the alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ (no I, L, O or U) and is shown in the e-mail as two groups of five with a hyphen. It is normalized before comparison: spaces and hyphens removed, uppercased, O read as 0, I and L read as 1. Apply the same rules if you validate the input on the website.
- A code is valid for Sign-in Code Validity (Min.), 15 minutes by default, and works once. After a successful sign-in, every other open code for that address expires.
- Wrong guesses do not lock anyone out. They are counted on the newest open code up to Maximum Code Attempts, as a trace for abuse detection.
- Several codes can be alive at once: a new code does not invalidate earlier ones.
RequestCodealways answerstrue, whether or not the address is known, linked, blocked or the mail failed, and always takes at least 1.5 seconds. That way the endpoint does not tell anyone which addresses are customers.- Between two codes for the same address there is a minimum wait of Sign-in Code Wait Time (Sec.), 60 seconds by default. A request within that time is answered
trueand does nothing. - The code mail is sent immediately from the
RequestCodecall, not by the job queue, in the language of the account request the portal user came from, else the customer's language, else the inside sales language.
Session tokens
- A token is 64 characters, two GUIDs as uppercase hex. Treat it as opaque; Business Central stores only its SHA-256 hash. Leading and trailing spaces in
tokenare ignored. - Sliding expiry: a session expires after Session Duration (Min.), 480 by default, without use. Every successful call extends it, written at most once per minute, so a session can end up to a minute earlier than the nominal time.
- Absolute maximum: no session outlives Maximum Session Age (Hours), 168 by default, after sign-in, however actively it is used.
- Every call checks that the token exists, is not revoked, has not expired and is within the maximum age, that the portal user still exists and is active, and that the customer is still allowed in the portal. If the portal user or customer check fails, the session is revoked at once.
- The role is read from the portal user on every call, not from the session, so a change from Buyer to Viewer applies immediately.
ReadSessionreturnsexpiresAt. Use it for the cookie lifetime.- Several calls with the same token at the same moment can make Business Central answer 409 to one of them. The reading procedures are safe to retry once.
The customer lock
Everything voaPortal returns is filtered on the customer of the session, and no parameter can remove that filter:
| Data | Filtered on |
|---|---|
| Web requests | Customer No. of the request |
| Sales quotes and orders, live and archived | Sell-to Customer No. |
| Posted shipments, invoices, credit memos | Sell-to Customer No. |
| Ship-to addresses | Customer No. |
| DXSteel Doc Archive | relation is this customer, relation type Customer, and an allowed archive category |
A document number is a parameter in DocumentLines, Document and AcceptQuote. It says what is asked, never whose it is. Document numbers are trimmed and uppercased; a number longer than 20 characters is treated as not found, not truncated.
Uniform empty answers
A document that does not exist and a document of another customer give exactly the same answer. Otherwise a list of numbers would be enough to probe what exists.
| Procedure | No valid session | Not found, not yours, not allowed |
|---|---|---|
RequestCode | not applicable | true (always) |
SignIn | not applicable | "" (wrong, expired or used code, inactive user) |
SignOut | true | true |
ReadSession, MyDetails, MyRequests, MyQuotes, MyOrders, MyShipments, MyDocuments | "" | not applicable: a list may be empty |
MyDeliveryAddresses | "" | "" for a Viewer |
LinkRequest | false | false |
DocumentLines | "" | ""; an existing document of yours without item lines gives {"lines":[]} |
Document | "" | "", also when no report is set up, or with the Steel 365 archive |
AcceptQuote | {"outcome":"refused",…} | {"outcome":"refused",…} |
Roles
A new portal user is a Viewer unless inside sales chooses Buyer, and an unknown role value is reported as Viewer. A Buyer may do everything a Viewer may, plus LinkRequest, MyDeliveryAddresses and AcceptQuote. For a Viewer those three answer false, "" and refused. A Viewer can still submit a quote request through voaRequests; it is then not linked to the customer, and inside sales handles it like a request from an anonymous visitor. MyDetails returns role, so the form can say so.
IP hash
Several calls take an ipHash. Business Central never receives the visitor's IP address: the website computes a keyed hash. The reference website uses HMAC-SHA256 with a secret key from its own configuration, as 64 lowercase hex characters. Business Central stores the value as received, up to 64 characters, on the sign-in code, the session, the quote acceptance and the requests, uses it only to recognize abuse, and the cleanup job erases it after the retention period. Keep the key secret and stable: without a key, an IPv4 hash can be reversed.
Portal settings
Per company, on CustomerGate Setup:
| Setting | Default | Allowed | Effect |
|---|---|---|---|
| Sign-in Code Validity (Min.) | 15 | 1 to 1440 | How long a code works. |
| Maximum Code Attempts | 5 | 1 to 20 | How many wrong attempts per code are counted. Not a lockout. |
| Sign-in Code Wait Time (Sec.) | 60 | 0 to 3600 | Minimum time between two codes for one address. 0 removes the wait. |
| Session Duration (Min.) | 480 | 5 to 20160 | Sliding expiry. |
| Maximum Session Age (Hours) | 168 | 1 to 480 | Absolute end of a session. |
| Portal Documents from Reports | on | See MyDocuments and Document. | |
| Refuse Requests | off | Makes CreateQuoteRequest and CreateAccountRequest fail with an error. Does not affect the portal. |
Catalog services
The catalog services are read-only list pages, meant for a nightly sync rather than for live page views. Use $select to fetch only the properties you need. The OData property names are the names of the page controls listed below and are part of the contract. The values are read live from the tables, including the DXSteel fields, which the app reads by field name so that it does not depend on the DXSteel app: without Steel 365, a DXSteel text field returns "" and a DXSteel number returns 0.
voaItems
Source table: Item. No prices, costs or inventory are exposed.
| Property | Type | Source |
|---|---|---|
No | string | No. |
Description | string | Description |
Description2 | string | Description 2 |
BaseUnitofMeasure | string | Base Unit of Measure |
DXPriceUOMSalesDMX | string | DXSteel Price UOM (Sales) DMX |
ItemCategoryCode | string | Item Category Code |
DXItemProfileCodeDMX | string | DXSteel Item Profile Code DMX |
DXQualityCodeDMX | string | DXSteel Quality Code DMX, the grade |
Blocked | Boolean | Blocked |
SalesBlocked | Boolean | Sales Blocked |
voaVariants
Source table: Item Variant.
| Property | Type | Source |
|---|---|---|
ItemNo | string | Item No. |
Code | string | Code |
Description | string | Description |
Description2 | string | Description 2 |
DXLength | number | DXSteel Length DMX |
DXWidth | number | DXSteel Width DMX |
DXThickness | number | DXSteel Thickness DMX |
DXExtraLength | number | DXSteel Extra Length DMX |
DXFormatType | string | DXSteel Format Type DMX, as the caption in the session language. Send a fixed Accept-Language if you map these captions. |
Blocked | Boolean | Blocked |
DXRemainingInventory | number | DXSteel Remaining Inventory DMX, summed over all locations |
voaCategories
Source table: Item Category, with the properties Code and Description. There is no parent field; the reference website derives the tree from the code (BALK-IPE is under BALK).
voaProfiles, voaGrades, voaItemTypes
These three point at pages of iFacto Steel 365 (DXSteel), not at pages of this app. Their properties are defined by iFacto and can change with iFacto updates; this reference does not specify them. They exist only where Steel 365 is installed.
voaRequests: quote and account requests
Two actions, each taking the whole request as one string parameter payload that contains JSON, and each returning {"number":"…"} as a string. The website account can create requests but cannot read them back: there is no service that lists requests or looks up a customer by e-mail address.
| Action | Parameter | Returns |
|---|---|---|
voaRequests_CreateQuoteRequest | payload | string: {"number":"WEB000123"} |
voaRequests_CreateAccountRequest | payload | string: {"number":"ACC000045"} |
How the payload is read: a missing key and a JSON null both mean empty ("", 0 or false); text values are truncated silently to the field length; Code fields are stored in uppercase; the call runs in one transaction, so if anything fails, nothing is stored. Business Central sets the request number, the status New, the received timestamp, the linked customer and, for account requests, the reviewer and the rejection reason; the payload never does.
CreateQuoteRequest: header keys
| Key | Type | Required | Max | Notes |
|---|---|---|---|---|
requestId | string | recommended | 64 | Idempotency key, uppercased. Also the only way LinkRequest can find the request. |
privacyAccepted | Boolean | yes, true | The request is refused without it. | |
privacyConsentVersion | string | yes | 20 | Which privacy statement was accepted. Uppercased. |
contactName | string | 100 | Contact person. | |
email | string | 100 | Where the requester expects the quote. | |
companyName | string | 100 | ||
vatRegistrationNo | string | 20 | Not verified. | |
phoneNo | string | 30 | ||
customerReference | string | 35 | The customer's own reference. Becomes External Document No. on the quote. | |
languageCode | string | 10 | A Business Central language code, for example NLB or FRB. The confirmation mail is written in this language. | |
address, address2, postCode, city, countryCode | string | 100 / 50 / 20 / 30 / 10 | The requester's billing address. The country is an ISO code, for example BE. | |
deliveryMethod | string | Pickup or Delivery. Anything else becomes Pickup. | ||
shipToCode | string | 10 | A registered ship-to address of the customer, from MyDeliveryAddresses. Only honoured after LinkRequest verifies it. | |
useShipToAddress | Boolean | The delivery address differs from the billing address. | ||
shipToName, shipToAddress, shipToAddress2, shipToPostCode, shipToCity, shipToCountryCode | string | 100 / 100 / 50 / 20 / 30 / 10 | The delivery address as text. A different delivery country can change the VAT treatment of the quote. | |
requestedDeliveryDate | string | YYYY-MM-DD. A value that is not a valid date makes the call fail. | ||
remarks | string | 2048 | Free text from the requester. | |
ipHash | string | 64 | See IP hash above. | |
source | string | 50 | The channel or page. counterVisit has a special meaning; see below. | |
assistedBy | string | 50 | The employee at the counter. Only for counter visits. | |
quoteRequestLines | array | Line objects. Missing or not an array: the request has no lines. Elements that are not objects are skipped. |
CreateQuoteRequest: line keys
Objects in quoteRequestLines, numbered 10000, 20000, … in array order:
| Key | Type | Max | Notes |
|---|---|---|---|
itemNo | string | 20 | Not validated on insert; checked when inside sales converts the request. Empty for a free-text line. |
variantCode | string | 10 | The size. |
profileCode | string | 30 | Steel 365 profile. |
qualityCode | string | 20 | Steel 365 grade. |
description | string | 100 | As shown on the website, or free text. |
quantity | number | The requested quantity. | |
unitOfMeasureCode | string | 10 | For example PCS, M or KG. Validated at conversion. |
lengthMm | number | 0 means the standard length of the size. | |
widthMm | number | Plates and strip only. | |
thicknessMm | number | Plates and strip only. | |
lineRemarks | string | 250 | For example a sawing instruction. |
An example payload, before it is serialized into payload:
{
"requestId": "3F2C9A1E7B0D4C5E",
"languageCode": "NLB",
"contactName": "Jan Example",
"companyName": "Example Ltd",
"email": "jan@example.com",
"customerReference": "Site 12",
"address": "Example Street 1",
"postCode": "2000",
"city": "Antwerp",
"countryCode": "BE",
"deliveryMethod": "Delivery",
"useShipToAddress": true,
"shipToName": "Example Ltd",
"shipToAddress": "Yard Road 5",
"shipToPostCode": "2100",
"shipToCity": "Deurne",
"shipToCountryCode": "BE",
"requestedDeliveryDate": "2026-10-01",
"remarks": "Please call before delivery.",
"privacyAccepted": true,
"privacyConsentVersion": "2026-09",
"ipHash": "<64 hex characters>",
"source": "shop/en/quote-request",
"quoteRequestLines": [
{
"itemNo": "1000",
"variantCode": "6000",
"quantity": 4,
"unitOfMeasureCode": "PCS",
"lineRemarks": "Cut to 3 m"
}
]
}What happens next:
- The request lands in its own table, not in a sales quote. Inside sales converts it with one action, from the Business Central client only.
- The mail job sends the requester a confirmation and inside sales a notification, depending on the switches in setup.
source=counterVisitmarks a request entered at the counter. It converts to a sales order instead of a quote, and no confirmation or notification mail is sent. For a pickup, the order gets a text line naming the employee inassistedBy.- If the visitor is signed in, call
LinkRequestright after this call to put the session's customer on the request. The customer number is never part of the payload. shipToCodeis only trusted afterLinkRequesthas checked it against the session's customer. On a request that was not linked that way, the conversion refuses the code until inside sales confirms it by hand, so send the address as text as well.
CreateAccountRequest
A request for a portal account. Inside sales links it to a customer and a role, or rejects it.
| Key | Type | Required | Max | Notes |
|---|---|---|---|---|
requestId | string | recommended | 64 | Idempotency key, uppercased. |
privacyAccepted | Boolean | yes, true | ||
privacyConsentVersion | string | yes | 20 | Uppercased. |
email | string | yes | 100 | The address the person will sign in with. |
contactName | string | 100 | ||
jobTitle | string | 50 | Helps inside sales choose Buyer or Viewer. | |
companyName | string | 100 | Not verified. | |
vatRegistrationNo | string | 20 | ||
phoneNo | string | 30 | ||
address, address2, postCode, city, countryCode | string | 100 / 50 / 20 / 30 / 10 | ||
existingCustomer | string | Yes, No or Unknown. Anything else becomes Unknown. | ||
statedCustomerNo | string | 20 | What the person says their customer number is. A hint for inside sales, never a link. | |
languageCode | string | 10 | Used for the mails to this person, including later sign-in codes. | |
remarks | string | 2048 | ||
ipHash | string | 64 | ||
source | string | 50 | The page the request came from. |
Idempotency: requestId
Generate one requestId per form submission and send the same value again if you retry that submission. A second request with a requestId that already exists is refused with HTTP 400, and the error message contains the marker [VOA-DUBBEL] and the number of the request that was already stored. Treat that as success: the request is in Business Central, only not from this call. The message text itself is translated, so match on the marker, never on the sentence. Comparison is effectively case-insensitive, because the value is stored uppercase; without a requestId there is no duplicate check. The rule holds for both actions, each in its own table.
Errors
All are raised as AL errors, so the call fails with HTTP 400 and the message in the body. The request is not stored.
| Condition | Applies to | The message starts with |
|---|---|---|
payload is not valid JSON | both | The request is not valid JSON. |
| Refuse Requests is on | both | CustomerGate is not accepting requests at the moment. |
privacyAccepted is not true | both | A request cannot be accepted without agreement… |
privacyConsentVersion is empty | both | Include in privacyConsentVersion… |
email is empty | account requests | An account request without an e-mail address is not possible… |
requestId already used | both | This submission has already been accepted under number… (contains [VOA-DUBBEL]) |
requestedDeliveryDate is not a valid date | quote requests | Business Central's own date error |
voaPortal: the customer portal
Every action is POST .../ODataV4/voaPortal_{Procedure}?company={company id}. These fifteen procedures are the only callable ones; every other procedure in the codeunit is local.
| Procedure | Parameters | Returns | Role | Changes data |
|---|---|---|---|---|
RequestCode | email, ipHash | Boolean | anyone | creates a code, sends a mail |
SignIn | email, code, ipHash | string: token or "" | anyone | uses the code, creates a session |
ReadSession | token | JSON string or "" | both | extends the session |
SignOut | token | Boolean (always true) | both | revokes the session |
MyDetails | token | JSON string or "" | both | extends the session |
MyDeliveryAddresses | token | JSON string or "" | Buyer | extends the session |
LinkRequest | token, requestId | Boolean | Buyer | links a request to the customer |
MyRequests | token | JSON string or "" | both | extends the session |
MyQuotes | token | JSON string or "" | both | extends the session |
MyOrders | token | JSON string or "" | both | extends the session |
MyShipments | token | JSON string or "" | both | extends the session |
DocumentLines | token, documentType, documentNo | JSON string or "" | both | extends the session |
AcceptQuote | token, quoteNo, ipHash | JSON string | Buyer | records an acceptance |
MyDocuments | token | JSON string or "" | both | extends the session |
Document | token, documentType, documentNo | JSON string or "" | both | extends the session |
Hard limits
| List | Maximum |
|---|---|
MyRequests | 200 requests |
MyQuotes, MyOrders, MyShipments | 100 rows each, live and archived together; truncated tells you when more existed |
MyDeliveryAddresses | 100 addresses |
DocumentLines | 500 lines |
MyDocuments, Steel 365 archive | 200 documents, from at most 1,000 archive rows examined |
MyDocuments, report source | 100 per document kind |
RequestCode, SignIn, SignOut
RequestCode always returns true, after at least 1.5 seconds; do not call it again automatically, because every call can send a mail. SignIn returns the 64-character session token, or "" when the code is wrong, expired or already used, or when the portal user or customer is no longer allowed; the answer never says which. A correct code for a user who has been deactivated in the meantime is used up and returns "". SignOut revokes the session if the token exists and always returns true, also for an unknown token; discard the cookie either way.
ReadSession
Who is behind the token. Call it on every page view of the portal: it checks the session, the portal user and the customer, and extends the session.
| Key | Type | Notes |
|---|---|---|
email | string | The portal user's address. |
customerNo | string | The customer of this session. Business Central tells the website; the website never tells Business Central. |
customerName | string | Customer name, or "". |
role | string | Buyer or Viewer. |
weightType | string | The customer's Steel 365 weight type (Theoretical, Trade, GermanTrade), or "" without Steel 365 or when not set. |
expiresAt | string | Timestamp when the session expires if it is not used again. |
MyDetails
The company details of the session's customer, to prefill the quote request form. Every field is a string and can be "". The customer number is deliberately not included, and there are no prices, discounts or credit data. The keys are company, vatRegistrationNo, address, address2, postCode, city, country, phoneNo, email (the portal user's address, not the customer card's) and role.
MyDeliveryAddresses
The registered ship-to addresses of the session's customer, Buyer only: {"addresses": [{"code", "name", "address", "address2", "postCode", "city", "country"}]}, all strings. A Viewer gets "", just like an invalid session. The billing address is not in this list; it is in MyDetails. Send the chosen code as shipToCode in CreateQuoteRequest.
LinkRequest
Puts the session's customer on a quote request that was just submitted, so call it right after CreateQuoteRequest, with the same requestId. It returns true only if the session is valid, the role is Buyer, requestId (trimmed, uppercased, at most 64 characters) is not empty, and a quote request with that Request ID exists and has no customer yet. It then sets the request's customer, keeps shipToCode only if it is a ship-to address of that customer and otherwise clears it (the address text stays), and sets the request's Source to portal. A false is not an error: the request exists either way and inside sales picks it up like any other.
MyRequests
The quote requests linked to the session's customer, newest first, at most 200. Anonymized requests and requests marked as spam are left out.
| Key | Type | Notes |
|---|---|---|
no | string | Request number. |
receivedAt | string | Timestamp. |
status | string | New, InProgress, Converted, Rejected, or "" for a status this version does not know. |
quoteNo | string | Optional. The sales document created from the request; for a counter visit an order number. |
quoteNoStatus | string | Present with quoteNo. active for a live quote of this customer, completed for a released quote in the quote archive, otherwise "". |
lines | number | Number of request lines. |
MyQuotes
The sales quotes of the session's customer: first the live quotes, highest number first, then quotes that exist only in the quote archive (converted or deleted), released versions only, the highest archived version per number. Together at most 100 rows. The answer is {"quotes": [ … ], "truncated": <Boolean>}.
| Key | Type | Notes |
|---|---|---|
no | string | Quote number. |
documentDate | string | Date. |
validUntil | string | Quote Valid Until Date, else the Steel 365 quote due date, else "". |
yourReference | string | The customer's reference: External Document No. |
requestNo | string | The Your Reference field. On quotes created from a web request it holds the request number; on other quotes it can contain anything. |
status | string | Open, Released, or "" for any other status. |
lines | number | Number of item lines. |
totalWeightKg | number | Optional, Steel 365 only. Total weight in kg. |
hasDocument | Boolean | true only when Document can print this quote. Always false for archived rows. |
accepted | Boolean | An acceptance was recorded through the portal. |
comment | string | Live rows only: the note inside sales wrote for the customer. Never the internal work description. |
orderNo, orderNoStatus | string | Archived rows, optional: the order this quote became, from the Steel 365 document archive, with active, completed or "". Only link to the order when this is not "". |
MyOrders
The sales orders of the session's customer: live orders, highest number first, then orders that exist only in the order archive, highest archived version per number, any status. Together at most 100 rows, as {"orders": [ … ], "truncated": <Boolean>}.
| Key | Type | Notes |
|---|---|---|
no | string | Order number. |
quoteNo, quoteNoStatus | string | The quote the order came from, with active, completed or "". Archived rows take it from the Steel 365 document archive. |
orderDate, requestedDeliveryDate, promisedDeliveryDate | string | Dates. |
yourReference | string | The customer's reference (External Document No.). |
status | string | Open, Released or "". |
completelyShipped | Boolean | Live: the order's own field. Archived: recalculated per line from posted shipment lines. |
lines | number | Number of item lines. |
hasDocument | Boolean | As in MyQuotes. |
comment | string | Live rows only: the note for the customer. |
MyShipments
The posted sales shipments of the session's customer, newest first, at most 100, as {"shipments": [ … ], "truncated": <Boolean>}.
| Key | Type | Notes |
|---|---|---|
no | string | Shipment number. |
deliveryDate | string | Posting date of the shipment. |
orderNo, orderNoStatus | string | The order the shipment belongs to, with active, completed or "". |
yourReference | string | The customer's reference (External Document No.). |
shipToAddress | object | {"name", "postCode", "city"}. Street and house number are not exposed. |
lines | number | Number of item lines. |
hasDocument | Boolean | As in MyQuotes. |
DocumentLines
Parameters: documentType is quote, order or shipment (case-insensitive; anything else returns ""), and documentNo is the document number. Ownership is checked before any line is read. For quote and order the live document is tried first; if it does not exist, the highest version in the archive, for quotes released versions only. Only lines of type Item are returned, at most 500; text and comment lines are left out. The answer is {"lines": [ … ]}.
| Key | Type | When | Notes |
|---|---|---|---|
lineNo | number | always | Line No. |
itemNo | string | always | Item number. |
description | string | always | |
format | string | always | Variant Code: the size. |
quantity | number | without Steel 365 | Quantity. |
unitOfMeasureCode | string | without Steel 365 | Unit of measure of quantity. |
outstandingQuantity, quantityShipped | number | without Steel 365, live order lines | |
profile, grade, formatDescription | string | Steel 365, optional | Item Profile Code DMX, Quality Code DMX, Item Variant Description DMX. |
unit | string | Steel 365, optional | Doc. Unit of Measure Code DMX, untranslated option name. Says which of the three quantities leads; the reference website expects PCS, KG or MTR. |
quantityPieces, quantityMeters, quantityKg | number | Steel 365 | Quantity Pieces DMX, Quantity Length DMX, Quantity Weight DMX. |
weightType | string | Steel 365, optional | Theoretical, Trade or GermanTrade. |
outstandingPieces, outstandingMeters, outstandingKg, shippedPieces, shippedMeters, shippedKg | number | Steel 365, live order lines | Outstanding and shipped quantities. |
Rules for reading lines: Steel 365 text keys are left out when empty, while Steel 365 number keys are present whenever the field exists, so 0 means zero and a missing key means unknown (show a dash, not 0). With Steel 365, pieces, meters and kilograms are three separate quantities: never add them up or pick one yourself, unit says which one leads. Outstanding and shipped values exist only on live order lines; do not calculate them yourself. No price, discount, margin or amount is ever returned.
{
"lines": [
{"lineNo": 10000, "itemNo": "1000", "description": "HEA 200 S235JR", "format": "12000",
"profile": "HEA200", "grade": "S235JR", "unit": "PCS", "quantityPieces": 4,
"quantityMeters": 48, "quantityKg": 2035.2, "weightType": "Theoretical"}
]
}AcceptQuote
Records that the customer accepts a released sales quote. Buyer only. Nothing is converted or released in Business Central: the acceptance is a signal for inside sales, who convert the quote by hand. It always returns an object with outcome (recorded, already, expired or refused) and acceptedAt (a timestamp, or ""). The checks, in order:
| Check | Outcome |
|---|---|
| Session not valid | refused |
| Role is not Buyer | refused |
quoteNo empty or longer than 20 characters | refused |
| No live quote with that number for this customer | refused |
| Quote status is not Released | refused |
| An acceptance already exists for this quote | already, with the original acceptedAt |
| The validity date is set and before today | expired |
| Another request is recording an acceptance for this quote at the same moment | already, acceptedAt = "" |
| Otherwise | recorded, acceptedAt = now |
refused never says why, and already is not an error: show that the acceptance is in. Do not retry this call automatically. The recorded acceptance stores the quote number, the customer, the portal user's e-mail, the moment, the IP hash, the document date, the amount including VAT, the currency, the number of lines, the latest archive version and a fingerprint of the lines, so it stays clear what was accepted if the quote changes later. The mail job then sends the customer a confirmation and inside sales a notification.
MyDocuments
The PDFs available to the session's customer, as {"documents": [ … ]}, with no truncated flag. The source is one of two, never both: the Steel 365 document archive when DXSteel Doc Archive is installed, otherwise the report source when Portal Documents from Reports is on. If the archive is not installed and the setting is off, the list is always empty. If anything goes wrong reading the archive, the list is empty rather than an error, and the setup card shows why.
Archive rows. A document is listed only if it hangs on this customer's number, the relation type is Customer, and its category is set up in Archive Categories with a kind other than none. Newest first.
| Key | Type | Notes |
|---|---|---|
id | string | SystemId of the archive record, lowercase with hyphens. The key for downloading. |
fileName | string | As stored in the archive. May contain spaces and accents; sanitize it before using it in a header. |
contentType | string | application/pdf for .pdf, otherwise application/octet-stream. |
category | string | The kind: SalesQuote, SalesOrder, SalesInvoice, SalesCreditMemo, Proforma, Waybill or WarehouseShipment. Not the archive's own category name. |
documentNo | string | The source document number, or "". |
documentType | string | quote, order, shipment or "". Set only after Business Central confirmed that documentNo is such a document of this customer. Invoices, credit memos and pro formas always get "". |
documentNoStatus | string | Only when documentType is order: active or completed. |
date | string | The archive's creation date, else the date the record was created, else "". |
orderNo, orderNoStatus, invoiceNo, creditMemoNo | string | Optional. Related document numbers, with active, completed or "" for the order. |
Downloading an archive document. Business Central cannot return the bytes through this codeunit without a dependency on the iFacto app, so the website fetches them from iFacto's API with the id:
POST https://api.businesscentral.dynamics.com/v2.0/{tenant}/{environment}/api/ifacto/dxsteel/v1.0/companies({company id})/documentArchives({id})/Microsoft.NAV.Download
{}
-> {"value": "<base64>"}Only download an id that appears in the MyDocuments list you just fetched for the same session. Never take an id from the browser and pass it on unchecked: the website's account needs read access to the archive to download, and the same archive holds purchase invoices and internal correspondence. VOA CG S365 ARCHIVE limits that access to the allowed categories with a security filter, but the ownership check is MyDocuments. An empty value means the archive record has no file.
Report rows. Only document kinds for which a report is set up (a document layout on the customer card, else Report Selections): live quotes and orders, posted shipments, invoices and credit memos, in that order, each highest number first and at most 100 per kind.
| Key | Type | Notes |
|---|---|---|
id | string | SystemId of the Business Central document. Informational; download with Document. |
source | string | Always report. Absent on archive rows: use it to tell the two apart. |
reportType | string | quote, order, shipment, invoice or creditMemo. Pass it as documentType to Document. |
fileName | string | {category} {document number}.pdf, for example SalesInvoice 103001.pdf. |
contentType | string | application/pdf. |
category | string | SalesQuote, SalesOrder, Waybill (shipments), SalesInvoice or SalesCreditMemo. |
documentNo | string | Document number. Pass it as documentNo to Document. |
documentType | string | quote, order or shipment; "" for invoices and credit memos. |
documentNoStatus | string | Only for orders: active. |
date | string | Quote: document date. Order: order date. Shipment, invoice, credit memo: posting date. |
orderNo, orderNoStatus, invoiceNo, creditMemoNo | string | Optional. Related document numbers. |
Document
The PDF of one document, printed with the report Business Central uses for it, and only with the report source: where the Steel 365 archive is installed this always returns "" and you use the archive download above. documentType is quote, order, shipment, invoice or creditMemo (case-insensitive) and documentNo is the number. It returns "" for an invalid session, an unknown type, a document that does not exist or is not this customer's, a document kind without a report, or an empty print. Quotes and orders must be live; archived versions cannot be printed. Otherwise it returns fileName, contentType and base64, the PDF itself.
The report runs with the permissions of the website account (tenant set VOA CG DOCUMENTS). If the report fails, typically because a customer-specific layout reads a table the set does not cover, the call fails with an error that names the object; add that table to the set. A download does not archive the quote or order, but the standard reports do increase No. Printed. hasDocument in MyQuotes, MyOrders and MyShipments is true exactly when the report source is active and a report is set up for that document kind for this customer.
Fixed values
These values are fixed tokens. They are never translated and never depend on the session language. Compare them exactly.
| Where | Key or parameter | Values |
|---|---|---|
ReadSession, MyDetails | role | Buyer, Viewer |
MyRequests | status | New, InProgress, Converted, Rejected, "" |
MyQuotes, MyOrders | status | Open, Released, "" |
MyRequests, MyOrders | quoteNoStatus | active, completed, "" |
MyQuotes, MyShipments, MyDocuments | orderNoStatus | active, completed, "" |
MyDocuments | documentNoStatus | active, completed |
AcceptQuote | outcome | recorded, already, expired, refused |
DocumentLines | parameter documentType | quote, order, shipment |
Document | parameter documentType | quote, order, shipment, invoice, creditMemo |
MyDocuments | documentType | quote, order, shipment, "" |
MyDocuments | category | SalesQuote, SalesOrder, SalesInvoice, SalesCreditMemo, Proforma, Waybill, WarehouseShipment |
MyDocuments | source | report (report rows only) |
MyDocuments | reportType | quote, order, shipment, invoice, creditMemo |
MyDocuments, Document | contentType | application/pdf, application/octet-stream |
ReadSession, DocumentLines | weightType | Theoretical, Trade, GermanTrade, "" |
DocumentLines | unit | Untranslated option names of the Steel 365 field; the reference website handles PCS, KG, MTR |
CreateQuoteRequest | deliveryMethod | Pickup, Delivery (anything else: Pickup) |
CreateQuoteRequest | source | Free text up to 50 characters. counterVisit makes it a counter visit; LinkRequest sets portal. |
CreateAccountRequest | existingCustomer | Yes, No, Unknown (anything else: Unknown) |
voaRequests errors | message | [VOA-DUBBEL] marks a duplicate requestId |
| Sign-in code | alphabet | 0123456789ABCDEFGHJKMNPQRSTVWXYZ, 10 characters |
Request numbers come from number series in setup. On installation they default to WEB000001 to WEB999999 for quote requests and ACC000001 to ACC999999 for account requests, but each company can change them: do not rely on the pattern.
Steel 365 differences
CustomerGate 365 is one app. It checks the environment itself and needs no setting for it: Steel 365 is active when the field Quantity Pieces DMX exists on the sales line, and the Steel 365 document archive is active when the table Document Archive DADMX exists.
| Area | With Steel 365 | Without Steel 365 |
|---|---|---|
| Catalog services | voaProfiles, voaGrades, voaItemTypes are published, and the DX… properties are filled | Only voaItems, voaVariants, voaCategories; DX… text is "" and numbers are 0 |
| Synchronization permissions | VOA WEB SYNC and VOA CG S365 SYNC | VOA WEB SYNC only |
DocumentLines quantities | quantityPieces, quantityMeters, quantityKg plus unit; quantity is never present | quantity plus unitOfMeasureCode |
DocumentLines on live order lines | outstandingPieces/Meters/Kg, shippedPieces/Meters/Kg | outstandingQuantity, quantityShipped |
DocumentLines extra keys | profile, grade, formatDescription, weightType when filled | none |
MyQuotes.totalWeightKg | present | absent |
ReadSession.weightType | the customer's weight type, or "" | "" |
| Quote validity | falls back to the Steel 365 quote due date | Quote Valid Until Date only |
The document archive is a separate switch:
| Area | With the archive | Without it |
|---|---|---|
MyDocuments | Archive rows, filtered by the allowed categories | Report rows if Portal Documents from Reports is on, else empty |
| PDF download | The iFacto API bound action with the id | Document returns base64 |
Document | always "" | prints the PDF |
hasDocument | always false | true when a report is set up |
| Archive-only quotes and orders | orderNo and quoteNo links from the archive | no such links |
| Website tenant set | VOA CG S365 ARCHIVE | VOA CG DOCUMENTS when the setting is on |
Versioning and compatibility
The names are the contract. Websites call them literally, so each of these is part of the interface: the service names, the procedure and parameter names including their case, the catalog property names, the JSON keys in payloads and responses, and every fixed value above. Renaming one breaks every website built on the app, silently in many cases: a renamed catalog property just leaves a column empty, and a renamed JSON key reads as null. There are no aliases for earlier names.
For consumers:
- Ignore keys you do not know. New keys can be added in a compatible release.
- Treat a missing optional key as null, and
""as no value. - Treat an unknown token value as unknown. Do not map it to a default that claims something, for example by treating an unknown
outcomeasrecorded. - Keep the session token, the
requestIdand the IP-hash key on the server side.
Adding a key, a procedure or a catalog property is a compatible change; removing or renaming one is not. Installation and updates create missing services but never change an existing one, so a renamed service would appear next to the old one while the old one keeps pointing where it did.
Where to go next
- Setup guide
From installing the app to a first request through the customer portal.
- Release notes
What is in each version of the app.
- Support
How to ask for help, and what to send along.