Publish a Custom API
Goal
Give another company's system one address to call — POST /api/par/v1/invoices/{id}/approve —
and keep that address working the same way after you rename a column.
Why this exists
Parsware already has an API for your tables. It is generated from your table's metadata, which
means it changes shape whenever your table does. That is exactly what you want inside the
platform, and exactly what you must not hand to somebody outside it: they wrote their code
against a shape, and they are not going to rewrite it because you renamed par_total to
par_amount.
A Custom API is the promise that a route keeps its shape while the tables behind it move.
It is a component of your solution, like a form or a view. It is authored in Dev, travels in the solution package, and answers in Test and Production because you imported it there.
Before you start
- A solution, with a publisher (its prefix becomes part of every route you publish).
- The table you want to expose, if this API is about a table.
- An App Registration for the system that will call you, holding the scope you are about to require. Registering one is an administrator's job — Register an external app.
Steps
- Open your solution and choose Custom APIs.
- Choose New.
- Give it a display name. The logical name fills itself in, prefixed by your publisher.
- Leave the version at 1. You will raise it later, and the old version will keep answering while whoever calls you catches up.
- Choose the method and type the route — no leading slash. A braced segment is a path
parameter:
invoices/{id}/approve. The screen shows you the full address as you type. - Choose the required scope — the scope the calling system's App Registration must hold, such
as
modelapps.read. It is a list, not a text box: a scope nobody can be granted would refuse every call, and you would not find that out until a partner did. - Choose what it answers with — JSON, text or HTML. See the section below; it also decides what the starter script does, so pick it before you write any code.
- The request schema and response schema are optional. Left empty, the contract is read
off your script — every
getInputit reads and everysetOutputit sets — and published in the OpenAPI document. Write a JSON Schema only to be stricter (types, required fields, limits); a written request schema is checked on every call, so a malformed request is refused at the door. - Leave Answering ticked, and Save.
- Select the row and press Edit code. The list is replaced by the editor — this is where the API's behaviour lives, written in BS Lang. Press the eye to see every function you can call. Save, then Close.
What it answers with
A Custom API answers one of three things, and you choose which on the contract:
| Answers with | The script writes | Good for |
|---|---|---|
| JSON | setOutput("name", value), once per field | Another system reading your data |
| Text | setBody("...") | One line — a reference, a token, a CSV row |
| HTML | setBody("<p>...</p>") | A confirmation email, a page a browser opens |
setOutput("reference", "BOOKING-42"); // JSON: { "reference": "BOOKING-42" }
setBody("BOOKING-42"); // text or HTML: exactly that, and nothing else
Use one or the other, never both. A script that calls setOutput and setBody is refused
when it runs, rather than one of them quietly winning — a response you cannot explain is worse than
an error you can read.
Two more functions go with them:
setStatus(404)— the status you want to answer with. Different fromthrowValidationError, which means the caller got it wrong (a 400);setStatusis your contract deciding. A204must carry no body.setContentType("html")— changes the type for one call. Most APIs never need it, because the contract already declares what they answer with.
Why the contract declares it and not just your code: the OpenAPI document is generated before anyone calls you, so it can only describe what the contract says. Declare HTML and the document says HTML; leave it as JSON and an integrator writes a JSON parser around your page.
Putting a stored value into a page
A page is built out of text, and some of that text is your records. Pass every stored value
through htmlEncode before it goes into the page:
var booking = getRecord('par_booking', getInput('id'));
setBody('<h1>Receipt ' + htmlEncode(booking.par_name) + '</h1>'
+ '<p>' + htmlEncode(booking.par_guest) + '</p>');
A guest who typed <b>, or worse <script>, into their own name is then shown as that text,
rather than becoming part of the receipt somebody else opens. A field nobody filled in comes back
empty, so it leaves a blank rather than the word null. The same goes for an email you send with
html: true. Your own markup, the tags you wrote in the script, is not encoded.
Answering with a list of records
setOutput carries a record, and a page of records, whole:
var rows = runQuery({
entity: 'par_invoice',
select: ['par_name', 'par_total'],
filter: { connector: 'And', conditions: [{ field: 'par_status', operator: 'Equals', value: 'Open' }] }
});
setOutput("count", rows.length());
setOutput("invoices", rows); // a JSON array, one object per row
The caller receives { "count": 2, "invoices": [ { "id": "...", "par_name": "INV-1", ... } ] },
with your field names exactly as they are on the table — which is what you write in the response
schema so the OpenAPI document describes it.
You cannot build a list of your own. BS Lang has no way to append to one, so the rows you
answer with are the rows a query handed you. Use select to choose the columns rather than
trying to assemble them: a column you did not select is a column that does not travel, which
is how you keep an internal note out of an answer you are publishing.
Answering a signed-in customer
Everything above assumes the caller is a system — one App Registration, doing what its security role permits. A portal is different: parsware.com, or any site you build on Portal Apps, calls you on behalf of a person who signed in there, and the one thing your operation must get right is answering about their company and nobody else's.
There is exactly one way to ask who that is:
| Function | Answers |
|---|---|
portalCustomerId() | the Customer (an Account record) the signed-in person belongs to |
portalUserId() | the portal user themselves |
portalContactId() | the Contact record they are, or empty if they are not linked to one |
portalEmail() | the address they signed in with |
portalRole() | "Owner", "Billing" or "Member" — what they may do on their company's behalf |
var customerId = portalCustomerId();
var rows = runQuery({
entity: 'par_invoice',
select: ['par_name', 'par_total'],
filter: { connector: 'And', conditions: [{ field: 'par_customer', operator: 'Equals', value: customerId }] }
});
setOutput("invoices", rows);
Never take the customer as an input. It is not an oversight that there is no
getInput("customerId") in that script — a customer id the caller can send is a customer id the
caller can change, and one edited request reads somebody else's invoices. The value comes off a
signed token, which nobody between the visitor and you can rewrite.
Two things follow from that, and both are deliberate:
-
A call with no signed-in person is refused, not answered with nothing. If your script calls
portalCustomerId()and the request carried no portal sign-in, the caller gets a 401 — rather than your script receiving an empty value and possibly filtering by it. -
portalRole()is how you keep something for the people who should see it. An invoice total or a payment method is for an Owner or the person who pays, not for everybody with a login:if (portalRole() == "Member") {throwValidationError("Ask an Owner or a Billing contact for this.");} else {setOutput("total", rows[0].par_total);}The three roles are a ladder — an Owner may do everything Billing may, and Billing everything a Member may — so testing for the one role that may not is usually shorter than listing those that may.
-
What the visitor may do is narrower than what the site may do. The permitted operations are the overlap between the portal application's permissions and the signed-in person's. A site with broad permissions does not make its visitors powerful.
Refusing stops your script
throwValidationError ends the script on the line it is called. Nothing after it runs — not
the rest of the block, not the lines below it, and not a catch you wrapped around it. The caller
gets a 400 carrying your sentence, and nothing the script would have written afterwards is written.
So check first, and write once the checks are behind you:
if (isEmpty(getInput("reference"))) {
throwValidationError("A reference is required.");
}
createRecord('par_invoice', { par_name: getInput("reference") });
setStatus(201);
The one thing a refusal cannot undo is a write that came before it: a script that creates a record and then refuses has created the record. Put the refusals above the writes.
Stamping the time
now() answers the current instant and today() the current day, both as text you can write
straight into a Date/Time field:
createRecord('par_lead', { par_name: getInput("name"), par_received_at: now() });
Use these rather than a time the caller sent you. "When did this arrive" is a fact about your platform; a timestamp in the request body is a claim the caller made, and it can be any date they like. Both answer in UTC — the reader's own calendar is applied when it is displayed, not when it is stored.
Confirm it worked
- The new row shows its served path —
/api/par/v1/invoicesor whatever you built. - Choose OpenAPI in the toolbar. The generated document is copied to your clipboard: paste it to the integrator who asked what your endpoint looks like. It is derived from your definitions, so it cannot describe something different from what the server actually does.
- Have the calling system try it, with a token from their App Registration.
Versioning: the part worth understanding
An API's identity is its name and its version together. par_approve_invoice v1 and v2 are
one contract at two ages, and they answer side by side.
So when you need to change the shape of a request or a response in a way a caller would notice:
- Create a new Custom API with the same display name and a version of 2. It is not a duplicate — the screen will accept it.
- Tell your integration partners, and give them time.
- When nobody is calling v1 any more, untick Answering on it.
That last step is the one people get wrong. Untick Answering; do not delete. A retired version stops answering, still exists, still travels in your solution, and still documents what v1 used to promise. Deleting it removes the route with no notice at all — anything still calling it gets a plain 404, which is indistinguishable from your service being broken.
What a Custom API does not do
It never widens access. A table operation goes through exactly the same door the platform's own screens use: the caller's permission on that table, and the data policy behind it, decide what comes back. A curated contract gives an outside system a stable view of your data, never a larger one.
If the calling system is getting less than you expect, the answer is almost always the security role behind their App Registration — not the API.
Troubleshooting
| What you see | What it means |
|---|---|
| "...is already answered by..." on save | Another API in this environment already claims that method and path at that version. Two contracts cannot share one address. |
| "...already has a version 1..." | You are re-creating a contract rather than versioning it. Raise the version. |
"...must declare an {id} segment" | Read, Update and Delete act on one record, and the route is the only place that record's id can come from. |
| "A GET request carries no body" | Remove the request schema, or change the method. |
| 403 from a caller | Their App Registration does not hold the scope you required. |
| "called both setOutput and setBody" | Your script builds its answer twice. Pick one: setOutput composes a JSON object, setBody writes one body of text. |
| "set a body and a 204 status" | A 204 tells the caller there is nothing to read, so they never see what you wrote. Use 200, or drop the body. |
| "An HTTP status is between 100 and 599" | setStatus was given something that is not a status. |
| A page arrives wrapped in quotes | The script used setOutput rather than setBody. setOutput always answers JSON, whatever the contract declares. |
| 404 from a caller | No enabled API answers that method and path at that version. Check Answering, and check the version segment. |
| 401 with "needs a signed-in portal user" | Your script calls portalCustomerId() and the request carried no portal sign-in. Either the caller is a system rather than a portal, or the visitor's session has expired. |
| 401 with "not valid" | A portal sign-in was presented and is not accepted — expired, or issued for a different portal. |
| 403 with "does not belong to one yet" | The person is signed in but has not been put in a company. Show them that, rather than an empty list. |
| A record was created even though you refused the request | The write ran before the refusal. throwValidationError stops the script where it is called, so move the check above the write. |