Connecting with OAuth
Let your app act as a ShipGenius user, on their account, with their permission.
Overview
An API key acts as your own app, on your own account. OAuth lets your software act as another ShipGenius user, on their account, with their permission: the user signs in to ShipGenius, approves what your app may do, and your app receives tokens that act as that user with the permissions they allowed. The user can take that access away at any time from their Connected apps page.
ShipGenius is an OAuth 2.1 authorization server. The flow is the standard authorization code flow with PKCE:
- Your app sends the user's browser to the authorization endpoint.
- The user signs in to ShipGenius and answers a consent page.
- The browser comes back to your redirect URL with a one-time authorization code.
- Your app exchanges the code at the token endpoint for an access token and a refresh token.
- Your app calls the GraphQL API with the access token, refreshes it when it expires, and revokes it when the user disconnects.
The protocol endpoints sit beside the GraphQL endpoint on the API origin, outside the /v1/graphql
path: https://api.lite.shipgeni.us for production and https://api.sandbox.lite.shipgeni.us
for the sandbox.
| Endpoint | Path |
|---|---|
| Authorization | /oauth/authorize |
| Token | /oauth/token |
| Revocation | /oauth/revoke |
| Metadata | /.well-known/oauth-authorization-server |
The sandbox and production are separate deployments with separate accounts, apps and clients. A client registered on one does not exist on the other. See Testing in the sandbox.
1. Register Your App as an OAuth Client
Open the app on the Apps page and use its OAuth client section. If you do not have an app yet, create one first; the app's roles and permissions do not matter for OAuth, since a token acts as the approving user, not as the app.
The section asks for two things:
Client type. Choose confidential when your app runs on a server that can keep a secret. It is issued a client secret when you save, shown once; the token and revocation endpoints then require it. Choose public for a browser, mobile or desktop app that cannot keep a secret. A public client has no secret and identifies itself by client ID alone; PKCE is what proves the code exchange came from the app that started the authorization. PKCE is required for both types.
Redirect URLs. The exact URLs the user's browser may be sent back to. Each must be an
absolute https URL with no fragment. For development, http is accepted on a loopback host
(localhost, 127.0.0.1 or [::1]), and a loopback URL may differ from the registered one in
its port only, so a local server on a random port still matches. Every other redirect URL must
match the registered value exactly, including the path and query string.
Your client ID is the app's ID, shown in the section once the client is set up. For a confidential client, store the secret where your server can read it and nowhere else; it cannot be read back, only rotated. Rotating issues a new secret and stops the old one working at once.
The client settings, the secret rotation and removal live on the dashboard only; they are not part of the API an app calls. Removing the client clears the settings and revokes every grant users gave the app, and leaves the app and its API keys in place.
2. Scopes Are Permissions
A scope is a permission from the catalogue, spelled exactly as the
Permission enum spells it: READ_SHIPMENT,
PURCHASE_LABELS, READ_ORDER. Identity flags such as IS_ADMIN cannot be asked for. In the
scope parameter, names are space-separated. The server metadata document (section 9) lists the
permissions AI assistants can ask for under scopes_supported; your own app is not limited to
that list.
Your app may ask only for permissions it holds itself, through the roles granted to it on the Apps page. A request naming anything else is refused before the user sees it.
The consent page shows each requested permission in plain words, grouped by the area of the account it belongs to, so ask only for what your app uses.
A token holds the requested permissions that the approving user holds themselves and that your app still holds. The permission checks on every field and mutation are the ones documented throughout this reference; a token can never do more than either the user who approved it or your app could do, and a role taken from your app narrows its tokens. Two further limits apply to every token:
- An account admin's token is not an admin. Account management (users, roles, apps, API keys, account settings) is not reachable with an OAuth token.
- The grant is bound to the account the user was on when they approved it. If the user later moves to a different ShipGenius account, tokens from the old grant are refused.
3. Send the User to the Authorization Endpoint
Build the authorization URL and send the user's browser to it, as a full-page redirect or a new window.
| Parameter | Required | Value |
|---|---|---|
response_type | Yes | code |
client_id | Yes | Your app's ID |
redirect_uri | Yes | One of the app's registered redirect URLs |
scope | Yes | Space-separated permission names, each one your app holds |
state | No | An unguessable value your app generates per authorization; echoed back on the redirect |
code_challenge | Yes | The base64url-encoded SHA-256 of your code_verifier |
code_challenge_method | Yes | S256; the plain method is not accepted |
Send state on every authorization: it is how your redirect handler tells an authorization it
started from one it did not. The PKCE verifier is a random string of 43 to 128 characters from the
set A-Z a-z 0-9 - . _ ~; the token endpoint refuses a verifier outside that length.
Generate a new one for every authorization and keep it with the state until the code is
exchanged. In a browser or Node:
function base64url(bytes) {
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
const code_verifier = base64url(crypto.getRandomValues(new Uint8Array(48)));
const code_challenge = base64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(code_verifier)));
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
const params = new URLSearchParams({
response_type: "code",
client_id: "0190c3a4-5e2b-7c1d-9e8f-1a2b3c4d5e6f",
redirect_uri: "https://app.example.com/oauth/callback",
scope: "READ_SHIPMENT PURCHASE_LABELS",
state,
code_challenge,
code_challenge_method: "S256",
});
window.location.assign(`https://api.lite.shipgeni.us/oauth/authorize?${params}`);
What the user sees:
- If they are not signed in to ShipGenius, the sign-in page, and then the consent page once they are.
- The consent page: your app's name and the account that owns it, each requested permission in plain words, and the host of the redirect URL they will be sent back to.
- Approve or Deny. Either answer sends the browser to your redirect URL.
A pending request lasts 15 minutes and can be answered once. If the user takes longer, they see a message asking them to start again from your app, and no redirect happens.
If the user already has an active grant for your app, approving a request for additional permissions widens that grant rather than creating a second one. Approving the same permissions again issues a fresh code under the existing grant.
Authorization errors
Two kinds of failure happen before the consent page. A request that names an unknown client, or
a redirect_uri the client has not registered, is answered directly with a 400 and a JSON
body, since there is no registered URL to send the browser to:
{
"error": "invalid_request",
"error_description": "The redirect URI is not registered for this client"
}
Any other problem with the request is reported to your redirect URL, with the state echoed
back, so your app can show the user something:
error | Cause |
|---|---|
unsupported_response_type | response_type was not code |
invalid_request | code_challenge missing, or code_challenge_method not S256 |
invalid_scope | scope empty, naming an unknown or identity permission, or one your app does not hold |
access_denied | The user chose Deny on the consent page |
https://app.example.com/oauth/callback?error=access_denied&error_description=The+user+declined+the+request&state=k3jF9...
4. Handle the Redirect
On approval, the browser arrives at your redirect URL with the code and your state:
https://app.example.com/oauth/callback?code=sgac_authorization_code_value&state=k3jF9...
Before doing anything with the code:
- Compare
statewith the value you stored when you started the authorization. A mismatch means the redirect did not come from an authorization your app began; stop there. - Exchange the code promptly. A code is valid for 10 minutes and for one exchange; a second
exchange of the same code fails, as does an exchange from a different client or with a
different
redirect_urithan the authorization used.
5. Exchange the Code for Tokens
Post the code to the token endpoint as an application/x-www-form-urlencoded body.
| Field | Required | Value |
|---|---|---|
grant_type | Yes | authorization_code |
code | Yes | The code from the redirect |
redirect_uri | Yes | The same redirect_uri the authorization was started with |
code_verifier | Yes | The verifier whose challenge the authorization carried |
client_id | Yes, unless using Basic | Your app's ID |
client_secret | Confidential clients | Your client secret |
A confidential client authenticates either with HTTP Basic authentication (client_id as the
username and client_secret as the password, each URL-encoded) or by sending both as form
fields. A public client sends client_id only.
curl -X POST https://api.lite.shipgeni.us/oauth/token \
-u "0190c3a4-5e2b-7c1d-9e8f-1a2b3c4d5e6f:sgcs_client_secret_value" \
-d grant_type=authorization_code \
-d code=sgac_authorization_code_value \
-d redirect_uri=https://app.example.com/oauth/callback \
-d code_verifier=the_verifier_you_generated
The response is the standard token response. Access tokens carry the sgoa_ prefix and refresh
tokens sgor_.
{
"access_token": "sgoa_access_token_value",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "sgor_refresh_token_value",
"scope": "READ_SHIPMENT PURCHASE_LABELS"
}
| Field | Meaning |
|---|---|
access_token | Send it as a bearer token on GraphQL requests. Valid for one hour. |
refresh_token | Trade it for a new pair when the access token expires. Valid for 30 days, once. |
expires_in | Seconds until the access token expires. |
scope | The permissions the tokens hold, space-separated. |
Store both tokens server-side, encrypted at rest, keyed by the user they belong to. The response
carries Cache-Control: no-store.
Token endpoint errors
Failures return a 4xx status with a JSON body of error and error_description.
| Status | error | Cause |
|---|---|---|
401 | invalid_client | Unknown client ID, a missing or wrong client secret for a confidential client, or the app is no longer an OAuth client |
400 | invalid_request | A required field is missing |
400 | invalid_grant | The code is unknown, spent or expired; the redirect_uri or code_verifier does not match; or the grant has been revoked |
400 | unsupported_grant_type | grant_type was not authorization_code or refresh_token |
A 401 to a request that used Basic authentication also carries a WWW-Authenticate: Basic
header.
6. Call the API
Use the access token exactly as you would an API key, as a bearer token on the public GraphQL
endpoint, /v1/graphql. Everything in this reference is available there; an OAuth token is a
third-party credential and is not accepted on the /graphql endpoint the ShipGenius dashboard
itself uses.
POST https://api.lite.shipgeni.us/v1/graphql
Authorization: Bearer sgoa_access_token_value
Content-Type: application/json
The ping query confirms what the token can do. permissions lists the permissions the token
holds: the approving user's own permissions, cut down to the granted permissions and to what
your app holds.
query {
ping {
name
permissions
current_datetime
}
}
Requests made with the token count as the approving user's activity on their account: labels
bought with a PURCHASE_LABELS token are charged to that account.
A request with a token that has expired or been revoked, or whose grant has been revoked, fails
with a 401-coded error. Revocation is checked on every request, so it takes effect at once; an
expired access token is the signal to refresh.
7. Refresh the Tokens
When the access token expires, post the refresh token to the token endpoint with the same client authentication as the code exchange:
curl -X POST https://api.lite.shipgeni.us/oauth/token \
-u "0190c3a4-5e2b-7c1d-9e8f-1a2b3c4d5e6f:sgcs_client_secret_value" \
-d grant_type=refresh_token \
-d refresh_token=sgor_refresh_token_value
The response is a new token response, with a new refresh token. Refresh tokens rotate: every live token of the grant is retired and a fresh pair is issued, so replace both stored tokens on every refresh. The new pair holds the same permissions as the old one; refreshing cannot change them.
A refresh token may be presented again within 60 seconds of being used, and gets another pair,
so a lost response and a retry recover on their own. Presented again later than that, it is
treated as a leaked token: the request fails with invalid_grant, and the whole grant is
revoked, ending every token under it. The user has to connect your app again. Make sure only
one process refreshes a given user's tokens at a time.
error | Cause |
|---|---|
invalid_grant | The refresh token is unknown, belongs to another client, has expired, was already used, or its grant has been revoked |
8. Revoke Access
When the user disconnects your app from inside your product, revoke the refresh token. Revoking a refresh token ends its grant and every token issued under it; revoking an access token ends that token only.
curl -X POST https://api.lite.shipgeni.us/oauth/revoke \
-u "0190c3a4-5e2b-7c1d-9e8f-1a2b3c4d5e6f:sgcs_client_secret_value" \
-d token=sgor_refresh_token_value
The endpoint returns 200 whether or not the token was still live; only a failure to
authenticate the client is an error (401 invalid_client).
Access can also end without your app's involvement:
- The user revokes it on their Connected apps page, or an admin of their account does.
- Your app's OAuth client is removed, or the app is deleted or deactivated.
- Thirty days pass without a refresh, and the refresh token expires.
In every case the next request with the access token fails with a 401-coded error and the
refresh fails with invalid_grant. Treat that as "disconnected" and send the user through the
authorization flow again when they next need the integration; the consent page issues a fresh
grant.
9. Server Metadata
The authorization server describes itself at the standard discovery path, which client libraries that support RFC 8414 can read instead of being configured by hand:
GET https://api.lite.shipgeni.us/.well-known/oauth-authorization-server
{
"issuer": "https://api.lite.shipgeni.us",
"authorization_endpoint": "https://api.lite.shipgeni.us/oauth/authorize",
"token_endpoint": "https://api.lite.shipgeni.us/oauth/token",
"revocation_endpoint": "https://api.lite.shipgeni.us/oauth/revoke",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
"revocation_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
"scopes_supported": ["CREATE_ORDER", "PURCHASE_LABELS", "READ_ORDER", "READ_SHIPMENT", "VOID_LABELS"]
}
10. Checklist
Before going live with an integration:
- The client is registered on production with your production redirect URLs; the sandbox client is a separate registration.
- The client secret, if any, lives on your server only. A public client never embeds a secret.
- Every authorization uses a fresh
stateand a freshcode_verifier, and the redirect handler checksstatebefore exchanging the code. - Tokens are stored per user, replaced on every refresh, and refreshed by a single process.
- A
401from the API leads to a refresh, and a failed refresh leads back to the authorization flow, not to a retry loop. - The user can disconnect from inside your product, and doing so revokes the refresh token.
- Your app asks for the smallest set of permissions it uses; the consent page shows each one.