Skip to content
GitmoapdocsBeta
Guide· How do I…?

Service accounts

Overview

A service account is an identity nobody logs into — a deploy bot, a release script, a nightly job — that belongs to a group or a single repository instead of to a person. It carries its own access level, authenticates with tokens minted for it, and appears in history under its own name, so your automation keeps working when the engineer who set it up changes team.

Give one the smallest access level that does its job, point your automation at its token, and revoke that token when the automation retires. Deleting the account takes every token issued for it with it, which is what makes a service account a safer home for a long-lived credential than somebody's personal token.

Quick start

Quick start3 steps

Give automation its own identity

You’ll need Maintainer on the repository, or Owner on the group.

Start at Service accounts in the side navigation.

  1. Choose New service account.

  2. Name it after the job it does, and pick the lowest level that job needs.

  3. Choose Create service account, then give it a token so the job can authenticate as it.

Before you start

  • Owner on the group to create a group account, or Maintainer on the repository to create a repository one. Creating in a group is the higher bar because a group account reaches every repository underneath it.
  • The same standing as creating it, to rename, re-level, re-picture, delete or mint a token for an account— Owner on the group for a group account, Maintainer on the repository for a repository one — and a level at least the account's own. You cannot steer an account that holds more access than you do: a token carries everything its account can do, so being able to mint one for an Owner-level account would be a way to act as Owner. Levels are inherited downwards, so a level you hold on any group above the account's scope counts; one held on an unrelated branch of the tree does not.
  • Reporter on the scope to read one.Listing and reading follow the scope's visibility: a public or internal group answers any signed-in person, a private one answers only its members. None of these calls answer an anonymous caller at all.
  • A signed-in person.A set of calls is closed to a service-account caller whatever scopes its token carries, because each one records a person: creating another service account, minting or listing access tokens, creating a group or a repository, starring one, opening, closing or merging a merge request, and asking for a limit increase. They answer 400 BAD_REQUEST saying the caller is not supported for the operation, rather than 403. A provisioning script running as a bot has to stop one step short of minting new identities, and a bot that needs a merge request opened needs a person to open it.
  • A token with the right scope, if you are calling the API or the CLI rather than clicking: service_account:read to list and read, service_account:create to create, service_account:update to rename, re-level or change the picture, service_account:delete to delete. Scopes are fixed when a token is minted and cannot be added afterwards.
  • token:manage as well, if the same script also mints the account's credentials. That is a separate call on a separate scope, described in Access tokens.
  • The group or repository already exists, and you have picked which one. An account's scope is fixed at creation and cannot be moved.

How it works

Where an account lives, and how far it reaches

Every service account is owned by exactly one scope, and its access level applies to that scope and everything nested inside it.

acme ← an account here works in every group and repository below
└── platform
├── api-gateway ← an account here works in this repository only
└── billing

A group account with Developer level can push to every repository in that group's subtree, including ones created after the account was. A repository account reaches that one repository and nothing else. There is no way to add a service account to a second scope: if a bot needs to work in two unrelated places, either create one account in each, or create a single account on the nearest group that contains both.

That reach is also why a service account does not appear in a scope's member list. Its access level is part of the account itself rather than a membership you could remove separately, so it lives and dies with the account and shows up under Service accounts rather than under Members.

The name you choose and the identity we generate

You choose one thing: the display name, between 1 and 100 characters, and it is the only part meant for humans. The username and the email address are generated for you at creation and never change — not when you rename the account, not when the group is renamed. A group account's username reads sa_group_9f2c1a04_7b3d81: the kind of scope, the first eight characters of that scope's id, and six random ones. A repository account's reads sa_repository_… instead. The email is that username at @noreply.gitmoap. Nothing is delivered to that address; it exists so the account can be an author, an assignee and a commit identity like anyone else.

Nothing about creation is idempotent. Two calls with the same name produce two separate accounts with two separate identities, so a provisioning script that runs twice leaves you with a duplicate bot rather than the one you meant.

You cannot create an account more powerful than you are

The level you assign is compared against your own level on the scope, and anything higher is refused. The same ceiling applies when you raise an existing account's level later, so there is no two-step route around it.

No account is an Owner, whoever creates it: Maintainer is the highest level a service account can hold. Owner is the level that changes who else has access, and that stays with people.

