Authentication
Every request carries a Bearer token in the Authorization header. That token is either an access token for a person signed in to your organisation, or an API key for a server.
| Credential | Who uses it | What it can do |
|---|---|---|
| Access token (JWT) | People, through the dashboard | What their role allows |
| API key | Your servers | The whole API, except managing the team and approving payouts |
Authorization: Bearer <access token or API key>
Signing In
Sign in with an email and password to receive an access token and a refresh token.
curl -X POST http://localhost:4000/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@acmecorp.com",
"password": "securepassword123"
}'
The response holds the tokens and who you are in the organisation:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"role": "OWNER",
"permissions": ["read", "billing.write", "money.move", "money.approve", "settings.developer", "settings.providers", "org.manage", "team.manage"],
"user": { "id": "usr_...", "name": "Jane Doe", "email": "admin@acmecorp.com" },
"tenant": { "id": "ten_...", "name": "Acme Corp", "slug": "acme-corp" }
}
A person who belongs to several organisations signs in to the one they used last; pass tenantId to choose, or switch later with POST /api/auth/switch.
Two-step sign-in
Anyone can turn on two-step sign-in in Settings → Security: scan the QR code with an authenticator app (Google Authenticator, Microsoft Authenticator, 1Password, Authy) and enter the code it shows. You get ten recovery codes, shown once; each one signs you in once if you lose your phone.
With it on, a correct password doesn't sign you in yet. The response asks for the code instead:
{ "mfaRequired": true, "mfaToken": "eyJhbGciOiJIUzI1NiIs..." }
Finish within five minutes with the six-digit code from the app, or a recovery code:
curl -X POST http://localhost:4000/api/auth/mfa/verify \
-H "Content-Type: application/json" \
-d '{ "mfaToken": "eyJhbGciOiJIUzI1NiIs...", "code": "123456" }'
The response is the same as a normal sign-in. Each code works once. Accepting an invitation with an existing account that uses two-step sign-in also needs mfaCode.
Owners and admins can require two-step sign-in for everyone in the organisation (Settings → Security, or PUT /api/auth/mfa/organisation with { "required": true }), once they use it themselves. Until a member turns it on, the only thing they can do is set it up: every other request is refused with 403 and "code": "MFA_SETUP_REQUIRED". While an organisation requires it, its members cannot turn it off. API keys are not affected.
Refreshing the Access Token
Access tokens expire after 1 hour. Exchange the refresh token for a new pair without signing in again.
curl -X POST http://localhost:4000/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}'
const tokens = await client.auth.refreshToken({
refreshToken: session.refreshToken,
});
console.log('New access token:', tokens.accessToken);
tokens = client.auth.refresh_token(refresh_token=session.refresh_token)
print(f"New access token: {tokens.access_token}")
| Token | Lifetime | Setting |
|---|---|---|
| Access token | 1 hour | JWT_EXPIRATION |
| Refresh token | 7 days | JWT_REFRESH_EXPIRATION |
A refresh token stays valid until it expires, and stops working as soon as the person is deactivated or removed from the organisation.
Roles
Signed-in people are held to their role, read fresh on every request, so a role change or removal applies at once.
| Role | Can |
|---|---|
OWNER | Everything. Only owners can make, change or remove other owners |
ADMIN | Everything else: team, payment providers, organisation settings, billing and money |
FINANCE | Billing, collections, payouts and transfers, including approving payouts |
DEVELOPER | API keys, plus read access to everything |
VIEWER | Read only |
API Keys
API keys are for server-to-server calls. Your organisation's first key comes back when you register; create more from the dashboard or the API.
curl -X POST http://localhost:4000/api/tenants/me/api-keys \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk_live_a1b2c3d4..." \
-d '{
"name": "Production Key",
"scopes": ["customers:read", "customers:write"]
}'
const apiKey = await client.apiKeys.create({
name: 'Production Key',
scopes: ['customers:read', 'customers:write'],
});
console.log('API Key:', apiKey.key);
console.log('Key ID:', apiKey.id);
api_key = client.api_keys.create(name="Production Key", scopes=["customers:read", "customers:write"])
print(f"API Key: {api_key.key}")
print(f"Key ID: {api_key.id}")
Scopes
Every key acts only within its scopes; a request outside them is refused with 403.
| Scope | Allows |
|---|---|
read | GET requests anywhere in the API |
write | Every request (write includes read) |
<resource>:read | GET requests to one resource, e.g. customers:read covers /api/customers/... |
<resource>:write | Every request to one resource, e.g. invoices:write |
Give each system the narrowest scopes it needs: a reporting job only needs read. Team management and payout approval always need a signed-in person, whatever the key's scopes.
Your organisation's own key (returned at registration) has full access. Keys are stored only as a hash, so the dashboard shows them masked; rotate the organisation key from Settings → API Keys or with POST /api/tenants/me/api-key/rotate, which returns the new key once and stops the old one at once.
Listing API Keys
curl http://localhost:4000/api/tenants/me/api-keys \
-H "Authorization: Bearer sk_live_a1b2c3d4..."
const keys = await client.apiKeys.list();
for (const key of keys) {
console.log(`${key.name} (${key.id}) - Created: ${key.createdAt}`);
}
for key in client.api_keys.list():
print(f"{key.name} ({key.id}) - Created: {key.created_at}")
Deleting API Keys
Delete a key as soon as it is compromised or no longer needed; requests with it fail immediately.
curl -X DELETE http://localhost:4000/api/tenants/me/api-keys/key_m9n8o7p6q5r4 \
-H "Authorization: Bearer sk_live_a1b2c3d4..."
await client.apiKeys.delete('key_m9n8o7p6q5r4');
client.api_keys.delete("key_m9n8o7p6q5r4")
Security Best Practices
- Keep keys on the server. Never put an API key in browser JavaScript, a mobile app, or a link you give a customer; those call your own backend, which calls PimaBill. For the hosted customer portal, create a short-lived portal link instead (see below).
- Use environment variables. Never commit keys to source control.
export PIMABILL_API_KEY="sk_live_a1b2c3d4e5f6g7h8i9j0..." - One key per service and environment. Separate keys for development, staging and production, and per service, so one can be deleted without touching the others.
- Rotate keys. Create a new key, deploy it, then delete the old one.
- Review usage. Each key records when it was last used.
- Use two-step sign-in for everyone who signs in to the dashboard, and require it for the organisation once your team has it on.
Error Responses
401 Unauthorized
No credentials, or an invalid or expired token or key.
{
"statusCode": 401,
"message": "Invalid credentials"
}
403 Forbidden
The credentials are valid, but the person's role does not allow this.
{
"statusCode": 403,
"message": "Your role (viewer) cannot do this. Ask an owner or admin."
}
429 Too Many Requests
More than 100 requests in a minute from one address.
{
"statusCode": 429,
"message": "ThrottlerException: Too Many Requests"
}
Customer Portal Links
The hosted customer portal is opened with a portal token, never an API key: an API key in a link would give the customer full access to your account. Your server asks for a link for one customer, and hands that link to the customer:
curl -X POST http://localhost:4000/api/portal/customers/{externalId}/sessions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk_live_a1b2c3d4..." \
-d '{ "expiresInMinutes": 60 }'
The response has the token, the ready-to-use url and expiresAt. A portal token works only for that customer's portal routes (their billing, invoices, invoice PDFs, payments and checkout), and for at most 7 days. Links that carry ?key= no longer work.