curl --request POST \
--url https://iam.basaltic.sh/v1/oauth/token \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data grant_type=client_credentials \
--data client_id=BYCLD1a2b3c4d5e6f7 \
--data 'client_secret=<string>' \
--data duration_seconds=3600 \
--data 'code=<string>' \
--data 'code_verifier=<string>' \
--data redirect_uri=http://127.0.0.1:53682/callback \
--data 'refresh_token=<string>'import requests
url = "https://iam.basaltic.sh/v1/oauth/token"
payload = {
"grant_type": "client_credentials",
"client_id": "BYCLD1a2b3c4d5e6f7",
"client_secret": "<string>",
"duration_seconds": "3600",
"code": "<string>",
"code_verifier": "<string>",
"redirect_uri": "http://127.0.0.1:53682/callback",
"refresh_token": "<string>"
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
response = requests.post(url, data=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: 'BYCLD1a2b3c4d5e6f7',
client_secret: '<string>',
duration_seconds: '3600',
code: '<string>',
code_verifier: '<string>',
redirect_uri: 'http://127.0.0.1:53682/callback',
refresh_token: '<string>'
})
};
fetch('https://iam.basaltic.sh/v1/oauth/token', 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://iam.basaltic.sh/v1/oauth/token",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => "grant_type=client_credentials&client_id=BYCLD1a2b3c4d5e6f7&client_secret=%3Cstring%3E&duration_seconds=3600&code=%3Cstring%3E&code_verifier=%3Cstring%3E&redirect_uri=http%3A%2F%2F127.0.0.1%3A53682%2Fcallback&refresh_token=%3Cstring%3E",
CURLOPT_HTTPHEADER => [
"Content-Type: application/x-www-form-urlencoded"
],
]);
$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://iam.basaltic.sh/v1/oauth/token"
payload := strings.NewReader("grant_type=client_credentials&client_id=BYCLD1a2b3c4d5e6f7&client_secret=%3Cstring%3E&duration_seconds=3600&code=%3Cstring%3E&code_verifier=%3Cstring%3E&redirect_uri=http%3A%2F%2F127.0.0.1%3A53682%2Fcallback&refresh_token=%3Cstring%3E")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://iam.basaltic.sh/v1/oauth/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=client_credentials&client_id=BYCLD1a2b3c4d5e6f7&client_secret=%3Cstring%3E&duration_seconds=3600&code=%3Cstring%3E&code_verifier=%3Cstring%3E&redirect_uri=http%3A%2F%2F127.0.0.1%3A53682%2Fcallback&refresh_token=%3Cstring%3E")
.asString();require 'uri'
require 'net/http'
url = URI("https://iam.basaltic.sh/v1/oauth/token")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/x-www-form-urlencoded'
request.body = "grant_type=client_credentials&client_id=BYCLD1a2b3c4d5e6f7&client_secret=%3Cstring%3E&duration_seconds=3600&code=%3Cstring%3E&code_verifier=%3Cstring%3E&redirect_uri=http%3A%2F%2F127.0.0.1%3A53682%2Fcallback&refresh_token=%3Cstring%3E"
response = http.request(request)
puts response.read_body{
"access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6Ii4uLiJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<string>"
}{
"error": "unsupported_grant_type",
"error_description": "unsupported grant type"
}{
"error": "invalid_client",
"error_description": "client authentication failed"
}{
"error": "invalid_grant",
"error_description": "Organization is suspended"
}{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests, please try again later",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}{
"error": "temporarily_unavailable",
"error_description": "try again shortly"
}Exchange an access key for a bearer token
Exchange a service account’s access key pair for a short-lived bearer
token, then send that token as Authorization: Bearer <token> on every
other call.
This is the ordinary way to authenticate. The access key pair stays the one long-lived credential a service account has; what changes is that you present a token derived from it rather than signing each request.
curl -s -u "$KEY_ID:$SECRET" -d grant_type=client_credentials \
https://iam.basaltic.sh/v1/oauth/token
The same key pair is also the AWS SigV4 credential for the S3-compatible object endpoint, which speaks nothing else. Use the token for this API and the key pair for S3; there is no need to choose.
Errors here use the OAuth 2.0 shape, not this API’s usual envelope —
{"error": "...", "error_description": "..."} — because every OAuth
client library parses that and nothing else. Two answers matter and their
remedies are opposite. invalid_client means the key was rejected: check
or rotate it. invalid_grant means the key is fine and the organization
is suspended or still onboarding, where rotating a working key would
waste your time.
An unknown access key and a wrong secret both answer invalid_client
with the same message, so the endpoint cannot be used to discover which
keys exist.
curl --request POST \
--url https://iam.basaltic.sh/v1/oauth/token \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data grant_type=client_credentials \
--data client_id=BYCLD1a2b3c4d5e6f7 \
--data 'client_secret=<string>' \
--data duration_seconds=3600 \
--data 'code=<string>' \
--data 'code_verifier=<string>' \
--data redirect_uri=http://127.0.0.1:53682/callback \
--data 'refresh_token=<string>'import requests
url = "https://iam.basaltic.sh/v1/oauth/token"
payload = {
"grant_type": "client_credentials",
"client_id": "BYCLD1a2b3c4d5e6f7",
"client_secret": "<string>",
"duration_seconds": "3600",
"code": "<string>",
"code_verifier": "<string>",
"redirect_uri": "http://127.0.0.1:53682/callback",
"refresh_token": "<string>"
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
response = requests.post(url, data=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: 'BYCLD1a2b3c4d5e6f7',
client_secret: '<string>',
duration_seconds: '3600',
code: '<string>',
code_verifier: '<string>',
redirect_uri: 'http://127.0.0.1:53682/callback',
refresh_token: '<string>'
})
};
fetch('https://iam.basaltic.sh/v1/oauth/token', 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://iam.basaltic.sh/v1/oauth/token",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => "grant_type=client_credentials&client_id=BYCLD1a2b3c4d5e6f7&client_secret=%3Cstring%3E&duration_seconds=3600&code=%3Cstring%3E&code_verifier=%3Cstring%3E&redirect_uri=http%3A%2F%2F127.0.0.1%3A53682%2Fcallback&refresh_token=%3Cstring%3E",
CURLOPT_HTTPHEADER => [
"Content-Type: application/x-www-form-urlencoded"
],
]);
$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://iam.basaltic.sh/v1/oauth/token"
payload := strings.NewReader("grant_type=client_credentials&client_id=BYCLD1a2b3c4d5e6f7&client_secret=%3Cstring%3E&duration_seconds=3600&code=%3Cstring%3E&code_verifier=%3Cstring%3E&redirect_uri=http%3A%2F%2F127.0.0.1%3A53682%2Fcallback&refresh_token=%3Cstring%3E")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://iam.basaltic.sh/v1/oauth/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=client_credentials&client_id=BYCLD1a2b3c4d5e6f7&client_secret=%3Cstring%3E&duration_seconds=3600&code=%3Cstring%3E&code_verifier=%3Cstring%3E&redirect_uri=http%3A%2F%2F127.0.0.1%3A53682%2Fcallback&refresh_token=%3Cstring%3E")
.asString();require 'uri'
require 'net/http'
url = URI("https://iam.basaltic.sh/v1/oauth/token")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/x-www-form-urlencoded'
request.body = "grant_type=client_credentials&client_id=BYCLD1a2b3c4d5e6f7&client_secret=%3Cstring%3E&duration_seconds=3600&code=%3Cstring%3E&code_verifier=%3Cstring%3E&redirect_uri=http%3A%2F%2F127.0.0.1%3A53682%2Fcallback&refresh_token=%3Cstring%3E"
response = http.request(request)
puts response.read_body{
"access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6Ii4uLiJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<string>"
}{
"error": "unsupported_grant_type",
"error_description": "unsupported grant type"
}{
"error": "invalid_client",
"error_description": "client authentication failed"
}{
"error": "invalid_grant",
"error_description": "Organization is suspended"
}{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests, please try again later",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
}{
"error": "temporarily_unavailable",
"error_description": "try again shortly"
}Body
An OAuth 2.0 token request. Form-encoded is what RFC 6749 specifies and what client libraries send; JSON is accepted too.
Client credentials may be sent as HTTP Basic (Authorization: Basic base64(key_id:secret), which is what most libraries do by default) or as
client_id and client_secret fields. Basic wins if both are present.
client_credentials is the one to use for a service account: it
exchanges an access key pair for a token, and needs nothing else.
authorization_code and refresh_token belong to the interactive
login a person runs (basaltic login), where the token names a USER
rather than a service account. They are driven by the CLI, not written
by hand. Check the authorization-server metadata document before
branching on them — they are advertised only where an authorization
endpoint is configured.
client_credentials, authorization_code, refresh_token "client_credentials"
The access key id. Omit when using HTTP Basic.
"BYCLD1a2b3c4d5e6f7"
The secret access key. Omit when using HTTP Basic.
Requested token lifetime. A Basaltic extension, not an OAuth parameter — omit it and you get the default. Values outside the range are clamped into it rather than refused, so asking for a day yields the longest token allowed.
900 <= x <= 432003600
The authorization code from the consent redirect. Single use, and valid
for five minutes. authorization_code grant only.
The PKCE verifier whose SHA-256 was sent as code_challenge when the
flow started (RFC 7636). Required with authorization_code: it is what
proves this is the client that began the flow, since a CLI holds no
client secret.
The same redirect_uri the code was issued for. Re-checked here, so a
code cannot be redeemed against a different destination.
"http://127.0.0.1:53682/callback"
refresh_token grant only. Renews a user session without another trip
through the browser. Rotated on every use — store the new one.
Response
A bearer token
RFC 6749 token response.
Send as Authorization: Bearer <token>. Opaque to clients: do not
parse it, and do not key anything on the token string.
"eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6Ii4uLiJ9..."
Bearer "Bearer"
Seconds until the token expires.
3600
Returned only by the user grants (authorization_code and
refresh_token). Present it to the refresh_token grant to renew
without another browser round trip; it is ROTATED on each use, so
replace the stored copy every time.
A service account gets none. It already holds a long-lived access key
and can simply run client_credentials again, so a refresh token would
be a second credential to store for no gain.