Your level on the scopeLevels you can assign
OwnerGuest, Reporter, Developer, Maintainer
MaintainerGuest, Reporter, Developer, Maintainer

Who can see one, and what is recorded

Names, levels and creation details are readable by anyone who can read the scope — on a public or internal group or repository, that is every signed-in person. Tokens are never part of that: no listing, detail view or export contains a secret, and a token's plaintext is shown exactly once, at the moment it is minted.

Reading a single service account is recorded in the audit log, as is every create, rename, level change, picture change and delete. Listing is not. If you need to prove who gave a bot Maintainer and when, that is the record to read.

How to

Create a service account for a whole group

Pick the group whose subtree the account should work in, give it a name a human will recognise in an activity list, and give it a level. Start lower than feels comfortable — Reporter reads, Developer pushes to unprotected branches and opens merge requests, Maintainer pushes to protected branches and changes settings. Raising the level later is one call; discovering a bot had Owner for a year is not recoverable.

Open the group, choose Settings, then Service accounts, then New service account. Give it a display name and pick an access level — the picker offers only the levels at or below your own. Create adds it to the list and shows its generated username, which is the name the account acts under everywhere else.

Terminalbash
gitmoap sa create-group \
--group-id <id> \
--access-level guest|reporter|developer|maintainer|owner \
--name <name>
curlbash
curl -X POST https://api.gitmoap.example/groups/{group_id}/service-accounts \
-H "Authorization: Bearer $GITMOAP_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "access_level": "<one of developer | guest | maintainer | owner | reporter>", "name": "<string>" }'

Creating the account does not give it any credentials. It cannot do anything at all until you mint it a token.

Create a service account for one repository

Use this when the automation only ever touches one repository — a deploy job, a docs publisher, a bot that labels merge requests. The account has no standing anywhere else in the tree, so a leaked token cannot be pointed at a sibling repository.

Open the repository, choose Settings, then Service accounts, then New service account. Name it, pick a level, and Create. The list on this page shows only this repository's accounts — accounts created on a group above it work here too, and are managed from that group.

Terminalbash
gitmoap sa create-repo \
--repo-id <id> \
--access-level guest|reporter|developer|maintainer|owner \
--name <name>
curlbash
curl -X POST https://api.gitmoap.example/repositories/{repo_id}/service-accounts \
-H "Authorization: Bearer $GITMOAP_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "access_level": "<one of developer | guest | maintainer | owner | reporter>", "name": "<string>" }'

Find the service accounts in a group or repository

Accounts come back newest first, with their level, their generated username and who created them. A group's listing shows the accounts owned by that group; it does not include the accounts owned by its child groups and repositories, which are listed on those.

Open the group, choose Settings, then Service accounts. Each row shows the display name, the generated username, the access level and who created it. Long lists page as you scroll.

Terminalbash
gitmoap sa list-group --group-id <id>
curlbash
curl -X GET https://api.gitmoap.example/groups/{group_id}/service-accounts \
-H "Authorization: Bearer $GITMOAP_TOKEN"

Open the repository, choose Settings, then Service accounts for the same list, scoped to this repository.

Terminalbash
gitmoap sa list-repo --repo-id <id>
curlbash
curl -X GET https://api.gitmoap.example/repositories/{repo_id}/service-accounts \
-H "Authorization: Bearer $GITMOAP_TOKEN"

Look up one service account

Useful when a comment, a work item or a commit turns out to be the work of sa_repository_… and you want to know what it is, who created it and what it can do.

Choose the account in the Service accounts list, or click the author name anywhere it appears in history. The detail panel shows the display name, the generated username and email, the owning scope, the access level, and who created it and when.

Terminalbash
gitmoap sa get --sa-id <id>
curlbash
curl -X GET https://api.gitmoap.example/service-accounts/{sa_id} \
-H "Authorization: Bearer $GITMOAP_TOKEN"

Give a service account its credentials

A service account authenticates with tokens minted for it, which begin gms_. Minting one needs token:manage on your own token as well as the standing to manage the account — Owner on the group for a group account, Maintainer on the repository for a repository one, and a level at least the account's own — and the plaintext is shown once and never again.

