Authentication
HappyColis uses OAuth 2.0 for API authentication. This guide covers all grant types, token lifecycle, introspection, and security best practices.
Application Types
Private Applications
Designed for a specific organization. Credentials (client_id / client_secret) are generated through the HappyColis dashboard. Use the client_credentials grant type.
Public Applications
Can be installed by any organization. Use the authorization_code grant type (3-legged OAuth) to obtain tokens on behalf of an organization.
OAuth Endpoints
| Endpoint | Method | Description |
|---|---|---|
/token | POST / GET | Obtain or refresh access tokens |
/oauth2/token | POST | Alternative token endpoint |
/oauth2/me | GET | Token introspection |
/oauth2/grant | GET | Authorization page (public apps) |
Client Credentials Flow (Server-to-Server)
The recommended flow for private backend integrations. Your server exchanges credentials directly for an access token — no user interaction needed.
Request Format
POST /token
Content-Type: application/x-www-form-urlencoded| Parameter | Required | Description |
|---|---|---|
grant_type | ✅ | Must be client_credentials |
client_id | ✅ | Your application's client ID |
client_secret | ✅ | Your application's client secret |
organization_id | ✅ | The organization UUID to authenticate against |
scopes | ❌ | Space-separated scope list (defaults to all client scopes) |
Response Format
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
"expires_in": 3600,
"scope": "view_products,create_orders"
}| Field | Type | Description |
|---|---|---|
access_token | string | JWT bearer token — include in Authorization header |
token_type | string | Always bearer |
refresh_token | string | Opaque token for refreshing; longer-lived (default: 86400s) |
expires_in | integer | Access token lifetime in seconds (default: 3600) |
scope | string | Comma-separated list of granted scopes |
JWT Claims
The access token is a signed JWT with the following claims:
| Claim | Description |
|---|---|
iss | Token ID |
aud | Organization ID |
sub | client://{clientId} for client credentials |
jti | Token ID (same as iss) |
scope | Array of granted scopes |
exp | Expiry timestamp (Unix seconds) |
iat | Issued-at timestamp (Unix seconds) |
client_id | Client application ID |
Code Examples
curl -X POST https://api-v3.happycolis.com/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "organization_id=YOUR_ORGANIZATION_ID"async function getAccessToken(clientId, clientSecret, organizationId) {
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
organization_id: organizationId,
});
const response = await fetch('https://api-v3.happycolis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || 'Authentication failed');
}
return response.json();
// { access_token, token_type, refresh_token, expires_in, scope }
}
const tokens = await getAccessToken('client_id', 'client_secret', 'org_uuid');
console.log(tokens.access_token);import requests
def get_access_token(client_id: str, client_secret: str, organization_id: str) -> dict:
response = requests.post(
'https://api-v3.happycolis.com/token',
headers={'Content-Type': 'application/x-www-form-urlencoded'},
data={
'grant_type': 'client_credentials',
'client_id': client_id,
'client_secret': client_secret,
'organization_id': organization_id,
},
)
response.raise_for_status()
return response.json()
tokens = get_access_token('client_id', 'client_secret', 'org_uuid')
print(tokens['access_token'])function getAccessToken(string $clientId, string $clientSecret, string $organizationId): array
{
$client = new \GuzzleHttp\Client();
$response = $client->post('https://api-v3.happycolis.com/token', [
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
'form_params' => [
'grant_type' => 'client_credentials',
'client_id' => $clientId,
'client_secret' => $clientSecret,
'organization_id' => $organizationId,
],
]);
return json_decode($response->getBody()->getContents(), true);
}
$tokens = getAccessToken('client_id', 'client_secret', 'org_uuid');
echo $tokens['access_token'];func getAccessToken(clientID, clientSecret, orgID string) (map[string]interface{}, error) {
data := url.Values{
"grant_type": {"client_credentials"},
"client_id": {clientID},
"client_secret": {clientSecret},
"organization_id": {orgID},
}
resp, err := http.PostForm("https://api-v3.happycolis.com/token", data)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}Using the Access Token
Include the token in the Authorization header on every API request:
Authorization: Bearer YOUR_ACCESS_TOKENToken Introspection
Verify a token and inspect its claims:
curl -X GET https://api-v3.happycolis.com/oauth2/me \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"Response:
{
"sub": "client://your_client_id",
"aud": "org_123",
"scope": ["view_products", "create_orders"],
"exp": 1699999999,
"iat": 1699996399,
"client_id": "your_client_id"
}The sub field format indicates the token type:
sub prefix | Token type |
|---|---|
client:// | Client credentials token |
user:// | User (password grant) token |
admin:// | Admin user token |
Refresh Token Flow
Access tokens expire after expires_in seconds (default: 3600). Use the refresh token to obtain a new access token without re-authenticating.
Request
POST /token
Content-Type: application/x-www-form-urlencoded| Parameter | Required | Description |
|---|---|---|
grant_type | ✅ | refresh_token |
refresh_token | ✅ | The refresh token from a previous response |
client_id | ✅ | Your application's client ID |
curl -X POST https://api-v3.happycolis.com/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"async function refreshAccessToken(clientId, refreshToken) {
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: clientId,
refresh_token: refreshToken,
});
const response = await fetch('https://api-v3.happycolis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || 'Token refresh failed');
}
return response.json();
}def refresh_access_token(client_id: str, refresh_token: str) -> dict:
response = requests.post(
'https://api-v3.happycolis.com/token',
headers={'Content-Type': 'application/x-www-form-urlencoded'},
data={
'grant_type': 'refresh_token',
'client_id': client_id,
'refresh_token': refresh_token,
},
)
response.raise_for_status()
return response.json()function refreshToken(string $clientId, string $refreshToken): array
{
$client = new \GuzzleHttp\Client();
$response = $client->post('https://api-v3.happycolis.com/token', [
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
'form_params' => [
'grant_type' => 'refresh_token',
'client_id' => $clientId,
'refresh_token' => $refreshToken,
],
]);
return json_decode($response->getBody()->getContents(), true);
}func refreshToken(clientID, refreshToken string) (map[string]interface{}, error) {
data := url.Values{
"grant_type": {"refresh_token"},
"client_id": {clientID},
"refresh_token": {refreshToken},
}
resp, err := http.PostForm("https://api-v3.happycolis.com/token", data)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
return result, nil
}Authorization Code Flow (Public Applications)
Used for public apps that need to act on behalf of an organization (3-legged OAuth).
Step 1: Redirect to Authorization Page
GET https://api-v3.happycolis.com/oauth2/grant
?client_id=YOUR_CLIENT_ID
&organization=ORGANIZATION_ID
&scope=view_products,create_orders
&redirect_uri=https://your-app.com/callback
&state=random_csrf_token| Parameter | Required | Description |
|---|---|---|
client_id | ✅ | Your application's client ID |
organization | ✅ | The organization UUID |
scope | ✅ | Comma-separated list of requested scopes |
redirect_uri | ✅ | URL to redirect after authorization |
state | Recommended | Random string for CSRF protection |
Step 2: Handle the Callback
After the user grants permission, HappyColis redirects to your redirect_uri with:
code— Authorization code (short-lived, single-use)hmac— HMAC-SHA256 signature of the query string
Always verify the HMAC signature before processing the code.
Step 3: Exchange Code for Token
curl -X POST https://api-v3.happycolis.com/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTHORIZATION_CODE" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"OAuth Scopes
Request specific scopes during authentication to limit access. Use * for full access (if the client is configured to allow it).
Catalog Scopes
| Scope | Description |
|---|---|
view_products | View products and variants |
create_products | Create products and variants |
edit_products | Update products and variants |
view_vendors | View vendors |
create_vendors | Create vendors |
edit_vendors | Update vendors |
view_preparation_profiles | View preparation profiles |
create_preparation_profiles | Create preparation profiles |
edit_preparation_profiles | Update preparation profiles |
Order Scopes
| Scope | Description |
|---|---|
view_orders | View orders |
create_orders | Create orders |
edit_orders | Update, cancel, hold, resume orders and request fulfillment |
delete_orders | Delete orders |
manage_orders | Full access to orders |
Stock Scopes
| Scope | Description |
|---|---|
view_stock_references | View stock references and inventory |
create_stock_references | Create stock references |
edit_stock_references | Adjust inventory, update stock references |
delete_stock_references | Delete stock references |
manage_stock_references | Full access to stock |
view_storage_profiles | View storage profiles |
create_storage_profiles | Create storage profiles |
edit_storage_profiles | Update storage profiles |
Location Scopes
| Scope | Description |
|---|---|
view_locations | View locations |
create_locations | Create locations |
edit_locations | Update locations, connect fulfillment services |
delete_locations | Delete locations |
manage_locations | Full access to locations |
Delivery Order Scopes
| Scope | Description |
|---|---|
view_delivery_orders | View delivery orders |
create_delivery_orders | Create delivery orders |
edit_delivery_orders | Update delivery orders, send fulfillment events |
delete_delivery_orders | Delete delivery orders |
manage_delivery_orders | Full access to delivery orders |
Shipment Scopes
| Scope | Description |
|---|---|
view_shipments | View shipments |
create_shipments | Create shipments |
edit_shipments | Update shipments, tracking events |
delete_shipments | Delete shipments |
manage_shipments | Full access to shipments |
Integration & Webhook Scopes
| Scope | Description |
|---|---|
view_integrations | View integrations |
create_integrations | Create integrations |
edit_integrations | Update integrations |
view_integrations_webhooks | View webhooks |
create_integrations_webhooks | Create webhooks |
edit_integrations_webhooks | Update webhooks |
Error Responses
All token errors return HTTP 401 with a JSON body:
Invalid Client Credentials
{
"statusCode": 401,
"message": "Invalid client ID and/or grant type is not allowed"
}Causes:
client_iddoes not existclient_secretis wronggrant_typeis not enabled for this clientorganization_iddoes not match the client's organization binding
Invalid Refresh Token
{
"statusCode": 401,
"message": "Invalid refresh token"
}Causes:
- Refresh token does not exist
- Refresh token has expired (default TTL: 86400s / 24h)
Missing Organization ID
{
"statusCode": 401,
"message": "Client credentials grant type requires an organization ID"
}Cause: organization_id was not included in the request body.
Invalid User Credentials (password grant)
{
"statusCode": 401,
"message": "Invalid user credentials"
}Grant Type Not Allowed
{
"statusCode": 401,
"message": "Grant type not allowed"
}Using an Expired Token
{
"statusCode": 401,
"message": "Unauthorized"
}The JWT verification middleware rejects tokens past their
expclaim.
Security Best Practices
- Keep
client_secretserver-side only. Never expose it in browser code, mobile apps, or version control. - Use HTTPS exclusively. All API endpoints require TLS.
- Implement token refresh proactively. Refresh the access token before it expires rather than waiting for a 401. Check
expires_inand refresh ~60 seconds before expiry. - Request minimum scopes. Only request the scopes your integration actually needs. Limiting scope reduces blast radius if credentials are compromised.
- Rotate credentials periodically. If you suspect a secret leak, rotate immediately via the dashboard.
- Validate HMAC on authorization code callbacks. Always verify the
hmacparameter before exchanging the code for a token (authorization code flow). - Use the
stateparameter in authorization code flow to prevent CSRF attacks. Verify the returnedstatematches what you sent. - Store tokens securely. Prefer secure, encrypted storage over plain environment variables for production deployments.