Skip to main content

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​

  1. Open your solution and choose Custom APIs.
  2. Choose New.
  3. Give it a display name. The logical name fills itself in, prefixed by your publisher.
  4. Leave the version at 1. You will raise it later, and the old version will keep answering while whoever calls you catches up.
  5. 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.
  6. 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.
  7. 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.
  8. The request schema and response schema are optional. Left empty, the contract is read off your script — every getInput it reads and every setOutput it 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.
  9. Leave Answering ticked, and Save.
  10. 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 withThe script writesGood for
JSONsetOutput("name", value), once per fieldAnother system reading your data
TextsetBody("...")One line — a reference, a token, a CSV row
HTMLsetBody("<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 from throwValidationError, which means the caller got it wrong (a 400); setStatus is your contract deciding. A 204 must 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:

FunctionAnswers
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/invoices or 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:

  1. 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.
  2. Tell your integration partners, and give them time.
  3. 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 seeWhat it means
"...is already answered by..." on saveAnother 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 callerTheir 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 quotesThe script used setOutput rather than setBody. setOutput always answers JSON, whatever the contract declares.
404 from a callerNo 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 requestThe write ran before the refusal. throwValidationError stops the script where it is called, so move the check above the write.

See also​