Skip to content
Van Oosten Advies B.V.

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.

ServiceKindPurposeCalled by
voaItemspage, read-onlyItems for the catalogsynchronization application
voaVariantspage, read-onlyItem variants (sizes)synchronization application
voaCategoriespage, read-onlyItem categoriessynchronization application
voaRemovalspage, read-onlyWhat has left the catalog and must therefore come off the websitesynchronization application
voaCatalogMediacodeunit, unbound actionThe bytes of one item or variant picturesynchronization application
voaProfilespage (iFacto DXSteel)Steel profilessynchronization application
voaGradespage (iFacto DXSteel)Steel gradessynchronization application
voaItemTypespage (iFacto DXSteel)Steel item typessynchronization application
voaRequestscodeunit, unbound actionsAccept a quote request or an account request, and attach a file to onewebsite application
voaPortalcodeunit, unbound actionsSign-in and everything a signed-in customer sees or doeswebsite application
voaPingcodeunit, unbound actionWhat this installation is: version, environment, modules, document source. No session and no personal datawebsite 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 fieldUsed forMay call
Website Client IDQuote and account requests, the customer portal, the pingvoaRequests, voaPortal, voaPing
Synchronization Client IDThe 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

SetAssigned toWhat it allows
VOA WEB APIwebsite, all companiesExecute voaRequests. Indirect insert only on the request tables, no read.
VOA WEB PORTALwebsite, all companiesExecute voaPortal and the codeunits it calls. Indirect table rights only.
VOA WEB CONTRACTwebsite, all companiesExecute 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 BASELINEwebsite, all companiesThe floor a web service session needs, instead of D365 BASIC, which would expose customer and sales data.
VOA CG EXECUTEwebsite, tenant setExecute rights on the objects the mail, write and PDF paths reach.
VOA CG MAILwebsite, tenant setWhat the e-mail connectors need to send mail, such as the sign-in code.
VOA CG S365 ARCHIVEwebsite, only with DXSteel Doc ArchiveRead the archive tables, with a security filter on the allowed archive categories.
VOA CG DOCUMENTSwebsite, only without that archive and with Portal Documents from Reports onRead what the standard sales reports read, and run those reports, so Document can print PDFs.
CDC BASICwebsite, only with Continia Document CaptureContinia hooks the triggers of the tables the website writes to.
VOA WEB SYNCsynchronization, per companyThe 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 SYNCsynchronization, only with Steel 365The 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/.default

Send 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_readSession does not exist, and a parameter called emailAddress instead of email gets a 400 with no further explanation.
  • Every parameter is a string: all procedure parameters are AL Text. Even a number such as pictureIndex is sent as "1".
  • A JSON result comes back as a string inside value, because an unbound action cannot return a nested object. Parse value a second time. A Boolean outcome is a JSON true or false in value.
  • An empty string is an answer. For most voaPortal procedures, "" 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-Language sets 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 sends nl-NL.

Status codes and retrying

StatusWhat it means here
200The procedure ran. Look in value for the outcome.
400An 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 / 403Token or permission sets are wrong. The body names the object that is missing.
404The service is not published under that name, or the company is wrong.
409Concurrent calls with the same session token tried to update the same session row.
429Business Central is throttling. Nothing was processed; honour Retry-After.
5xxServer 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; ReadSession and MyDetails name every customer the address may act for, and SwitchCustomer moves 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

  1. The visitor enters their e-mail address; the website calls RequestCode(email, ipHash).
  2. 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.
  3. The visitor enters the code; the website calls SignIn(email, code, ipHash) and gets a session token, or "".
  4. The website keeps the token, for example in an HttpOnly cookie, and passes it as token to every other voaPortal procedure.
  5. 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.
  • RequestCode always answers true, 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 true and does nothing.
  • The code mail goes out immediately from the RequestCode call, 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 token are 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.
  • ReadSession returns expiresAt. 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:

DataFiltered on
Web requests with their lines and attachmentsCustomer No. of the request
Sales quotes and orders, live and archivedSell-to Customer No.
Posted shipments, invoices, credit memosSell-to Customer No.
Ship-to addressesCustomer No.
DXSteel Doc Archivethe 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.

WhatWhere
Which customers this session may act forcustomers in ReadSession and MyDetails
Which customer is active right nowcustomerNo in ReadSession
Move the session to another oneSwitchCustomer
Create or revoke a linkInside 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.

