Skip to content

Give another system access

A partner, a portal or a piece of your own infrastructure has to call into Kiln. Somebody has asked you for “an API key”, and what you are about to issue is not one. It is an identity with a list of what it may do, and the list is most of the work.

By the end of this you will have a client that authenticates, a printed list of the exact endpoints it can call, and a running flow started from outside Kiln.

About twenty minutes.

One screen, four tabs, in order: Details, Access, Secrets, Connect. Everything after that happens from the calling system.

Open Settings, then App clients, and click New. The screen needs appClient:manage, so if you cannot see it, that is why.

1. Name it and pick the environment (Details)

Section titled “1. Name it and pick the environment (Details)”

Three things matter here.

Code is the client’s stable identifier and it is what the integration will send as its client_id. Pick it as you would a database name, because it does not change afterwards.

Name is what a person reads. Name it after the system rather than the team or the project, because the name is what somebody reads in two years when deciding whether it is still needed.

Environment is which environment the client’s tokens are good for. Set it before you go any further, because a credential issued against the wrong one fails in a way that looks like a bad secret.

A client starts with no authority at all. The Access tab gives you four switches, and each one is a real capability rather than a category.

Template rendering lets the client turn a template into a document.

Flow access lets the client run a flow, read the state of a run, and release a run that is paused waiting for a decision.

Flow discovery lets the client list your flows and read the shape of one.

Case alerts lets the client report an alert, which opens a case of whichever published type claims that kind of alert. This is the switch a telematics feed or a monitoring system wants.

For each one you then say which resources it applies to: all of them, or a named list. Prefer the named list. The failure you get from too little authority tells you exactly what to add, and a generous first guess never gets narrowed afterwards.

Two of these are worth a sentence on their own.

Flow discovery is separate from flow access on purpose. Being able to run a flow does not let a client enumerate your flows or read their shape. Granting discovery is a deliberate act, so an integration that should fire one known flow and nothing else cannot go looking.

Case alerts is necessarily broader than the others. Every other grant names the resources it applies to. A case that does not exist yet has no identifier to name, so this is a grant over the kind rather than over particular members of it. A client that may open cases may open any case of that kind, and there is no narrower version to reach for.

3. Save, then publish (and this is the step people miss)

Section titled “3. Save, then publish (and this is the step people miss)”

Edits on Details and Access buffer locally until you press Save. Then press Publish.

An unpublished client cannot issue a token at all, and a draft edit to an existing client has no effect until it is published. Only the latest published version is authoritative. If a credential that used to work stops working, or a grant you just added appears not to have taken, this is almost always why: the token endpoint refuses the client with invalid_client, which reads like a bad secret and is not one.

A published client is read only. Press Create Draft to branch a new draft and carry on editing, exactly as a flow works.

Generate the secret here, and copy it before you leave the screen. It is shown once. Not once per session, once ever. Only a sha-256 hash of it is kept, so it cannot be read back out of Kiln afterwards, by you or by us. If it is lost, nothing is broken: generate another and revoke the first.

Every generated secret starts with ca_, so a credential that turns up somewhere it should not be is identifiable as ours at a glance. That is worth passing on to whoever runs your secret scanning.

A plain hash is the right shape here, which is worth a sentence because it would be the wrong shape for a password. The value is machine generated with a great deal of entropy and never chosen by a person, so there is nothing to guess and no dictionary to run against it. The protections a password needs are protections against human predictability.

Generating and revoking take effect immediately. They are outside the Save and Publish cycle, unlike everything on the first two tabs. Revoking a secret is not a draft edit and does not wait for a publish.

5. Exchange the secret for a token (Connect)

Section titled “5. Exchange the secret for a token (Connect)”

The Connect tab hands the integrator everything needed to authenticate: the client id, the token endpoint for this environment, the scope string to request, and a working curl with all of it filled in. Send them to that tab rather than typing a URL into an email.

It is a standard OAuth2 client credentials exchange, so an off the shelf OAuth client works unchanged:

Terminal window
curl -X POST "https://api.<your Kiln host>/ca/v1/oauth/token" \
-d grant_type=client_credentials \
-d client_id=<your client code> \
-d client_secret=<the secret you copied> \
-d scope="flow:run flow:read"

Credentials may go in an Authorization: Basic header instead of the body, which takes precedence if both are present.

What comes back is the usual four fields:

{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 43200,
"scope": "flow:run flow:read"
}

Send the token as Authorization: Bearer <access_token> on every call after that.

Scope strings are resource:action, space separated: template:render, flow:run, flow:read, flow:discover, case:create. The scope returned tells you what you actually got.

A client may ask for less than it holds, and get it. It may not ask for more. A request naming a scope the client does not hold is refused as invalid_scope rather than quietly narrowed, so a misconfigured integration fails loudly at the point of the mistake rather than succeeding with no authority and failing later somewhere less obvious.