shellscript
gitmoap access-tokens create-sa-token \
--sa-id <service-account-id> \
--name "deploy from CI" \
--scopes '["git:read","git:write"]' \
--expires-in-days 90

Replace <service-account-id> with the id from gitmoap sa list-group --group-id <your-group> --output json. A token cannot raise the account's access level: one carrying git:write still pushes as a Reporter if that is what the account is. If you bind the token to a group or repository, that scope has to be one you can see yourself. And minting, like creating, has to be done as a person — a service account's own token cannot mint tokens, for itself or for anything else. The full treatment, including expiry, resource binding and revocation, is in Access tokens.

Change a service account's name or access level

Renaming is cosmetic and safe. Changing the level takes effect on the account's very next request, including requests already in flight from a running job, and applies to every token the account holds — you cannot give one token more reach than another by editing the account.

At least one field has to change; an empty update is refused rather than treated as a no-op.

Open the account from the Service accounts list and choose Edit. Change the display name, the access level, or both, then Save. The level picker again offers only the levels at or below your own.

Terminalbash
gitmoap sa update --sa-id <id>
curlbash
curl -X PATCH https://api.gitmoap.example/service-accounts/{sa_id} \
-H "Authorization: Bearer $GITMOAP_TOKEN"

The scope is not editable here, and there is no route that makes it editable. Moving a bot from one group to another means creating a new account in the destination, minting it a token, switching your automation over, then deleting the old one.

Give a service account a picture

A picture makes a bot's activity obvious at a glance in a busy discussion. Uploading happens in two steps: ask for an upload address, then send the image bytes to it yourself.

You have to declare the exact byte size up front. That number is bound into the upload address, so an upload that sends a different number of bytes is rejected by the storage service rather than silently truncated, and the address itself stops working after five minutes. image/png, image/jpeg, image/gif and image/webp are accepted; anything else is refused before an address is issued.

Open the account from the Service accounts list, choose Edit, then click the picture and pick a file. The browser handles the upload for you and the new picture appears once it finishes.

Terminalbash
gitmoap sa upload-avatar \
--sa-id <id> \
--content-length <n> \
--content-type <content type>
curlbash
curl -X PUT https://api.gitmoap.example/service-accounts/{sa_id}/avatar \
-H "Authorization: Bearer $GITMOAP_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "content_length": "<int64>", "content_type": "<string>" }'

An account can instead carry the address of a picture you host elsewhere, set with the avatar field when you create or update it. When that is set it wins over anything uploaded here.

Remove a service account's picture

Removing the picture deletes the stored image, and no copy is kept — keep the original file somewhere if you might want it back. The account keeps working exactly as before and falls back to the default image derived from its generated address.

Open the account, choose Edit, then Remove picture. The change is immediate.

Terminalbash
gitmoap sa delete-avatar --sa-id <id>
curlbash
curl -X DELETE https://api.gitmoap.example/service-accounts/{sa_id}/avatar \
-H "Authorization: Bearer $GITMOAP_TOKEN"

Retire a service account

Warning

Deleting a service account is immediate and cannot be undone. Every token ever minted for it stops working in the same instant, so any job still using one starts failing its next request with 401 UNAUTHORIZED. The identity is gone and cannot be recreated: a new account with the same display name gets a new username and a new email, and none of the old activity attaches to it. Commits it made are git objects and are untouched, but they now name an identity nothing resolves.

Before you delete, find out what is still using it. The account's own contribution history shows what it has been doing and how recently, and its token list shows how many credentials exist and when each was last used. A bot with a token used in the last hour is a bot something depends on.

An account that has left records behind often cannot be deleted at all. While it is still named as the creator of something the platform keeps — a comment it wrote, a work item it filed, a release it cut, a pipeline run it started — the delete is refused with 409 CONFLICT rather than performed, so the record is never quietly detached from whoever made it. Revoking its tokens is the way to retire a bot that has done work.

If you only need to stop it temporarily, revoke its tokens instead and keep the account. Revoking is reversible in the sense that matters — you mint a new token when you want the bot back — and it leaves the identity, its history and its level intact.

Open the account from the Service accounts list and choose Delete. The confirmation names the account; Delete is immediate.

