Authentication and Token Lifecycle
The public token journey is:
API key
-> POST /api/v1/auth/token
-> access token + refresh token
-> Bearer requests
-> POST /api/v1/auth/token/refresh
-> rotated token pair
-> POST /api/v1/auth/token/revoke
An API key is not a Bearer token. Keep API keys, access tokens, and refresh tokens out of URLs, browser bundles, source control, and logs.
The credential lifecycle, end to end:
- The API key comes from the Mailhub application. There is no public operation that creates one.
- The key travels in the request body of the token exchange. Never put it in
the
Authorizationheader. - The exchange returns an access token, a refresh token, and
accessTokenExpiresAtUtcandrefreshTokenExpiresAtUtc. - Protected operations use
Authorization: Bearer <access token>. The access token authenticates as exactly one Account. - On an authentication failure or a planned renewal, refresh once. Refresh returns a new pair, and the previous refresh token is spent — replace both stored tokens before the next request.
- If refresh fails, exchange the API key again. Do not refresh in a loop, and retry the original request only once after a refresh succeeds.
- Revoke is optional and takes the refresh token; discard the pair locally afterwards.
- Keys and tokens do not cross environments — a Sandbox key never produces a Production token.
Exchange an API key for a token pair
POST/api/v1/auth/token
Open token exchange in the API Reference.
This anonymous operation accepts the API key in its JSON request body. Use the configured base URL and synthetic environment-variable values in examples:
- cURL
- JavaScript
- Python
- Java
curl -X POST "${MAILHUB_API_BASE_URL}/api/v1/auth/token" \
-H "Content-Type: application/json" \
-d '{"apiKey":"'"${MAILHUB_API_KEY}"'"}'
const response = await fetch(`${process.env.MAILHUB_API_BASE_URL}/api/v1/auth/token`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({apiKey: process.env.MAILHUB_API_KEY}),
});
const {data: session} = await response.json();
// Retain session.accessToken, session.refreshToken, and both expiry fields.
import os
import requests
response = requests.post(
f"{os.environ['MAILHUB_API_BASE_URL']}/api/v1/auth/token",
json={"apiKey": os.environ["MAILHUB_API_KEY"]},
)
session = response.json()["data"]
# Retain session["accessToken"], session["refreshToken"], and both expiry fields.
// Java 11+ java.net.http — no Mailhub package to install and no third-party client.
var request = HttpRequest.newBuilder()
.uri(URI.create(System.getenv("MAILHUB_API_BASE_URL") + "/api/v1/auth/token"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"apiKey\":\"" + System.getenv("MAILHUB_API_KEY") + "\"}"))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
// Parse response.body() with your own JSON library, then retain data.accessToken,
// data.refreshToken, and both expiry fields.
A successful response uses the public envelope and returns the values to retain for the session:
{
"success": true,
"data": {
"accountId": "<account-id>",
"apiKeyId": "<api-key-id>",
"accessToken": "<access-token>",
"refreshToken": "<refresh-token>",
"accessTokenExpiresAtUtc": "<utc-timestamp>",
"refreshTokenExpiresAtUtc": "<utc-timestamp>"
},
"errors": null
}
Retain the access token, refresh token, and their response expiry fields. The response fields are the source of truth; this portal does not publish token lifetime values.
Use the access token
Send the access token on protected public operations:
Authorization: Bearer <access-token>
Refresh once, then re-authenticate
POST/api/v1/auth/token/refresh
Open token refresh in the API Reference.
Send the retained refresh token in the JSON request body. Like token exchange,
this operation is anonymous — it carries no Authorization header.
- cURL
- JavaScript
- Python
- Java
curl -X POST "${MAILHUB_API_BASE_URL}/api/v1/auth/token/refresh" \
-H "Content-Type: application/json" \
-d '{"refreshToken":"'"${MAILHUB_REFRESH_TOKEN}"'"}'
const response = await fetch(`${process.env.MAILHUB_API_BASE_URL}/api/v1/auth/token/refresh`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({refreshToken: process.env.MAILHUB_REFRESH_TOKEN}),
});
const {data: session} = await response.json();
// Replace BOTH stored tokens before the next request.
import os
import requests
response = requests.post(
f"{os.environ['MAILHUB_API_BASE_URL']}/api/v1/auth/token/refresh",
json={"refreshToken": os.environ["MAILHUB_REFRESH_TOKEN"]},
)
session = response.json()["data"]
# Replace BOTH stored tokens before the next request.
var request = HttpRequest.newBuilder()
.uri(URI.create(System.getenv("MAILHUB_API_BASE_URL") + "/api/v1/auth/token/refresh"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"refreshToken\":\"" + System.getenv("MAILHUB_REFRESH_TOKEN") + "\"}"))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
// Replace BOTH stored tokens with data.accessToken and data.refreshToken.
A successful refresh returns the same fields as token exchange — a new access token and a new refresh token:
{
"success": true,
"data": {
"accountId": "<account-id>",
"apiKeyId": "<api-key-id>",
"accessToken": "<new-access-token>",
"refreshToken": "<new-refresh-token>",
"accessTokenExpiresAtUtc": "<utc-timestamp>",
"refreshTokenExpiresAtUtc": "<utc-timestamp>"
},
"errors": null
}
After a successful refresh, replace both stored tokens with the returned access token and refresh token before making another request. For a failed protected request, make at most one controlled refresh attempt. If refresh fails, re-authenticate with the API key. Do not create an infinite refresh or retry loop.
A failed refresh uses the standard error envelope — 400 for a request that
could not be read, 422 when refreshToken is missing, empty, or
whitespace-only, and 401 for a refresh token that is unrecognized, expired, or
already rotated or revoked.
The 422 is decided by validation before any token lookup; every other refresh
failure is a 401. See Authentication Errors.
Revoke when access should end
POST/api/v1/auth/token/revoke
Open token revocation in the API Reference.
Revocation takes the same anonymous, refresh-token body as refresh does.
- cURL
- JavaScript
- Python
- Java
curl -X POST "${MAILHUB_API_BASE_URL}/api/v1/auth/token/revoke" \
-H "Content-Type: application/json" \
-d '{"refreshToken":"'"${MAILHUB_REFRESH_TOKEN}"'"}'
const response = await fetch(`${process.env.MAILHUB_API_BASE_URL}/api/v1/auth/token/revoke`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({refreshToken: process.env.MAILHUB_REFRESH_TOKEN}),
});
const {data} = await response.json();
// data.isSuccess is the documented operation result.
import os
import requests
response = requests.post(
f"{os.environ['MAILHUB_API_BASE_URL']}/api/v1/auth/token/revoke",
json={"refreshToken": os.environ["MAILHUB_REFRESH_TOKEN"]},
)
result = response.json()["data"]
# result["isSuccess"] is the documented operation result.
var request = HttpRequest.newBuilder()
.uri(URI.create(System.getenv("MAILHUB_API_BASE_URL") + "/api/v1/auth/token/revoke"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"refreshToken\":\"" + System.getenv("MAILHUB_REFRESH_TOKEN") + "\"}"))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
// Read data.isSuccess from response.body() with your own JSON library.
A successful revocation returns the bounded operation result and nothing else:
{
"success": true,
"data": {
"isSuccess": true
},
"errors": null
}
Revoke the refresh session when access should end. Check the public response before discarding your locally retained credentials.
Revoking a token Mailhub has already revoked — by an earlier revoke, or by the
rotation a successful refresh performs — answers 200 again rather than an
error, so a repeated revoke after a lost response is safe. That is the bounded
statement, not blanket idempotency: a token Mailhub does not recognize answers
401, and a missing, empty, or whitespace-only refreshToken answers 422.
Sub-account scoping
The Sub-account must belong to the authenticated Account. Use the same optional scope consistently for one workflow, but do not assume it has one uniform effect across every operation:
- Token exchange validates the supplied Sub-account but does not persist it in either token.
- List Orders filters the list, and Order creation tags the new Order.
- Get Order restricts the lookup to the selected Sub-account: an Order that
belongs to a different Sub-account is reported as
404 Not Found, even though the same Order is returned when the header is omitted. - Rates and Label purchase evaluate the request against the selected Sub-account's carrier availability, which can narrow the carriers on offer. It does not change pricing, and it does not restrict record access.
- Update an Order's status, download/cancel a Label, and refresh/revoke have no effect from a well-formed header.
A malformed header can return 400. Token exchange can return 404 when the
Sub-account is unavailable to the Account and 409 when it is inactive. See
Work with Sub-accounts for the complete
operation-by-operation matrix and the API Reference for each
operation's current header description.
Next step
Continue to Make Your First Request for the safe exchange-then-list flow.