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:

  1. Your app sends the user's browser to the authorization endpoint.
  2. The user signs in to ShipGenius and answers a consent page.
  3. The browser comes back to your redirect URL with a one-time authorization code.
  4. Your app exchanges the code at the token endpoint for an access token and a refresh token.
  5. 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.

EndpointPath
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.

ParameterRequiredValue
response_typeYescode
client_idYesYour app's ID
redirect_uriYesOne of the app's registered redirect URLs
scopeYesSpace-separated permission names, each one your app holds
stateNoAn unguessable value your app generates per authorization; echoed back on the redirect
code_challengeYesThe base64url-encoded SHA-256 of your code_verifier
code_challenge_methodYesS256; 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:

  1. If they are not signed in to ShipGenius, the sign-in page, and then the consent page once they are.
  2. 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.
  3. 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:

errorCause
unsupported_response_typeresponse_type was not code
invalid_requestcode_challenge missing, or code_challenge_method not S256
invalid_scopescope empty, naming an unknown or identity permission, or one your app does not hold
access_deniedThe 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 state with 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_uri than the authorization used.

5. Exchange the Code for Tokens

Post the code to the token endpoint as an application/x-www-form-urlencoded body.

FieldRequiredValue
grant_typeYesauthorization_code
codeYesThe code from the redirect
redirect_uriYesThe same redirect_uri the authorization was started with
code_verifierYesThe verifier whose challenge the authorization carried
client_idYes, unless using BasicYour app's ID
client_secretConfidential clientsYour 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"
}
FieldMeaning
access_tokenSend it as a bearer token on GraphQL requests. Valid for one hour.
refresh_tokenTrade it for a new pair when the access token expires. Valid for 30 days, once.
expires_inSeconds until the access token expires.
scopeThe 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.

StatuserrorCause
401invalid_clientUnknown client ID, a missing or wrong client secret for a confidential client, or the app is no longer an OAuth client
400invalid_requestA required field is missing
400invalid_grantThe code is unknown, spent or expired; the redirect_uri or code_verifier does not match; or the grant has been revoked
400unsupported_grant_typegrant_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.

errorCause
invalid_grantThe 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 state and a fresh code_verifier, and the redirect handler checks state before exchanging the code.
  • Tokens are stored per user, replaced on every refresh, and refreshed by a single process.
  • A 401 from 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.