Last updated: 23 September 2026
CustomerGate 365: web service reference
For developers who build a website on CustomerGate 365. This page describes signing in, the shape of the endpoints, every service and procedure, the JSON keys and fixed values, the webhooks Business Central sends your way, and the security rules behind them. It describes app version 1.0.42.0, the version on AppSource. Keys and values marked Since 1.0.44 come with the next version; a website built on 1.0.42.0 keeps working without them. Names are case-sensitive, and the app's AL source is what counts.
The services
CustomerGate 365 publishes eleven web services: eight of its own and three of iFacto. The app publishes them itself when it is installed and again on every update: a missing service is created, and a service that already exists under that name is never changed, not even when someone unpublished or edited it by hand. The three iFacto services only exist 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 |
voaRemovals | page, read-only | What has left the catalog and must therefore come off the website | synchronization application |
voaCatalogMedia | codeunit, unbound action | The bytes of one item or variant picture | 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, and attach a file to one | website application |
voaPortal | codeunit, unbound actions | Sign-in and everything a signed-in customer sees or does | website application |
voaPing | codeunit, unbound action | What this installation is: version, environment, modules, document source. No session and no personal data | website application |
The website application may call voaRequests, voaPortal and voaPing; the synchronization application may call the catalog pages, voaRemovals and voaCatalogMedia. Neither can use the other's services: the website application has no execute right on the catalog, and the synchronization application has none on the portal or the requests. The next section says which application is which.
Signing in
The website talks to Business Central as two Microsoft Entra applications. Their client IDs go on CustomerGate Setup, and Repair Permissions registers them in Business Central and assigns their permission sets. That action requires SUPER.
| Setup field | Used for | May call |
|---|---|---|
| Website Client ID | Quote and account requests, the customer portal, the ping | voaRequests, voaPortal, voaPing |
| Synchronization Client ID | The catalog synchronization. Leave blank if the catalog is not synchronized. | voaItems, voaVariants, voaCategories, voaRemovals, voaCatalogMedia, 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, voaRequests or voaPing.
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. Indirect table rights only. |
VOA WEB CONTRACT | website, all companies | Execute voaPing and the codeunits it calls (since 1.0.44 also the attachment rules it reports under attachments), and nothing else. A set of its own, because the two sets above are closed statements about their own call chains and voaPing is in neither. |
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 those reports, 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 four own catalog pages with their source tables and the picture service, plus read on item ledger entries for the remaining inventory and on the media table for the pictures. |
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 on every call as Authorization: Bearer {token}.
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
You read the catalog pages 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 gone. The records are in value.
Codeunit services
voaRequests, voaPortal, voaPing and voaCatalogMedia expose every 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. Even a number such aspictureIndexis sent as"1". - A JSON result comes back as a string inside
value, because an unbound action cannot return a nested object. Parsevaluea second time. A Boolean outcome is a JSONtrueorfalseinvalue. - An empty string is an answer. For most
voaPortalprocedures,""means: no valid session, or nothing here for you. - 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 further down. The reference website sendsnl-NL.
Status codes and retrying
| Status | What it means here |
|---|---|
| 200 | The procedure ran. Look in value for the outcome. |
| 400 | An AL error was raised, for example a refused request (the message is in the body, with a [VOA-…] marker in front of it), 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 call that changes something, 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, RequestLines, MyQuotes, MyOrders, MyShipments, DocumentLines, MyDocuments, Document, MyAttachments, Attachment, MyNotificationSettings, Ping and the catalog pages.
Do not retry RequestCode, SignIn, SignOut, LinkRequest, AcceptQuote, AcceptQuoteWithDetails, RejectQuote, SetNotificationSettings or InviteColleague on your own. SwitchCustomer is safe with the same customerNo: switching to the customer the session is already on changes nothing and answers switched. For CreateQuoteRequest and CreateAccountRequest, a retry is safe only with the same requestId. AddAttachment is always safe with the same bytes: identical content on the same request stays one attachment.
The 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 does not travel from the website to Business Central: only SwitchCustomer takes one, and it switches between customers that are linked to the signed-in address. No other procedure takes a customer number, an e-mail filter or any other parameter that decides whose data comes back.
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.
- One address may be linked to several customers. A portal user is the pair (e-mail address, customer), so the same person can have a link at two companies, each with its own role. At sign-in the session gets the lowest customer number that is allowed in the portal;
ReadSessionandMyDetailsname every customer the address may act for, andSwitchCustomermoves the session to another one. - The portal user must be active and not anonymized, and the customer must exist, must not be privacy blocked and must not be blocked with All. Customers blocked for Ship or Invoice only keep their access.
Signing 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 appears 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 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 lock nobody out. Wrong attempts are counted on the newest open code, up to Maximum Code Attempts, as a trace for recognizing abuse.
- Several codes can live at once: a new code does not invalidate earlier ones.
RequestCodealways answerstrue, whether the address is known, linked or blocked or the mail failed, and always takes at least 1.5 seconds. That way the endpoint tells nobody 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 gets
trueand does nothing. - The code mail goes out immediately from the
RequestCodecall, not through 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 a minute, so a session can end up to a minute earlier than the nominal time.
- Absolute limit: 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 at once.
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 with their lines and attachments | 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 | the relation is this customer, the relation type is Customer, and the category is allowed |
A document number is a parameter in DocumentLines, Document, AcceptQuote, AcceptQuoteWithDetails and RejectQuote, and a request number in RequestLines and MyAttachments. It says what is asked, never whose it is. Document numbers are trimmed and uppercased; a number longer than 20 characters counts as not found, not as truncated.
One address at several customers
A buyer who purchases for two companies of the same group is an ordinary case in the trade. Such a person has two links, each with its own role, and signs in once.
| What | Where |
|---|---|
| Which customers this session may act for | customers in ReadSession and MyDetails |
| Which customer is active right now | customerNo in ReadSession |
| Move the session to another one | SwitchCustomer |
| Create or revoke a link | Inside sales, in the Business Central client. There is no web service for it. |
The active customer lives on the session row in Business Central, not in a cookie. That is the whole security of the feature: the website cannot decide whose data it sees, it can only ask to switch to a customer that Business Central can confirm is linked to the signed-in address. Switching to any other customer number is refused, and nothing changes.
SwitchCustomer is the only procedure in the portal that takes a customer number, which is why the rules around it are stricter than anywhere else:
- the link (address, customer) must exist, be active and not be anonymized;
- the customer must be allowed in the portal, with the same check as at sign-in: not privacy blocked, not blocked with All;
- the role of the new session comes from the link that was switched to, not from the session. The same person can be a Buyer at one company and a Viewer at the other;
- every attempt, successful or refused, is written to a log in Business Central with the address, both customer numbers, the IP hash and the reason. The website is never told the reason.
After a refused switch the session is unchanged: it still reads the data of the customer it was on. There is no half-completed switch. And refused never says why, exactly as everywhere else in this interface: a reason would turn this endpoint into a free check on which customer numbers exist and who is linked to them.
The same empty answer everywhere
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":[]} |
RequestLines | "" | ""; a request of yours without lines gives {"requestNo":…,"lines":[]} |
MyAttachments | "" | ""; a request of yours without files gives {"requestNo":…,"attachments":[]} |
Attachment | "" | "" (unknown id, an id that is not yours, or an attachment without content) |
Document | "" | "", also when no report is set up, or with the Steel 365 archive |
AcceptQuote, AcceptQuoteWithDetails, RejectQuote | {"outcome":"refused",…} | {"outcome":"refused",…} |
SwitchCustomer | {"outcome":"refused",…} | {"outcome":"refused",…} |
MyNotificationSettings, SetNotificationSettings | "" | not applicable |
InviteColleague | {"outcome":"refused",…} | {"outcome":"already"} for an address that already has access; {"outcome":"throttled"} past the daily limit |
Roles
| Role | Token | May |
|---|---|---|
| Buyer | Buyer | Everything a Viewer may, plus LinkRequest, MyDeliveryAddresses, AcceptQuote, AcceptQuoteWithDetails, RejectQuote and InviteColleague. |
| Viewer | Viewer | Reading: session, details, requests, request lines, quotes, orders, shipments, document lines, documents and attachments. Plus their own notification settings and SwitchCustomer. |
A new portal user is a Viewer unless inside sales chooses Buyer, and an unknown role value is reported as Viewer. For a Viewer, LinkRequest answers false, MyDeliveryAddresses answers "", and AcceptQuote, AcceptQuoteWithDetails, RejectQuote and InviteColleague answer refused. The notification settings and SwitchCustomer are open to both roles: a Viewer is exactly the person who wants to know that a shipment has been posted, and the role of a session comes from the link that was switched to. 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 sees 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 hexadecimal characters. Business Central stores the value as it arrives, up to 64 characters, on the sign-in code, the session, the acceptance and rejection of a quote, the customer switch 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. | |
| Portal Notifications Enabled | off | The company's master switch for the notifications in MyNotificationSettings. It is off after a fresh installation and after every update, and off means nothing is queued and nothing is sent, whatever a portal user has chosen. | |
| Maximum Invitations per Day | 5 | 0 to 100 | How many colleagues one Buyer may invite per day with InviteColleague. 0 switches inviting off and does not fall back to the default. |
| 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 synchronization and not for live page views. Use $select to fetch only the properties you need. The OData property names are the names of the page controls 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.
The feed contains only what belongs on the website
voaItems, voaVariants and voaCategories hand over published records only. Which items those are is decided in Business Central and not by you: per item, per item category, with ordered publication rules and a policy on CustomerGate Setup. An excluded item is not in the feed at all, not with a flag and not with an empty description. The filter sits in the query and cannot be removed with $filter.
BlockedandSalesBlockedstay invoaItemsand are alwaysfalse. A blocked item is never published. The two properties remain for compatibility; do not remove your handling of them, but do not treat them as the way to keep items off the website either.- The feed is the truth, but a feed says nothing about what is not in it. An item that disappears does not come past, so an upsert leaves it standing. That is what
voaRemovalsis for: anything you hold that is not in the feed and does appear invoaRemovalsmust be removed. - Pictures follow the same rule: an item that is not in the feed hands over no picture either.
A company where the decision has never been computed gets nothing at all: the three catalog pages and voaCatalogMedia then fail with [VOA-CatalogNotComputed] instead of handing over an unfiltered catalog. The app computes the decision when it is installed, when a company is created and on every update, so this should not happen. If it does, stop the synchronization and keep what you have: a refusal is not an empty catalog. The administrator chooses Recompute Web Catalog on CustomerGate Setup.
voaItems
Source table: Item. No prices, costs or inventory values 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. Always false: a blocked item is not published. |
SalesBlocked | Boolean | Sales Blocked. Always false, for the same reason. |
webQuotable | Boolean | Not Orderable Online, inverted. false means the item is in the catalog but may not be put in a request: price on request, call us, or a drawing is needed first. Show such an item without an order button; voaRequests refuses a line for it. Note the lower-case w. |
pictureCount | number | How many pictures the item has in Business Central. |
pictureId | string | A mark over the current pictures: 64 hexadecimal characters, or "" when the item has no picture. See Pictures below. |
voaVariants
Source table: Item Variant. A variant follows its item: variants of an item that is not published are not in this feed.
| 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 onto your own model. |
Blocked | Boolean | Blocked. This is the variant's own flag and it is not filtered on: the website decides. |
DXRemainingInventory | number | DXSteel Remaining Inventory DMX, summed over all locations |
pictureCount | number | How many pictures this variant has of its own. 0 means no own picture, not no picture: fall back to the item's. |
pictureId | string | The same kind of mark as on voaItems, for this variant's own pictures. |
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 sits under BALK). An item category that is excluded, itself or through a category above it, is not in this feed, so an excluded branch does not leave an empty menu entry on the website.
voaRemovals
Source table: Web Catalog - Removals. Everything that has left the catalog and has not come back. Read this service in the same round as the other three, or an item you excluded in Business Central stays on the website forever.
| Property | Type | What it is |
|---|---|---|
Kind | string | Item, Variant or Category: a fixed token, never translated |
No | string | The item number, or the item category code |
VariantCode | string | The variant, for Kind = Variant; "" otherwise |
RemovedAt | timestamp | When the record left the catalog |
Nothing else: no description, no price, no reason. An excluded item is precisely the thing the outside world should not see. Ask for everything after your previous successful round, remove or unpublish those records, and store the timestamp of the round. There is no acknowledgement to send back: the filter on RemovedAt is the whole protocol.
GET .../ODataV4/voaRemovals?$filter=RemovedAt gt 2026-09-19T02:00:00Z&$orderby=RemovedAt- A row disappears as soon as the record returns to the catalog. A record that is in the feed again wins from a removal row you fetched earlier, so apply the feed first and the removals second, or re-check them against the feed.
- Rows are written for an exclusion, for a block, for a deleted item, variant or category, and for the old number after a rename (the new number is in the feed).
- A
Variantrow is only written when the variant disappears while its item stays. When the item goes, its own row covers its variants: remove the item with everything under it. - Rows older than 180 days are cleaned up. A website that has not synchronized for half a year cannot rely on this list and must compare its whole catalog against the feed once.
Pictures
The pictures of an item live in Business Central as a MediaSet, and OData cannot hand over their bytes through a page property. The catalog therefore splits the job: the feed says whether there is anything to fetch and whether it changed, and one action hands over one picture at a time.
| What | Where |
|---|---|
| Does this item have pictures, and did they change? | pictureCount and pictureId on voaItems |
| Does this variant have pictures of its own? | pictureCount and pictureId on voaVariants |
| The bytes of one picture | voaCatalogMedia_Picture |
pictureId is a mark, not a name. It is a SHA-256 over the ids of the pictures in the set, in order. The id of the MediaSet itself is not usable for this: it identifies the set, and it can stay the same while the pictures inside it are replaced, so a synchronization that trusted it would never fetch the new photo. Compare pictureId with what you stored; if it differs, fetch the pictures again. If it is "", the item has no picture.
A picture that is removed in Business Central arrives as an empty pictureId, in the next round of the same feed you are reading anyway. It is deliberately not in voaRemovals: that service is about records that have left the catalog, and mixing a field-level change into it would force you to reconcile two mechanisms for the same thing. Delete your copy when pictureId goes from filled to empty, and when the item itself appears in voaRemovals.
voaCatalogMedia
voaCatalogMedia_Picture hands over one picture. The parameters are strings, as everywhere:
| Parameter | Notes |
|---|---|
itemNo | The item number. Trimmed and uppercased. |
variantCode | The variant code for a variant picture, or "" for the item's own picture. |
pictureIndex | "1" up to pictureCount, as a string. Empty or unreadable is read as 1. |
| Key | Type | Notes |
|---|---|---|
itemNo | string | |
variantCode | string | "" for an item picture. |
pictureIndex | number | Which picture this is. |
pictureCount | number | How many there are, so you can loop without going back to the feed. |
pictureId | string | The same mark as in the feed; check it against what made you fetch. |
contentType | string | image/jpeg, image/png and so on, as Business Central stored it; application/octet-stream when it does not know. |
fileName | string | {item}-{index}.{ext} or {item}-{variant}-{index}.{ext}, built from the content type. |
byteSize | number | The size of the picture in bytes, before base64. |
base64 | string | The bytes. Absent when the picture is over the limit or cannot be read. |
tooLarge | Boolean | Only present, and then true, when the picture is over the limit. |
unreadable | Boolean | Only present, and then true, when Business Central cannot load the picture at all. byteSize is then 0. |
The service returns "", the same empty answer as everywhere, when the item does not exist, is not in the web catalog, the variant does not belong to it, or that picture is not there. An excluded item hands over no picture: otherwise a guessed item number would still get the photo of something internal. In a company where the catalog has never been computed, this service fails with [VOA-CatalogNotComputed] as well.
The limit is Maximum Picture Size (MB) on CustomerGate Setup, 10 MB by default. Above it you get the whole answer without base64 and with tooLarge: true. That is deliberately not an error: one photo straight from a camera should not end a nightly run. A picture Business Central cannot load comes back the same way, with unreadable: true and no base64. Treat the two alike: skip that one picture, report it, and carry on. Do not read unreadable as "this item has no picture": pictureCount still counts it, and the picture in Business Central needs replacing.
A nightly run with pictures
- Fetch
voaItemswithpictureIdandpictureCountalongside the columns you sync anyway. - For every item whose
pictureIddiffers from what you stored, fetch the pictures one by one withvoaCatalogMedia_Picture, from index 1 topictureCount, and storepictureIdnext to the files. IfpictureIdhas become empty, delete your copy. - Do the same for
voaVariants, withvariantCodefilled in. - Fetch only what changed. A catalog of ten thousand items with pictures is gigabytes; a nightly run that fetches them all is a nightly run that never finishes. That is what
pictureIdis for: the second night costs almost nothing. - One picture per call. There is no batch: base64 grows the payload by a third, and Business Central answers one web service call at a time anyway. Walk the list and stop when your time budget runs out; the marks let the next run continue where you left off.
- Nothing is resized. You get exactly what is stored in Business Central, at its original size and format. Make your own thumbnails and web formats.
voaProfiles, voaGrades, voaItemTypes
These three point at pages of iFacto Steel 365 (DXSteel), not at pages of this app. Their properties belong to iFacto and can change with an iFacto update; this reference does not specify them. They exist only where Steel 365 is installed, and they are not filtered by the web catalog: they are reference data, not items. If you build a filter list from them, build it from the items you actually received.
voaRequests: quote and account requests
Three actions. Two of them take the whole request as one string parameter payload containing JSON and return {"number":"…"} as text; the third attaches a file to a request and is covered under Attachments below. 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 | Parameters | Returns |
|---|---|---|
voaRequests_CreateQuoteRequest | payload | string: {"number":"WEB000123"} |
voaRequests_CreateAccountRequest | payload | string: {"number":"ACC000045"} |
voaRequests_AddAttachment | requestNo, fileName, contentBase64 | string: {"ok":true,"attachmentId":10000,…} |
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. | |
projectName | string | 100 | Since 1.0.44. The job site or project the steel is for, for example "New build Hoeve" or the address of a site. Becomes the first text line of the quote, Project: <name>; see Project, certificate and processing below. Personal data: erased after the retention period, from the request and from the quote. | |
languageCode | string | 10 | A Business Central language code, for example NLB or FRB. The confirmation mail is written in that 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 has verified 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 with [VOA-InvalidInput]. Leave the key out when there is no date; an empty string is fine too. | ||
remarks | string | 2048 | Free text from the requester. | |
ipHash | string | 64 | See IP hash above. | |
source | string | 50 | The channel or the page. staffOrder has a special meaning; see below. | |
assistedBy | string | 50 | The staff member who entered a staff order. Only for a staff order. | |
quoteRequestLines | array | Line objects. If the key is missing or is 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 | Whether the item exists is not validated on insert; that is checked when inside sales converts the request. Whether it may be requested is checked: a line for an item that is not in the web catalog or is not orderable online is refused. Empty for a free-text line, which is always accepted. |
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. |
inspectionCertificate | string | Since 1.0.44. The inspection document according to EN 10204 asked for with this line: None (the default), Certificate22 (test report 2.2), Certificate31 or Certificate32 (inspection certificate 3.1 or 3.2). Compared without regard to case. Anything else, such as an unknown name, a number like 31 or an object, becomes None and does not fail the call. | |
processing | array of string | Since 1.0.44. The processing asked for with this line, any number of: CutToLength, MitreCutting, Drilling, Punching, ThermalCutting (laser, plasma or oxy-fuel), Bending, Blasting, Priming, Galvanizing (hot-dip), Painting. Compared without regard to case. Unknown values and elements that are not strings are skipped, the same value twice is stored once, and a processing that is not an array is ignored. None of this fails the call. |
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",
"projectName": "New build Hoeve, Kerkstraat 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",
"inspectionCertificate": "Certificate31",
"processing": ["CutToLength", "Drilling", "Galvanizing"]
}
]
}What happens next:
- The request lands in its own table, not in a sales quote. Inside sales converts it with one action, only from the Business Central client.
- The mail job sends the requester a confirmation and inside sales a notification, depending on the switches in setup.
source=staffOrdermarks a staff order: a list a staff member enters for a customer who is present. It is converted into a sales order instead of a quote, may contain any item the company sells, and no confirmation or notification goes out. For a pickup, the order gets a text line naming the staff member fromassistedBy. Staff orders are off after installation: while Staff Orders Enabled is off, the call is refused withStaffOrdersDisabledand nothing is stored. Version 1.0.39 usedcounterVisitfor this.- 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. shipToCodeonly counts 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; send the address as text as well.
Project, certificate and processing
Since 1.0.44. Until this version a website could only write a job site, a 3.1 certificate or "cut, drill, galvanize" into remarks and lineRemarks. The three keys carry them as data: inside sales sees the project on the request card and in the worklist, and the certificate and the processing as columns on the request lines. Stop writing them into the remarks once you send the keys, or they appear on the quote twice.
At conversion they reach the sales quote, or the sales order for a staff order, as text lines, so they are on the document the customer receives and on the work order of whoever cuts the steel:
| Key | Where on the quote | Language |
|---|---|---|
projectName | The first line of the document: Project: <name>, split over several lines when it is longer than one line. | Always Project: …, in every language. The cleanup finds this line again word for word to erase it, and a translated line might not be found. |
inspectionCertificate | A text line under the item line it belongs to, for example "Inspection certificate 3.1 (EN 10204)". | The language of the quote, from its Language Code. |
processing | A text line under that, for example "Processing: Cut to length, Drilling, Hot-dip galvanizing". | The language of the quote. |
They come before the line's own lineRemarks. No field of the sales header is written: Your Reference holds the request number and External Document No. the customerReference, and Business Central has no standard field for a job site. A certificate and processing are not personal data and stay on the quote; the project is, and the cleanup erases it from the request and from the quote like the delivery address.
The project is also in the two mails about the request: the confirmation to the requester has a line with the project, labelled in the language of the request, and the notification to inside sales a Project row. Neither shows it when there is no project, and the certificate and the processing are not in the mails. The screening counts links in the project together with those in remarks; the check for a long text without a digit stays on remarks, because a project is at most 100 characters. MyRequests and RequestLines return the project.
Lines that are refused
A line for an item that is not in the web catalog makes the whole call fail with [VOA-ItemNotOnWebsite]; a line for an item that is in the catalog but is not orderable online (webQuotable is false) fails with [VOA-ItemNotQuotable]. Nothing is stored. The first is usually a stale page: refresh the catalog and drop the line. The second belongs to an item you show as "price on request", without an order button.
A staff order is exempt: with source = staffOrder the two catalog refusals are not applied, because with the customer present everything the company sells may be sold, including what is deliberately not online.
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 between Buyer and 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 | The customer number the person states. 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. |
Avoiding duplicates: 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 carries the marker [VOA-DUBBEL], the code DuplicateRequest 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 is translated, so match on the marker and never on the sentence. The comparison is effectively case-insensitive, because the value is stored in uppercase; without a requestId there is no duplicate check. The rule holds for both actions, each in its own table.
Screening after intake
Once a request is stored, Business Central screens it: is it a repeat of an earlier request from the same address within Duplicate Window (Hours), and how much does it look like unwanted traffic. None of that reaches the website. The response is unchanged, the status code is unchanged, and the screening can never make the call fail. A request that looks like spam is stored, answered with its number and shown to inside sales like any other; it is never refused, hidden or deleted. So do not build anything on the website that expects a different answer for a flagged request: there is none. The ipHash does matter here: without it the signal about the number of requests per address cannot be counted. It stays what it was, a pseudonymous HMAC made by the website; Business Central never sees an IP address.
The VAT number in MyDetails
If you prefill the quote request form with MyDetails, you get the customer's VAT number back under the key vatRegistrationNo, from whichever field this localization stores it in: VAT Registration No., or Enterprise No. for a domestic customer in a Belgian installation (field 11310 on the customer card, which exists only in the Belgian localization). The key and its meaning are unchanged; only the place Business Central reads the number from differs per country.
Errors
All errors are AL errors, so the call fails with HTTP 400 and the message in the body. The request is not stored. Every message starts with a [VOA-…] marker that is not translated; see Error codes further down.
| Condition | Applies to | Code | Message starts with |
|---|---|---|---|
payload is not valid JSON | both | InvalidInput | The request is not valid JSON. |
| Refuse Requests is on | both | RequestsClosed | CustomerGate is not accepting requests at the moment. |
privacyAccepted is not true | both | PrivacyConsentMissing | A request cannot be accepted without agreement… |
privacyConsentVersion is empty | both | PrivacyConsentMissing | Include in privacyConsentVersion… |
email is empty | account requests | InvalidInput | An account request without an e-mail address is not possible… |
requestId was already used | both | DuplicateRequest | This submission has already been accepted under number… (also contains [VOA-DUBBEL]) |
| A line names an item that is not in the web catalog | quote requests | ItemNotOnWebsite | Item … is not in the web catalog… |
| A line names an item that is not orderable online | quote requests | ItemNotQuotable | Item … is available on request only… |
requestedDeliveryDate is not a valid date | quote requests | InvalidInput | The value of requestedDeliveryDate in the request is not valid. |
source is staffOrder while Staff Orders Enabled is off | quote only | StaffOrdersDisabled | Staff orders are switched off in this company… |
| No number series in this company | both | SetupIncomplete | CustomerGate is not set up in this company… |
projectName, inspectionCertificate and processing (since 1.0.44) add no error: a long project is cut to 100 characters like every other text, and a value that is not one of the value names is dropped. The request itself is the message.
Attachments on a request
A drawing, a DXF, a photo of the old part or a cutting list. Uploading goes through voaRequests, reading back through voaPortal: that difference is not sloppiness, it is the heart of the design.
AddAttachment
This action is on voaRequests and not on voaPortal, because a request can come from anyone, including a visitor who is not signed in, and that visitor is exactly the one whose drawing inside sales would otherwise have to ask for by e-mail. Reading back is on voaPortal, where there is a session to check ownership against.
| Parameter | Notes |
|---|---|
requestNo | The request number (WEB000123) or the requestId of the submission. Prefer the requestId: a request number counts up and is therefore guessable. |
fileName | Name with extension, no folder. A leading path is stripped; a name that still contains a backslash, a slash or a colon is refused. At most 250 characters are stored. |
contentBase64 | The file, base64-encoded. Nothing else: a content type you send along would not be believed anyway. |
The answer, after parsing value:
| Key | Type | Notes |
|---|---|---|
ok | Boolean | Always true; a failure is an error, not ok:false. |
attachmentId | number | The line number within the request (10000, 20000, …). For your own bookkeeping; Attachment on voaPortal uses the id from MyAttachments instead. |
fileName | string | The name as Business Central stored it, stripped of any folder. |
size | number | The size in bytes, measured after decoding. |
contentHash | string | The SHA-256 of the content, 64 lowercase hexadecimal characters. |
- The same content twice is one attachment. A second call with the same bytes on the same request returns the same
attachmentIdand stores nothing extra, so retrying an upload after a time-out is safe. The name of the first upload is kept. - Status: accepted while the request is
NeworInProgress. Refused once it isConverted,RejectedorSpam, and refused for a request whose personal data has been erased. - The size is checked twice: first on the length of the base64 string, before decoding, so a huge payload cannot exhaust the session, and then on the real bytes.
- The content type is never taken from you. CustomerGate derives it from the extension; an unknown extension gets
application/octet-stream.
The settings, and what no setting can change
Five fields on CustomerGate Setup, under Attachments, govern what AddAttachment accepts. They are per company, so do not copy the defaults below into your form. Since 1.0.44 voaPing returns the values in force in this company under attachments: whether uploads work at all, the size and count limits with the fallbacks for 0 already applied, the allowed types exactly as AddAttachment checks them, the deny list and the types whose first bytes are checked. They come from the same rules the upload check uses, so a form built on them accepts exactly what Business Central accepts. Read them when your application starts, together with the rest of voaPing. Before 1.0.44, ask the administrator what they are before you promise a customer anything.
| Setting | Default | Allowed | Effect |
|---|---|---|---|
| Attachments Enabled | on | Off makes AddAttachment fail with an error. Files that are already stored stay readable through MyAttachments and Attachment. | |
| Maximum Attachment Size (KB) | 10240 | 0 to 20480 | Per file, measured on the decoded bytes. 0 falls back to 10240. |
| Allowed Attachment Types | pdf,png,jpg,jpeg,dxf,dwg,step,stp,xlsx,csv | Comma-separated extensions without a dot. Anything else is refused. | |
| Maximum Attachments per Request | 10 | 0 to 100 | Per request. 0 falls back to 10. |
| Copy Attachments to Sales Document | on | Whether conversion copies the files to the sales document's attachments. Not visible through any web service. |
The deny list exe, dll, js, vbs, ps1, bat, cmd, scr, msi, jar, sh, html, htm, svg is in the code, is checked against every dot-separated part of the name (drawing.exe.pdf is refused) and wins from Allowed Attachment Types. No setting can override that list. The same goes for the check on the first bytes: for pdf, png, jpg, jpeg, xlsx, dwg, step and stp the content must start the way the extension promises. dxf and csv have no fixed start and are not content-checked.
CustomerGate checks the type, not the integrity. A file whose first bytes match its extension is stored and handed back exactly as it arrived, even if it is truncated or otherwise damaged, the same as a file sent by e-mail. Business Central is deliberately not allowed to interpret the content, so a damaged image can never turn into a platform error in the middle of an upload. If you need more than a type check, validate the file at the website before calling AddAttachment.
MyAttachments
voaPortal_MyAttachments(token, requestNo) returns the files on one quote request of the customer behind this session: what the customer sent themselves, plus what inside sales added. Ownership is checked on the request, because an attachment carries no customer number: the request must belong to this session's customer, must not be anonymized and must not be marked as spam, the same three conditions as MyRequests, so a customer never sees in one screen what disappeared in another. A request of somebody else gives the same "" as a request that does not exist.
| Key | Type | Notes |
|---|---|---|
requestNo | string | The request, echoed back. |
attachments | array | The files, oldest first. |
Each element:
| Key | Type | Notes |
|---|---|---|
id | string | The attachment's SystemId, lowercase and without braces. This is what you pass to Attachment. |
fileName | string | The name as it was stored. |
extension | string | Lowercase, without a dot. |
contentType | string | The MIME type CustomerGate derived from the extension. |
size | number | Bytes. |
contentHash | string | SHA-256 of the content, 64 lowercase hexadecimal characters. |
uploadedAt | string | Timestamp. |
origin | string | PortalUser or InsideSales. |
The content is never in this list: ten drawings would be a hundred megabytes. Fetch one file at a time with Attachment.
Attachment
voaPortal_Attachment(token, attachmentId) returns the content of one file, base64-encoded, in the same shape as Document. attachmentId is the id from MyAttachments, not the attachmentId that AddAttachment returned. That is on purpose: a line number is 10000, 20000, 30000 and therefore guessable, and so is a request number next to it. The ownership check is there regardless, so guessing gets you nowhere; a key that cannot be guessed is an attempt that never starts.
| Key | Type | Notes |
|---|---|---|
fileName | string | |
contentType | string | |
size | number | Bytes. |
contentHash | string | SHA-256, so you can verify what you received. |
base64 | string | The file. |
The answer is "" for an invalid session, an id that is not a GUID, an unknown id, an attachment of another customer, or an attachment without content: all the same empty answer.
Errors when uploading
AddAttachment has its own errors, all HTTP 400 with the message in the body. Nothing is stored. MyAttachments and Attachment throw nothing: they answer "".
| Condition | Message starts with |
|---|---|
| Attachments are switched off | Attachments are switched off. |
requestNo matches no request | There is no web request … |
| The request is Converted, Rejected or Spam | Request … no longer accepts files: its status is … |
| The request has been anonymized | Request … has been anonymized and no longer accepts files. |
fileName is empty | A file without a name cannot be accepted. |
fileName still holds a folder or a colon | The file name … is not a file name… |
fileName has no extension, or one longer than 10 characters | The file … has no extension… |
| Any part of the name is on the deny list | Files of type … are never accepted… |
| The extension is not in Allowed Attachment Types | Files of type … are not accepted. The allowed types are: … |
| Larger than Maximum Attachment Size (KB) | The file … is larger than the maximum of … KB. |
contentBase64 is not valid base64 | The content of … is not valid base64. |
| Empty content | The file … is empty. |
| The first bytes do not match the extension | The content of … is not a … file. |
| The request already has the maximum number of files | Request … already has … files, and that is the maximum. |
voaPortal: the customer portal
Every action is POST .../ODataV4/voaPortal_{Procedure}?company={company id}. These twenty-four 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 |
RequestLines | token, requestNo | 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 |
AcceptQuoteWithDetails | token, quoteNo, ipHash, poNumber, requestedDeliveryDate | JSON string | Buyer | records an acceptance with two extra values |
RejectQuote | token, quoteNo, ipHash, reasonCode, remark | JSON string | Buyer | records a rejection |
MyDocuments | token | JSON string or "" | both | extends the session |
Document | token, documentType, documentNo | JSON string or "" | both | extends the session |
MyAttachments | token, requestNo | JSON string or "" | both | extends the session |
Attachment | token, attachmentId | JSON string or "" | both | extends the session |
SwitchCustomer | token, customerNo, ipHash | JSON string | both | moves the session to another customer of the same address |
MyNotificationSettings | token | JSON string or "" | both | extends the session |
SetNotificationSettings | token, settings | JSON string or "" | both | changes the caller's own notification choices |
InviteColleague | token, email, name, role, privacyConsentVersion | JSON string | Buyer | creates an account request for a colleague |
Hard limits
| List | Maximum |
|---|---|
MyRequests | 200 requests |
MyQuotes, MyOrders, MyShipments | 100 rows each, live and archived together; truncated says whether there were more |
MyDeliveryAddresses | 100 addresses |
DocumentLines | 500 lines |
RequestLines | 500 lines |
MyDocuments, Steel 365 archive | 200 documents, from at most 1,000 archive rows examined |
MyDocuments, uit rapporten | 100 per document kind |
MyAttachments | as many files as the request carries; Maximum Attachments per Request caps that at 100 |
ReadSession, MyDetails | customers has no fixed maximum; in practice it is one or two |
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 the customer is no longer allowed; the answer never says which of those it is. 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 regardless.
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 it is not set. |
expiresAt | string | Timestamp at which the session expires if it is not used again. |
customers | array | Every customer this address may act for, each as {"no": …, "name": …, "role": …}. Always present and never empty for a valid session; one element in the ordinary case. Revoked links and customers that are not allowed in the portal are left out, so anything in this list can be switched to. |
{
"email": "buyer@example.com",
"customerNo": "C00010",
"customerName": "Example Ltd",
"role": "Buyer",
"weightType": "",
"expiresAt": "2026-09-19T22:30:00.000Z",
"customers": [
{"no": "C00010", "name": "Example Ltd", "role": "Buyer"},
{"no": "C00044", "name": "Example Holding", "role": "Viewer"}
]
}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 one on the customer card) and role. It also carries customers, the same array as in ReadSession, so a form can say on whose behalf somebody is asking. That array says which customers may be chosen, not which one is active: that is customerNo in ReadSession.
MyDeliveryAddresses
The registered ship-to addresses of the session's customer, for a 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 when 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 that has no customer yet. It then puts the customer on the request, keeps shipToCode only when 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; an order number for a staff order. |
documentType | string | Present with quoteNo. quote, or order for a staff order. Says which list quoteNo should link to. |
quoteNoStatus | string | Present with quoteNo. active for a live document of this customer, completed for a released quote in the quote archive or an archived order, otherwise "". |
lines | number | Number of request lines. |
projectName | string | Since 1.0.44. The project the requester named (projectName of CreateQuoteRequest), or "". Always present. |
RequestLines
The lines of one of your own quote requests, in the shape CreateQuoteRequest takes them back. This is what "order the same again" is built on: the website reads the lines of last month's request and fills a new form with them. Ownership is checked on the request, with exactly the same three conditions as MyAttachments. A converted request is included on purpose: the request that became an order last month is precisely the one somebody wants to repeat, so there is no filter on status other than spam.
The answer is {"requestNo": …, "lines": [ … ]}, the lines in line-number order, at most 500. Each element has lineNo (informational, do not send it back) and otherwise the keys CreateQuoteRequest expects: itemNo, variantCode, profileCode, qualityCode, description, quantity, unitOfMeasureCode, lengthMm, widthMm, thicknessMm, lineRemarks and, since 1.0.44, inspectionCertificate (None, Certificate22, Certificate31 or Certificate32) and processing (an array of the value names in the order of the enum, [] when none). Since 1.0.44 the answer also carries projectName, the project of the request or ""; a repeat order is often for another site, so offer it as a suggestion, not as a given. A request of yours without lines gives {"requestNo": "WEB000123", "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 | The quote's validity date, else the Steel 365 due date, else "". |
yourReference | string | The customer's reference: External Document No. |
requestNo | string | The web request this quote was made from, or "". Business Central only returns a number that is an existing request of this quote or this customer; free text in Your Reference is never passed on. Do not rely on the number pattern: the number series is configurable. |
status | string | Open, Released, or "" for any other status. |
lines | number | Number of item lines. |
totalWeightKg | number | Optional, only with Steel 365. Total weight in kg. |
hasDocument | Boolean | true only when Document can print this quote. Always false on archived rows. |
accepted | Boolean | An acceptance was recorded through the portal. |
acceptedAt | string | Since 1.0.44. The moment of that acceptance, the same timestamp AcceptQuote answered, or "". |
rejectedAt | string | Since 1.0.44. The moment the customer declined the quote with RejectQuote, the same timestamp that call answered, or "". |
rejectionReason | string | Since 1.0.44. The reason given with that rejection: TooExpensive, DeliveryTooLate, OrderedElsewhere, NoLongerNeeded, Other, or NotStated when the website sent a reason it did not know. "" without a rejection. The customer's own remark is not returned; it is for inside sales. |
comment | string | Live rows only: the note inside sales wrote for the customer. Never the internal work description. |
orderNo, orderNoStatus | string | On 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 "". |
Rules for reading the customer's answer (since 1.0.44):
- Read these keys after every
AcceptQuoteandRejectQuoteand build the buttons on them: no buttons whenacceptedistrueorrejectedAtis not"". Before 1.0.44 a website that refreshed after a decline showed both buttons again, and a second decline answeredalready. - The four keys are always present, filled or
""orfalse. An archived quote carries them too: a quote that was declined and then converted or deleted still shows the decline. - Both can be filled.
RejectQuoterefuses a quote that is already accepted, butAcceptQuotedoes not refuse a quote that was declined: a customer who said no and changes their mind can still accept. When both are filled, the later timestamp is the customer's last word. - The timestamps are ISO 8601 in UTC, like every timestamp here; compare them as moments, not as text.
MyOrders
The sales orders of the session's customer: live orders, highest number first, then orders that exist only in the order archive, the 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 from the order header. Per line they are in DocumentLines. |
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. |
packageTrackingNo | string | The carrier's package number, or "". |
shippingAgentCode | string | The code of the shipping agent on the shipment, or "". |
trackingUrl | string | A ready-made link to the carrier's tracking page, or "". |
Those three keys are always present and often empty: a steel merchant delivers most of its work with its own lorries, and then there is no carrier and no package number. Read "" as "no tracking for this shipment" and do not show a button. Business Central builds trackingUrl from the internet address on the shipping agent's card, which contains a placeholder for the package number, with the same helper the standard action in Business Central itself uses. It is empty when there is no agent code, when the agent no longer exists, when the agent has no internet address, or when there is no package number: a link to a carrier's search page with nothing filled in only disappoints. Where the template leaves the scheme out, it becomes https://. CustomerGate never exposes the template itself, only the finished link.
DocumentLines
Parameters: documentType is quote, order or shipment (case-insensitive; anything else returns ""), and documentNo is the document number. Ownership is checked before a single 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 come back, 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, the 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. |
lengthMm, widthMm, thicknessMm | number | Steel 365, optional | Since 1.0.44. Length DMX, Width DMX and Thickness DMX of the line's item variant (itemNo + format), in millimeters as DXSteel stores them: the same numbers voaVariants returns as DXLength, DXWidth and DXThickness. |
customFormat | Boolean | Steel 365, optional | Since 1.0.44. true when the variant is a one-off format (Format Type DMX is OneTime): a size DXSteel created for this order, for example a beam cut to 9000 mm. false for a trade size or a non-trade size. |
outstandingPieces, outstandingMeters, outstandingKg, shippedPieces, shippedMeters, shippedKg | number | Steel 365, live order lines | Outstanding and shipped quantities. |
requestedDeliveryDate | string | live order lines | What the customer asked for, on this line. "" when it is not set. |
promisedDeliveryDate | string | live order lines | What was promised, on this line. "" when it is not set. |
lineStatus | string | live order lines | How far this line is: Open, PartlyShipped, Shipped or Invoiced. |
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
0means zero and a missing key means unknown. Show a dash then, not0. - With Steel 365, pieces, meters and kilograms are three separate quantities: never add them up and do not pick one yourself,
unitsays which quantity leads. - Outstanding and shipped values exist only on live order lines; do not calculate them yourself.
requestedDeliveryDate,promisedDeliveryDateandlineStatusexist only on live order lines for the same reason: on a quote line nothing has been ordered, so any statement about delivery would be about something that does not exist yet.- The two dates come from the line and not from the header. That is the point of them: on a partial delivery one line moves and the other does not, and the header then tells you the average of two stories.
MyOrderskeeps carrying the header's dates for the list view. lineStatusis derived, and it is derived from the standard quantities (Quantity, Quantity Shipped, Quantity Invoiced) that Business Central maintains on every sales line, with or without Steel 365. So it means the same thing in both kinds of environment, while the quantities next to it do not. The order of the tests is furthest-first: invoiced beats shipped, shipped beats partly shipped. A line for a quantity of zero is alwaysOpenand neverShipped.- No price, discount, margin or amount is ever returned.
- The size of a line (
lengthMm,widthMm,thicknessMm,customFormat, since 1.0.44) comes from the item variant of the line, on every kind of line: quote, order or shipment, live or archived. The four keys follow the Steel 365 rule above: present whenever DXSteel has the field, so0means zero (a plate has a width, a beam does not) and a missing key means unknown. They are also missing when the line has no variant code or its variant no longer exists. Without Steel 365 they are never present.
{
"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",
"lengthMm": 12000, "widthMm": 0, "thicknessMm": 0, "customFormat": false,
"requestedDeliveryDate": "2026-11-03", "promisedDeliveryDate": "2026-11-05",
"lineStatus": "PartlyShipped"}
]
}Ordering a line again
Since 1.0.44. To put a line of an earlier quote or order into a new CreateQuoteRequest, send:
| `CreateQuoteRequest` line key | From `DocumentLines` |
|---|---|
itemNo | itemNo |
variantCode | format, only when customFormat is false (a trade or non-trade size from the catalog). Leave it out for a one-off format. |
lengthMm, widthMm, thicknessMm | lengthMm, widthMm, thicknessMm, when customFormat is true. Send them as they came; a 0 is skipped. |
profileCode, qualityCode | profile, grade |
description | description |
quantity, unitOfMeasureCode | The leading quantity: unit says which of quantityPieces, quantityKg and quantityMeters it is. Send it with the unit of measure code your form uses for that unit, the same as for a new request. Without Steel 365: quantity and unitOfMeasureCode. |
Why not the variant code of a one-off format: a one-off format is a variant DXSteel made for one order, not a size from the catalog. It is not meant to be offered again, and it can be blocked or cleaned up. With the item number and the dimensions and no variantCode, the conversion does what a salesperson does who types the size into the quote line: DXSteel first looks for an existing variant with exactly these dimensions and only creates a new one-off format when there is none. This path is accepted at intake, where the catalog check looks at the item and not at the variant, and at conversion, where an item with several sizes and no variant code is only refused when no dimension is given either.
A line of an earlier request comes back in exactly this shape from RequestLines; use that one when you have the request number.
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), acceptedAt (a timestamp, or "") and, only with refused or expired, errorCode. The checks, in order:
| Check | Outcome |
|---|---|
| Session not valid | refused |
| The role is not Buyer | refused |
quoteNo is empty or longer than 20 characters | refused |
| No live quote with that number for this customer | refused |
| The quote status is not Released | refused |
| An acceptance already exists for this quote | already, with the original acceptedAt |
| The validity date is set and is before today | expired |
| Another call 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 address, 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 agreed to if the quote changes later. The mail job then sends the customer a confirmation and inside sales a notification.
AcceptQuoteWithDetails
The same acceptance as AcceptQuote, with the two things a customer wants to say alongside it: their purchase order number and the date they want the steel. Every check, every outcome and every key of the answer is identical; only the two parameters are new.
Why a second procedure and not two extra parameters. OData binds parameters by name, so a call to voaPortal_AcceptQuote that does not send the new names would be refused with a bare 400. Adding a parameter to a published action is therefore a breaking change. AcceptQuote keeps its three parameters, keeps working, and calls this procedure with two empty values.
| Parameter | Notes |
|---|---|
poNumber | The customer's purchase order number, at most 35 characters. Longer is truncated. |
requestedDeliveryDate | YYYY-MM-DD. A value that is not a date does not fail the call: the acceptance is the important part and must not be lost over a typo in a field beside it. The date then stays empty, exactly as if the parameter had been left out. This is deliberately different from CreateQuoteRequest, where the same value does fail the call, because there the date is part of what is being asked for. |
Where the two values end up: on the acceptance, which inside sales sees in Website Quote Acceptances. They are not written to the sales quote by this call, because the portal reads sales documents and never writes to them, and that is what keeps this interface small. They reach the sales document along two paths that both run under a person in Business Central: automatically when inside sales turns the quote into an order, where the purchase order number lands in Your Reference and External Document No. and the date in Requested Delivery Date, or with the Apply to Sales Document action on the acceptances list. Neither path ever overwrites a value that is already on the document: what inside sales typed wins over what was chosen on a website a week earlier.
RejectQuote
Records that the customer is not going ahead with a released quote, with a reason. Buyer only, with the same customer lock, the same requirement that the quote is released, and the same uniform refusal as AcceptQuote. The quote is not cancelled: nothing changes in Business Central, the quote stays exactly as it was, and inside sales gets a worklist entry saying the customer said no. Somebody who clicks "too expensive" usually wants a conversation, and a portal that closes the quote takes that conversation away.
| Parameter | Notes |
|---|---|
reasonCode | One of TooExpensive, DeliveryTooLate, OrderedElsewhere, NoLongerNeeded, Other. Anything else, including an empty string, is stored as NotStated and does not fail the call: the rejection itself is the message. Show the list in your own language and send the fixed value. |
remark | Free text, optional, at most 250 characters. It is personal data and is erased after the retention period; the reason and the quote are kept. |
The answer has outcome (recorded, already or refused), rejectedAt (a timestamp, or "") and, only with refused, errorCode, which is always NotAuthorized. An invalid session, another role, an empty or overlong quoteNo, no live released quote for this customer, and a quote that already has an acceptance all give refused: withdrawing an acceptance is a conversation, not a click. If a rejection already exists, the answer is already with the original rejectedAt. There is no expired: rejecting a quote that is past its validity date is allowed, and is exactly the moment a customer says so.
SwitchCustomer
Moves this session to another customer that the signed-in address is linked to. Read One address at several customers above first: this is the only procedure in the portal that takes a customer number. customerNo is trimmed and uppercased; longer than 20 characters is refused, not truncated. ipHash goes on the log row of every attempt.
| Key | Type | Notes |
|---|---|---|
outcome | string | switched or refused. |
customerNo | string | The customer the session is on after the call. "" with refused: a refusal never names a customer. |
errorCode | string | Only with refused. Always NotAuthorized. |
| Check | Outcome |
|---|---|
| Session not valid | refused, and nothing is logged: without a session there is nobody to log |
customerNo is empty or longer than 20 characters | refused |
| Already on this customer | switched, nothing is written and nothing is logged |
| No link between this address and that customer | refused |
| The link exists but is revoked or anonymized | refused |
| The customer is not allowed in the portal | refused |
| Otherwise | switched; the session's customer and role are taken from the link |
Call ReadSession again after a switch, or use the customerNo from the answer: everything the portal returns from that moment on is filtered on the new customer, including the role.
MyNotificationSettings
What this portal user has asked to be e-mailed about. Everything is off until they switch it on, and nothing but this endpoint can switch it on: inside sales can read the flags and not set them, and there is no bulk action.
| Key | Type | Notes |
|---|---|---|
enabled | Boolean | The company's master switch (Portal Notifications Enabled). Off means nothing is queued and nothing is sent, whatever the five below say. It is reported so your screen can explain why nothing arrives; a website cannot change it. |
quoteReleased | Boolean | A quote for this customer has been released. |
orderConfirmed | Boolean | An order for this customer has been released. |
shipmentPosted | Boolean | A shipment for this customer has been posted. |
invoicePosted | Boolean | An invoice for this customer has been posted. |
requestReceived | Boolean | A quote request of this customer has been received in the portal. |
The five sit per portal user and per customer, because they live on the link. Somebody who buys for two companies can want shipment mail for one and nothing for the other. Every mail names why it was sent and where to switch it off, with a link to the portal, where the recipient signs in as usual. There is deliberately no unsubscribe link that works without signing in: such a link works for whoever holds the e-mail, and that is exactly the kind of endpoint this app does not have.
SetNotificationSettings
Changes the caller's own choices and returns the new state, in the same shape as MyNotificationSettings. The settings parameter is a JSON object as a string, with the keys of MyNotificationSettings, for example {"invoicePosted":true}.
- Only the keys you send change. Anything you leave out keeps its value, so a screen that toggles one checkbox does not have to send the other four and cannot undo a choice made in another tab.
enabledis ignored if you send it. It is the company's switch; it is in the answer so you can explain, not so you can set it.- A value that is not
trueorfalseleaves that key alone. - A
settingsthat is not valid JSON changes nothing and returns the current state. Nothing is broken: the customer clicked, nothing happened, and the screen shows what is really stored. - Switching off takes effect immediately, including for messages that were already queued: the queue is checked against these flags again at the moment of sending.
InviteColleague
A Buyer asks for portal access for a colleague at the same customer. This does not grant access: it creates an ordinary account request with the customer already filled in, which inside sales approves by hand in Business Central, with the same confirmation as any other. A buyer who could let their own colleagues in would be handing out access to company data, and that is the one action this app keeps with a person.
| Parameter | Notes |
|---|---|
email | The colleague's address. Normalized like every address here; it must contain an @. |
name | The colleague's name, at most 100 characters. |
role | Buyer or Viewer, as a proposal. Anything else becomes Viewer. Whoever approves chooses the role. |
privacyConsentVersion | Required. The version of the privacy statement the inviter confirmed on the invitation form. This call records a third party's address, and the inviter is the one who signs for it; the value is stored on the request next to the inviter's own address. Without it the call is refused. |
The answer has outcome (created, already, throttled or refused), number (the account request number with created, otherwise "") and, only with refused, errorCode, which is always NotAuthorized. An invalid session, another role, Maximum Invitations per Day set to 0, an email without an @ and an empty privacyConsentVersion all give refused. An address that already has an active portal user at this customer gives already; an inviter who has reached their maximum today gets throttled.
already and throttled are separate on purpose, unlike the uniform refusals elsewhere. There is nothing to leak here: the caller is signed in, typed the address themselves, and already only says something about the portal users of their own customer, which they may act for anyway. It lets a screen say "this colleague can already sign in" instead of "it did not work". A revoked link is not access, so a colleague who left and came back can be invited again. The colleague gets the ordinary account request acknowledgement, with one extra line naming who invited them, because "we have received your request" means nothing to somebody who asked for nothing. That mail goes out with the next mail round.
MyDocuments
The PDFs available to the session's customer, as {"documents": [ … ]}, without truncated. The source is one of two, never both: the Steel 365 document archive when DXSteel Doc Archive is installed, otherwise the reports when Portal Documents from Reports is on. If the archive is not there and that 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 in the list only when it hangs on this customer's number, the relation type is Customer, and its category is on the Archive Categories page 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 the name before putting 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 your own category name. |
documentNo | string | The source document number, or "". |
documentType | string | quote, order, shipment or "". Set only after Business Central has 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. Numbers of related documents, 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 that 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 in the same archive live 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 document in Business Central. Informational; download with Document. |
source | string | Always report. Absent on archive rows: that is how you 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 | Orders only: active. |
date | string | Quote: document date. Order: order date. Shipment, invoice, credit memo: posting date. |
orderNo, orderNoStatus, invoiceNo, creditMemoNo | string | Optional. Numbers of related documents. |
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 fileName, contentType and base64 come back, the PDF itself.
The report runs with the permissions of the website account (tenant set VOA CG DOCUMENTS). If the report fails, usually 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.
MyAttachments and Attachment
These two belong to the attachments on a quote request and are described above, under Attachments on a request. They behave like every other reading procedure of the portal: they extend the session, are open to both roles, and return "" when there is nothing for you.
Webhooks: Business Central calls you
Everything above is the website asking Business Central a question. This is the other direction: Business Central calls an address of yours when something has happened. That way a website does not have to poll, and does not have to wait for the nightly synchronization to learn that a shipment went out this morning.
Webhooks are off until an administrator creates a subscription in the Business Central client, on Webhook Subscriptions. There is no web service that creates or reads subscriptions: an address that Business Central will call, and the secret it signs with, are not something a website should be able to set for itself. The administrator also has to allow the app to make outgoing calls, in Extension Management; until that happens, events are queued but nothing is sent.
The events
| Value of event | When it fires | Where it comes from |
|---|---|---|
QuoteReleased | A sales quote is released | the standard release routine (not in preview mode) |
QuoteAccepted | A customer accepts a quote in the portal | AcceptQuote, at the moment the acceptance is recorded |
OrderCreated | A sales order is created from a sales quote | the standard quote-to-order conversion |
ShipmentPosted | A sales shipment is posted | the standard posting routine |
InvoicePosted | A sales invoice is posted | the standard posting routine |
AccountApproved | An account request is approved | the account worklist |
AccountRejected | An account request is rejected | the account worklist |
RequestConverted | A quote request becomes a sales quote or a sales order | the status of the request, whichever door changed it |
RequestRejected | A quote request is rejected | the status of the request, whichever door changed it |
Ping | Only by Send Test Event on the subscription list | never by itself |
Two deliberate gaps:
- A staff order does not raise
OrderCreated. A request that becomes a sales order directly reportsRequestConvertedwithdocumentTyperequestand anorderNo. Each event has exactly one source, so a receiver never gets two names for one fact. - Spam is not an event. An account request marked as spam raises nothing: that is not something that happened in the business, and a reply to it confirms that the address exists.
- The cleanup job sets old quote requests that were still New or In Progress to Rejected while it anonymizes them. Those do not raise
RequestRejected; otherwise every night would bring a shower of events about requests from a year ago.
The request
POST https://yoursite.example/api/bc-webhook
Content-Type: application/json; charset=utf-8
Accept: application/json
X-VOA-Timestamp: 2026-09-20T09:12:33.000Z
X-VOA-Signature: sha256=6687de2bb04779680fe96e12682e4a2b564bc4a3cc329edb2f551056de039e44
X-VOA-Event: InvoicePosted
X-VOA-Event-Id: 5b7a1f0c-2d43-4e18-9a55-0c7e3b6d81f2
X-VOA-Subscription: WEBSITE-ORDERS
{"event":"InvoicePosted", …}| Header | Meaning |
|---|---|
X-VOA-Timestamp | When the delivery was signed, ISO 8601 in UTC. Part of the signature. |
X-VOA-Signature | sha256= followed by 64 lowercase hexadecimal characters. |
X-VOA-Event | The same value as event in the body, so a router does not have to parse first. |
X-VOA-Event-Id | The same value as eventId in the body. Use it to deduplicate. |
X-VOA-Subscription | The code of the subscription, so one endpoint can serve several. |
Answer with a 2xx status code as soon as you have stored the event, and do the work afterwards. Anything other than 2xx counts as a failure, and so does a connection that does not answer within 10 seconds. The address must answer directly: a redirect is not followed with the signature headers, so a 301 to another host arrives there without a usable signature.
The payload
The body is a single JSON object. It carries identifiers, states, amounts and dates, and no personal data: no names, no e-mail addresses, no phone numbers, no addresses and no free text. That is a rule of the app and not a habit of the current version; an AL test fails as soon as a payload contains any of them. If you need the name behind a request, ask the portal or Business Central for it with the number you got here.
Every payload has these keys:
| Key | Type | Meaning |
|---|---|---|
eventId | string (GUID) | Identifies the event, not the delivery. It stays the same across retries. Two subscriptions on the same event get the same eventId. |
event | string | One of the values in the table above. |
occurredAt | string | When it happened in Business Central, ISO 8601 in UTC. Not the moment of delivery. |
company | string | The name of the Business Central company. |
documentType | string | quote, order, shipment, invoice, request, account or ping. |
documentNo | string | Optional. The document the event is about. |
customerNo | string | Optional. The customer the event is about. |
And per event:
| Event | Extra keys |
|---|---|
QuoteReleased | status (Released), amountIncludingVat, currencyCode, documentDate, validUntil |
QuoteAccepted | status (Accepted), amountIncludingVat, currencyCode, documentDate, lineCount |
OrderCreated | quoteNo, amountIncludingVat, currencyCode, documentDate |
ShipmentPosted | orderNo (when the shipment came from an order), documentDate (the posting date) |
InvoicePosted | orderNo (when the invoice came from an order), amountIncludingVat, currencyCode, documentDate |
RequestConverted | status (Converted), requestNo, and quoteNo or orderNo |
RequestRejected | status (Rejected), requestNo |
AccountApproved | status (Approved), requestNo |
AccountRejected | status (Rejected), requestNo |
Ping | none |
amountIncludingVat is a JSON number in the document's currency; an empty currencyCode means the local currency of the company. Dates are YYYY-MM-DD and an empty date is "", as everywhere else in this reference.
{
"event": "InvoicePosted",
"company": "Booischotse Metalen",
"documentType": "invoice",
"documentNo": "PS-INV101023",
"customerNo": "C00010",
"orderNo": "PS-ORD100411",
"amountIncludingVat": 4235.18,
"currencyCode": "",
"documentDate": "2026-09-20",
"eventId": "5b7a1f0c-2d43-4e18-9a55-0c7e3b6d81f2",
"occurredAt": "2026-09-20T09:12:31.117Z"
}Verifying the signature
A receiver that cannot verify is a receiver that trusts anyone who knows the address. The recipe is short and there is no excuse for skipping it. Each subscription has its own secret of 64 hexadecimal characters, generated in Business Central with Generate Secret and shown once. Put it in your server's configuration; it is never readable again, not on the page, not in an export and not in a copy of the database.
X-VOA-Signature = "sha256=" + lowercase_hex( HMAC_SHA256( secret, X-VOA-Timestamp + "." + raw_request_body ) )Three details decide whether it works:
- Over the raw body. Compute it over the bytes as they arrived, before any JSON parsing and re-serialization. Whitespace and key order are part of what was signed.
- The timestamp is inside the signature, not next to it. That is what makes it worth checking: without it, anyone who captured a delivery could replay it tomorrow with a fresh
X-VOA-Timestampand a signature that still matched. - Compare in constant time, and reject a timestamp that is more than a few minutes old or in the future.
import crypto from 'node:crypto'
// Express: express.raw({ type: 'application/json' }), so the raw body, not the parsed one.
export function handleWebhook (req, res) {
const secret = process.env.VOA_WEBHOOK_SECRET
const timestamp = req.get('X-VOA-Timestamp') || ''
const received = req.get('X-VOA-Signature') || ''
const rawBody = req.body // Buffer
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + rawBody.toString('utf8'))
.digest('hex')
const a = Buffer.from(received)
const b = Buffer.from(expected)
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end() // not from Business Central
}
const age = Math.abs(Date.now() - Date.parse(timestamp))
if (!Number.isFinite(age) || age > 5 * 60 * 1000) {
return res.status(401).end() // replay, or a clock that is far off
}
const event = JSON.parse(rawBody.toString('utf8'))
if (alreadyHandled(event.eventId)) return res.status(200).end() // a retry
store(event) // do the work after answering, where you can
res.status(200).end()
}These test values let you check your implementation without Business Central. They are the same ones the AL test uses:
| What | Value |
|---|---|
| secret | voa-test-secret |
X-VOA-Timestamp | 2026-09-20T09:12:33.000Z |
| body | {"event":"InvoicePosted","documentNo":"PS-INV101023"} |
| signature | sha256=6687de2bb04779680fe96e12682e4a2b564bc4a3cc329edb2f551056de039e44 |
One second later, 2026-09-20T09:12:34.000Z over the same body, gives sha256=60bd375378f87dd5a30241d3c524a6a38d5f036222756e2727642488b73e8e92. If your implementation gives the same answer for both timestamps, you are not signing the timestamp.
Retries, duplicates and order
- A delivery is attempted five times, with 1, 5, 30 and 120 minutes in between. After that it is parked and appears on the Webhook Deliveries list in Business Central, where a person can put it back in the queue with Retry Now.
- Deliveries are queued and are never sent from inside a posting. An event that arises while an invoice is being posted is written to a queue table in that same transaction, and a job queue entry does the calling. A slow or broken endpoint can therefore never hold up a posting and never roll one back. If the posting is rolled back, the queued event disappears with it.
- Expect duplicates. A delivery that timed out on your side may well have arrived. Deduplicate on
eventId, which is stable across retries. - Do not expect order. Two events within the same minute can arrive in either order, and a retried event arrives after events that happened later. Use
occurredAtif order matters. - After twenty failed attempts in a row, configurable on CustomerGate Setup, a subscription turns itself off, and it never turns back on by itself. Someone checks the endpoint and chooses Enable. An environment that blocks outgoing HTTP is not counted as a failure of the endpoint: that is a setting, not a broken receiver.
Answer 2xx to an event you do not know. Values can be added in a compatible release, and a receiver that answers an unknown name with an error turns the subscription off over an event that does not even concern it.
voaPing: what this installation is
One action, POST .../ODataV4/voaPing_Ping?company={company id} with an empty body, that answers the questions you would otherwise have to find out with trial calls: is this production or a sandbox, is Steel 365 installed (and are there therefore three quantities per line instead of one), where do the PDFs come from, is the setup finished, and is this a demo company whose mail goes nowhere.
It takes no session token and returns no personal data: no customer name, no e-mail address, no counts. Call it once when your application starts, and again after a deployment. It is cheap, but it is also the only endpoint that tells a caller without a session anything, so do not poll it.
{
"app": {"name": "CustomerGate 365", "publisher": "Van Oosten Software", "version": "1.0.39.0"},
"contractVersion": "1.0",
"environment": {"type": "Production", "companyName": "CRONUS BE"},
"modules": {"steel365": false, "steel365Archive": false},
"documentSource": "report",
"demoMode": false,
"setupComplete": true,
"supportedLocales": ["en-US", "nl-NL", "nl-BE", "fr-BE", "fr-FR"],
"serverTimeUtc": "2026-09-20T11:24:07.421Z",
"licence": {
"status": "Active",
"plan": "Portal",
"validUntil": "2027-12-31",
"servicesStopOn": "2028-01-31",
"features": {"requests": true, "catalog": true, "portal": true, "attachments": true, "webhooks": false}
},
"attachments": {
"enabled": true,
"maxFileSizeKb": 10240,
"maxPerRequest": 10,
"allowedTypes": ["pdf", "png", "jpg", "jpeg", "dxf", "dwg", "step", "stp", "xlsx", "csv"],
"deniedTypes": ["exe", "dll", "js", "vbs", "ps1", "bat", "cmd", "scr", "msi", "jar", "sh", "html", "htm", "svg"],
"contentCheckedTypes": ["pdf", "png", "jpg", "jpeg", "xlsx", "dwg", "step", "stp"]
}
}| Key | Type | Notes |
|---|---|---|
app.name, app.publisher | string | The name and the publisher of the app, from the module itself. |
app.version | string | The app version, four numbers. It changes with every build. |
contractVersion | string | The version of this interface, not of the app. It only changes when the agreement changes. |
environment.type | string | Production or Sandbox. |
environment.companyName | string | The name of the Business Central company the call ran in. A codeunit service is called with ?company={guid}, so this is the only way to see which company that was. It is not a customer name. |
modules.steel365 | Boolean | iFacto Steel 365 (DXSteel) is installed. It decides the shape of DocumentLines. |
modules.steel365Archive | Boolean | DXSteel Doc Archive is installed. |
documentSource | string | archive, report or none. Where the PDFs in the portal come from; see the table below. |
demoMode | Boolean | Demo mode is on: every message CustomerGate sends goes to one demo address instead of to the customer. |
setupComplete | Boolean | false while the setup record is missing, a number series is empty, or the website's client ID has not been filled in. false means calls will fail; say so on your own health page instead of guessing at a 403. |
supportedLocales | array of string | The languages the app is translated into. Use one of these in Accept-Language. |
serverTimeUtc | string | The server's clock, ISO 8601 in UTC. Useful to spot a clock difference before it turns into an expired session. |
licence.status | string | The licence in this company today: NotRequired, Demo, Trial, Active, Expiring, Grace, EnvironmentChanged, Lapsed or CompanyNotCovered. Only the last two stop the web services. |
licence.plan | string | Requests, Portal, Complete, or None without a valid key. Complete in a sandbox, in demo mode and during the trial. |
licence.validUntil | string | The last day of the licence (yyyy-mm-dd), or empty without a key. |
licence.servicesStopOn | string | The first day on which the web services stop (or stopped) if nothing changes, or empty when that is not in sight. |
licence.features | object | Per function, whether a call would work right now: requests, catalog, portal, attachments, webhooks. |
attachments.enabled | Boolean | Since 1.0.44. Whether AddAttachment works right now: Attachments Enabled is on in this company and the licence serves attachments (licence.features.attachments). false means: do not show an upload field. |
attachments.maxFileSizeKb | number | Since 1.0.44. The largest file AddAttachment accepts, in kilobytes of decoded content, with the fallback applied: 0 in the setup is 10240 here. |
attachments.maxPerRequest | number | Since 1.0.44. How many files one request may carry. 0 in the setup is 10 here. |
attachments.allowedTypes | array of string | Since 1.0.44. The extensions AddAttachment accepts, lower case, without a dot, each once, exactly as the upload check reads them. An entry of Allowed Attachment Types that is on the deny list, contains a dot (.pdf) or is longer than ten characters can never let a file through and is left out. An empty setting gives the default list. |
attachments.deniedTypes | array of string | Since 1.0.44. The fixed deny list from the code. Refused on every part of a file name, whatever the setup says (drawing.exe.pdf is refused). |
attachments.contentCheckedTypes | array of string | Since 1.0.44. The extensions whose first bytes must match the extension. A type that is allowed but not in this list (dxf, csv) is accepted on its name. |
documentSource deserves its own line, because it decides which of two download paths your site must build:
| Value | Meaning | What to build |
|---|---|---|
archive | DXSteel Doc Archive is installed. | MyDocuments returns archive rows; fetch the bytes from iFacto's own API with the id. Document always returns "". |
report | No archive, and Portal Documents from Reports is on. | MyDocuments returns report rows; download with Document. |
none | No archive and the setting is off. | No documents at all. MyDocuments is always empty and Document always returns ""; do not show a download button. |
Like every codeunit service, the answer is a string inside value: parse it a second time. voaPing needs the permission set VOA WEB CONTRACT, which Repair Permissions assigns to the website's Entra application alongside the other three. An environment that was set up before this service existed gets the service on the next update of the app and the set on the next Repair Permissions; until then the call answers 403.
voaPing never stops on the licence: it is how a website finds out why the rest did. Which licence function each call needs is in the contract file, as licence per procedure and per page.
Error codes
A sentence is not something to branch on. The sentence is translated, because the call carries an Accept-Language, and it gets rewritten whenever someone words it more clearly: two ways to lose your error handling without anything turning red.
Every failure a client can trigger therefore carries a machine-readable marker in front of the message:
[VOA-PrivacyConsentMissing] A request cannot be accepted without agreement to the privacy statement. Send privacyAccepted = true.- The marker is
[VOA-plus the code plus], at the start of the message, and is never translated. - The message behind it is unchanged and stays in the language of the session.
- Branch on the marker, never on the sentence.
- The older marker
[VOA-DUBBEL]is still in the message about a duplicaterequestId, where it always was. It has not been replaced, so a website that only learns about the codes later does not break.
The codes
| Code | Where | What happened | What the client should do |
|---|---|---|---|
RequestsClosed | CreateQuoteRequest, CreateAccountRequest | Refuse Requests is on in the setup: CustomerGate is temporarily not accepting anything. | Show that submitting is temporarily unavailable. This is the one code you may retry later. Do not resubmit automatically. |
PrivacyConsentMissing | CreateQuoteRequest, CreateAccountRequest | privacyAccepted was not true, or privacyConsentVersion was empty. | A bug in the form. Show the consent checkbox; do not retry. |
InvalidInput | CreateQuoteRequest, CreateAccountRequest | The payload is not valid JSON, a required value is missing (email on an account request), or a value cannot be read (requestedDeliveryDate is not a date). The message names the property. | Fix the input. Do not retry with the same payload. |
DuplicateRequest | CreateQuoteRequest, CreateAccountRequest | This requestId was already accepted. The message also carries [VOA-DUBBEL] and the number of the request that was stored. | Treat it as success. The request is in Business Central. |
SetupIncomplete | CreateQuoteRequest, CreateAccountRequest | CustomerGate is not set up in this company: there is no number series for the requests. | Not a visitor's problem: alert whoever runs the environment. voaPing reports the same thing as setupComplete: false. |
ItemNotOnWebsite | CreateQuoteRequest | A line names an item that is not in the web catalog. | A bug or a stale page: the visitor is looking at something that is not for sale online. Refresh the catalog and drop the line. |
ItemNotQuotable | CreateQuoteRequest | A line names an item that is in the catalog but is not orderable online (webQuotable is false). | Show "price on request" for that item instead of an order button, and offer the contact form. |
CatalogNotComputed | voaItems, voaVariants, voaCategories, voaCatalogMedia | The web catalog has never been computed in this company, so Business Central cannot say which items belong on the website. | Stop the synchronization and keep what you have. Do not read it as an empty catalog. Whoever runs the environment chooses Recompute Web Catalog on CustomerGate Setup. |
LicenceRequired | Every service except voaPing and SignOut | There is no valid licence for this environment (after the trial or grace period), or the licence does not cover this company. Nothing was read or written. | Show that the function is temporarily unavailable and keep what you have. Not a visitor's problem: voaPing says why and since when. Retrying is pointless until there is a key. |
NotInPlan | voaPortal and AddAttachment on Requests. Below Complete, webhooks give no error code: nothing is queued or sent. | The plan of this environment does not include this function, and that stays so until the plan changes. | Hide the function. voaPing lists what works in licence.features. |
NotAuthorized | AcceptQuote, AcceptQuoteWithDetails, RejectQuote, SwitchCustomer, InviteColleague (as errorCode) | The call was refused. Always the same code, for every reason, in all five places. | Show that it cannot be done. See below. |
QuoteNotOpen | AcceptQuote, AcceptQuoteWithDetails (as errorCode) | The quote is past its validity date. | Show that the quote has expired and offer to ask for a new one. |
StaffOrdersDisabled | CreateQuoteRequest | source is staffOrder, but Staff Orders Enabled is off in CustomerGate Setup. Nothing was stored. | Not a visitor's problem: whoever runs the environment turns the setting on, or staff orders are not meant for this company. Do not resend it as an ordinary request. |
errorCode on AcceptQuote and its siblings
AcceptQuote does not throw; it answers with an object, and that object carries an errorCode next to the keys it already had, only when the outcome is a failure.
| Outcome | errorCode | Notes |
|---|---|---|
recorded | absent | The acceptance is in. |
already | absent | There was already an acceptance; not an error. |
expired | QuoteNotOpen | The quote is demonstrably this customer's, and past its validity date. |
refused | NotAuthorized | Always the same code, for every reason. |
That last row is deliberate and will not change. A quote of another customer, a number that does not exist, a quote that is not released and a Viewer who is not allowed to accept all give the same answer, because anything else turns this endpoint into a free check on what exists and whose it is. The code adds a stable thing to branch on, not a reason. RejectQuote, SwitchCustomer and InviteColleague follow the same pattern: errorCode appears only with refused and is then always NotAuthorized. The outcomes already and throttled are not errors and carry no code.
Codes that exist but are never returned
The list of codes also contains SessionExpired, SessionUnknown, SignInCodeInvalid, SignInCodeExpired, SignInCodeThrottled, AccountNotActive, AccountUnknown, DocumentNotFound, DocumentNotAllowed, QuoteChanged, RequestNotFound, ModuleNotAvailable and Internal. The portal never returns them today, and that is not an omission: voaPortal answers an invalid session, a document of another customer and a document that does not exist with exactly the same empty answer, on purpose. The names are published so that they are part of the interface from the start, and so a later version can use one without having to extend the list. In the contract file they are marked "reserved": true.
The contract as a file, and the client
Everything above is prose. A generator needs data, so the same interface is also published as a file: contract/customergate-365.contract.json. It is generated from the AL source and shipped with the app, and regenerated and compared on every pull request, so a change to the interface shows up as a difference and never happens quietly.
| Key | Contents |
|---|---|
contractVersion | The same value voaPing returns. |
app | Id, name, publisher, version and the supported locales. |
services[] | Every published service, with its object type and number. |
services[].procedures[] | For a codeunit service: every callable procedure, the name of the OData action (voaPortal_MyQuotes), the parameters with their AL types, the return type, the JSON keys it writes (jsonKeys) and the keys it reads from the payload (payloadKeys, for voaRequests). |
services[].fixedValues[] | Per JSON key, the fixed values it can carry, with the name of the AL helper they come from. status appears twice, because a request status and a document status are different lists. |
services[].properties[] | For a page service: the OData property names. |
enums[] | Every enum of the app with its value names: the tokens from Fixed values below. |
errorCodes[] | Every code from Error codes, its marker, where it is thrown, and whether it is reserved. |
What is deliberately not in it: which key is optional, which key only appears with Steel 365, what a value means, and which procedure only reads. That is editorial and lives on this page. Use the file for the names and the shapes, and this page for the rules.
The generated TypeScript client
If you build on this app in TypeScript, you do not have to write the plumbing on this page. There is a client package that is generated from the contract file, so it cannot describe a service that does not exist and cannot miss one that does.
const cg = createClient({ tenant, environment, company: { id, name }, getToken });
const session = await cg.voaPortal.readSession({ token }); // already parsed twiceWhat it takes off your hands:
- The OData envelope and the second parse, per procedure, from
returnsandjsonKeysin the contract: a text answer with JSON keys is parsed and a session token is not. - The empty answer:
""arrives asnull, for every reason it can have. - The parameter names, checked before the call, because OData binds by name and a wrong name is a bare 400.
[VOA-…]as a typedcodeon an error object, with a separate flag for a duplicate submission, including the older[VOA-DUBBEL].- The retry rules: always after a 429, once after a refused token, and nothing else unless you declare a procedure a read yourself.
- The fixed values and the enum value names, as unions.
- Paging over
@odata.nextLinkfor the catalog services.
The package has no runtime dependencies, never reads an environment variable and never holds a secret: you pass it a function that returns a token. Its version tracks contractVersion and not the version of the app; 1.0.x speaks contract 1.0, and the third number is for fixes to the package itself. The generator refuses to run when the two disagree.
You get the contract file and the client package from Van Oosten Software; the package is not on npm yet, so you install it from the source you were given. At start-up, check what you are talking to by comparing contractVersion from voaPing with the version your client was built against.
Fixed values
These values are fixed tokens. They are never translated and never depend on the language of the session. Compare them exactly.
| Where | Key or parameter | Values |
|---|---|---|
ReadSession, MyDetails | role | Buyer, Viewer |
ReadSession, MyDetails | customers[].role | Buyer, Viewer |
MyRequests | status | New, InProgress, Converted, Rejected, "" |
MyRequests | documentType | quote, order |
MyQuotes, MyOrders | status | Open, Released, "" |
MyRequests, MyOrders | quoteNoStatus | active, completed, "" |
MyQuotes, MyShipments, MyDocuments | orderNoStatus | active, completed, "" |
MyDocuments | documentNoStatus | active, completed |
DocumentLines | lineStatus | Open, PartlyShipped, Shipped, Invoiced |
AcceptQuote, AcceptQuoteWithDetails | outcome | recorded, already, expired, refused |
RejectQuote | outcome | recorded, already, refused |
RejectQuote | parameter reasonCode | TooExpensive, DeliveryTooLate, OrderedElsewhere, NoLongerNeeded, Other (anything else is stored as NotStated) |
MyQuotes | rejectionReason | TooExpensive, DeliveryTooLate, OrderedElsewhere, NoLongerNeeded, Other, NotStated, "" (since 1.0.44) |
CreateQuoteRequest, RequestLines | line inspectionCertificate | None, Certificate22, Certificate31, Certificate32 (on input anything else becomes None) (since 1.0.44) |
CreateQuoteRequest, RequestLines | line processing (array) | CutToLength, MitreCutting, Drilling, Punching, ThermalCutting, Bending, Blasting, Priming, Galvanizing, Painting (on input anything else is skipped) (since 1.0.44) |
SwitchCustomer | outcome | switched, refused |
InviteColleague | outcome | created, already, throttled, refused |
InviteColleague | parameter role | Buyer, Viewer (anything else: Viewer) |
MyNotificationSettings, SetNotificationSettings | keys | enabled, quoteReleased, orderConfirmed, shipmentPosted, invoicePosted, requestReceived |
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 |
MyAttachments | origin | PortalUser, InsideSales |
MyAttachments, Attachment | contentType | Derived from the extension: application/pdf, image/png, image/jpeg, image/vnd.dxf, image/vnd.dwg, model/step, text/csv, the Office types, else application/octet-stream |
AddAttachment | deny list | exe, dll, js, vbs, ps1, bat, cmd, scr, msi, jar, sh, html, htm, svg: fixed in code, never overridable by setup; voaPing returns it as attachments.deniedTypes (since 1.0.44) |
ReadSession, DocumentLines | weightType | Theoretical, Trade, GermanTrade, "" (Steel 365) |
DocumentLines | unit | Untranslated option names of the Steel 365 field; the reference website handles PCS, KG, MTR |
voaRemovals | Kind | Item, Variant, Category |
voaCatalogMedia | contentType | Whatever Business Central stored, for example image/jpeg or image/png; application/octet-stream when it is unknown |
CreateQuoteRequest | deliveryMethod | Pickup, Delivery (anything else: Pickup) |
CreateQuoteRequest | source | Free text up to 50 characters. staffOrder makes it a staff order (only while Staff Orders Enabled is on); LinkRequest sets portal. |
CreateAccountRequest | existingCustomer | Yes, No, Unknown (anything else: Unknown) |
| Webhook | event | Ping, QuoteReleased, QuoteAccepted, OrderCreated, ShipmentPosted, InvoicePosted, AccountApproved, AccountRejected, RequestConverted, RequestRejected |
| Webhook | documentType | quote, order, shipment, invoice, request, account, ping |
| Webhook | X-VOA-Signature | sha256= plus 64 lowercase hexadecimal characters |
Ping | environment.type | Production, Sandbox |
Ping | documentSource | archive, report, none |
Ping | contractVersion | 1.0 |
Errors from voaRequests | message | [VOA-DUBBEL] marks a duplicate requestId |
| Errors from the catalog | message | [VOA-CatalogNotComputed], [VOA-ItemNotOnWebsite], [VOA-ItemNotQuotable] |
| Every error a client can trigger | message | Starts with [VOA-<code>]; see Error codes |
| Sign-in code | alphabet | 0123456789ABCDEFGHJKMNPQRSTVWXYZ, 10 characters |
Request numbers come from number series in the setup. On installation they default to WEB000001 to WEB999999 for quote requests and ACC000001 to ACC999999 for account requests, but every company can change them: do not rely on the pattern.
Differences with Steel 365
CustomerGate 365 is one app. It checks the environment itself and needs no setting for it: Steel 365 is active as soon as the field Quantity Pieces DMX exists on the sales line, and the Steel 365 document archive as soon as the table Document Archive DADMX exists.
| Area | With Steel 365 | Without Steel 365 |
|---|---|---|
| Catalog services | voaProfiles, voaGrades and voaItemTypes are published, and the DX… properties are filled | Only voaItems, voaVariants, voaCategories, voaRemovals and voaCatalogMedia; DX… text is "" and numbers are 0 |
| Synchronization permissions | VOA WEB SYNC and VOA CG S365 SYNC | Only VOA WEB SYNC |
Quantities in DocumentLines | 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 |
Extra keys in DocumentLines | profile, grade, formatDescription, weightType when they are filled; since 1.0.44 lengthMm, widthMm, thicknessMm and customFormat from the line's variant | none |
requestedDeliveryDate, promisedDeliveryDate and lineStatus per line | present on live order lines; lineStatus comes from the standard quantities | the same: these three do not depend on Steel 365 |
MyQuotes.totalWeightKg | present | absent |
ReadSession.weightType | the customer's weight type, or "" | "" |
| Validity of a quote | falls back to the Steel 365 due date | the quote's own validity date only |
The document archive is a separate switch:
| Area | With the archive | Without the archive |
|---|---|---|
MyDocuments | Archive rows, filtered by the allowed categories | Report rows when Portal Documents from Reports is on, otherwise empty |
| Downloading a PDF | The bound action of iFacto's API with the id | Document returns base64 |
Document | always "" | prints the PDF |
hasDocument | always false | true when a report is set up |
| Quotes and orders that exist only in the archive | orderNo and quoteNo references from the archive | no references |
| Tenant set of the website | VOA CG S365 ARCHIVE | VOA CG DOCUMENTS when the setting is on |
Ping.documentSource | archive | report, or none when the setting is off |
Versions and compatibility
The names are the contract. Websites call them literally, so all of this is part of it: the names of the services, the names of procedures and parameters including their case, the names of the catalog properties, the JSON keys in payloads and answers, every fixed value above, the error codes and the [VOA-…] form of the marker. Renaming any one of them breaks every website built on the app, silently in many cases: a renamed catalog property simply leaves a column empty, and a renamed JSON key reads as null. There are no aliases for earlier names.
The same rules hold for the webhooks: the names of the headers, the keys in the payload, the values of event and the recipe for the signature are part of the interface. A new value of event needs a change on the receiving side before customers see it, and a receiver that does not answer an unknown name with 2xx turns its own subscription off over something that does not concern it.
For those who use the services:
- Ignore keys you do not know. Keys can be added in a compatible release.
- Treat a missing optional key as null, and
""as no value. - Treat an unknown value as unknown. Do not map it to a default that claims something, for example by reading an unknown
outcomeasrecorded. - The values of
inspectionCertificateandprocessing(since 1.0.44) can grow: another app can add one, and it then comes back fromRequestLinesunder its own name. Treat a name you do not know as unknown. - Keep the session token, the
requestId, the webhook secret and 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. Adding a parameter to an existing action is not either, because OData binds by name: that is why AcceptQuoteWithDetails sits next to AcceptQuote instead of inside it. Installation and updates create missing services but never change an existing one: a renamed service would appear next to the old one, while the old one keeps pointing where it pointed.
contractVersion is not the app version. The app gets a new four-number version with every build, while contractVersion only moves when something incompatible about this agreement changes. So check contractVersion from voaPing at start-up, not app.version.
Where to go next
- Setup guide
From installing the app to a first request, and on: the job queue entries, the web catalog, webhooks and the diagnostics screen.
- Release notes
What is in each version of the app.
- Support
How to ask for help, and what to send along.