BUILD AN INTEGRATION FOR SWAMP SERVE
This guide shows you how to build an integration: an app, such as a chat bot,
that people link to their Swamp account so it can run and approve workflows on a
swamp serve as them. It uses
Slackwater, Swamp's reference Slack
app, as the worked example, and shows every call as curl, so you can build the
same thing in any language.
You run the app yourself. It talks only to swamp serve, and serve talks to swamp-club, where each person signs in and agrees to what the app may do.
How an integration acts for a person
Three parties each hold one part:
| Who | Does | Holds |
|---|---|---|
| An operative | Registers the integration on serve with a ceiling, and grants people access to workflows | Serve and its grants |
| A person | Signs in to swamp-club and approves a link for their own account | Their Swamp account |
| Your app | Starts links, stores each person's tokens, and makes requests as that person | The integration credential and people's tokens |
Every request the app makes as a person is allowed only if all three of these allow it:
- the person's own grants on serve
- the integration's ceiling, which the operative sets
- what the person agreed to when they linked
The app never adds to what a person can do, and an admin's blanket authority
doesn't carry through it: only grants on workflows count. Serve records the
person as the actor and integration:<id> as the client on every run, gate
decision and audit event.
Before you start
- Serve runs in OAuth mode, as in Set Up OAuth Authentication.
- Serve is set up with a collective API key, as in Deploy with Headless OAuth, so its swamp-club client belongs to the collective. A serve set up through the browser device approval can't link people.
- Your collective is on an active trial or a paid plan, any plan. A personal collective qualifies when its owner belongs to a collective that is. swamp-club refuses links for any other collective.
- Every serve instance runs Swamp 20261111.012552.0 or later. While any live
instance is older, starting and redeeming a link answer
team_not_ready. - The workflows people will use exist on serve, with any approval gates they
need. This guide uses Slackwater's demo workflow,
scale-web(demo/swamp/workflows/workflow-scale-web.yaml), which plans a change, waits at a gate namedapprove, then applies it.
Register the integration
On the serve's repository, register the app and set its ceiling, the most any link may ever allow:
swamp access integration register chatops --name "ChatOps bot" \
--ceiling 'run,read,approve=workflow:scale-web'Integration: chatops
Name: ChatOps bot
State: active
Client: integration:chatops
Created: 2026-11-10T23:07:49.617Z
Ceiling: sha256:e4fcd8a23d416ccf9081b7dabca8de9180876bdc0da95982674195dcbb61d4f1
workflow:scale-web approve, read, run
Credential:
swi1.chatops.…
This is the only time it is shown. Store it securely: only its hash is kept.- Store the credential (
swi1.…) in your app's secret store now. It can't be shown again or rotated. If it's lost or leaked, revoke the integration withswamp access integration revoke chatops, which ends every link it holds, and register a new ID. A revoked ID can't be used again. - A ceiling names workflows:
--ceiling '<actions>=workflow:<selector>', repeated for each workflow. The actions arerun,read,approveandsignal. A selector can end in a wildcard, but name each workflow instead:runalso allows cancelling any run of that workflow, including other people's. - Actions in a ceiling match literally. A ceiling with
runalone doesn't allow approving a gate, even though a grant withrundoes. - Add
--require-email-matchif you want swamp-club to refuse a link unless the account that approves has the email your app sends. Without it, a mismatch needs the person's confirmation (see Link a person).
To change the ceiling later, use swamp access integration ceiling. A wider
ceiling applies to a person only after they consent again.
Grant people access
The ceiling only limits the app. Each person still needs grants on the workflows, as in Manage Access Grants:
swamp access grant create --subject collective:acme \
--allow run,read,approve --on workflow:scale-webTo stop a person approving a run they started, write a deny with a condition,
once for every subject that holds run, approve or admin on the workflow:
swamp access grant create --subject collective:acme \
--deny approve --on workflow:scale-web \
--when 'principal.sub == run.initiator.sub'To keep one integration away from a workflow that a person's grants otherwise allow, deny on the client:
swamp access grant create --subject collective:acme \
--deny run,approve --on workflow:scale-web \
--when 'client == "integration:chatops"'Grant changes wait for a reload unless serve runs with --grant-reload auto:
swamp access reload --server https://serve.example.comRegistering, narrowing or revoking an integration applies without a reload.
Link a person
A link ties one person on your platform to their Swamp account. Each call uses
the integration credential as a bearer token. In these examples it's in the
SWAMP_INTEGRATION_CREDENTIAL environment variable, and serve is at
https://serve.example.com.
1. Start the link
Send who the person is on your platform, their email, and the ceiling to ask them for:
curl -X POST https://serve.example.com/api/v1/delegations/links \
-H "Authorization: Bearer $SWAMP_INTEGRATION_CREDENTIAL" \
-H "Content-Type: application/json" \
-d '{
"external": {
"platform": "slack",
"team_id": "T0001",
"user_id": "U0ALICE",
"display": "Alice",
"workspace": "Acme",
"email": "alice@example.com"
},
"ceiling": {
"version": 1,
"ceiling": [{ "actions": ["run", "read", "approve"], "resource": "workflow:scale-web" }]
}
}'{
"pendingId": "da985e72-2960-4e16-9087-77a1cbc496b3",
"redeemSecret": "swdx1.…",
"consentUrl": "https://swamp-club.com/link?code=…",
"expiresIn": 600,
"interval": 5
}- Always send
email, the address your platform holds for the person. If someone forwards the consent URL to a colleague who approves it, the email is what shows the mismatch: swamp-club warns the colleague and asks them to confirm the account is theirs, or refuses outright under--require-email-match. Without it, only the page's wording protects the person. Slackwater reads the email with Slack'susers:read.emailscope. platformis lowercase.team_idanduser_idare your platform's stable IDs, anddisplayandworkspaceare shown to the person as names. swamp-club can't check them, and says so on the page.- Ask for every action the app needs. A later consent replaces this one.
- Keep
pendingIdandredeemSecretuntil the link is redeemed. - Starting again for the same person replaces the pending link; redeeming the
old one then answers
refusedwith reasonreplaced.
2. Show the consent URL to that person only
Send consentUrl to the person being linked, privately: a direct or ephemeral
message, never a shared channel. Don't log it or echo it anywhere else. It works
for 10 minutes (expiresIn), and opening it doesn't use it up, so a link
preview does no harm.
The person signs in to swamp-club and sees who they're signed in as, what your app claims about them, the collective and serve, and what each action allows. Linking an app to swamp serve describes the page, and the email they get afterwards, which lets them end the link.
3. Poll until the person answers
Ask serve whether the person has answered, waiting interval seconds between
calls:
curl -X POST https://serve.example.com/api/v1/delegations/redeem \
-H "Authorization: Bearer $SWAMP_INTEGRATION_CREDENTIAL" \
-H "Content-Type: application/json" \
-d '{"pendingId": "da985e72-2960-4e16-9087-77a1cbc496b3", "redeemSecret": "swdx1.…"}'| Status | Body | Do |
|---|---|---|
| 202 | {"status": "pending", "interval": 5} |
Ask again after interval seconds |
| 200 | {"status": "issued", …} |
Go to the next step |
| 200 | {"status": "refused", "reason": "…"} |
Tell the person, and offer to start again |
| 200 | {"status": "ended", "cause": "…"} |
Tell the person, and offer to start again |
An issued answer comes once:
{
"status": "issued",
"delegationId": "da985e72-2960-4e16-9087-77a1cbc496b3",
"accessToken": "swda1.…",
"accessTokenExpiresAt": "2026-11-11T00:11:19.600Z",
"refreshToken": "swdr1.…",
"expiresAt": "2026-12-10T23:11:16.298Z",
"person": { "username": "alice", "email": "alice@example.com" }
}A later redeem answers 409 already_redeemed. So if a redeem call times out,
the tokens may have been issued and lost: end nothing, and ask the person to
link again.
A person who can't approve, because the email doesn't match under
--require-email-match or they aren't in the serve's collective, leaves the
link pending. Your app sees refused with consent_expired when the 10 minutes
run out.
4. Check who approved, then store the tokens
Compare person.email with the email you sent. If they differ, the person
ticked a box saying the account is theirs and they started the link themselves,
so keep the link, and tell them which Swamp account it's tied to. Slackwater
names the username and email it linked, says they differ from the Slack
profile's, and says how to unlink if that's wrong.
Store the tokens against the platform user who started the link, encrypted, and never let one person's tokens act for another.
Linking again
When the same person consents again for the same platform account, the new
consent replaces what they agreed to, wider or narrower, and keeps the
delegationId, so runs started under it carry on. The 30 days start again.
Redeeming the new link issues fresh tokens and retires the old ones; until then
the old tokens keep working.
Act for the person
Every action is a POST to /api/v1/action with that person's access token.
Serve applies their grants, the ceiling and their consent, and records them as
the actor:
curl -X POST https://serve.example.com/api/v1/action \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "workflow.run",
"payload": {
"workflowIdOrName": "scale-web",
"idempotencyKey": "slack-trigger-8723",
"inputs": { "count": 3, "reason": "traffic" }
}
}'{
"requestId": "25be8e13-2ea7-48fd-bc48-aaaa1609d11e",
"type": "workflow.run",
"data": {
"runId": "eb9eea1c-80fd-439d-aee3-1ce7f6e80612",
"workflowName": "scale-web",
"replayed": false
}
}The route takes a bearer token only, never a cookie, and these types:
| Type | Payload | Needs in the ceiling |
|---|---|---|
workflow.run |
workflowIdOrName, idempotencyKey, inputs |
run |
workflow.resume |
workflowIdOrName, runId, inputs |
run |
workflow.history.get |
runId |
read |
workflow.approvals |
{} |
read |
workflow.approve |
workflowIdOrName, runId, stepName, suspendedAt, reason, jobName |
approve |
workflow.reject |
the same as workflow.approve |
approve |
workflow.signal |
waitId, payload, idempotencyKey, or workflow and key for waitId |
signal |
Payloads are strict: an unknown key answers 400 invalid_request.
Start a run once per user action
idempotencyKey is required on workflow.run. Make it from the user's action,
such as Slack's trigger or form ID, using letters, digits and ._:-, at most
128 characters. Sending the same key with the same inputs returns the same run
with "replayed": true, so a retry never starts a second run. The same key with
different inputs answers 409 idempotency_conflict.
Decide a gate
List the gates the person can see, then send the gate's suspendedAt back with
the decision:
curl -X POST https://serve.example.com/api/v1/action \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type": "workflow.approvals", "payload": {}}'{
"requestId": "e677bf72-8c88-4e0a-8930-2924ddb44744",
"type": "workflow.approvals",
"data": {
"approvals": [
{
"workflowName": "scale-web",
"runId": "eb9eea1c-80fd-439d-aee3-1ce7f6e80612",
"stepName": "approve",
"suspendedAt": "2026-11-10T23:11:29.573Z",
"prompt": "Scale web-demo to 3 servers? Reason: traffic",
"initiatedBy": "user:6af3a357572a4b7820677e54",
"canDecide": false,
"canDecideReason": "grant-condition"
}
],
"expired": []
}
}curl -X POST https://serve.example.com/api/v1/action \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "workflow.approve",
"payload": {
"workflowIdOrName": "scale-web",
"runId": "eb9eea1c-80fd-439d-aee3-1ce7f6e80612",
"stepName": "approve",
"suspendedAt": "2026-11-10T23:11:29.573Z",
"reason": "looks right"
}
}'{
"requestId": "13b1b38b-965b-46a2-a9f7-0f0fa8d6a2cf",
"type": "workflow.approve",
"data": {
"runId": "eb9eea1c-80fd-439d-aee3-1ce7f6e80612",
"workflowName": "scale-web",
"stepName": "approve",
"approved": true,
"decidedBy": "user:6af3a357572a4b7820677e59",
"client": "integration:chatops",
"reason": "looks right",
"allGatesDecided": true,
"autoResumed": true
}
}canDecide is serve's view of whether this person may decide the gate; here the
self-approval deny refuses the person who started the run. suspendedAt means a
click on an old message can't decide a gate that has since been asked again:
serve answers 409 gate_reopened, and a second decision answers
409 already_decided. If a step name appears in more than one job, send
jobName too.
Runs outlive the token
A run started through a link is bound to the link, not to the access token that started it, so refreshing tokens never cuts it off. Once the link stops standing, serve refuses any resume of the run before a step runs, but a step already running carries on.
Anyone whose link allows run on the workflow can resume a run someone else
started, but the resumed steps use a vault only if both of them may use it, and
only the person who started the run, or an access admin, can send inputs with
the resume. Anyone else gets 403 resume_inputs_refused and the run stays
suspended. An admin acting through an integration isn't an admin here. So if the
person resuming isn't the one who started the run, don't send inputs, and if
serve refuses, explain why rather than retrying.
A run waiting at an approval gate continues once someone decides the gate, so
decide it rather than resuming the run. A resume of a run whose gate is still
open answers 409 awaiting_approval, with the gate in gate as job and
step, which workflow.approve takes as jobName and stepName. Releases
before Swamp 20261111.161908.0 answer 500 action_failed instead.
The action route doesn't take workflow.cancel. A ceiling with run still
allows cancelling any run of the workflow over serve's other transports, and an
operative can keep that from every integration with
--restricted-commands workflow.cancel.
Keep the tokens fresh
| Token | Form | Lasts |
|---|---|---|
| Access | swda1.… |
1 hour, and never past the link's end |
| Refresh | swdr1.… |
Until the link ends. It never changes |
| The link | — | 30 days from the person's latest consent (expiresAt) |
Refresh before the access token runs out, with the integration credential and the refresh token:
curl -X POST https://serve.example.com/api/v1/delegations/refresh \
-H "Authorization: Bearer $SWAMP_INTEGRATION_CREDENTIAL" \
-H "Content-Type: application/json" \
-d '{"refreshToken": "swdr1.…"}'{
"status": "refreshed",
"delegationId": "da985e72-2960-4e16-9087-77a1cbc496b3",
"accessToken": "swda1.…",
"accessTokenExpiresAt": "2026-11-11T00:12:35.362Z",
"expiresIn": 3600,
"expiresAt": "2026-12-10T23:11:16.298Z",
"person": { "username": "alice", "email": "alice@example.com" }
}- Keep your refresh token: it isn't returned again.
- The new access token and the two before it each work until they expire, so two copies of your app refreshing at once do no harm.
- Use
accessTokenExpiresAt, notexpiresIn, which is always 3600 even when the token is cut short at the link's end. - Refreshing never extends the link. After 30 days the person links again.
A refused refresh is 401 with code: invalid_grant and a reason. Three
reasons mean the link is paused and comes back on its own: keep the tokens, tell
the person, and try again later. Any other reason means the link is over.
| Reason | Means | Do |
|---|---|---|
not_admitted |
The person isn't admitted to serve now, for example they left the collective | Keep the tokens, tell the person, try later |
suspended |
swamp-club has suspended the person's account | Keep the tokens, tell the person, try later |
links_unavailable |
Serve can't confirm the link with swamp-club right now | Keep the tokens, tell the person, try later |
not_issued |
The link has ended, by any route | Discard the tokens, offer to link again |
expired |
The 30 days are over | Discard the tokens, offer to link again |
superseded |
A newer consent for this person replaced it | Discard the tokens, use the newer link |
unknown |
Serve doesn't know this refresh token | Discard the tokens, offer to link again |
mismatch |
The refresh token is wrong for this link | Check your storage; nothing has ended |
unreadable |
Serve can't read the link's record | Tell the person, and the operative |
End a link
When the person unlinks in your app, end the link with either of its tokens:
curl -X POST https://serve.example.com/api/v1/delegations/end \
-H "Authorization: Bearer $SWAMP_INTEGRATION_CREDENTIAL" \
-H "Content-Type: application/json" \
-d '{"token": "swdr1.…"}'{ "ended": true }A 401 invalid_grant here means the link had already ended. Then discard the
tokens either way.
A link also ends without your app:
- the person ends it from the link email, or deletes their account
- an operative runs
swamp access delegation endor revokes the integration - swamp-club can't deliver the link email
- the collective's OAuth client is deleted, or its secret is rotated with links ended
- the collective's trial ends or its paid plan stops, recorded as
plan_lapsed; the links stay ended if it pays again later, so people link again
Serve hears about an end on swamp-club within about 30 seconds. After that, the
person's access token answers 401 and refresh answers not_issued. When
swamp-club refuses serve's own client, because it was deleted or its secret
rotated, links pause at once (links_unavailable) and end after an hour unless
serve is given a working client.
What your app must do
Serve can't check these, so your app has to:
- Keep secrets secret. Store the integration credential, refresh tokens and access tokens encrypted, and never log them, show them or put them in an error.
- Bind tokens to the person who linked. Key the tokens on the platform user who started the link, and never let one person's request use another's tokens.
- Show the consent URL only to that person, privately, and never log it.
- Send the person's email when starting a link, and tell them when a different account approved.
- Make idempotency keys from the user's action, not from a timer or a counter, so a retry of the same click replays.
- Keep the ceiling narrow. Name each workflow rather than using a wildcard.
- Treat display names as unchecked. Serve shows what your app sends.
- Back off when refused. Honour
Retry-Afteron429and503, and stop using a token serve has refused. Failed attempts with a link's tokens count against that link from your app's address, and after five serve refuses it for a while. Failed redeems and refreshes count against the whole integration, so retrying them can hold up other people's links.
Known limits
- A compromised app can act for everyone linked to it, within each link's consent and the ceiling, until the integration is revoked. Revoking ends every link at once.
- Work already started outlives the link. A step running when a link ends finishes, though nothing resumes the run afterwards through that link.
- A run uses the workflow's vaults. Allowing
runon a workflow lets the person cause it to use whatever vaults it touches. - Ending a link takes effect within about 30 seconds, not at once, on other instances and when the end comes from swamp-club.
- A signal continues a run with its starter's access. Allowing
signalon a workflow lets the person send a payload that a run someone else started acts on, as that person.
Refusals and what to do
Every error is JSON with a code and a message. Most action-route errors add
a requestId to quote when reporting a problem.
Starting a link
| Code | Status | Means | Do |
|---|---|---|---|
invalid_client |
401 | The integration credential is wrong, or the integration was revoked | Stop and tell the operative |
invalid_request |
400 | The body is malformed, for example an uppercase platform or a name over 80 characters |
Fix the request |
email_required |
400 | The integration requires an email and you sent none | Send the person's email |
invalid_ceiling |
400 | The ceiling isn't in the format above | Fix the ceiling |
ceiling_too_wide |
400 | You asked for more than the integration's ceiling, or it isn't active | Ask for less, or have the operative widen it |
too_many_pending |
429 | 50 of this integration's links are waiting for consent | Wait for some to finish or expire |
team_not_ready |
409 | A serve instance runs an older release | Try again after the upgrade |
links_unavailable |
503 | Serve isn't in OAuth mode with a swamp-club client | Tell the operative |
links_require_plan |
409 | The collective isn't on an active trial or a paid plan | Show the person the message, which says what's needed, and tell the operative |
links_not_enabled |
409 | swamp-club has turned links off for now | Try again later |
no_instance |
409 | swamp-club has no record of this serve running | Tell the operative |
club_refused |
400 | swamp-club refused the request; the message names its reason | Tell the operative |
club_busy |
503 | swamp-club is limiting requests | Try again shortly |
links_require_collective_client |
409 | Serve was set up through the browser device approval, so its swamp-club client isn't a collective's | Tell the operative to set serve up with a collective API key |
club_unavailable |
502 | swamp-club couldn't be reached, or refused serve's own client for another reason | Try again later; if it lasts, tell the operative |
Redeeming
| Answer | Means | Do |
|---|---|---|
refused, consent_denied |
The person denied it | Accept it |
refused, consent_expired |
Nobody approved within 10 minutes, including a person the page wouldn't let approve | Offer to start again |
refused, not_admitted |
The person who approved isn't admitted to serve | Tell them to ask the operative |
refused, replaced |
You started a newer link for the same person | Poll the newer one |
refused, integration_revoked |
The integration was revoked | Stop and tell the operative |
refused, other reasons |
swamp-club or serve couldn't complete the link | Offer to start again |
ended, not_redeemed |
Tokens weren't collected within an hour of approval | Offer to start again |
409 already_redeemed |
The tokens were already handed out | Use the ones you stored, or relink |
404 not_found |
Serve doesn't know this pendingId and secret |
Stop polling it |
401 invalid_grant, with reason |
A relink whose link is paused (suspended, links_unavailable) |
Keep polling |
Acting
| Code | Status | Means | Do |
|---|---|---|---|
unauthorized |
401 | The access token expired, was replaced, or the link isn't standing | Refresh once and retry; the refresh reason says which |
forbidden |
403 | The person's grants, the ceiling or a deny refuse it | Tell the person they can't; don't retry |
consent_required |
403 | The person's grants allow it, but they didn't agree to it when linking | Offer to link again |
resume_inputs_refused |
403 | Only the person who started the run, or an access admin, can send inputs with a resume |
Explain that the starter must resume it; don't retry |
not_found |
404 | No such workflow, run or gate, or one the person can't see | Tell the person |
type_not_allowed |
404 | The route doesn't take this type | Use a listed type |
invalid_request |
400 | The payload or the workflow's inputs are wrong | Fix the request |
idempotency_conflict |
409 | This key was used with different inputs | Use a new key for a new action |
already_decided |
409 | Someone already decided the gate | Show the outcome |
gate_reopened |
409 | The gate was asked again since you read it | Read the approvals again |
gate_closed |
409 | The gate expired or closed | Show that it's closed |
gate_not_open |
409 | The run hasn't reached the gate | Wait, then read the approvals again |
run_not_resumable |
409 | The run can't be resumed now, for example it's waiting on another run | Read the run's history, then try later |
awaiting_approval |
409 | The run is waiting at an approval gate, named in gate |
Decide the gate instead of resuming |
ambiguous_gate |
409 | The step name is in more than one job | Send jobName |
busy |
409 | The run is busy | Retry after Retry-After |
rate_limited |
429 | Too many requests, or too many failed ones | Wait for Retry-After |
too_many_runs |
429 | Serve's run limit is reached | Wait for Retry-After |
still_working |
503 | No answer within 30 seconds; the work carries on | Retry with the same idempotency key, or check the run |
outcome_unknown |
503 | Serve can't confirm whether it happened | Check the run before retrying |
shutting_down, not_ready, serve_not_leader, audit_unavailable, access_unavailable, leader_upgrade_required, runs_unavailable |
503 | Serve can't take the request now | Retry after Retry-After |
action_failed |
500 | The action failed on serve | Tell the person, with the requestId |
When serve doesn't answer
Give every call a timeout. A call that fails without an answer may still have happened: retry a run with the same idempotency key, read the approvals again before re-sending a decision, and don't assume a redeem failed, since its tokens are handed out once.
Slackwater as the reference
Slackwater does all of this for
Slack, in TypeScript under the Apache 2.0 licence. Every call it makes to serve
is in one module,
src/serve/client.ts:
port that file to build the same app in another language.
| Step | Slackwater |
|---|---|
| Start a link | ServeClient.startLink, called from /slackwater link in src/app.ts |
| Show the consent URL | consentPrompt in src/messages.ts, sent as an ephemeral reply |
| Poll and store | ServeClient.redeem; the app polls at interval and stores the tokens in src/store.ts |
| Check who approved | The email comparison after redeem in src/app.ts |
| Run a workflow | ServeClient.runWorkflow, with Slack's trigger or form ID as the idempotency key |
| Decide a gate | ServeClient.listApprovals, then approve or reject with the gate's suspendedAt |
| Refresh | TokenKeeper in src/tokens.ts, which keeps the tokens on a refusal the link recovers from |
| End | ServeClient.end, from /slackwater unlink |
Its store, EncryptedFileStore in
src/store.ts,
encrypts every token at rest, and the Secret type in
src/serve/secret.ts
keeps tokens out of logs and JSON.
To run it beside serve, follow its
README: register the
integration and grants as above, create the Slack app from a manifest built from
your commands file (the demo's is
demo/manifest.json),
put the credential and Slack's tokens in .env, and start it with
deno task start. It uses Slack's Socket Mode, so it needs no public address:
it connects out to Slack and to serve.
Related
- Linking an app to swamp serve — what the person sees and agrees to
- Manage Access Grants — grants and conditions
- Gate a Workflow with Manual Approval — adding the gates an integration decides
- Deploy with Headless OAuth — starting serve with a collective client