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.
The shape of it
Section titled “The shape of it”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.
2. Decide what it may do (Access)
Section titled “2. Decide what it may do (Access)”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.
4. Generate the secret (Secrets)
Section titled “4. Generate the secret (Secrets)”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:
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.
6. Find out what it can call
Section titled “6. Find out what it can call”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.
Get a token that can discover
Section titled “Get a token that can discover”Discovery is its own scope, so ask for it by name:
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)Ask what flows you can see
Section titled “Ask what flows you can see”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.
Ask what one flow actually is
Section titled “Ask what one flow actually is”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.
The same URL, a different credential
Section titled “The same URL, a different credential”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.
Follow the link rather than building it
Section titled “Follow the link rather than building it”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:
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.
Discovery covers flows, and only flows
Section titled “Discovery covers flows, and only flows”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.
7. Rotate without an outage
Section titled “7. Rotate without an outage”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.
What an App client is not
Section titled “What an App client is not”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.
Where to go next
Section titled “Where to go next”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.