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:

GuideWhy
Getting StartedIntegration overview and prerequisites
CredentialsWhere client_id and client_secret come from
EnvironmentsBase URLs per environment

Overview

The Corporate API uses two tokens. They are not interchangeable, and almost every endpoint accepts only
the second one.

Partner tokenCorporation token
Obtained fromPOST /api/v1/tokenPOST /api/v1/corporations/login
ProvesYour company's identityAn employee's identity inside one corporation
Carriescompany_id, azpCorporation address and id, employee id, role, permission set, chain id
ReachesFour endpoints (listed below)Every corporation-scoped endpoint
Needs identity headersYes — X-User-Wallet and X-User-EmailNo — identity is in the token
Lifetime48 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"
}
FieldTypeRequiredDescription
client_idstringYesClient identifier issued by Wirex
client_secretstringYesClient secret issued by Wirex
grant_typestringYesMust be client_credentials
audiencestringNoTarget API audience. Accepted and not applied — the audience is taken from environment configuration

Response:

{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_at": 1739612400
}
FieldDescription
access_tokenPartner token
token_typeAlways Bearer
expires_atUnix timestamp (seconds) at which the token expires

Token Characteristics

PropertyValue
AlgorithmHMAC-SHA256
Validity48 hours (172800 seconds)
Scopepartner: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.

KeyScope
client_idPer client
Client IPPer source address, taken from CF-Connecting-IP, then X-Forwarded-For, then the socket address

The rate-limit rejection is returned as HTTP 500, not 429. 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

EndpointPurpose
GET /api/v1/config/tokensToken catalogue for your company
GET /api/v1/employees/corporationsCorporations the identified employee belongs to
POST /api/v1/corporations/registerRegister a corporation
POST /api/v1/corporations/loginObtain 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:

HeaderValue
X-User-WalletThe employee's EOA address — the signer of the corporation wallet, not the wallet address
X-User-EmailThe employee's email address
X-User-IdThe employee's Wirex employee id (UUID)
X-Chain-IdTarget 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/register and POST /api/v1/corporations/login need 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-Email passes the authorization check and then fails inside the handler with
500 ErrorGeneral and Failed 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:

HeaderValue
AuthorizationBearer <partner_token>
X-User-WalletEmployee EOA address
X-User-EmailEmployee email address
X-Chain-IdChain the corporation token is bound to

Request body:

{
  "corporation_address": "0xA7E41d5680dE394EaA2ed417169DFf56840Fb3EE"
}
FieldTypeRequiredDescription
corporation_addressstringYesAddress 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..."
}
FieldDescription
access_tokenCorporation token. The response carries no expiry — read the exp claim from the token

What Login Does

  1. Looks up the employee by X-User-Wallet within the corporation. If no record matches, it retries by
    X-User-Email.
  2. When the record was found by email, it must be in status Invited and hold no wallet address —
    otherwise login is refused with 401 and Employee registration is already completed.
  3. Refuses a Disabled employee with 401 and Employee is disabled.
  4. Completes the registration of an Invited employee, binding X-User-Wallet and X-User-Email to
    the employee record. This is how an invited employee claims their account — there is no separate
    accept-invitation endpoint.
  5. 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
}
ClaimDescription
corporation_addressThe corporation's wallet address — the on-chain identity
corporation_idThe corporation's Wirex id (UUID)
corporation_account_addressThe corporation's primary wallet address, used as the funding source
employee_idEmployee id, also the sub
role / role_idRole name and id at the time of login
permissionsPermission set resolved from the role, as a map of permission code to true
chain_idChain 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. After PUT /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."
    }
  ]
}
FieldDescription
corporation_descriptors[].corporation_addressValue to send as corporation_address at login
corporation_descriptors[].corporation_nameRegistered 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:

EndpointPurpose
GET /api/v1/configClient and chain configuration
GET /api/v1/global/capabilitiesCapability matrix per country
GET /api/v1/validation/rulesField validation regexes

7. Security Practices

  • Store client_secret in 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

CategoryHTTPMeaning
CategoryValidationFailure400Invalid request data, missing fields, format errors
CategoryUnauthorized401Authentication failure
CategoryForbidden403Authenticated, but the role lacks the required permission
CategoryTransientFailure429Transient rejection
CategoryInternalFailure500Server configuration or internal error
CategoryTransportFailure503Downstream service unavailable

Partner Token Exchange (POST /api/v1/token)

HTTPReasonMessageResolution
400ErrorMissingFieldclient id is requiredSend client_id
400ErrorMissingFieldclient secret is requiredSend client_secret
400ErrorInvalidFieldInvalid value for grant typeSet grant_type to client_credentials
401ErrorPermissionDeniedNice guess, but no, we don't know you.The client_id is not registered — check the environment
401ErrorPermissionDeniedInvalid client credentialsWrong client_secret
401ErrorPermissionDeniedClient credentials access is not enabled for this accountNo secret configured for the client — contact Wirex
500ErrorConfigurationInvalidYou'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
500ErrorTooManyRequestsWoah! Hold on a second...Rate limit reached after 3 failed attempts. Wait and retry

Requests Carrying a Partner Token

HTTPReasonMessageResolution
400ErrorMissingFieldMissing Authorization HeadersAdd the Authorization header
400ErrorInvalidFieldAuthorization token is not in the correct formatUse Bearer <token>
400ErrorMissingFieldNo valid user identifier providedAdd X-User-Wallet, X-User-Email or X-User-Id
401ErrorExpiredToken is expiredExchange credentials again
401ErrorInvalidFieldFailed to parse tokenThe token is malformed or signed with a different secret
401ErrorPermissionDeniedToken signature is not validToken was tampered with
401ErrorMissingFieldClient id claim is missingMalformed token — re-exchange
401ErrorMissingFieldCompany id claim is missingMalformed token — re-exchange
401ErrorNotSupportedNo authorization provider found for requested methodThe endpoint does not accept a partner token — log in first
500ErrorConfigurationInvalidCompany for partner client not foundCompany configuration mismatch — contact Wirex

Login (POST /api/v1/corporations/login)

HTTPReasonMessageResolution
400ErrorMissingFieldcorporation address is requiredSend corporation_address
400ErrorInvalidFieldInvalid format for corporation addressUse a valid address for the chain
401ErrorGeneralEmployee is disabledThe employee was disabled in this corporation
401ErrorGeneralEmployee registration is already completedThe email matches an employee already bound to a different wallet
500ErrorGeneralFailed to get user from contextX-User-Wallet or X-User-Email is missing — send both
500ErrorGeneralFailed to query employeeThe employee does not exist in this corporation
500ErrorGeneralFailed to query corporation walletsThe corporation has no primary wallet — complete on-chain registration

Requests Carrying a Corporation Token

HTTPReasonMessageResolution
401ErrorExpiredToken is expiredLog in again
401ErrorPermissionDeniedToken signature is not validToken was tampered with
401ErrorMissingFieldCorporation address claim is missingMalformed token — log in again
401ErrorMissingFieldCorporation id claim is missingMalformed token — log in again
401ErrorMissingFieldCorporation account claim is missingMalformed token — log in again
401ErrorMissingFieldEmployee id claim is missingMalformed token — log in again
401ErrorMissingFieldEmployee email claim is missingMalformed token — log in again
401ErrorMissingFieldEmployee address claim is missingThe employee has no wallet bound — log in with X-User-Wallet
403ErrorPermissionDeniedUser does not have any permissionsThe employee's role grants nothing
403ErrorPermissionDeniedUser does not have required permissionsSee Permissions

Did this page help you?