Terminalbash
gitmoap sa delete --sa-id <id>
curlbash
curl -X DELETE https://api.gitmoap.example/service-accounts/{sa_id} \
-H "Authorization: Bearer $GITMOAP_TOKEN"

Deleting the owning group or repository deletes its service accounts too, with the same consequences and no separate confirmation.

Reference

9 endpoints

POST/groups/{group_id}/service-accountsservice_account:create

Create a service account scoped to a group

create_group_saPOST /groups/{group_id}/service-accountsOpen in the API reference

Token scope

service_account:create

Counts against

service_accounts_per_scope

Send

Path
  • group_iduuidGroup ID
Body
  • access_leveldeveloperguestmaintainerownerreporter
  • avatarobjectoptional
  • namestring

Refuses

  • 400Invalid request
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden
  • 404Group not found
  • 409The request conflicts with the resource's current state; `reason` says how
  • 429Service-account limit reached for this group

Receive

  • access_leveldeveloperguestmaintainerownerreporter
  • avatarobjectoptional
  • created_atstring
  • created_byobject4 fields
    • avatar_urlobjectoptional
    • emailstring
    • idstring
    • usernamestring
  • emailstring
  • namestring
  • scope_idstring
  • scope_typegrouprepository
  • service_account_iduuid
  • usernamestring
POST/repositories/{repo_id}/service-accountsservice_account:create

Create a service account scoped to a repository

create_repo_saPOST /repositories/{repo_id}/service-accountsOpen in the API reference

Token scope

service_account:create

Counts against

service_accounts_per_scope

Send

Path
  • repo_iduuidRepository ID
Body
  • access_leveldeveloperguestmaintainerownerreporter
  • avatarobjectoptional
  • namestring

Refuses

  • 400Invalid request
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden
  • 404Repository not found
  • 409The request conflicts with the resource's current state; `reason` says how
  • 429Service-account limit reached for this repository

Receive

The same shape create_group_sa returns — 10 fields. Go to it

Show it here
  • access_leveldeveloperguestmaintainerownerreporter
  • avatarobjectoptional
  • created_atstring
  • created_byobject4 fields
    • avatar_urlobjectoptional
    • emailstring
    • idstring
    • usernamestring
  • emailstring
  • namestring
  • scope_idstring
  • scope_typegrouprepository
  • service_account_iduuid
  • usernamestring
GET/groups/{group_id}/service-accountsservice_account:read

List service accounts in a group

list_group_sasGET /groups/{group_id}/service-accountsOpen in the API reference

Token scope

service_account:read

Send

Path
  • group_iduuidGroup ID
Query
  • limitint32optionalPage size (1-100)
  • next_tokenstringoptionalPagination cursor

Refuses

  • 400Bad request — a path parameter is not a well-formed value of its type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not found — or found and private, which is masked as absent

Receive

  • items[]object13 fields
    • access_leveldeveloperguestmaintainerownerreporter
    • avatarobjectoptional
    • created_atstring
    • created_byobject4 fields
      • avatar_urlobjectoptional
      • emailstring
      • idstring
      • usernamestring
    • emailstring
    • namestring
    • scope_idstring
    • scope_typegrouprepository
    • service_account_iduuid
    • usernamestring
  • next_tokenobjectoptional
GET/repositories/{repo_id}/service-accountsservice_account:read

List service accounts in a repository

list_repo_sasGET /repositories/{repo_id}/service-accountsOpen in the API reference

Token scope

service_account:read

Send

Path
  • repo_iduuidRepository ID
Query
  • limitint32optionalPage size (1-100)
  • next_tokenstringoptionalPagination cursor

Refuses

  • 400Bad request — a path parameter is not a well-formed value of its type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Not found — or found and private, which is masked as absent

Receive

The same shape list_group_sas returns — 2 fields. Go to it

Show it here
  • items[]object13 fields
    • access_leveldeveloperguestmaintainerownerreporter
    • avatarobjectoptional
    • created_atstring
    • created_byobject4 fields
      • avatar_urlobjectoptional
      • emailstring
      • idstring
      • usernamestring
    • emailstring
    • namestring
    • scope_idstring
    • scope_typegrouprepository
    • service_account_iduuid
    • usernamestring
  • next_tokenobjectoptional
GET/service-accounts/{sa_id}service_account:read

Get a service account by ID

