Authentication
Exchange client credentials for a partner token, then a partner token for a corporation token.
Before You Start
Read the following guides before proceeding:
| Guide | Why |
|---|---|
| Getting Started | Integration overview and prerequisites |
| Credentials | Where client_id and client_secret come from |
| Environments | Base URLs per environment |
Overview
The Corporate API uses two tokens. They are not interchangeable, and almost every endpoint accepts only
the second one.
| Partner token | Corporation token | |
|---|---|---|
| Obtained from | POST /api/v1/token | POST /api/v1/corporations/login |
| Proves | Your company's identity | An employee's identity inside one corporation |
| Carries | company_id, azp | Corporation address and id, employee id, role, permission set, chain id |
| Reaches | Four endpoints (listed below) | Every corporation-scoped endpoint |
| Needs identity headers | Yes — X-User-Wallet and X-User-Email | No — identity is in the token |
| Lifetime | 48 hours (172800 seconds) | Set per environment; read exp from the token |
client_id + client_secret → partner token
partner token + user headers → corporation token
corporation token → cards, transfers, employees, roles, activities
Sensitive operations need a second, short-lived action token on top of the corporation token. All
token types are inventoried in Auth Tokens.
Naming across trees. The retail API calls these the S2S token and the user token. Same
two-step shape, different names — see Retail Authentication.
1. Partner Token
Token Exchange
POST /api/v1/token
Request body:
{
"client_id": "wirex-partner-app-123",
"client_secret": "sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"grant_type": "client_credentials"
}| Field | Type | Required | Description |
|---|---|---|---|
client_id | string | Yes | Client identifier issued by Wirex |
client_secret | string | Yes | Client secret issued by Wirex |
grant_type | string | Yes | Must be client_credentials |
audience | string | No | Target API audience. Accepted and not applied — the audience is taken from environment configuration |
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_at": 1739612400
}| Field | Description |
|---|---|
access_token | Partner token |
token_type | Always Bearer |
expires_at | Unix timestamp (seconds) at which the token expires |
Token Characteristics
| Property | Value |
|---|---|
| Algorithm | HMAC-SHA256 |
| Validity | 48 hours (172800 seconds) |
| Scope | partner:full |
Token Claims
{
"iss": "https://api-business.wirexapp.com/srv",
"sub": "wirex-partner-app-123",
"azp": "wirex-partner-app-123",
"aud": ["https://api-business.wirexapp.com"],
"company_id": "550e8400-e29b-41d4-a716-446655440000",
"scope": "partner:full",
"iat": 1739525600,
"exp": 1739612400
}The /srv suffix on iss is what routes the token to the partner validator. A corporation token carries
the issuer without the suffix.
Rate Limiting
POST /api/v1/token is rate limited on two keys, each allowing 3 failed attempts per 60 minutes.
Successful exchanges are not counted, so a healthy integration never reaches the limit.
| Key | Scope |
|---|---|
client_id | Per client |
| Client IP | Per source address, taken from CF-Connecting-IP, then X-Forwarded-For, then the socket address |
The rate-limit rejection is returned as HTTP
500, not429. The body carries
"error_reason": "ErrorTooManyRequests"and
"error_category": {"category": "CategoryInternalFailure", "http_status_code": 500}. Branch on
error_reason, not on the status code, and back off before retrying.
If Wirex has configured authorized IP addresses for your company, calls from those addresses bypass the
per-client limiter. Contact Wirex to register your egress addresses.
Code
const response = await fetch(`${baseUrl}/api/v1/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'client_credentials'
})
});
const { access_token: partnerToken, expires_at: expiresAt } = await response.json();response = requests.post(
f"{base_url}/api/v1/token",
json={
"client_id": client_id,
"client_secret": client_secret,
"grant_type": "client_credentials",
},
)
data = response.json()
partner_token, expires_at = data["access_token"], data["expires_at"]body, _ := json.Marshal(map[string]string{
"client_id": clientID,
"client_secret": clientSecret,
"grant_type": "client_credentials",
})
resp, _ := http.Post(baseURL+"/api/v1/token", "application/json", bytes.NewBuffer(body))
defer resp.Body.Close()
var tokenResp struct {
AccessToken string `json:"access_token"`
ExpiresAt int64 `json:"expires_at"`
}
json.NewDecoder(resp.Body).Decode(&tokenResp)Cache the partner token until shortly before expires_at. Re-exchanging on every request wastes the
allowance you may need after a credential rotation.
2. Endpoints the Partner Token Reaches
| Endpoint | Purpose |
|---|---|
GET /api/v1/config/tokens | Token catalogue for your company |
GET /api/v1/employees/corporations | Corporations the identified employee belongs to |
POST /api/v1/corporations/register | Register a corporation |
POST /api/v1/corporations/login | Obtain a corporation token |
Every other endpoint rejects a partner token with 401 ErrorNotSupported
(No authorization provider found for requested method).
Identity Headers
A partner token identifies your company, not a person. The endpoints above resolve the acting employee
from headers:
| Header | Value |
|---|---|
X-User-Wallet | The employee's EOA address — the signer of the corporation wallet, not the wallet address |
X-User-Email | The employee's email address |
X-User-Id | The employee's Wirex employee id (UUID) |
X-Chain-Id | Target chain id. Optional; the environment's default chain is used when omitted |
At least one identifier header is required. Sending none returns 400 ErrorMissingField with
"error_details": [{"key": "field", "details": "X-User-Email|X-User-Wallet|X-User-Id"}].
POST /api/v1/corporations/registerandPOST /api/v1/corporations/loginneed two headers, not
one. Both read the employee address and the employee email from the request. Sending only one of
X-User-Wallet/X-User-Emailpasses the authorization check and then fails inside the handler with
500 ErrorGeneralandFailed to get user from context. Always send both on these two endpoints.
GET /api/v1/employees/corporations accepts any single identifier. It resolves in order: wallet address
first, then email, then employee id.
3. Corporation Token
Login
POST /api/v1/corporations/login
Headers:
| Header | Value |
|---|---|
Authorization | Bearer <partner_token> |
X-User-Wallet | Employee EOA address |
X-User-Email | Employee email address |
X-Chain-Id | Chain the corporation token is bound to |
Request body:
{
"corporation_address": "0xA7E41d5680dE394EaA2ed417169DFf56840Fb3EE"
}| Field | Type | Required | Description |
|---|---|---|---|
corporation_address | string | Yes | Address of the corporation wallet. Must match ^(0x[a-fA-F0-9]{40}|C[A-Za-z2-7]{55}|G[A-Za-z2-7]{55}|T[1-9A-HJ-NP-Za-km-z]{33})$ |
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIs..."
}| Field | Description |
|---|---|
access_token | Corporation token. The response carries no expiry — read the exp claim from the token |
What Login Does
- Looks up the employee by
X-User-Walletwithin the corporation. If no record matches, it retries by
X-User-Email. - When the record was found by email, it must be in status
Invitedand hold no wallet address —
otherwise login is refused with401andEmployee registration is already completed. - Refuses a
Disabledemployee with401andEmployee is disabled. - Completes the registration of an
Invitedemployee, bindingX-User-WalletandX-User-Emailto
the employee record. This is how an invited employee claims their account — there is no separate
accept-invitation endpoint. - Reads the corporation, its primary wallet and the employee's role, then mints the token.
Token Claims
{
"iss": "https://api-business.wirexapp.com",
"sub": "64120850-73a1-4df5-a074-d463258c9deb",
"aud": ["https://api-business.wirexapp.com"],
"azp": "wirex-partner-app-123",
"company_id": "550e8400-e29b-41d4-a716-446655440000",
"corporation_address": "0xA7E41d5680dE394EaA2ed417169DFf56840Fb3EE",
"corporation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"corporation_account_address": "0xAAFF0821A09A1Aac28B72dD3Ff410A7ea5FEb874",
"employee_id": "64120850-73a1-4df5-a074-d463258c9deb",
"employee_address": "0xA7E41d5680dE394EaA2ed417169DFf56840Fb3EE",
"employee_email": "[email protected]",
"role": "Owner",
"role_id": "ed7a5845-8727-4b06-9ba0-7d43ddc9e6aa",
"permissions": { "101": true },
"chain_id": "8453",
"scope": "openid profile email",
"provider": "self",
"iat": 1739525600,
"exp": 1739612400
}| Claim | Description |
|---|---|
corporation_address | The corporation's wallet address — the on-chain identity |
corporation_id | The corporation's Wirex id (UUID) |
corporation_account_address | The corporation's primary wallet address, used as the funding source |
employee_id | Employee id, also the sub |
role / role_id | Role name and id at the time of login |
permissions | Permission set resolved from the role, as a map of permission code to true |
chain_id | Chain fixed at login from X-Chain-Id |
Permissions are frozen at login. Changing an employee's role does not change a token already
issued to them. AfterPUT /api/v1/employees/{employeeId}/role, that employee must log in again
before the new permissions apply.
Usage
Authorization: Bearer <corporation_token>
Content-Type: application/json
No identity headers and no X-Chain-Id are needed — both are in the token.
Code
const response = await fetch(`${baseUrl}/api/v1/corporations/login`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${partnerToken}`,
'Content-Type': 'application/json',
'X-User-Wallet': userEoaAddress,
'X-User-Email': userEmail,
'X-Chain-Id': chainId
},
body: JSON.stringify({ corporation_address: corporationAddress })
});
const { access_token: corporationToken } = await response.json();response = requests.post(
f"{base_url}/api/v1/corporations/login",
headers={
"Authorization": f"Bearer {partner_token}",
"Content-Type": "application/json",
"X-User-Wallet": user_eoa_address,
"X-User-Email": user_email,
"X-Chain-Id": chain_id,
},
json={"corporation_address": corporation_address},
)
corporation_token = response.json()["access_token"]body, _ := json.Marshal(map[string]string{"corporation_address": corporationAddress})
req, _ := http.NewRequest("POST", baseURL+"/api/v1/corporations/login", bytes.NewBuffer(body))
req.Header.Set("Authorization", "Bearer "+partnerToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-Wallet", userEoaAddress)
req.Header.Set("X-User-Email", userEmail)
req.Header.Set("X-Chain-Id", chainId)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var loginResp struct {
AccessToken string `json:"access_token"`
}
json.NewDecoder(resp.Body).Decode(&loginResp)4. Choosing a Corporation
One employee can belong to several corporations. List them before logging in:
GET /api/v1/employees/corporations
Response:
{
"corporation_descriptors": [
{
"corporation_address": "0xA7E41d5680dE394EaA2ed417169DFf56840Fb3EE",
"corporation_name": "Acme Inc."
}
]
}| Field | Description |
|---|---|
corporation_descriptors[].corporation_address | Value to send as corporation_address at login |
corporation_descriptors[].corporation_name | Registered name of the corporation |
A corporation whose record cannot be read is skipped rather than failing the call, so a short list is
not proof that the employee belongs to nothing else.
5. Other Authentication Providers
Wirex also accepts Privy and Crossmint tokens on the corporation-agnostic endpoints
(/api/v1/corporations/register, /api/v1/corporations/login, /api/v1/employees/corporations), for
integrations where employees sign in through one of those providers in your own front end.
Both take the acting employee from the provider's identity token — the wallet address and email are
read from its linked_accounts claim — so the X-User-* headers are not sent on this path. The outcome
is the same corporation token, and everything after login is identical.
Provider configuration is part of onboarding. Contact Wirex before building against this path.
6. Anonymous Endpoints
These endpoints take no Authorization header:
| Endpoint | Purpose |
|---|---|
GET /api/v1/config | Client and chain configuration |
GET /api/v1/global/capabilities | Capability matrix per country |
GET /api/v1/validation/rules | Field validation regexes |
7. Security Practices
- Store
client_secretin a secrets manager; never in source control or client-side code - Cache the partner token until shortly before
expires_at, and refresh with a margin - A corporation token carries an employee's permissions — treat it as that employee's credential
- Never log a full token; truncate for debugging
- Use HTTPS with TLS 1.2 or later and validate certificates
Error Handling
{
"error_reason": "ErrorPermissionDenied",
"error_description": "Invalid client credentials",
"error_category": {
"category": "CategoryUnauthorized",
"http_status_code": 401
},
"error_details": [
{ "key": "field", "details": "client_secret" }
]
}Error Categories
| Category | HTTP | Meaning |
|---|---|---|
CategoryValidationFailure | 400 | Invalid request data, missing fields, format errors |
CategoryUnauthorized | 401 | Authentication failure |
CategoryForbidden | 403 | Authenticated, but the role lacks the required permission |
CategoryTransientFailure | 429 | Transient rejection |
CategoryInternalFailure | 500 | Server configuration or internal error |
CategoryTransportFailure | 503 | Downstream service unavailable |
Partner Token Exchange (POST /api/v1/token)
POST /api/v1/token)| HTTP | Reason | Message | Resolution |
|---|---|---|---|
| 400 | ErrorMissingField | client id is required | Send client_id |
| 400 | ErrorMissingField | client secret is required | Send client_secret |
| 400 | ErrorInvalidField | Invalid value for grant type | Set grant_type to client_credentials |
| 401 | ErrorPermissionDenied | Nice guess, but no, we don't know you. | The client_id is not registered — check the environment |
| 401 | ErrorPermissionDenied | Invalid client credentials | Wrong client_secret |
| 401 | ErrorPermissionDenied | Client credentials access is not enabled for this account | No secret configured for the client — contact Wirex |
| 500 | ErrorConfigurationInvalid | You're in the wrong place. Please go to retail api with this kind of requests. | The client_id is a retail company. Use the retail API — For Retail Developers |
| 500 | ErrorTooManyRequests | Woah! Hold on a second... | Rate limit reached after 3 failed attempts. Wait and retry |
Requests Carrying a Partner Token
| HTTP | Reason | Message | Resolution |
|---|---|---|---|
| 400 | ErrorMissingField | Missing Authorization Headers | Add the Authorization header |
| 400 | ErrorInvalidField | Authorization token is not in the correct format | Use Bearer <token> |
| 400 | ErrorMissingField | No valid user identifier provided | Add X-User-Wallet, X-User-Email or X-User-Id |
| 401 | ErrorExpired | Token is expired | Exchange credentials again |
| 401 | ErrorInvalidField | Failed to parse token | The token is malformed or signed with a different secret |
| 401 | ErrorPermissionDenied | Token signature is not valid | Token was tampered with |
| 401 | ErrorMissingField | Client id claim is missing | Malformed token — re-exchange |
| 401 | ErrorMissingField | Company id claim is missing | Malformed token — re-exchange |
| 401 | ErrorNotSupported | No authorization provider found for requested method | The endpoint does not accept a partner token — log in first |
| 500 | ErrorConfigurationInvalid | Company for partner client not found | Company configuration mismatch — contact Wirex |
Login (POST /api/v1/corporations/login)
POST /api/v1/corporations/login)| HTTP | Reason | Message | Resolution |
|---|---|---|---|
| 400 | ErrorMissingField | corporation address is required | Send corporation_address |
| 400 | ErrorInvalidField | Invalid format for corporation address | Use a valid address for the chain |
| 401 | ErrorGeneral | Employee is disabled | The employee was disabled in this corporation |
| 401 | ErrorGeneral | Employee registration is already completed | The email matches an employee already bound to a different wallet |
| 500 | ErrorGeneral | Failed to get user from context | X-User-Wallet or X-User-Email is missing — send both |
| 500 | ErrorGeneral | Failed to query employee | The employee does not exist in this corporation |
| 500 | ErrorGeneral | Failed to query corporation wallets | The corporation has no primary wallet — complete on-chain registration |
Requests Carrying a Corporation Token
| HTTP | Reason | Message | Resolution |
|---|---|---|---|
| 401 | ErrorExpired | Token is expired | Log in again |
| 401 | ErrorPermissionDenied | Token signature is not valid | Token was tampered with |
| 401 | ErrorMissingField | Corporation address claim is missing | Malformed token — log in again |
| 401 | ErrorMissingField | Corporation id claim is missing | Malformed token — log in again |
| 401 | ErrorMissingField | Corporation account claim is missing | Malformed token — log in again |
| 401 | ErrorMissingField | Employee id claim is missing | Malformed token — log in again |
| 401 | ErrorMissingField | Employee email claim is missing | Malformed token — log in again |
| 401 | ErrorMissingField | Employee address claim is missing | The employee has no wallet bound — log in with X-User-Wallet |
| 403 | ErrorPermissionDenied | User does not have any permissions | The employee's role grants nothing |
| 403 | ErrorPermissionDenied | User does not have required permissions | See Permissions |
Updated 20 days ago