This is the part most integrations get wrong, and there are two answers depending on who is asking.

For the person holding the client, the Connect tab lists every endpoint the client could be pointed at, with the switch that opens each one. Endpoints the client cannot currently call are listed too, greyed rather than hidden, named with the Access tab switch that would enable them. So “I cannot call this” has an obvious next move.

For the integration itself, a client holding flow:discover can ask Kiln directly. Everything below is one worked example, against a fictional tenant with synthetic data.

Discovery is its own scope, so ask for it by name:

Terminal window
TOKEN=$(curl -s -X POST "https://api.<your Kiln host>/ca/v1/oauth/token" \
-d grant_type=client_credentials \
-d client_id=portside-ap-portal \
-d client_secret=$KILN_CLIENT_SECRET \
-d scope="flow:discover flow:run flow:read" \
| jq -r .access_token)
Terminal window
curl -s "https://api.<your Kiln host>/flow/v1/public/flows" \
-H "Authorization: Bearer $TOKEN"
{
"flows": [
{
"id": "f3c1a8e2-5b47-4d90-9c22-7a1e6f0b48d3",
"name": "Supplier invoice intake and approval",
"code": "supplier-invoice-approval",
"version": 7,
"effectiveFrom": "2026-08-14T02:11:07.482Z"
}
]
}

The list is filtered, not refused. A flow this credential may not discover never appears in it. An unentitled caller gets an empty list rather than a refusal that would confirm the flow exists.

Terminal window
curl -s "https://api.<your Kiln host>/flow/v1/public/flows/f3c1a8e2-5b47-4d90-9c22-7a1e6f0b48d3" \
-H "Authorization: Bearer $TOKEN"
{
"id": "f3c1a8e2-5b47-4d90-9c22-7a1e6f0b48d3",
"name": "Supplier invoice intake and approval",
"code": "supplier-invoice-approval",
"version": 7,
"effectiveFrom": "2026-08-14T02:11:07.482Z",
"entryPoints": [
{
"startNodeId": "n_trigger_invoice_received",
"shortname": "invoice-received",
"inputs": [
{ "name": "supplierAbn", "dataType": "string" },
{ "name": "invoiceNumber", "dataType": "string" },
{ "name": "amountExGst", "dataType": "number" },
{ "name": "purchaseOrderRef", "dataType": "string" }
],
"triggerUrl": "/api/flow/v1/public/flows/f3c1a8e2-5b47-4d90-9c22-7a1e6f0b48d3/trigger/n_trigger_invoice_received"
}
],
"gates": [
{
"reason": "Invoice does not match the purchase order. A finance lead must decide.",
"resumeInputs": [
{ "name": "decision", "dataType": "string" },
{ "name": "note", "dataType": "string" }
]
}
],
"outcomes": ["approved", "rejected", "referred-to-procurement"],
"resumeUrlTemplate": "/api/flow/v1/public/flows/f3c1a8e2-5b47-4d90-9c22-7a1e6f0b48d3/runs/{instanceId}",
"instanceUrlTemplate": "/api/flow/v1/public/flow-instances/{instanceId}"
}

Three things in there are worth naming.

It describes the process, not just the payload. gates is every point where a person has to decide, each with its reason and the decision payload a resume must supply. That is what lets you build the human step into your own product without asking us what the process does. A specification describes the shape of a POST body and stops.

It is the contract and deliberately not the internals. No node graph, no wiring, no pin defaults, no thresholds, no policies. And it reflects the published effective version, never anybody’s draft, which is why version and effectiveFrom are on it.

outcomes are business results. rejected is a successful run. Engine failure is never an outcome.

This is the bit worth understanding before you design against it. Point a discovery-only token at the identical URL, one holding flow:discover and neither flow:run nor flow:read, and you get the same flow back with three fields gone:

{
"id": "f3c1a8e2-5b47-4d90-9c22-7a1e6f0b48d3",
"name": "Supplier invoice intake and approval",
"code": "supplier-invoice-approval",
"version": 7,
"effectiveFrom": "2026-08-14T02:11:07.482Z",
"entryPoints": [
{
"startNodeId": "n_trigger_invoice_received",
"shortname": "invoice-received",
"inputs": [
{ "name": "supplierAbn", "dataType": "string" },
{ "name": "invoiceNumber", "dataType": "string" },
{ "name": "amountExGst", "dataType": "number" },
{ "name": "purchaseOrderRef", "dataType": "string" }
]
}
],
"gates": [ "..." ],
"outcomes": ["approved", "rejected", "referred-to-procurement"]
}

No triggerUrl, no resumeUrlTemplate, no instanceUrlTemplate. The difference between those two responses is not a filter anybody applied afterwards. It is what each credential is allowed to know.