get_saGET /service-accounts/{sa_id}Open in the API reference

Token scope

service_account:read

Send

Path
  • sa_iduuidService account ID

Refuses

  • 400Bad request — a path parameter is not a well-formed value of its type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden — the caller's access level or token scope is insufficient
  • 404Service account not found

Receive

The same shape create_group_sa returns — 10 fields. Go to it

Show it here
  • access_leveldeveloperguestmaintainerownerreporter
  • avatarobjectoptional
  • created_atstring
  • created_byobject4 fields
    • avatar_urlobjectoptional
    • emailstring
    • idstring
    • usernamestring
  • emailstring
  • namestring
  • scope_idstring
  • scope_typegrouprepository
  • service_account_iduuid
  • usernamestring
PATCH/service-accounts/{sa_id}service_account:update

Update a service account

update_saPATCH /service-accounts/{sa_id}Open in the API reference

Token scope

service_account:update

Send

Path
  • sa_iduuidService account ID
Body
  • access_levelobjectoptional
  • avatarobjectoptional
  • nameobjectoptional

Refuses

  • 400Invalid request
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden
  • 404Service account not found
  • 409The request conflicts with the resource's current state; `reason` says how

Receive

The same shape create_group_sa returns — 10 fields. Go to it

Show it here
  • access_leveldeveloperguestmaintainerownerreporter
  • avatarobjectoptional
  • created_atstring
  • created_byobject4 fields
    • avatar_urlobjectoptional
    • emailstring
    • idstring
    • usernamestring
  • emailstring
  • namestring
  • scope_idstring
  • scope_typegrouprepository
  • service_account_iduuid
  • usernamestring
PUT/service-accounts/{sa_id}/avatarservice_account:update

Get a URL to upload a service account's avatar to

upload_sa_avatarPUT /service-accounts/{sa_id}/avatarOpen in the API reference

Token scope

service_account:update

Counts against

avatar_bytes

Send

Path
  • sa_iduuidService account ID
Body
  • content_lengthint64Exact byte size of the upload. Bound into the presigned PUT, so the upload must send exactly this many bytes; rejected up front if over the avatar cap.
  • content_typestring

Refuses

  • 400Invalid request
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden
  • 404Service account not found
  • 409The request conflicts with the resource's current state; `reason` says how
  • 501Avatar uploads are not available on this deployment

Receive

  • avatar_urlstring
  • upload_urlstring
DELETE/service-accounts/{sa_id}/avatarservice_account:update

Delete a service account avatar

delete_sa_avatarDELETE /service-accounts/{sa_id}/avatarOpen in the API reference

Token scope

service_account:update

Send

Path
  • sa_iduuidService account ID

Refuses

  • 400Bad request — a path parameter is not a well-formed value of its type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden
  • 404Service account not found
  • 409The request conflicts with the resource's current state; `reason` says how

Receive

No body.

DELETE/service-accounts/{sa_id}service_account:delete

Delete a service account

delete_saDELETE /service-accounts/{sa_id}Open in the API reference

Token scope

service_account:delete

Send

Path
  • sa_iduuidService account ID

Refuses

  • 400Bad request — a path parameter is not a well-formed value of its type
  • 401Unauthenticated — no credential, or an invalid one
  • 403Forbidden
  • 404Service account not found
  • 409Something still references this service account

Receive

No body.

Limits

Service accounts per scope — 10 on the Free plan. The cap is counted per scope, not per tree: a group can hold 10 of its own while each repository inside it holds its own 10, and accounts belonging to a child scope never count against the parent's allowance.

PlanService accounts per group or repository
Free10
Pro50
Team200
Enterprise1 000

At the cap, a create call is refused with 429 TOO_MANY_REQUESTS and nothing is created — no half-made account, no orphaned identity, nothing to clean up. Delete an account you no longer need, move the automation to a group account that serves several repositories, or raise the ceiling: a higher plan lifts every scope's allowance, and an Owner can ask for a higher limit on one namespace. To know how close a scope is before a script finds out for you, count the accounts in its own listing.

Pictures — 5 MiB per upload. You declare the exact size when you ask for an upload address; 0 bytes or anything over 5 MiB is refused with 400 BAD_REQUEST before an address is issued, and the address that comes back accepts exactly that many bytes and expires after five minutes.

