API Docs

OAuth Integration Deep Dive

Everything you need to build a production-grade OAuth 2.0 integration with the 3PLGuys API.

How OAuth Works with 3PLGuys

The API uses the Authorization Code grant type — the most secure OAuth 2.0 flow for server-side applications.

OAuth 2.0 Authorization Code Flow
1. Redirect
User → 3PLGuys login
2. Authorize
User grants access
3. Callback
Code → your server
4. Exchange
Code → tokens
5. API Calls
Bearer token

Step 1: Build the Authorization URL

Redirect the user to the 3PLGuys authorization endpoint. The API provides a GET /oauth/authorize endpoint that validates your parameters and redirects the user to the consent page:

https://api.3plguys.com/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=shipments inventory invoices
&state=RANDOM_CSRF_TOKEN
ParameterRequiredDescription
response_typeYesAlways code
client_idYesYour application's client ID
redirect_uriYesWhere to send the user after authorization
scopeNoSpace-separated list of requested scopes
stateRecommendedRandom string to prevent CSRF attacks
code_challengeNoPKCE code challenge (RFC 7636). Base64url-encoded SHA-256 hash of a code verifier.
code_challenge_methodNoMust be S256 if using PKCE.

Security

Always include a state parameter with a cryptographically random value. Verify it matches when the user returns to your callback. This prevents CSRF attacks.

PKCE support

For public clients or additional security, you can use Proof Key for Code Exchange (PKCE). Generate a random code_verifier, derive the code_challenge as its SHA-256 hash (base64url-encoded), and include code_challenge and code_challenge_method=S256 in the authorization URL. You'll pass the code_verifier when exchanging the code for tokens.


Step 2: Handle the Callback

After the user authorizes, they're redirected to your redirect_uri with an authorization code:

https://yourapp.com/callback?code=AUTH_CODE_HERE&state=YOUR_STATE_VALUE
app.get("/callback", async (req, res) => {
const { code, state } = req.query;
// Verify state matches what we stored in the session
if (state !== req.session.oauthState) {
return res.status(403).send("Invalid state parameter");
}
// Exchange code for tokens (Step 3)
const tokens = await exchangeCode(code);
req.session.tokens = tokens;
res.redirect("/dashboard");
});
@app.route("/callback")
def oauth_callback():
code = request.args.get("code")
state = request.args.get("state")
if state != session.get("oauth_state"):
abort(403, "Invalid state parameter")
tokens = exchange_code(code)
session["tokens"] = tokens
return redirect("/dashboard")

Step 3: Exchange Code for Tokens

The token endpoint uses application/x-www-form-urlencoded — not JSON.

curl -X POST https://api.3plguys.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTH_CODE_HERE" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "redirect_uri=https://yourapp.com/callback"
async function exchangeCode(code) {
const res = await fetch("https://api.3plguys.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
redirect_uri: "https://yourapp.com/callback",
}),
});
return res.json();
}
def exchange_code(code: str) -> dict:
res = httpx.post("https://api.3plguys.com/oauth/token", data={
"grant_type": "authorization_code",
"code": code,
"client_id": os.environ["CLIENT_ID"],
"client_secret": os.environ["CLIENT_SECRET"],
"redirect_uri": "https://yourapp.com/callback",
})
return res.json()

If you used PKCE during authorization, include the code_verifier parameter as well:

curl -X POST https://api.3plguys.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTH_CODE_HERE" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "redirect_uri=https://yourapp.com/callback" \
-d "code_verifier=YOUR_CODE_VERIFIER"
{
"token_type": "Bearer",
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJl...",
"scope": "shipments inventory invoices"
}
FieldDescription
token_typeAlways Bearer.
access_tokenUse in the Authorization: Bearer header. Valid for 1 hour.
expires_inSeconds until the access token expires (3600 = 1 hour).
refresh_tokenUse to get new access tokens. Rotates on each use.
scopeThe scopes actually granted (may differ from what you requested).

Step 4: Refreshing Tokens

Access tokens expire after 1 hour. Use the refresh token to get a new one without requiring the user to re-authorize:

curl -X POST https://api.3plguys.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=YOUR_REFRESH_TOKEN" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"
async function refreshTokens(refreshToken) {
const res = await fetch("https://api.3plguys.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
}),
});
return res.json(); // Contains NEW access_token AND refresh_token
}
def refresh_tokens(refresh_token: str) -> dict:
res = httpx.post("https://api.3plguys.com/oauth/token", data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": os.environ["CLIENT_ID"],
"client_secret": os.environ["CLIENT_SECRET"],
})
return res.json() # Contains NEW access_token AND refresh_token

Refresh token rotation

Each refresh returns a new refresh token. Always store the latest one. The previous refresh token is invalidated immediately.