ProcedureNo valid sessionNot found, not yours, not allowed
RequestCodenot applicabletrue (always)
SignInnot applicable"" (wrong, expired or used code, inactive user)
SignOuttruetrue
ReadSession, MyDetails, MyRequests, MyQuotes, MyOrders, MyShipments, MyDocuments""not applicable: a list may be empty
MyDeliveryAddresses"""" for a Viewer
LinkRequestfalsefalse
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

RoleTokenMay
BuyerBuyerEverything a Viewer may, plus LinkRequest, MyDeliveryAddresses, AcceptQuote, AcceptQuoteWithDetails, RejectQuote and InviteColleague.
ViewerViewerReading: 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:

SettingDefaultAllowedEffect
Sign-in Code Validity (Min.)151 to 1440How long a code works.
Maximum Code Attempts51 to 20How many wrong attempts per code are counted. Not a lockout.
Sign-in Code Wait Time (Sec.)600 to 3600Minimum time between two codes for one address. 0 removes the wait.
Session Duration (Min.)4805 to 20160Sliding expiry.
Maximum Session Age (Hours)1681 to 480Absolute end of a session.
Portal Documents from ReportsonSee MyDocuments and Document.
Portal Notifications EnabledoffThe 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 Day50 to 100How many colleagues one Buyer may invite per day with InviteColleague. 0 switches inviting off and does not fall back to the default.
Refuse RequestsoffMakes 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.

  • Blocked and SalesBlocked stay in voaItems and are always false. 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 voaRemovals is for: anything you hold that is not in the feed and does appear in voaRemovals must 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.

PropertyTypeSource
NostringNo.
DescriptionstringDescription
Description2stringDescription 2
BaseUnitofMeasurestringBase Unit of Measure
DXPriceUOMSalesDMXstringDXSteel Price UOM (Sales) DMX
ItemCategoryCodestringItem Category Code
DXItemProfileCodeDMXstringDXSteel Item Profile Code DMX
DXQualityCodeDMXstringDXSteel Quality Code DMX, the grade
BlockedBooleanBlocked. Always false: a blocked item is not published.
SalesBlockedBooleanSales Blocked. Always false, for the same reason.
webQuotableBooleanNot 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.
pictureCountnumberHow many pictures the item has in Business Central.
pictureIdstringA 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.

PropertyTypeSource
ItemNostringItem No.
CodestringCode
DescriptionstringDescription
Description2stringDescription 2
DXLengthnumberDXSteel Length DMX
DXWidthnumberDXSteel Width DMX
DXThicknessnumberDXSteel Thickness DMX
DXExtraLengthnumberDXSteel Extra Length DMX
DXFormatTypestringDXSteel Format Type DMX, as the caption in the session language. Send a fixed Accept-Language if you map these captions onto your own model.
BlockedBooleanBlocked. This is the variant's own flag and it is not filtered on: the website decides.
DXRemainingInventorynumberDXSteel Remaining Inventory DMX, summed over all locations
pictureCountnumberHow many pictures this variant has of its own. 0 means no own picture, not no picture: fall back to the item's.
pictureIdstringThe 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.

PropertyTypeWhat it is
KindstringItem, Variant or Category: a fixed token, never translated
NostringThe item number, or the item category code
VariantCodestringThe variant, for Kind = Variant; "" otherwise
RemovedAttimestampWhen 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 Variant row 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.

WhatWhere
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 picturevoaCatalogMedia_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:

ParameterNotes
itemNoThe item number. Trimmed and uppercased.
variantCodeThe 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.
KeyTypeNotes
itemNostring
variantCodestring"" for an item picture.
pictureIndexnumberWhich picture this is.
pictureCountnumberHow many there are, so you can loop without going back to the feed.
pictureIdstringThe same mark as in the feed; check it against what made you fetch.
contentTypestringimage/jpeg, image/png and so on, as Business Central stored it; application/octet-stream when it does not know.
fileNamestring{item}-{index}.{ext} or {item}-{variant}-{index}.{ext}, built from the content type.
byteSizenumberThe size of the picture in bytes, before base64.
base64stringThe bytes. Absent when the picture is over the limit or cannot be read.
tooLargeBooleanOnly present, and then true, when the picture is over the limit.
unreadableBooleanOnly 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

  1. Fetch voaItems with pictureId and pictureCount alongside the columns you sync anyway.
  2. For every item whose pictureId differs from what you stored, fetch the pictures one by one with voaCatalogMedia_Picture, from index 1 to pictureCount, and store pictureId next to the files. If pictureId has become empty, delete your copy.
  3. Do the same for voaVariants, with variantCode filled in.
  4. 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 pictureId is for: the second night costs almost nothing.
  5. 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.
  6. 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.

ActionParametersReturns
voaRequests_CreateQuoteRequestpayloadstring: {"number":"WEB000123"}
voaRequests_CreateAccountRequestpayloadstring: {"number":"ACC000045"}
voaRequests_AddAttachmentrequestNo, fileName, contentBase64string: {"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

KeyTypeRequiredMaxNotes
requestIdstringrecommended64Idempotency key, uppercased. Also the only way LinkRequest can find the request.
privacyAcceptedBooleanyes, trueThe request is refused without it.
privacyConsentVersionstringyes20Which privacy statement was accepted. Uppercased.
contactNamestring100Contact person.
emailstring100Where the requester expects the quote.
companyNamestring100
vatRegistrationNostring20Not verified.
phoneNostring30
customerReferencestring35The customer's own reference. Becomes External Document No. on the quote.
projectNamestring100Since 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.
languageCodestring10A Business Central language code, for example NLB or FRB. The confirmation mail is written in that language.
address, address2, postCode, city, countryCodestring100 / 50 / 20 / 30 / 10The requester's billing address. The country is an ISO code, for example BE.
deliveryMethodstringPickup or Delivery. Anything else becomes Pickup.
shipToCodestring10A registered ship-to address of the customer, from MyDeliveryAddresses. Only honoured after LinkRequest has verified it.
useShipToAddressBooleanThe delivery address differs from the billing address.
shipToName, shipToAddress, shipToAddress2, shipToPostCode, shipToCity, shipToCountryCodestring100 / 100 / 50 / 20 / 30 / 10The delivery address as text. A different delivery country can change the VAT treatment of the quote.
requestedDeliveryDatestringYYYY-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.
remarksstring2048Free text from the requester.
ipHashstring64See IP hash above.
sourcestring50The channel or the page. staffOrder has a special meaning; see below.
assistedBystring50The staff member who entered a staff order. Only for a staff order.
quoteRequestLinesarrayLine 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:

KeyTypeMaxNotes
itemNostring20Whether 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.
variantCodestring10The size.
profileCodestring30Steel 365 profile.
qualityCodestring20Steel 365 grade.
descriptionstring100As shown on the website, or free text.
quantitynumberThe requested quantity.
unitOfMeasureCodestring10For example PCS, M or KG. Validated at conversion.
lengthMmnumber0 means the standard length of the size.
widthMmnumberPlates and strip only.
thicknessMmnumberPlates and strip only.
lineRemarksstring250For example a sawing instruction.
inspectionCertificatestringSince 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.
processingarray of stringSince 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 = staffOrder marks 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 from assistedBy. Staff orders are off after installation: while Staff Orders Enabled is off, the call is refused with StaffOrdersDisabled and nothing is stored. Version 1.0.39 used counterVisit for this.
  • If the visitor is signed in, call LinkRequest right after this call to put the session's customer on the request. The customer number is never part of the payload.
  • shipToCode only counts after LinkRequest has 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:

KeyWhere on the quoteLanguage
projectNameThe 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.
inspectionCertificateA 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.
processingA 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.

KeyTypeRequiredMaxNotes
requestIdstringrecommended64Idempotency key, uppercased.
privacyAcceptedBooleanyes, true
privacyConsentVersionstringyes20Uppercased.
emailstringyes100The address the person will sign in with.
contactNamestring100
jobTitlestring50Helps inside sales choose between Buyer and Viewer.
companyNamestring100Not verified.
vatRegistrationNostring20
phoneNostring30
address, address2, postCode, city, countryCodestring100 / 50 / 20 / 30 / 10
existingCustomerstringYes, No or Unknown. Anything else becomes Unknown.
statedCustomerNostring20The customer number the person states. A hint for inside sales, never a link.
languageCodestring10Used for the mails to this person, including later sign-in codes.
remarksstring2048
ipHashstring64
sourcestring50The 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.

ConditionApplies toCodeMessage starts with
payload is not valid JSONbothInvalidInputThe request is not valid JSON.
Refuse Requests is onbothRequestsClosedCustomerGate is not accepting requests at the moment.
privacyAccepted is not truebothPrivacyConsentMissingA request cannot be accepted without agreement…
privacyConsentVersion is emptybothPrivacyConsentMissingInclude in privacyConsentVersion…
email is emptyaccount requestsInvalidInputAn account request without an e-mail address is not possible…
requestId was already usedbothDuplicateRequestThis submission has already been accepted under number… (also contains [VOA-DUBBEL])
A line names an item that is not in the web catalogquote requestsItemNotOnWebsiteItem … is not in the web catalog…
A line names an item that is not orderable onlinequote requestsItemNotQuotableItem … is available on request only…
requestedDeliveryDate is not a valid datequote requestsInvalidInputThe value of requestedDeliveryDate in the request is not valid.
source is staffOrder while Staff Orders Enabled is offquote onlyStaffOrdersDisabledStaff orders are switched off in this company…
No number series in this companybothSetupIncompleteCustomerGate 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.

ParameterNotes
requestNoThe request number (WEB000123) or the requestId of the submission. Prefer the requestId: a request number counts up and is therefore guessable.
fileNameName 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.
contentBase64The file, base64-encoded. Nothing else: a content type you send along would not be believed anyway.

The answer, after parsing value:

KeyTypeNotes
okBooleanAlways true; a failure is an error, not ok:false.
attachmentIdnumberThe line number within the request (10000, 20000, …). For your own bookkeeping; Attachment on voaPortal uses the id from MyAttachments instead.
fileNamestringThe name as Business Central stored it, stripped of any folder.
sizenumberThe size in bytes, measured after decoding.
contentHashstringThe 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 attachmentId and 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 New or InProgress. Refused once it is Converted, Rejected or Spam, 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.

SettingDefaultAllowedEffect
Attachments EnabledonOff makes AddAttachment fail with an error. Files that are already stored stay readable through MyAttachments and Attachment.
Maximum Attachment Size (KB)102400 to 20480Per file, measured on the decoded bytes. 0 falls back to 10240.
Allowed Attachment Typespdf,png,jpg,jpeg,dxf,dwg,step,stp,xlsx,csvComma-separated extensions without a dot. Anything else is refused.
Maximum Attachments per Request100 to 100Per request. 0 falls back to 10.
Copy Attachments to Sales DocumentonWhether 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.

KeyTypeNotes
requestNostringThe request, echoed back.
attachmentsarrayThe files, oldest first.

Each element:

KeyTypeNotes
idstringThe attachment's SystemId, lowercase and without braces. This is what you pass to Attachment.
fileNamestringThe name as it was stored.
extensionstringLowercase, without a dot.
contentTypestringThe MIME type CustomerGate derived from the extension.
sizenumberBytes.
contentHashstringSHA-256 of the content, 64 lowercase hexadecimal characters.
uploadedAtstringTimestamp.
originstringPortalUser 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.

KeyTypeNotes
fileNamestring
contentTypestring
sizenumberBytes.
contentHashstringSHA-256, so you can verify what you received.
base64stringThe 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 "".

ConditionMessage starts with
Attachments are switched offAttachments are switched off.
requestNo matches no requestThere is no web request …
The request is Converted, Rejected or SpamRequest … no longer accepts files: its status is …
The request has been anonymizedRequest … has been anonymized and no longer accepts files.
fileName is emptyA file without a name cannot be accepted.
fileName still holds a folder or a colonThe file name … is not a file name…
fileName has no extension, or one longer than 10 charactersThe file … has no extension…
Any part of the name is on the deny listFiles of type … are never accepted…
The extension is not in Allowed Attachment TypesFiles 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 base64The content of … is not valid base64.
Empty contentThe file … is empty.
The first bytes do not match the extensionThe content of … is not a … file.
The request already has the maximum number of filesRequest … 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.

ProcedureParametersReturnsRoleChanges data
RequestCodeemail, ipHashBooleananyonecreates a code, sends a mail
SignInemail, code, ipHashstring: token or ""anyoneuses the code, creates a session
ReadSessiontokenJSON string or ""bothextends the session
SignOuttokenBoolean (always true)bothrevokes the session
MyDetailstokenJSON string or ""bothextends the session
MyDeliveryAddressestokenJSON string or ""Buyerextends the session
LinkRequesttoken, requestIdBooleanBuyerlinks a request to the customer
MyRequeststokenJSON string or ""bothextends the session
RequestLinestoken, requestNoJSON string or ""bothextends the session
MyQuotestokenJSON string or ""bothextends the session
MyOrderstokenJSON string or ""bothextends the session
MyShipmentstokenJSON string or ""bothextends the session
DocumentLinestoken, documentType, documentNoJSON string or ""bothextends the session
AcceptQuotetoken, quoteNo, ipHashJSON stringBuyerrecords an acceptance
AcceptQuoteWithDetailstoken, quoteNo, ipHash, poNumber, requestedDeliveryDateJSON stringBuyerrecords an acceptance with two extra values
RejectQuotetoken, quoteNo, ipHash, reasonCode, remarkJSON stringBuyerrecords a rejection
MyDocumentstokenJSON string or ""bothextends the session
Documenttoken, documentType, documentNoJSON string or ""bothextends the session
MyAttachmentstoken, requestNoJSON string or ""bothextends the session
Attachmenttoken, attachmentIdJSON string or ""bothextends the session
SwitchCustomertoken, customerNo, ipHashJSON stringbothmoves the session to another customer of the same address
MyNotificationSettingstokenJSON string or ""bothextends the session
SetNotificationSettingstoken, settingsJSON string or ""bothchanges the caller's own notification choices
InviteColleaguetoken, email, name, role, privacyConsentVersionJSON stringBuyercreates an account request for a colleague

Hard limits

ListMaximum
MyRequests200 requests
MyQuotes, MyOrders, MyShipments100 rows each, live and archived together; truncated says whether there were more
MyDeliveryAddresses100 addresses
DocumentLines500 lines
RequestLines500 lines
MyDocuments, Steel 365 archive200 documents, from at most 1,000 archive rows examined
MyDocuments, uit rapporten100 per document kind
MyAttachmentsas many files as the request carries; Maximum Attachments per Request caps that at 100
ReadSession, MyDetailscustomers 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.

KeyTypeNotes
emailstringThe portal user's address.
customerNostringThe customer of this session. Business Central tells the website; the website never tells Business Central.
customerNamestringCustomer name, or "".
rolestringBuyer or Viewer.
weightTypestringThe customer's Steel 365 weight type (Theoretical, Trade, GermanTrade), or "" without Steel 365 or when it is not set.
expiresAtstringTimestamp at which the session expires if it is not used again.
customersarrayEvery 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.

KeyTypeNotes
nostringRequest number.
receivedAtstringTimestamp.
statusstringNew, InProgress, Converted, Rejected, or "" for a status this version does not know.
quoteNostringOptional. The sales document created from the request; an order number for a staff order.
documentTypestringPresent with quoteNo. quote, or order for a staff order. Says which list quoteNo should link to.
quoteNoStatusstringPresent with quoteNo. active for a live document of this customer, completed for a released quote in the quote archive or an archived order, otherwise "".
linesnumberNumber of request lines.
projectNamestringSince 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>}.

KeyTypeNotes
nostringQuote number.
documentDatestringDate.
validUntilstringThe quote's validity date, else the Steel 365 due date, else "".
yourReferencestringThe customer's reference: External Document No.
requestNostringThe 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.
statusstringOpen, Released, or "" for any other status.
linesnumberNumber of item lines.
totalWeightKgnumberOptional, only with Steel 365. Total weight in kg.
hasDocumentBooleantrue only when Document can print this quote. Always false on archived rows.
acceptedBooleanAn acceptance was recorded through the portal.
acceptedAtstringSince 1.0.44. The moment of that acceptance, the same timestamp AcceptQuote answered, or "".
rejectedAtstringSince 1.0.44. The moment the customer declined the quote with RejectQuote, the same timestamp that call answered, or "".
rejectionReasonstringSince 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.
commentstringLive rows only: the note inside sales wrote for the customer. Never the internal work description.
orderNo, orderNoStatusstringOn 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 AcceptQuote and RejectQuote and build the buttons on them: no buttons when accepted is true or rejectedAt is not "". Before 1.0.44 a website that refreshed after a decline showed both buttons again, and a second decline answered already.
  • The four keys are always present, filled or "" or false. An archived quote carries them too: a quote that was declined and then converted or deleted still shows the decline.
  • Both can be filled. RejectQuote refuses a quote that is already accepted, but AcceptQuote does 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>}.

KeyTypeNotes
nostringOrder number.
quoteNo, quoteNoStatusstringThe quote the order came from, with active, completed or "". Archived rows take it from the Steel 365 document archive.
orderDate, requestedDeliveryDate, promisedDeliveryDatestringDates from the order header. Per line they are in DocumentLines.
yourReferencestringThe customer's reference (External Document No.).
statusstringOpen, Released or "".
completelyShippedBooleanLive: the order's own field. Archived: recalculated per line from posted shipment lines.
linesnumberNumber of item lines.
hasDocumentBooleanAs in MyQuotes.
commentstringLive 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>}.

KeyTypeNotes
nostringShipment number.
deliveryDatestringPosting date of the shipment.
orderNo, orderNoStatusstringThe order the shipment belongs to, with active, completed or "".
yourReferencestringThe customer's reference (External Document No.).
shipToAddressobject{"name", "postCode", "city"}. Street and house number are not exposed.
linesnumberNumber of item lines.
hasDocumentBooleanAs in MyQuotes.
packageTrackingNostringThe carrier's package number, or "".
shippingAgentCodestringThe code of the shipping agent on the shipment, or "".
trackingUrlstringA 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": [ … ]}.

KeyTypeWhenNotes
lineNonumberalwaysLine No.
itemNostringalwaysItem number.
descriptionstringalways
formatstringalwaysVariant code: the size.
quantitynumberwithout Steel 365Quantity.
unitOfMeasureCodestringwithout Steel 365Unit of measure of quantity.
outstandingQuantity, quantityShippednumberwithout Steel 365, live order lines
profile, grade, formatDescriptionstringSteel 365, optionalItem Profile Code DMX, Quality Code DMX, Item Variant Description DMX.
unitstringSteel 365, optionalDoc. 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, quantityKgnumberSteel 365Quantity Pieces DMX, Quantity Length DMX, Quantity Weight DMX.
weightTypestringSteel 365, optionalTheoretical, Trade or GermanTrade.
lengthMm, widthMm, thicknessMmnumberSteel 365, optionalSince 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.
customFormatBooleanSteel 365, optionalSince 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, shippedKgnumberSteel 365, live order linesOutstanding and shipped quantities.
requestedDeliveryDatestringlive order linesWhat the customer asked for, on this line. "" when it is not set.
promisedDeliveryDatestringlive order linesWhat was promised, on this line. "" when it is not set.
lineStatusstringlive order linesHow 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 0 means zero and a missing key means unknown. Show a dash then, not 0.
  • With Steel 365, pieces, meters and kilograms are three separate quantities: never add them up and do not pick one yourself, unit says which quantity leads.
  • Outstanding and shipped values exist only on live order lines; do not calculate them yourself.
  • requestedDeliveryDate, promisedDeliveryDate and lineStatus exist 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. MyOrders keeps carrying the header's dates for the list view.
  • lineStatus is 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 always Open and never Shipped.
  • 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, so 0 means 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 keyFrom `DocumentLines`
itemNoitemNo
variantCodeformat, only when customFormat is false (a trade or non-trade size from the catalog). Leave it out for a one-off format.
lengthMm, widthMm, thicknessMmlengthMm, widthMm, thicknessMm, when customFormat is true. Send them as they came; a 0 is skipped.
profileCode, qualityCodeprofile, grade
descriptiondescription
quantity, unitOfMeasureCodeThe 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:

CheckOutcome
Session not validrefused
The role is not Buyerrefused
quoteNo is empty or longer than 20 charactersrefused
No live quote with that number for this customerrefused
The quote status is not Releasedrefused
An acceptance already exists for this quotealready, with the original acceptedAt
The validity date is set and is before todayexpired
Another call is recording an acceptance for this quote at the same momentalready, acceptedAt = ""
Otherwiserecorded, 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.

ParameterNotes
poNumberThe customer's purchase order number, at most 35 characters. Longer is truncated.
requestedDeliveryDateYYYY-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.

ParameterNotes
reasonCodeOne 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.
remarkFree 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.

KeyTypeNotes
outcomestringswitched or refused.
customerNostringThe customer the session is on after the call. "" with refused: a refusal never names a customer.
errorCodestringOnly with refused. Always NotAuthorized.
CheckOutcome
Session not validrefused, and nothing is logged: without a session there is nobody to log
customerNo is empty or longer than 20 charactersrefused
Already on this customerswitched, nothing is written and nothing is logged
No link between this address and that customerrefused
The link exists but is revoked or anonymizedrefused
The customer is not allowed in the portalrefused
Otherwiseswitched; 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.

KeyTypeNotes
enabledBooleanThe 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.
quoteReleasedBooleanA quote for this customer has been released.
orderConfirmedBooleanAn order for this customer has been released.
shipmentPostedBooleanA shipment for this customer has been posted.
invoicePostedBooleanAn invoice for this customer has been posted.
requestReceivedBooleanA 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.
  • enabled is 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 true or false leaves that key alone.
  • A settings that 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.

ParameterNotes
emailThe colleague's address. Normalized like every address here; it must contain an @.
nameThe colleague's name, at most 100 characters.
roleBuyer or Viewer, as a proposal. Anything else becomes Viewer. Whoever approves chooses the role.
privacyConsentVersionRequired. 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.

KeyTypeNotes
idstringSystemId of the archive record, lowercase with hyphens. The key for downloading.
fileNamestringAs stored in the archive. May contain spaces and accents; sanitize the name before putting it in a header.
contentTypestringapplication/pdf for .pdf, otherwise application/octet-stream.
categorystringThe kind: SalesQuote, SalesOrder, SalesInvoice, SalesCreditMemo, Proforma, Waybill or WarehouseShipment. Not your own category name.
documentNostringThe source document number, or "".
documentTypestringquote, 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 "".
documentNoStatusstringOnly when documentType is order: active or completed.
datestringThe archive's creation date, else the date the record was created, else "".
orderNo, orderNoStatus, invoiceNo, creditMemoNostringOptional. 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.

KeyTypeNotes
idstringSystemId of the document in Business Central. Informational; download with Document.
sourcestringAlways report. Absent on archive rows: that is how you tell the two apart.
reportTypestringquote, order, shipment, invoice or creditMemo. Pass it as documentType to Document.
fileNamestring{category} {document number}.pdf, for example SalesInvoice 103001.pdf.
contentTypestringapplication/pdf.
categorystringSalesQuote, SalesOrder, Waybill (shipments), SalesInvoice or SalesCreditMemo.
documentNostringDocument number. Pass it as documentNo to Document.
documentTypestringquote, order or shipment; "" for invoices and credit memos.
documentNoStatusstringOrders only: active.
datestringQuote: document date. Order: order date. Shipment, invoice, credit memo: posting date.
orderNo, orderNoStatus, invoiceNo, creditMemoNostringOptional. 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 eventWhen it firesWhere it comes from
QuoteReleasedA sales quote is releasedthe standard release routine (not in preview mode)
QuoteAcceptedA customer accepts a quote in the portalAcceptQuote, at the moment the acceptance is recorded
OrderCreatedA sales order is created from a sales quotethe standard quote-to-order conversion
ShipmentPostedA sales shipment is postedthe standard posting routine
InvoicePostedA sales invoice is postedthe standard posting routine
AccountApprovedAn account request is approvedthe account worklist
AccountRejectedAn account request is rejectedthe account worklist
RequestConvertedA quote request becomes a sales quote or a sales orderthe status of the request, whichever door changed it
RequestRejectedA quote request is rejectedthe status of the request, whichever door changed it
PingOnly by Send Test Event on the subscription listnever by itself

Two deliberate gaps:

  • A staff order does not raise OrderCreated. A request that becomes a sales order directly reports RequestConverted with documentType request and an orderNo. 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", …}
HeaderMeaning
X-VOA-TimestampWhen the delivery was signed, ISO 8601 in UTC. Part of the signature.
X-VOA-Signaturesha256= followed by 64 lowercase hexadecimal characters.
X-VOA-EventThe same value as event in the body, so a router does not have to parse first.
X-VOA-Event-IdThe same value as eventId in the body. Use it to deduplicate.
X-VOA-SubscriptionThe 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:

KeyTypeMeaning
eventIdstring (GUID)Identifies the event, not the delivery. It stays the same across retries. Two subscriptions on the same event get the same eventId.
eventstringOne of the values in the table above.
occurredAtstringWhen it happened in Business Central, ISO 8601 in UTC. Not the moment of delivery.
companystringThe name of the Business Central company.
documentTypestringquote, order, shipment, invoice, request, account or ping.
documentNostringOptional. The document the event is about.
customerNostringOptional. The customer the event is about.

And per event:

EventExtra keys
QuoteReleasedstatus (Released), amountIncludingVat, currencyCode, documentDate, validUntil
QuoteAcceptedstatus (Accepted), amountIncludingVat, currencyCode, documentDate, lineCount
OrderCreatedquoteNo, amountIncludingVat, currencyCode, documentDate
ShipmentPostedorderNo (when the shipment came from an order), documentDate (the posting date)
InvoicePostedorderNo (when the invoice came from an order), amountIncludingVat, currencyCode, documentDate
RequestConvertedstatus (Converted), requestNo, and quoteNo or orderNo
RequestRejectedstatus (Rejected), requestNo
AccountApprovedstatus (Approved), requestNo
AccountRejectedstatus (Rejected), requestNo
Pingnone

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:

  1. 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.
  2. 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-Timestamp and a signature that still matched.
  3. 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:

WhatValue
secretvoa-test-secret
X-VOA-Timestamp2026-09-20T09:12:33.000Z
body{"event":"InvoicePosted","documentNo":"PS-INV101023"}
signaturesha256=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 occurredAt if 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"]
  }
}
KeyTypeNotes
app.name, app.publisherstringThe name and the publisher of the app, from the module itself.
app.versionstringThe app version, four numbers. It changes with every build.
contractVersionstringThe version of this interface, not of the app. It only changes when the agreement changes.
environment.typestringProduction or Sandbox.
environment.companyNamestringThe 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.steel365BooleaniFacto Steel 365 (DXSteel) is installed. It decides the shape of DocumentLines.
modules.steel365ArchiveBooleanDXSteel Doc Archive is installed.
documentSourcestringarchive, report or none. Where the PDFs in the portal come from; see the table below.
demoModeBooleanDemo mode is on: every message CustomerGate sends goes to one demo address instead of to the customer.
setupCompleteBooleanfalse 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.
supportedLocalesarray of stringThe languages the app is translated into. Use one of these in Accept-Language.
serverTimeUtcstringThe server's clock, ISO 8601 in UTC. Useful to spot a clock difference before it turns into an expired session.
licence.statusstringThe licence in this company today: NotRequired, Demo, Trial, Active, Expiring, Grace, EnvironmentChanged, Lapsed or CompanyNotCovered. Only the last two stop the web services.
licence.planstringRequests, Portal, Complete, or None without a valid key. Complete in a sandbox, in demo mode and during the trial.
licence.validUntilstringThe last day of the licence (yyyy-mm-dd), or empty without a key.
licence.servicesStopOnstringThe first day on which the web services stop (or stopped) if nothing changes, or empty when that is not in sight.
licence.featuresobjectPer function, whether a call would work right now: requests, catalog, portal, attachments, webhooks.
attachments.enabledBooleanSince 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.maxFileSizeKbnumberSince 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.maxPerRequestnumberSince 1.0.44. How many files one request may carry. 0 in the setup is 10 here.
attachments.allowedTypesarray of stringSince 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.deniedTypesarray of stringSince 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.contentCheckedTypesarray of stringSince 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:

ValueMeaningWhat to build
archiveDXSteel Doc Archive is installed.MyDocuments returns archive rows; fetch the bytes from iFacto's own API with the id. Document always returns "".
reportNo archive, and Portal Documents from Reports is on.MyDocuments returns report rows; download with Document.
noneNo 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 duplicate requestId, where it always was. It has not been replaced, so a website that only learns about the codes later does not break.

The codes

CodeWhereWhat happenedWhat the client should do
RequestsClosedCreateQuoteRequest, CreateAccountRequestRefuse 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.
PrivacyConsentMissingCreateQuoteRequest, CreateAccountRequestprivacyAccepted was not true, or privacyConsentVersion was empty.A bug in the form. Show the consent checkbox; do not retry.
InvalidInputCreateQuoteRequest, CreateAccountRequestThe 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.
DuplicateRequestCreateQuoteRequest, CreateAccountRequestThis 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.
SetupIncompleteCreateQuoteRequest, CreateAccountRequestCustomerGate 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.
ItemNotOnWebsiteCreateQuoteRequestA 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.
ItemNotQuotableCreateQuoteRequestA 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.
CatalogNotComputedvoaItems, voaVariants, voaCategories, voaCatalogMediaThe 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.
LicenceRequiredEvery service except voaPing and SignOutThere 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.
NotInPlanvoaPortal 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.
NotAuthorizedAcceptQuote, 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.
QuoteNotOpenAcceptQuote, AcceptQuoteWithDetails (as errorCode)The quote is past its validity date.Show that the quote has expired and offer to ask for a new one.
StaffOrdersDisabledCreateQuoteRequestsource 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.

OutcomeerrorCodeNotes
recordedabsentThe acceptance is in.
alreadyabsentThere was already an acceptance; not an error.
expiredQuoteNotOpenThe quote is demonstrably this customer's, and past its validity date.
refusedNotAuthorizedAlways 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.

KeyContents
contractVersionThe same value voaPing returns.
appId, 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 twice

What it takes off your hands:

  • The OData envelope and the second parse, per procedure, from returns and jsonKeys in the contract: a text answer with JSON keys is parsed and a session token is not.
  • The empty answer: "" arrives as null, 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 typed code on 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.nextLink for 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.

WhereKey or parameterValues
ReadSession, MyDetailsroleBuyer, Viewer
ReadSession, MyDetailscustomers[].roleBuyer, Viewer
MyRequestsstatusNew, InProgress, Converted, Rejected, ""
MyRequestsdocumentTypequote, order
MyQuotes, MyOrdersstatusOpen, Released, ""
MyRequests, MyOrdersquoteNoStatusactive, completed, ""
MyQuotes, MyShipments, MyDocumentsorderNoStatusactive, completed, ""
MyDocumentsdocumentNoStatusactive, completed
DocumentLineslineStatusOpen, PartlyShipped, Shipped, Invoiced
AcceptQuote, AcceptQuoteWithDetailsoutcomerecorded, already, expired, refused
RejectQuoteoutcomerecorded, already, refused
RejectQuoteparameter reasonCodeTooExpensive, DeliveryTooLate, OrderedElsewhere, NoLongerNeeded, Other (anything else is stored as NotStated)
MyQuotesrejectionReasonTooExpensive, DeliveryTooLate, OrderedElsewhere, NoLongerNeeded, Other, NotStated, "" (since 1.0.44)
CreateQuoteRequest, RequestLinesline inspectionCertificateNone, Certificate22, Certificate31, Certificate32 (on input anything else becomes None) (since 1.0.44)
CreateQuoteRequest, RequestLinesline processing (array)CutToLength, MitreCutting, Drilling, Punching, ThermalCutting, Bending, Blasting, Priming, Galvanizing, Painting (on input anything else is skipped) (since 1.0.44)
SwitchCustomeroutcomeswitched, refused
InviteColleagueoutcomecreated, already, throttled, refused
InviteColleagueparameter roleBuyer, Viewer (anything else: Viewer)
MyNotificationSettings, SetNotificationSettingskeysenabled, quoteReleased, orderConfirmed, shipmentPosted, invoicePosted, requestReceived
DocumentLinesparameter documentTypequote, order, shipment
Documentparameter documentTypequote, order, shipment, invoice, creditMemo
MyDocumentsdocumentTypequote, order, shipment, ""
MyDocumentscategorySalesQuote, SalesOrder, SalesInvoice, SalesCreditMemo, Proforma, Waybill, WarehouseShipment
MyDocumentssourcereport (report rows only)
MyDocumentsreportTypequote, order, shipment, invoice, creditMemo
MyDocuments, DocumentcontentTypeapplication/pdf, application/octet-stream
MyAttachmentsoriginPortalUser, InsideSales
MyAttachments, AttachmentcontentTypeDerived 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
AddAttachmentdeny listexe, 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, DocumentLinesweightTypeTheoretical, Trade, GermanTrade, "" (Steel 365)
DocumentLinesunitUntranslated option names of the Steel 365 field; the reference website handles PCS, KG, MTR
voaRemovalsKindItem, Variant, Category
voaCatalogMediacontentTypeWhatever Business Central stored, for example image/jpeg or image/png; application/octet-stream when it is unknown
CreateQuoteRequestdeliveryMethodPickup, Delivery (anything else: Pickup)
CreateQuoteRequestsourceFree text up to 50 characters. staffOrder makes it a staff order (only while Staff Orders Enabled is on); LinkRequest sets portal.
CreateAccountRequestexistingCustomerYes, No, Unknown (anything else: Unknown)
WebhookeventPing, QuoteReleased, QuoteAccepted, OrderCreated, ShipmentPosted, InvoicePosted, AccountApproved, AccountRejected, RequestConverted, RequestRejected
WebhookdocumentTypequote, order, shipment, invoice, request, account, ping
WebhookX-VOA-Signaturesha256= plus 64 lowercase hexadecimal characters
Pingenvironment.typeProduction, Sandbox
PingdocumentSourcearchive, report, none
PingcontractVersion1.0
Errors from voaRequestsmessage[VOA-DUBBEL] marks a duplicate requestId
Errors from the catalogmessage[VOA-CatalogNotComputed], [VOA-ItemNotOnWebsite], [VOA-ItemNotQuotable]
Every error a client can triggermessageStarts with [VOA-<code>]; see Error codes
Sign-in codealphabet0123456789ABCDEFGHJKMNPQRSTVWXYZ, 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.

AreaWith Steel 365Without Steel 365
Catalog servicesvoaProfiles, voaGrades and voaItemTypes are published, and the DX… properties are filledOnly voaItems, voaVariants, voaCategories, voaRemovals and voaCatalogMedia; DX… text is "" and numbers are 0
Synchronization permissionsVOA WEB SYNC and VOA CG S365 SYNCOnly VOA WEB SYNC
Quantities in DocumentLinesquantityPieces, quantityMeters, quantityKg plus unit; quantity is never presentquantity plus unitOfMeasureCode
DocumentLines on live order linesoutstandingPieces/Meters/Kg, shippedPieces/Meters/KgoutstandingQuantity, quantityShipped
Extra keys in DocumentLinesprofile, grade, formatDescription, weightType when they are filled; since 1.0.44 lengthMm, widthMm, thicknessMm and customFormat from the line's variantnone
requestedDeliveryDate, promisedDeliveryDate and lineStatus per linepresent on live order lines; lineStatus comes from the standard quantitiesthe same: these three do not depend on Steel 365
MyQuotes.totalWeightKgpresentabsent
ReadSession.weightTypethe customer's weight type, or """"
Validity of a quotefalls back to the Steel 365 due datethe quote's own validity date only

The document archive is a separate switch:

AreaWith the archiveWithout the archive
MyDocumentsArchive rows, filtered by the allowed categoriesReport rows when Portal Documents from Reports is on, otherwise empty
Downloading a PDFThe bound action of iFacto's API with the idDocument returns base64
Documentalways ""prints the PDF
hasDocumentalways falsetrue when a report is set up
Quotes and orders that exist only in the archiveorderNo and quoteNo references from the archiveno references
Tenant set of the websiteVOA CG S365 ARCHIVEVOA CG DOCUMENTS when the setting is on
Ping.documentSourcearchivereport, 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 outcome as recorded.
  • The values of inspectionCertificate and processing (since 1.0.44) can grow: another app can add one, and it then comes back from RequestLines under 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.