One more ceiling worth knowing, because it is the one a busy bot meets: a single account holds at most 100 tokens. Revoke the ones you have stopped using rather than accumulating them — Access tokens covers listing and revoking.

Troubleshooting

400 BAD_REQUESTmentioning sa_id, group_id or repo_id
Cause
The id in the URL is not a well-formed id — often a path like acme/platform pasted where an account id belongs
What to do
Use the id from the listing. The CLI accepts a group or repository path for --group-id and --repo-id, but an account is only ever addressed by its id
400 BAD_REQUESTmentioning name
Cause
The display name is empty or longer than 100 characters
What to do
Shorten it. Any characters are fine, including spaces and emoji
400 BAD_REQUESTmentioning access_level
Cause
The level is not one of guest, reporter, developer, maintainer — or it is owner, which no account can hold
What to do
Send one of the four, lowercase. admin, write and read are not levels here
400 BAD_REQUEST— At least one field must be provided
Cause
An update was sent with nothing in it
What to do
Include the name, the level or the picture address. An empty update is refused rather than accepted as a no-op
400 BAD_REQUEST— Service account / pipeline run callers are not supported for this operation
Cause
A service account's token tried to create another service account, or to mint an access token
What to do
Run the call as a person. This holds even when the token carries service_account:create or token:manage, so provisioning scripts have to stop one step short of minting identities and credentials
400 BAD_REQUESTon a picture upload, mentioning content type
Cause
The file is not image/png, image/jpeg, image/gif or image/webp
What to do
Convert it. The type is checked before an upload address is issued, so nothing was uploaded
400 BAD_REQUESTon a picture upload, mentioning size
Cause
The declared size is 0 or above the 5 MiB cap
What to do
Resize the image and declare its real byte count. Declaring a smaller number than you intend to send does not help — the size is bound into the upload address
401 UNAUTHORIZED
Cause
No credential, an expired one, or a revoked one. A bot that worked yesterday and fails everywhere today usually has an expired token — or its account was deleted, which revokes every token it held
What to do
Mint a fresh token. If minting is refused with 404, the account itself is gone and needs recreating
403 FORBIDDENon creating an account in a group
Cause
Creating a group service account needs Owner, one level above what a Maintainer holds
What to do
Ask an Owner of the group to create it, or to raise your level. A repository account needs only Maintainer, so a repository-scoped account may be the better fit. Renaming, re-levelling and deleting an account that already exists need only Maintainer, group or repository alike
403 FORBIDDEN— Cannot assign an access level higher than your own
Cause
You asked for a level above your own on that scope
What to do
Ask for a level at or below yours, or have an Owner create the account. Raising it afterwards hits the same ceiling
403 INSUFFICIENT_SCOPE
Cause
The token is valid but was minted without the scope this call needs
What to do
Mint a new token carrying service_account:read, service_account:create, service_account:update or service_account:delete, whichever the call needs. Scopes are chosen at mint time and cannot be added to an existing token. Both picture calls need service_account:update, not a scope of their own
403 FORBIDDEN— Access token is not authorized for this resource
Cause
The token is bound to a different group or repository than the one you are reaching
What to do
Use a token bound to this scope, or an unbound one. A binding cannot be widened after minting
404 NOT_FOUNDon a group or repository you can see in the web app
Cause
The scope is private and your token's identity is not a member of it
What to do
Check you are a member. A private scope answers 404 rather than 403 to a non-member so its existence stays private
404 NOT_FOUNDon an account that existed this morning
Cause
It was deleted, or the group or repository that owned it was
What to do
Check the audit log for the delete. Recreating gives you a new identity and a new username; the old one cannot be restored
409 CONFLICTon delete
Cause
The account is still named as the creator of something kept — a comment, a work item, a release, a pipeline run
What to do
Revoke its tokens and leave the account in place; that stops the bot without detaching its work. Delete only an account that has created nothing
429 TOO_MANY_REQUESTSon create
Cause
The scope is at its service-account cap
What to do
Delete an account you no longer need, or raise the ceiling. Nothing was created by the refused call
The bot can clone but every push is rejected
Cause
The account's level is below Developer, or the branch is protected and the level is below Maintainer
What to do
Raise the account's level, or push to an unprotected branch. A token scope does not substitute for a level — git:write on a Reporter account still cannot push
A group account cannot reach a repository in a different group
Cause
An account's reach is its own scope and everything nested inside it, and nothing else
What to do
Create an account on the nearest group containing both, or a second account in the other place
The account is missing from the group's member list
Cause
Service accounts are not members. Their level is part of the account rather than a membership row
What to do
Look under Service accounts in the group or repository settings
Running the provisioning script twice left two identical bots
Cause
Creation is not idempotent — every call mints a new account, even with the same name
What to do
List the scope's accounts before creating, and delete the duplicate. Match on the account id you stored, never on the display name
The upload address returns an error from storage rather than from Gitmoap
Cause
The address expired after five minutes, or the upload sent a different number of bytes than was declared
What to do
Ask for a fresh address and send exactly the declared byte count