Note that the ungranted links are absent rather than null, which makes the link set a capability hint: render the actions you were given, and you will never offer a button that returns a refusal. triggerUrl appears only with flow:run, instanceUrlTemplate only with flow:read.

The URLs come back root relative, so prefix them with the same API base you used for the discovery call and send what inputs asked for:

Terminal window
TRIGGER=$(curl -s "https://api.<your Kiln host>/flow/v1/public/flows/$FLOW_ID" \
-H "Authorization: Bearer $TOKEN" | jq -r '.entryPoints[0].triggerUrl')
curl -s -X POST "https://api.<your Kiln host>${TRIGGER#/api}" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"supplierAbn": "51824753556",
"invoiceNumber": "INV-88213",
"amountExGst": 4820.00,
"purchaseOrderRef": "PO-2026-0441"
}'

Read the instanceId from that response, fill it into instanceUrlTemplate to watch the run, and into resumeUrlTemplate to release it when it pauses at a gate. No path in your code is ever assembled from parts.

If the trigger is a slow one, start it and poll instead. There is a second entry, trigger-async, that returns immediately rather than waiting for the run, which is what you want for anything with sub-flows behind it. Poll the instance URL until the run reaches a terminal status. A synchronous trigger on a long flow will time out at the gateway.

Worth knowing before you design around it, because the guide has just spent a page on discovery and the boundary is not obvious.

flow:discover is the only discovery grant that exists. A client holding case:create can post alerts indefinitely and has no way to ask which alert kinds a published case type claims. A client holding template:render cannot ask which template codes exist or what payload each expects. Both have to be agreed with whoever authored them and written into the calling system’s configuration.

So for those two, the out of band conversation that discovery removes for flows is still the out of band conversation. Plan for it rather than going looking for a route that is not there.

Where discovery starts, and what it cannot tell you

Section titled “Where discovery starts, and what it cannot tell you”

Discovery is link following rather than a manifest, so it is worth saying plainly what an integrator still has to be given.

There is no unauthenticated document to point a client at. No .well-known, no root home document, and no published machine readable specification. Discovery begins one step in, at a flow list you need a token to read.

Which leaves a hard floor of two things that must be configuration on the calling side, and this is a property of the problem rather than a gap: the API base URL and the credentials. You need a token to call discovery and you need the token endpoint to get a token, so neither can be discovered.

So the honest version of “nothing hardcoded” is: one base URL and a credential are configured, and no path is ever built by string concatenation after that.

A client can hold more than one secret at a time, and that is the whole mechanism for rotating safely. Generate the new secret, move the integration onto it, watch until you are satisfied the old one has stopped being used, then revoke the old one. There is no window in which the integration has no working credential, so a rotation needs neither a maintenance window nor a Saturday.

Worth contrasting with Secrets, the credentials Kiln uses to reach outward, which hold one value per name and have no overlap. The direction of the arrow changes what is possible. Kiln controls when it calls out, so it can be pointed at a new value at a moment of your choosing. It does not control when somebody calls in.

The limit to plan around before you revoke anything

Section titled “The limit to plan around before you revoke anything”

Revoking a secret does not stop tokens that have already been issued. They keep working until they expire.

The token is self-contained. It is signed, it carries the client’s grants as they stood when it was issued, and the check on arrival is a signature and an expiry and nothing else, with no lookup. That is the decision that makes it fast and the same decision that makes revocation a ceiling rather than a stop.

Revoking stops anybody minting new tokens. Clearing the grants and deleting the client do the same, and no more. A token already minted keeps the access it carries until it runs out.

So the lifetime is the whole of your control. Twelve hours is the default. A client may be given its own lifetime, capped at thirty days, and the cap is enforced rather than silently clamped: ask for more and you are told no in a plain sentence.

Read that number as how long a leaked token stays useful, and set it deliberately rather than accepting what an integrator asked for on a Friday. A long lifetime should be a decision somebody can account for.

If you need to stop an integration faster than its token expires, the only lever that exists today is at the other end. Change what the credential is allowed to do in the system being called, or block the caller upstream.

It is not a user account. A client has no roles, is not a member of your tenant the way a person is, and cannot open the builder. Its authority is the grants on it and nothing else.

It is not how you give a person access. A person is invited to the tenant and holds roles. If you are reaching for a client because somebody needs to get at their own data, you are solving the wrong problem.

It is not a Connection. A Connection is how Kiln proves itself to somebody else’s system. An App client is how somebody else’s system proves itself to Kiln. An integration of any substance usually needs both, pointed in opposite directions.

The full surface. App clients covers the screen and the authorisation model in more depth than a walkthrough should.

What a client can start. Every event that can start a flow lists the events and the values each hands the flow it starts.

Mail rather than an API. If the system on the other end can send an email but cannot hold a credential, an Inbox is the simpler answer.

Before you hand the credential over. Prove a change before you publish is the habit that stops a flow changing under an integration that is already calling it.