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.
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
| Parameter | Required | Description |
|---|---|---|
| response_type | Yes | Always code |
| client_id | Yes | Your application's client ID |
| redirect_uri | Yes | Where to send the user after authorization |
| scope | No | Space-separated list of requested scopes |
| state | Recommended | Random string to prevent CSRF attacks |
| code_challenge | No | PKCE code challenge (RFC 7636). Base64url-encoded SHA-256 hash of a code verifier. |
| code_challenge_method | No | Must 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 sessionif (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"] = tokensreturn 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"}
| Field | Description |
|---|---|
| token_type | Always Bearer. |
| access_token | Use in the Authorization: Bearer header. Valid for 1 hour. |
| expires_in | Seconds until the access token expires (3600 = 1 hour). |
| refresh_token | Use to get new access tokens. Rotates on each use. |
| scope | The 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:
| Scope | Access |
|---|---|
| shipments | List, create, and manage shipments (SPD + Pick & Pack) |
| inventory | Products, cartons, stock levels |
| locations | Warehouse locations and details |
| invoices | Read-only access to invoices and line items |
| notifications | Activity feed and action items |
| recommendations | AI-powered operational suggestions |
| user-account | Organization 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 minutesif (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 revokedif (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 databaseif (this.onTokenRefresh) await this.onTokenRefresh(data);}}// Usageconst 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, timeclass ThreePLClient:def __init__(self, client_id, client_secret, tokens, on_refresh=None):self.client_id = client_idself.client_secret = client_secretself.access_token = tokens["access_token"]self.refresh_token = tokens["refresh_token"]self.expires_at = time.time() + tokens["expires_in"]self.on_refresh = on_refreshdef 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 resdef _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)# Usageclient = 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
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.
Validate the state parameter
Generate a cryptographic random string, store it in the session, and verify it matches on callback.
Use HTTPS everywhere
Your redirect URI must use HTTPS. Never transmit tokens over unencrypted connections.
Request minimal scopes
Only request the scopes your application actually needs. You can always request additional scopes later.
Handle token revocation gracefully
If a refresh fails with 401, the user may have revoked access. Prompt them to re-authorize instead of crashing.
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
| Status | Error | Cause |
|---|---|---|
| 400 | invalid_grant | Authorization code expired, already used, or redirect_uri mismatch |
| 400 | invalid_scope | Requested scope not allowed for your application |
| 401 | invalid_client | Wrong client_id or client_secret |
| 401 | invalid_token | Access token expired or revoked — refresh and retry |
| 429 | rate_limit_exceeded | Too many requests — use exponential backoff |
Next Steps
- Getting Started — Make your first API call in under 5 minutes
- Automating Pick & Pack — Use your auth client to automate order fulfillment
- Connecting Shopify — Build a Shopify integration with OAuth tokens
- Building AI Integrations — Power AI agents with 3PLGuys API access