Examples

Provision a deploy bot for a group, end to end

Creates a Developer-level account for everything under acme/platform, mints it a 90-day token that can push, and prints the token once.

shellscript
SA_ID=$(gitmoap sa create-group \
--group-id acme/platform \
--name "Deploy bot" \
--access-level developer \
--output json | jq -r '.service_account_id')
gitmoap access-tokens create-sa-token \
--sa-id "$SA_ID" \
--name "deploy from CI" \
--scopes '["git:read","git:write"]' \
--expires-in-days 90 \
--output json | jq -r '.token'

Put that last value straight into your CI secret store — it is not retrievable afterwards. Your job then authenticates git with it:

shellscript
export GITMOAP_TOKEN="<the token>"
git clone gitmoap://acme/platform/api-gateway

Audit what a scope's bots can do

Lists a group's accounts newest first with just the fields worth reviewing, which makes an over-privileged account easy to spot.

shellscript
gitmoap sa list-group --group-id acme/platform --limit 100 --output json \
| jq -r '.items[] | [.access_level, .name, .username, .created_at] | @tsv'

Anything at maintainer deserves a reason. Lower one with gitmoap sa update --sa-id <id> --access-level developer, which takes effect on that account's next request.

FAQ

Can a service account be a member of a second group?

No. Its reach is its owning scope and everything nested inside it, and there is no way to add it anywhere else. Create an account on the nearest common parent, or one account per place.

Can I move an account to another group?

No — the scope is fixed at creation. Create a new account in the destination, mint it a token, switch the automation over, then delete the old account.

Do service accounts expire?

The account does not. Its tokens do, if you gave them an expiry, and that is where the expiry belongs: a bot that has to keep working needs a rotation you plan, not an account that vanishes.

How do I pause a bot without losing it?

Revoke its tokens. The account, its history and its level stay exactly as they are, and minting a new token brings it back. Deleting is the irreversible option, and it is refused outright once the account is named as the creator of anything the platform keeps.

Can I read a token back?

No. The plaintext is shown once when it is minted and is never recoverable — not by you, not by an Owner, not by us. Mint a new one and revoke the old.

Does a service account count against my member limit?

No. It counts against the service-account limit for its scope, and members are counted separately.

Why does the username look nothing like the name I chose?

The username and email are generated so they are unique and stable no matter what anyone renames later. The display name is the human-facing half, and you can change it whenever you like.

Who shows as the author of a bot's commits?

Whoever the commit itself names. A commit carries the author name and email git recorded when it was written, and pushing it with the account's token does not rewrite that — so a bot that pushes somebody else's work leaves that person as the author. Set your job's git identity to the account's generated email if you want its own commits credited to it. Everything the account does through the API — the comments it leaves, the work items it files, the releases it cuts, the pipeline runs it starts — is attributed to it directly, and it has its own contribution history, so that activity never quietly merges into the record of the person who created it.

  • Access tokensminting, binding, expiring and revoking the credentials a service account actually uses
  • Groupsthe nesting that decides how far a group account reaches, and how a level held above a scope carries down into it
  • Repositoriesrepository members, visibility, and what each access level lets a principal do
  • Audit logwho created a bot, who raised its level, and when
  • Pipelineswhen a job needs to act inside Gitmoap, and whether it needs its own identity at all
  • Billing and plansupgrading, and asking for a higher limit on one namespace