Apps
An App is a git repository that JetDeploy builds into a container image and runs
for you. Anything that runs in a container works: Python, Node.js, PHP, Go, Ruby, Java or a plain
Dockerfile.
Deploy with git push
Each App has a private git remote on JetDeploy. Add it to your repository and push the branch you
chose when creating the App (main by default):
git remote add jetdeploy https://git:<token>@jetdeploy.com/git/<app>
git push jetdeploy main
Once the App has at least one process, every push to that branch
triggers a new build and a rolling deploy: the new version starts, passes its readiness check,
and only then replaces the old one. A push before the App has a process is only recorded: no
build starts, since there is nothing yet to run it in. Add a process, then click Deploy
on the App page (or call POST /api/v1/apps/{app_id}/deploy); pushing again deploys
only when the push carries a new commit, since git sends nothing for a branch that is already
up to date. Pushes to other branches are ignored.
<token> is your Git Access Token, shown on the App page and on the
Git Access Token page under Integrations. Prefer to keep it out of your git config? Use https://jetdeploy.com/git/<app>
and, when git asks, answer git as the username and paste the token as the password: your operating
system remembers it, on macOS and Windows out of the box, on Linux after
git config --global credential.helper store. The token works on
every App of every organization you belong to: never commit it, regenerate it from the Git Access Token page
if it leaks.
An agent or a CI job should not store the token at all: pass it through a one-shot credential
helper from an exported environment variable (export JD_GIT_TOKEN=<token>).
Reset the helper list first with an empty -c credential.helper=, or git appends
yours to whatever is already configured (such as the store one above): a stale
stored token is then tried first and fails the push, and a successful push saves your token to
disk.
git -c credential.helper= -c credential.helper='!f() { echo username=git; echo "password=$JD_GIT_TOKEN"; }; f' \
push https://jetdeploy.com/git/<app> HEAD:refs/heads/<branch>
An agent working from the API token alone reads the Git Access Token from
GET /api/v1/profile, field git_token.secret; see
Your first deploy, call by call.
The build uses the Dockerfile at the root of your repository. If your project has
none, add one: a few lines are usually enough.
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:application"]
Processes
A process is one container started from the App image. An App usually has one external-facing
web process, and can add more: a worker, a scheduler, a queue consumer. Each process has:
- Name: unique within the App, letters, numbers, underscores or hyphens.
- Command: optional, overrides the
CMD of your Dockerfile. A single command receives the shutdown signal directly; end a shell script with exec <process> so that it does too.
- Container port: the port your process listens on. JetDeploy waits for it to accept connections before sending traffic.
- External-facing: whether the process receives HTTP traffic from the internet on the App host and on the attached domains.
- Warmup delay: extra seconds to wait before the process is marked ready, on top of the port check.
- Shutdown grace period: seconds your process gets to finish after
SIGTERM before it is killed, 1 to 900.
Processes can be started, stopped and restarted one by one from the App page. Stopping the whole
App stops every process and keeps your data services running.
HTTPS and proxy headers
The JetDeploy edge terminates HTTPS and forwards the request to your process as plain HTTP, so a
framework check like Django's request.is_secure() sees an insecure request unless
you tell it where to look. Plain http:// requests to your App host and to your
custom domains never reach your process: the edge redirects them permanently to
https:// first (301 for GET, 308 for other methods).
On every request the edge replaces the X-Forwarded-For, X-Forwarded-Proto,
X-Forwarded-Host, X-Forwarded-Port and X-Real-Ip headers
the client sent with its own: X-Forwarded-Proto (https),
X-Forwarded-For and X-Real-Ip (the address that connected to the
edge), X-Forwarded-Host and X-Forwarded-Port. Your process can trust
those five; every other header, such as Forwarded, X-Forwarded-Ssl or
CF-Connecting-IP, reaches it exactly as the client sent it. For a domain in
proxy mode the connecting address is your CDN or WAF, not the
visitor: the visitor's address is in a header the CDN adds, such as
CF-Connecting-IP for Cloudflare. Trust it only when X-Real-Ip is one
of your CDN's published addresses, since anyone can reach the JetDeploy origin directly and set
that header themselves.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
Leave Django's SECURE_SSL_REDIRECT off: the edge already redirects plain HTTP.
Turning it on also redirects the plain HTTP requests other Apps and services of your
organization make to this process on its internal address
(http://<app>-<process>:<port>), which never go through the edge
and carry no X-Forwarded-Proto: exempt those paths, or leave the redirect to the
edge. Other frameworks: trust X-Forwarded-Proto from the proxy the same way.
Environment variables
Configuration lives in environment variables, set from the App page and visible to every process.
Use them for secrets, connection strings and feature flags instead of committing them.
Changes are applied with a restart of the processes, which the Console does for you when you
click Apply.
DATABASE_URL=postgresql://user:password@host:5432/dbname
SECRET_KEY=change-me
Pre-deploy script
A pre-deploy script runs after the build and before the new version receives traffic, inside the
freshly built image with the same environment variables as your processes, so it is the place
for steps that change shared state, such as a database migration or a cache warmup. A script that fails is retried
once, in a new container, so write it to be safe to run twice, the way a migration already is.
The build has 1200 seconds. After it, the script has at
most 510 seconds, both attempts included, and the script
and the start of the new version together at most 600
seconds. If the build fails, the script fails twice, or either runs out of time, the deploy
fails and the previous version, if there is one, keeps serving. Past the time limit the
pre-deploy is stopped wherever it got to, whether the script was running or its container never
started, and a script stopped halfway can leave a database without transactional DDL, such as
MariaDB or MySQL, half migrated. A step that needs longer runs outside the pre-deploy script:
deploy without the script, run the step once in the new version with
POST /api/v1/apps/{app_id}/pods/{pod_id}/exec or the shell of the App page, then set
the script again. Until the step finishes the new version runs against the old schema, so such a
migration has to be backward compatible.
If the new version does not become ready in time the deploy fails too, but the rollout
goes on and the new version takes over as soon as it is ready.
The pre-deploy script runs in a container of its own, with no filesystem shared with your
processes: anything it writes there never reaches them. A step that produces files — compiling
assets, collecting static files — belongs in the build, in your Dockerfile, not in
the pre-deploy script. The build does not see the environment variables of the App, so a step
there has to run without them: for Django, give the settings a value to build with, e.g.
RUN SECRET_KEY=build-only python manage.py collectstatic --noinput.
python manage.py migrate --noinput
Logs, metrics and shell
The App page shows the deploy logs of every build and the live
runtime logs of every process. CPU and memory metrics are
charted per process, so you can see when your App needs more resources. A browser
shell opens a terminal inside a running process for one-off commands and
debugging.
The level shown and filtered on is the one the log store detects on each line when it
arrives. A JSON or logfmt line is read from its level field (or
severity, lvl) when that is a word such as info,
warn or error; a numeric level, such as pino's 30, is
not read. Any other line, and a JSON or logfmt line without such a field, is matched on its
text: it is info as soon as it contains info or INFO
anywhere, even inside a word or a URL, and that wins over everything else; otherwise it is
error for [ERROR], [error], ERR: or
err: (not ERROR: or Error:), warn for
[WARN], WARN: or the word warning/WARNING
anywhere, and debug for debug/DEBUG anywhere. A line
with none of these comes back unknown, which is what the other
filter matches. To make the filter reliable, log JSON with a level field set to
a word.
API
Everything the Console does, the JSON API does too: apps, processes, environment variables,
data services, domains, operations, logs, metrics and billing. It is meant for scripts, CI
pipelines and AI agents, and it is plain HTTP with no websocket: what the Console shows live,
the API streams line by line in the body of a normal request.
The schema and the interactive docs are public and need no token: point your client, your
code generator or your agent at the schema and it knows every path, every field and every
error this page describes.
Authentication
Two different tokens, each on its own page under Integrations in the Console:
-
the API token, on the
API Access Token page, sent as
Authorization: Bearer <token> on every call under /api/v1/;
-
the Git Access Token, on the
Git Access Token page, the password of the
git remote used by
git push. It never goes in the
Authorization header.
curl -sS -H "Authorization: Bearer $JD_TOKEN" https://jetdeploy.com/api/v1/me
A missing or wrong token is answered with 401 and code unauthorized.
Rotate a token from its page under Integrations in the Console or with
POST /api/v1/profile/api-token/regenerate and
POST /api/v1/profile/git-token/regenerate: the new secret is returned only in that
answer, and the old token stops working immediately.
GET /api/v1/profile returns the Git Access Token in clear as
git_token.secret: it is never hidden, so an agent that only holds the API token can
read it and push code without ever opening the console.
Organizations
Every app, service, domain and operation belongs to an organization. Endpoints that list or
create take ?organization=<id>; left out, they use the current organization of
your user. Endpoints that address one object by id need no organization at all.
curl -sS -H "Authorization: Bearer $JD_TOKEN" https://jetdeploy.com/api/v1/organizations
curl -sS -H "Authorization: Bearer $JD_TOKEN" "https://jetdeploy.com/api/v1/apps?organization=3"
GET /api/v1/organizations lists the ones you belong to, with
is_current on the default one; POST /api/v1/organizations/<id>/select
makes another one the default. GET /api/v1/me tells you which user and which
organization a token is working as.
Errors
Every error, whatever the status, has the same shape: a machine readable code, a
message for humans and a hint saying what to do about it, which is
often the exact next call to make.
HTTP/1.1 409 Conflict
{"code": "no_push",
"message": "This app has no pushed code to deploy",
"hint": "push to https://jetdeploy.com/git/my-app first"}
Validation errors add an errors object with the messages per field, under the
field name or under __all__ when they belong to no field in particular:
HTTP/1.1 400 Bad Request
{"code": "validation_error",
"message": "The request is not valid",
"hint": null,
"errors": {"name": ["Enter a valid value."]}}
Operations
Anything that changes what is running takes time: deploy, apply, restart, start, stop, expose,
unexpose and destroy answer 202 with an operation instead of
waiting.
curl -sS -X POST -H "Authorization: Bearer $JD_TOKEN" \
https://jetdeploy.com/api/v1/apps/42/deploy
HTTP/1.1 202 Accepted
{"id": 871, "kind": "deploy", "target": "app", "target_id": 42, "target_name": "my-app",
"status": "PENDING", "is_finished": false, "error_message": "",
"created_at": "2026-09-09T10:12:03.114Z", "started_at": null}
Poll GET /api/v1/operations/871 until is_finished is true: the final
status is SUCCESS, FAILURE (with
error_message) or REVOKED. While an operation is still running on the
same app, service or process, another one is refused with 409 and code
operation_pending, so wait rather than retry blindly.
GET /api/v1/operations lists them, newest first, and
POST /api/v1/operations/<id>/cancel stops one that has not finished.
Cancelling a deploy while its pre-deploy script runs stops the script within seconds, wherever
it got to, and the new version is not rolled out.
Your first deploy, call by call
The eight calls that take an empty account to a running app. The examples use:
export JD_TOKEN=<your API token>
export JD_GIT_TOKEN=$(curl -sS -H "Authorization: Bearer $JD_TOKEN" \
https://jetdeploy.com/api/v1/profile | jq -r .git_token.secret)
API=https://jetdeploy.com/api/v1
AUTH="Authorization: Bearer $JD_TOKEN"
The API token alone is enough here: GET /api/v1/profile hands back the Git Access
Token secret too, so an agent never needs the console to get it.
-
Create the app. The answer carries its id, its
git_remote_url, its url (where it will answer once deployed)
and a next_step telling you what is missing.
curl -sS -X POST -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"name": "my-app", "branch": "main"}' $API/apps
-
Set the environment variables. GET and PUT on
$API/apps/42/envs both answer the flat map of the whole set,
{"KEY": "value", ...}: the PUT request body is
{"envs": {...}}, but the answer is not wrapped, and PUT
replaces the whole set, so the keys you leave out are deleted.
POST $API/apps/42/envs with {"key": "...", "value": "..."}
adds one key and answers 201; GET and PATCH
(body {"value": "..."}) on $API/apps/42/envs/<key> read
or change one key. All three answer
{"key", "value", "apply_required"}; DELETE on
$API/apps/42/envs/<key> answers 204. Every value comes
back in clear, secrets included: mask it before printing or logging any of these
answers.
curl -sS -X PUT -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"envs": {"SECRET_KEY": "change-me", "DATABASE_URL": "postgresql://..."}}' \
$API/apps/42/envs | jq 'keys'
-
Add the process. One process per container: the external one is the
one that receives HTTP traffic, and its port is required.
curl -sS -X POST -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"name": "web", "port": 8080, "external": true}' $API/apps/42/pods
-
Set the pre-deploy script, if you need one. Do it now, before the push
below: once the app has a pod, a push starts the build and the deploy by itself, so the
script has to be in place first. It runs after the build and before the new version
receives traffic — the right place for database migrations. A failing script is
retried once, so write it to be safe to run twice. The script may take at most
510 seconds, both attempts included, and at most
600 seconds together with the start of the new
version; the build before it has its own 1200
seconds. If the script fails twice or runs out of time, the deploy fails and the
previous version, if there is one, keeps serving; past the time limit the pre-deploy is
stopped wherever it got to, even if its container never started, and a script stopped
halfway can leave a MariaDB or MySQL database half migrated. Run a step that needs longer
outside the script: deploy without it, run the step once in the new version with
POST /api/v1/apps/{app_id}/pods/{pod_id}/exec, then set the script again;
until it finishes the new version runs against the old schema, so the migration has to be
backward compatible.
curl -sS -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"predeploy_script": "python manage.py migrate --noinput"}' $API/apps/42
-
Push the code to the git_remote_url of step 1, on the
branch of the app, with a one-shot credential helper carrying your Git Access Token as
the password so it is never stored on disk. This is the only step that is not an API
call. When the app already has a pod, the push itself starts the build and the deploy:
the push output says "App deploy in progress".
git -c credential.helper= -c credential.helper='!f() { echo username=git; echo "password=$JD_GIT_TOKEN"; }; f' \
push https://jetdeploy.com/git/my-app HEAD:refs/heads/main
-
Deploy, when the push did not. Needed only if the pod was added after
the push, or to redeploy the same push again. GET $API/apps/42 shows a
deploy already running in pending_operation, and its next_step
says what to wait for; a 409 operation_pending here means one is already
running, so wait for it instead of retrying.
curl -sS -X POST -H "$AUTH" $API/apps/42/deploy
-
Poll the operation until is_finished is true. A build takes
minutes: poll every few seconds, and read the build output live with
$API/apps/42/deploy-logs/tail or a page of it with
$API/apps/42/deploy-logs.
curl -sS -H "$AUTH" $API/operations/871
-
Watch it run. The app answers at its url
(https://my-app.jetdeployapp.com); the runtime logs stream as
they arrive, and GET $API/apps/42 shows the status of the app and of every
process.
curl -sS -N -H "$AUTH" "$API/apps/42/runtime-logs/tail?timeout=60"
Later changes follow the same pattern: change what you want, then apply it with
POST $API/apps/42/apply, whether the app is running or stopped. Fields
that need it come back with apply_required: true.
Data services
One call creates a data service and deploys it. kind is one of
postgresql, mariadb, redis, opensearch and
rabbitmq; GET $API/catalog/services lists the engines with the
settings each one takes in attrs, their types and their defaults.
storage_size (GiB) goes either at the top level of the request or inside
attrs, never both (400 code storage_size_twice). Redis
takes neither: its volume is sized from attrs.memory (MB), a top-level
storage_size is ignored and one inside attrs is refused with
400 code unknown_attribute.
curl -sS -X POST -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"name": "my-db", "kind": "postgresql", "storage_size": 10, "attrs": {"database_name": "app"}}' \
$API/services > /tmp/service.json
jq '.credentials |= (if . then map_values("***") else . end)' /tmp/service.json
DB_PASSWORD=$(jq -r '.credentials.password' /tmp/service.json)
POST $API/services answers at once, in created status, with its
pending_operation for the deploy just queued and everything an app needs to
connect, under credentials (username, password and, depending on the engine, the
database name or a management endpoint) with internal_host and port.
The deploy itself usually takes about a minute, and up to
1200 seconds before it gives up. The service accepts
connections once GET $API/services/17 shows status
ready. If its pending_operation turns null while status
is not ready, the deploy failed and deploy_failed is true: read the
error of that operation and retry with POST $API/services/17/deploy. An app that
needs it at deploy time, such as a pre-deploy database migration, should wait for
ready before its first push. That same answer
carries the size of its volume in GiB as storage_size (for Redis, computed from
memory; null while the service has no volume). Both the create and the get answer
carry the password in clear: mask credentials before printing or logging them.
JetDeploy sets none of this on your app automatically: read it from this answer and set it
yourself with PUT $API/apps/42/envs, either as separate variables or composed
into one such as DATABASE_URL; a generated password is letters and digits only,
so it needs no percent-encoding in a URL. POST $API/services/17/expose with
{"allowed_cidrs": ["203.0.113.10/32"]} publishes it outside the platform and
exposed_endpoint then says where.
Domains
Claiming a domain returns the records it needs: dns_target, the values your A or
CNAME record must point at, and verification_record, the TXT record proving you own
the name.
curl -sS -X POST -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"name": "www.example.com"}' $API/domains
curl -sS -X POST -H "$AUTH" $API/domains/9/validate
Validation checks your authoritative nameservers right away. When a record is still missing the
answer is 409 with code domain_unvalidated, and the hint spells out
the records to create:
HTTP/1.1 409 Conflict
{"code": "domain_unvalidated",
"message": "Domain unvalidated, please retry",
"hint": "Records to create: CNAME www.example.com -> jetdeployapp.com. Create the DNS records above, wait for them to propagate and call this again"}
Once it is validated, attach it to the app with
POST $API/apps/42/domains and {"domain_id": 9}, then
POST $API/apps/42/apply for the app to start serving it.
PATCH $API/domains/9 switches between direct and proxy
routing, and DELETE $API/apps/42/domains/9 detaches it again. Switching the mode of
a validated domain makes it unvalidated: create the records of the new mode from the answer,
validate it again and apply the app again.
Commands and log tails
A one-off command runs in a running process and streams its output back. There is no shell
unless you ask for one, so pass ["sh", "-c", "..."] when you need pipes or
redirections.
curl -sS -N -X POST -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"command": ["sh", "-c", "python manage.py migrate --noinput"], "timeout": 120}' \
$API/apps/42/pods/7/exec
The answer is chunked application/x-ndjson: one JSON object per line, flushed as it
is produced. Read it line by line; the last line always says why the stream ended.
{"stream":"stdout","data":"Applying workload.0042... "}
{"stream":"stdout","data":"OK\n"}
{"exit_code":0,"reason":"completed","message":null}
reason is completed, timeout,
output_limit, pod_gone, access_revoked or
error. A command may run 60 seconds by
default and at most 600: a longer
timeout is clamped. When it elapses, or when you stop reading, the stream ends
but the command is not killed and may keep running in the container: wrap a long command with
the timeout utility of the image, as in
["sh", "-c", "timeout 60 ..."], when it has to be stopped for good.
The level filter of the runtime and service tails and pages (the deploy logs take
none and ignore it) is one or more of error (also critical and
fatal), warn, info, debug (also
trace) and other. other matches exactly the lines where
no level was detected, which come back with level set to unknown.
Any other value, unknown included, is refused with 400.
The log tails work the same way:
$API/apps/42/runtime-logs/tail (filters: pod, level,
contains), $API/apps/42/deploy-logs/tail (operation, the
most recent deploy when left out) and $API/services/17/logs/tail. Their lines are
log lines, keep-alive pings and one final end line; a line from the deploy tail also carries
stream (build, predeploy or outcome), the
same field the paged deploy-log rows carry; a runtime or service tail line carries it too, as
an empty string:
{"timestamp":"2026-09-09T10:12:04.881Z","level":"info","message":"GET / 200","pod":"web","container":"web","ns":"1757412724881000000","stream":""}
{"ping":true}
{"end":"timeout","cursor":"1757412784000000000"}
A tail stays open 300 seconds by default and at most
3600, then ends with end set to
timeout, source_gone, access_revoked or
error. The deploy tail is the one that finishes on its own: when the run ends it
emits the outcome line saying whether the deploy succeeded or failed, then closes with
end set to source_gone, so an agent can wait on it instead of polling.
Without a cursor a tail starts from now. To keep following, call it again with
?cursor= set to the cursor of the end line, or to the ns
of the last line you handled: nothing is repeated between two tails, but a line that reaches the
log store late, in the last seconds before that cursor, can be missed; when you need every line,
read that stretch again as a page. For a search over what is
already there, read a page instead: $API/apps/42/runtime-logs,
$API/apps/42/deploy-logs and $API/services/17/logs.
A page answers {"rows": [...], "next_cursor", "prev_cursor", "window": {"from", "to"}}:
the matching lines oldest first, the window the filters resolved to, and the cursors to keep
paging. A cursor is always <ns>:<key>, taken from one of the rows of the
page it came from.
{"rows": [
{"ns":"1788948724881000000","timestamp":"2026-09-09T10:12:04.881000Z","level":"info",
"message":"GET / 200","pod":"web","container":"web"},
{"ns":"1788948725104000000","timestamp":"2026-09-09T10:12:05.104000Z","level":"info",
"message":"GET /health 200","pod":"web","container":"web"}
],
"next_cursor": "1788948724881000000:6c4198492dad67d0",
"prev_cursor": "1788948725104000000:aa0c71751b90a8e2",
"window": {"from":"2026-09-09T04:12:05.500000Z","to":"2026-09-09T10:12:05.500000Z"}}
Each row has the fields of a tail line: ns, timestamp,
level, message, pod and container; a row of
$API/apps/42/deploy-logs adds stream
(build, predeploy or outcome), and its page also
carries operation (the run the lines come from) and operations (the
runs still within the log retention). Pass next_cursor back as ?cursor=
to keep reading further in the same ?direction= (backward by default,
older lines first). To read the other way, pass prev_cursor as
?cursor= and flip ?direction= to the opposite value: on its
own, with the direction left as it was, prev_cursor is a position already inside
the page you have, so it only reads an overlapping page again instead of moving past it.
Rate limits
Every API token gets a fixed number of requests per minute; requests without a token, such as
the ones for the schema, are counted per IP address instead. Over the limit the answer is
429 with code throttled and a Retry-After header saying
how many seconds to wait. An agent polling an operation every second or two stays well within
the limit; a tail costs one request however long it stays open. Open streams are capped
separately: at most 8 commands and log tails can be
open at once per token, counted together, and beyond that the answer is 429 with
code too_many_streams until one of them ends.
Connect your agent
The same API is also served as an MCP server at https://jetdeploy.com/mcp, so an
agent can call it as tools instead of writing HTTP requests. There is a tool for every API
operation except the live streams, the payment methods, the token regenerations and the log
histogram and context parts — apps, processes, environment variables, data services,
domains, operations, logs, metrics and one-off commands are all there — and they do
exactly what the calls on this page do, with the same permissions, the same errors and the
same rate limits. The transport is plain HTTP: one POST per message, no websocket.
-
Claude: in claude.ai open Settings > Connectors > Add custom
connector and give
https://jetdeploy.com/mcp as the URL. Claude sends you
to the JetDeploy login and asks you to allow the connection, so there is no token to copy.
-
ChatGPT: turn developer mode on, add
https://jetdeploy.com/mcp as an MCP server and authorize it the same way.
-
Claude Code:
claude mcp add --transport http jetdeploy https://jetdeploy.com/mcp, then
/mcp to sign in.
One step of a deploy stays outside the tools, the same one that stays outside the API: pushing
the code. The create_app and get_app tools answer with
git_remote_with_token, the git remote of the app with your
Git Access Token already in it, so an agent with a terminal can
git push <url> <branch> straight away; from then on the push itself
starts the deploy, as it does for you. That credential is only handed to a connection that
was given the write scope: for a read-only connection
git_remote_with_token is absent and the secret of
git_token in get_profile comes back null.
Every agent you authorize this way shows up on your
AI Agents & MCP page,
with the scopes it was given and when it was last used, and one click revokes it. The same list
is GET /api/v1/profile/connections and the list_connections tool,
while revoking one is deliberately not a tool: that is the AI Agents & MCP page and
DELETE /api/v1/profile/connections/<id>, so no agent revokes a connection of
yours. An agent that is granted the read scope alone can read everything and
change nothing, and a write it tries anyway is refused with 403 and code
insufficient_scope.