Finish a connection's OAuth
Finish a connection started with auth: oauth or kind: app_connector, with the state, code and iss the OAuth callback handed to Fluid admin (connect=finish). Fluid uses up the state first, whoever sends it, and then refuses with not_connector unless you are the admin who started the connection, in the same company. So a consent URL sent to someone else can’t be finished by them, and once they try, not by you either: start the connection again.
You must still be allowed to connect: we_bots.update, and for a company connection company_settings.update, both checked now, not when you started. Fluid then exchanges the code with its PKCE verifier and stores the connection with its tools all off: an MCP server’s as it lists them, an app connector’s from the catalogue, those whose scope you granted, with the account it acts as. The state works once, for ten minutes from the start.
Needs the we_bots.update permission.
curl --request POST \
--url https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"state": "eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f",
"code": "4/0AVG7fiQ2h8kq",
"iss": "https://accounts.google.com"
}
'import requests
url = "https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete"
payload = {
"state": "eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f",
"code": "4/0AVG7fiQ2h8kq",
"iss": "https://accounts.google.com"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
state: 'eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f',
code: '4/0AVG7fiQ2h8kq',
iss: 'https://accounts.google.com'
})
};
fetch('https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'state' => 'eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f',
'code' => '4/0AVG7fiQ2h8kq',
'iss' => 'https://accounts.google.com'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete"
payload := strings.NewReader("{\n \"state\": \"eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f\",\n \"code\": \"4/0AVG7fiQ2h8kq\",\n \"iss\": \"https://accounts.google.com\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"state\": \"eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f\",\n \"code\": \"4/0AVG7fiQ2h8kq\",\n \"iss\": \"https://accounts.google.com\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"state\": \"eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f\",\n \"code\": \"4/0AVG7fiQ2h8kq\",\n \"iss\": \"https://accounts.google.com\"\n}"
response = http.request(request)
puts response.read_body{
"bot_connection": {
"id": "0190a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a9a",
"kind": "app_connector",
"scope": "personal",
"connector_key": "google_calendar",
"name": "Google Calendar",
"mcp_host": null,
"account_name": "ana@primex.example",
"status": "active",
"connected_by_admin_id": "0190a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a2c",
"has_api_key": false,
"auth": "oauth",
"tools_refreshed_at": "2026-10-25T09:00:00.000Z",
"last_used_at": null,
"created_at": "2026-10-25T09:00:00.000Z",
"connected_by": {
"id": "0190a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a2c",
"name": "Maya"
},
"can_disconnect": true,
"can_disable": true,
"tools": [
{
"name": "google_calendar_list_events",
"description": "List events on the person's calendars.",
"effect": "read",
"enabled": false
}
]
},
"status": 201,
"meta": {
"request_uuid": "7e2c9b41-3f8a-4d15-b6e0-9a4c21d7f583",
"timestamp": "2026-10-25T09:00:00Z"
}
}{
"message": "Invalid credentials."
}{
"error": {
"message": "Only the person who started this connection can finish it; start it again",
"details": {
"type": "not_connector"
}
},
"status": 403,
"meta": {
"request_uuid": "7e2c9b41-3f8a-4d15-b6e0-9a4c21d7f583",
"timestamp": "2026-10-25T09:00:00Z"
}
}{
"error": {
"message": "This sign-in link is invalid, expired or already used",
"details": {
"type": "invalid_state"
}
},
"status": 422,
"meta": {
"request_uuid": "7e2c9b41-3f8a-4d15-b6e0-9a4c21d7f583",
"timestamp": "2026-10-25T09:00:00Z"
}
}{
"error": {
"message": "The authorization server answered 400",
"details": {
"type": "mcp_oauth_failed"
}
},
"status": 502,
"meta": {
"request_uuid": "7e2c9b41-3f8a-4d15-b6e0-9a4c21d7f583",
"timestamp": "2026-10-25T09:00:00Z"
}
}Authorizations
The session token of an admin of your company. Company API, partner and droplet installation tokens are refused.
Body
What the OAuth callback handed to Fluid admin with connect=finish, posted back by the admin who started the connection.
The state from the callback.
1 - 4096The code from the callback.
1 - 4096The iss from the callback, when it carried one (RFC 9207). One naming another authorization server or provider is refused with invalid_state.
2048Response
The connection, with its tools all off.
curl --request POST \
--url https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"state": "eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f",
"code": "4/0AVG7fiQ2h8kq",
"iss": "https://accounts.google.com"
}
'import requests
url = "https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete"
payload = {
"state": "eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f",
"code": "4/0AVG7fiQ2h8kq",
"iss": "https://accounts.google.com"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
state: 'eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f',
code: '4/0AVG7fiQ2h8kq',
iss: 'https://accounts.google.com'
})
};
fetch('https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'state' => 'eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f',
'code' => '4/0AVG7fiQ2h8kq',
'iss' => 'https://accounts.google.com'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete"
payload := strings.NewReader("{\n \"state\": \"eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f\",\n \"code\": \"4/0AVG7fiQ2h8kq\",\n \"iss\": \"https://accounts.google.com\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"state\": \"eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f\",\n \"code\": \"4/0AVG7fiQ2h8kq\",\n \"iss\": \"https://accounts.google.com\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.fluid.app/api/company/v2026-10/bot-connections/oauth/complete")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"state\": \"eyJfcmFpbHMiOnsiZGF0YSI6IklqWkRiR1JxVkdGd1VXeEtOSHB2VTFaTVJGaHNjVkUiLCJleHAiOiIyMDI2LTEwLTI1VDA5OjEwOjAwLjAwMFoiLCJwdXIiOiJ3ZV9ib3RzX21jcF9vYXV0aCJ9fQ==--4b2e1f\",\n \"code\": \"4/0AVG7fiQ2h8kq\",\n \"iss\": \"https://accounts.google.com\"\n}"
response = http.request(request)
puts response.read_body{
"bot_connection": {
"id": "0190a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a9a",
"kind": "app_connector",
"scope": "personal",
"connector_key": "google_calendar",
"name": "Google Calendar",
"mcp_host": null,
"account_name": "ana@primex.example",
"status": "active",
"connected_by_admin_id": "0190a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a2c",
"has_api_key": false,
"auth": "oauth",
"tools_refreshed_at": "2026-10-25T09:00:00.000Z",
"last_used_at": null,
"created_at": "2026-10-25T09:00:00.000Z",
"connected_by": {
"id": "0190a1b2-7c3d-7e4f-8a5b-6c7d8e9f0a2c",
"name": "Maya"
},
"can_disconnect": true,
"can_disable": true,
"tools": [
{
"name": "google_calendar_list_events",
"description": "List events on the person's calendars.",
"effect": "read",
"enabled": false
}
]
},
"status": 201,
"meta": {
"request_uuid": "7e2c9b41-3f8a-4d15-b6e0-9a4c21d7f583",
"timestamp": "2026-10-25T09:00:00Z"
}
}{
"message": "Invalid credentials."
}{
"error": {
"message": "Only the person who started this connection can finish it; start it again",
"details": {
"type": "not_connector"
}
},
"status": 403,
"meta": {
"request_uuid": "7e2c9b41-3f8a-4d15-b6e0-9a4c21d7f583",
"timestamp": "2026-10-25T09:00:00Z"
}
}{
"error": {
"message": "This sign-in link is invalid, expired or already used",
"details": {
"type": "invalid_state"
}
},
"status": 422,
"meta": {
"request_uuid": "7e2c9b41-3f8a-4d15-b6e0-9a4c21d7f583",
"timestamp": "2026-10-25T09:00:00Z"
}
}{
"error": {
"message": "The authorization server answered 400",
"details": {
"type": "mcp_oauth_failed"
}
},
"status": 502,
"meta": {
"request_uuid": "7e2c9b41-3f8a-4d15-b6e0-9a4c21d7f583",
"timestamp": "2026-10-25T09:00:00Z"
}
}