Available Scopes

Request only the scopes your application needs:

ScopeAccess
shipmentsList, create, and manage shipments (SPD + Pick & Pack)
inventoryProducts, cartons, stock levels
locationsWarehouse locations and details
invoicesRead-only access to invoices and line items
notificationsActivity feed and action items
recommendationsAI-powered operational suggestions
user-accountOrganization account details

Production Pattern: Auto-Refresh Wrapper

Build a wrapper that transparently handles token expiry:

class ThreePLClient {
constructor({ clientId, clientSecret, tokens, onTokenRefresh }) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.accessToken = tokens.access_token;
this.refreshToken = tokens.refresh_token;
this.expiresAt = Date.now() + tokens.expires_in * 1000;
this.onTokenRefresh = onTokenRefresh; // Callback to persist new tokens
}
async request(path, options = {}) {
// Proactively refresh if token expires within 5 minutes
if (Date.now() > this.expiresAt - 300_000) {
await this.refresh();
}
const res = await fetch(`https://api.3plguys.com${path}`, {
...options,
headers: {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
...options.headers,
},
});
// Handle unexpected 401 — token may have been revoked
if (res.status === 401) {
await this.refresh();
return fetch(`https://api.3plguys.com${path}`, {
...options,
headers: {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
...options.headers,
},
});
}
return res;
}
async refresh() {
const res = await fetch("https://api.3plguys.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: this.refreshToken,
client_id: this.clientId,
client_secret: this.clientSecret,
}),
});
if (!res.ok) throw new Error("Token refresh failed — user must re-authorize");
const data = await res.json();
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token;
this.expiresAt = Date.now() + data.expires_in * 1000;
// Persist new tokens to your database
if (this.onTokenRefresh) await this.onTokenRefresh(data);
}
}
// Usage
const client = new ThreePLClient({
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
tokens: await db.getTokens(userId),
onTokenRefresh: (tokens) => db.saveTokens(userId, tokens),
});
const shipments = await client.request("/v0/shipments?take=10").then(r => r.json());
import httpx, os, time
class ThreePLClient:
def __init__(self, client_id, client_secret, tokens, on_refresh=None):
self.client_id = client_id
self.client_secret = client_secret
self.access_token = tokens["access_token"]
self.refresh_token = tokens["refresh_token"]
self.expires_at = time.time() + tokens["expires_in"]
self.on_refresh = on_refresh
def request(self, method, path, **kwargs):
if time.time() > self.expires_at - 300:
self._refresh()
res = httpx.request(
method,
f"https://api.3plguys.com{path}",
headers={"Authorization": f"Bearer {self.access_token}"},
**kwargs,
)
if res.status_code == 401:
self._refresh()
res = httpx.request(
method,
f"https://api.3plguys.com{path}",
headers={"Authorization": f"Bearer {self.access_token}"},
**kwargs,
)
res.raise_for_status()
return res
def _refresh(self):
res = httpx.post("https://api.3plguys.com/oauth/token", data={
"grant_type": "refresh_token",
"refresh_token": self.refresh_token,
"client_id": self.client_id,
"client_secret": self.client_secret,
})
data = res.json()
self.access_token = data["access_token"]
self.refresh_token = data["refresh_token"]
self.expires_at = time.time() + data["expires_in"]
if self.on_refresh:
self.on_refresh(data)
# Usage
client = ThreePLClient(
client_id=os.environ["CLIENT_ID"],
client_secret=os.environ["CLIENT_SECRET"],
tokens=db.get_tokens(user_id),
on_refresh=lambda t: db.save_tokens(user_id, t),
)
shipments = client.request("GET", "/v0/shipments", params={"take": 10}).json()

Security Checklist

Before going to production

1

Store secrets securely

Client secret and refresh tokens belong in environment variables or a secrets manager — never in source code, client-side code, or logs.

2

Validate the state parameter

Generate a cryptographic random string, store it in the session, and verify it matches on callback.

3

Use HTTPS everywhere

Your redirect URI must use HTTPS. Never transmit tokens over unencrypted connections.

4

Request minimal scopes

Only request the scopes your application actually needs. You can always request additional scopes later.

5

Handle token revocation gracefully

If a refresh fails with 401, the user may have revoked access. Prompt them to re-authorize instead of crashing.

6

Persist refresh tokens to a database

In-memory token storage is lost on restarts. Always save the latest refresh token to persistent storage.


Error Reference

StatusErrorCause
400invalid_grantAuthorization code expired, already used, or redirect_uri mismatch
400invalid_scopeRequested scope not allowed for your application
401invalid_clientWrong client_id or client_secret
401invalid_tokenAccess token expired or revoked — refresh and retry
429rate_limit_exceededToo many requests — use exponential backoff

Next Steps