Step 2 of 5
Authentication
Sign a JWT, exchange it for an authentication token in Vanguard, authorize an account to get the authorization token the Invoice API requires, and make a first call with it.
Concepts
Four ideas explain how Waya's authentication is organized:
- An integration is your software, identified by your Integration ID. One integration can reach several organizations.
- An organization is a creditor, which these docs also call a client.
- An organization has one or more accounts, for example one per service it uses at Waya.
- An account belongs to exactly one organization.
You call the Invoice Tools API on behalf of an account. Vanguard, Waya's authentication service, checks who you are and which accounts you may act on.
The flow
- Sign a JWT with your key material.
- Send the JWT to Vanguard. It returns an authentication token.
- Use the authentication token to authorize an account. Vanguard returns an authorization token.
- Call the Invoice API with the authorization token.
Two more calls are optional: finding your account and verifying a token.
Two tokens, two lifetimes
The authentication token identifies your integration and can authorize several accounts. It lasts about a week. The authorization token is tied to an account, and it is the token the Invoice API accepts. It lasts about 24 hours.
Both lifetimes are approximate. Build renewal into your integration instead of counting on exact times, as described under Renewal guidance.
Sign a JWT
Your app proves who it is by signing a JWT with the key material from Waya. Sign with the HS512 algorithm.
Keep the key material secret
The key material signs every token your integration creates. Never commit it to version control, put it in client-side code or share it. Keep your .env file out of version control as well.
These are the parts of the token:
| Part | Name | Value |
|---|---|---|
| Header | alg | HS512 |
| Header | kid | Your Key ID |
| Claim | iss | Your Issuer URL |
| Claim | iid | Your Integration ID |
| Claim | iat | When the JWT was issued, in Unix seconds |
| Claim | exp | When the JWT expires, in Unix seconds |
The JWT is used once, to get an authentication token, so a short lifetime is enough. The samples set exp to 10 minutes after iat.
The key material is base64url
The key material is encoded as base64url, which differs slightly from the default base64: it uses - and _ instead of + and /, and it can leave out the = padding. Decode it as base64url first, then use the decoded bytes as the HS512 key. A standard base64 decoder rejects the text or gives the wrong key. Signing with the encoded text instead of the decoded bytes uses a different key, so Vanguard cannot verify the signature.
Each sample reads the WAYA_* variables from Get started and prints a JWT. They use these libraries:
| Language | Library | Version |
|---|---|---|
| Java | jjwt | 0.12 (jjwt-api, with jjwt-impl and jjwt-jackson at runtime) |
| Node.js | jose | 5 |
| Python | PyJWT | 2 |
| C# | Microsoft.IdentityModel.JsonWebTokens | 7 or 8, on .NET 8 |
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.Date;
import javax.crypto.SecretKey;
public class WayaJwt {
static String createJwt(
String integrationId, String issuerUrl, String keyId, String keyMaterial, int minutes) {
// The key material is base64url, so use the URL decoder, not Base64.getDecoder().
SecretKey key = Keys.hmacShaKeyFor(Base64.getUrlDecoder().decode(keyMaterial));
Instant now = Instant.now();
return Jwts.builder()
.issuer(issuerUrl)
.header().keyId(keyId).and()
.claim("iid", integrationId)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(minutes, ChronoUnit.MINUTES)))
// Name HS512 explicitly, so the algorithm never depends on the key length.
.signWith(key, Jwts.SIG.HS512)
.compact();
}
public static void main(String[] args) {
System.out.println(createJwt(
System.getenv("WAYA_INTEGRATION_ID"),
System.getenv("WAYA_ISSUER_URL"),
System.getenv("WAYA_KEY_ID"),
System.getenv("WAYA_KEY_MATERIAL"),
10));
}
}
// sign-jwt.mjs, for Node.js 18 or later
import { SignJWT } from 'jose';
const { WAYA_INTEGRATION_ID, WAYA_ISSUER_URL, WAYA_KEY_ID, WAYA_KEY_MATERIAL } = process.env;
async function createJwt({ integrationId, issuerUrl, keyId, keyMaterial, minutes = 10 }) {
// The key material is base64url, which Buffer decodes directly.
const key = Buffer.from(keyMaterial, 'base64url');
return new SignJWT({ iid: integrationId })
.setProtectedHeader({ alg: 'HS512', kid: keyId })
.setIssuer(issuerUrl)
.setIssuedAt()
.setExpirationTime(`${minutes}m`)
.sign(key);
}
console.log(await createJwt({
integrationId: WAYA_INTEGRATION_ID,
issuerUrl: WAYA_ISSUER_URL,
keyId: WAYA_KEY_ID,
keyMaterial: WAYA_KEY_MATERIAL,
}));
import base64
import os
import time
import jwt # PyJWT
def base64url_decode(value: str) -> bytes:
# The key material is base64url. urlsafe_b64decode needs the padding, so add it back.
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def create_jwt(
integration_id: str, issuer_url: str, key_id: str, key_material: str, minutes: int = 10
) -> str:
now = int(time.time())
claims = {
"iss": issuer_url,
"iid": integration_id,
"iat": now,
"exp": now + minutes * 60,
}
return jwt.encode(
claims,
base64url_decode(key_material),
algorithm="HS512",
headers={"kid": key_id},
)
if __name__ == "__main__":
print(create_jwt(
os.environ["WAYA_INTEGRATION_ID"],
os.environ["WAYA_ISSUER_URL"],
os.environ["WAYA_KEY_ID"],
os.environ["WAYA_KEY_MATERIAL"],
))
using System;
using System.Collections.Generic;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
static string CreateJwt(
string integrationId, string issuerUrl, string keyId, string keyMaterial, int minutes = 10)
{
// The key material is base64url. KeyId becomes the "kid" header.
var key = new SymmetricSecurityKey(Base64UrlEncoder.DecodeBytes(keyMaterial)) { KeyId = keyId };
var now = DateTime.UtcNow;
var descriptor = new SecurityTokenDescriptor
{
Issuer = issuerUrl,
IssuedAt = now,
Expires = now.AddMinutes(minutes),
Claims = new Dictionary<string, object> { ["iid"] = integrationId },
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha512),
};
// Keeps the token to the claims in the table above. By default the handler also adds "nbf".
var handler = new JsonWebTokenHandler { SetDefaultTimesOnTokenCreation = false };
return handler.CreateToken(descriptor);
}
Console.WriteLine(CreateJwt(
Environment.GetEnvironmentVariable("WAYA_INTEGRATION_ID")!,
Environment.GetEnvironmentVariable("WAYA_ISSUER_URL")!,
Environment.GetEnvironmentVariable("WAYA_KEY_ID")!,
Environment.GetEnvironmentVariable("WAYA_KEY_MATERIAL")!));
The Java sample uses the jjwt 0.12 builder. On jjwt 0.11, use the calls that Vanguard's Swagger shows instead: setIssuer, setHeaderParam("kid", keyId), setIssuedAt, setExpiration and signWith(key). In 0.12, setIssuer, setHeaderParam, setIssuedAt and setExpiration still work but are deprecated. signWith(key) is not deprecated.
Decoded, the token looks like this. The values of iat and exp are examples:
{
"alg": "HS512",
"kid": "<Key ID>"
}
{
"iss": "<Issuer URL>",
"iid": "<Integration ID>",
"iat": 1761987600,
"exp": 1761988200
}
Get an authentication token
Send the JWT to Vanguard as subject.
/v2.0/authentications/integrationsauthenticate as integration with subject token in Swagger (opens in a new tab)curl --request POST \
--url 'https://vanguard.sandbox.waya.se/v2.0/authentications/integrations' \
--header 'Content-Type: application/json' \
--data '{ "subject": "<jwt>" }'
Vanguard answers 200 with the authentication token. error is null when the call succeeds.
{
"token": "<authentication-token>",
"error": null
}
The authentication token lasts about a week and can authorize several accounts. Keep it and reuse it. Do not sign a new JWT for every call.
Find your account
This step is optional. Waya gives you an Account ID, so you only need this call to list the organizations and accounts that your integration can reach. Use the authentication token.
curl --request GET \
--url 'https://vanguard.sandbox.waya.se/v2.0/organizations' \
--header 'Authorization: Bearer <authentication-token>'
An optional search query parameter filters the list by text.
{
"result": {
"count": 1,
"totalCount": 1,
"more": false,
"organizations": [
{
"id": "ORG-123",
"name": "Example Client AB",
"accounts": [
{
"id": "DEF-456",
"name": "Example Client AB",
"user": null,
"credentials": null
}
]
}
]
},
"error": null
}
The account IDs are at result.organizations[].accounts[].id. An organization can have several accounts, for example one per service.
Authorize an account
Ask Vanguard for an authorization token for one account. Send the authentication token in the Authorization header, and the account in the query string.
/v2.0/authorizationsauthorize against a set of organizations or accounts in Swagger (opens in a new tab)curl --request POST \
--url 'https://vanguard.sandbox.waya.se/v2.0/authorizations?accountId=<account-id>' \
--header 'Authorization: Bearer <authentication-token>' \
--header 'Content-Length: 0'
Use your Account ID as <account-id>. Vanguard answers 200 with the authorization token:
{
"token": "<authorization-token>",
"error": null
}
The authorization token lasts about 24 hours, so renew it automatically. Send it to the Invoice API in the Authorization header, as Bearer <authorization-token>.
Vanguard also accepts organizationId, but you typically authorize a single account.
Verify a token
This step is optional. It checks an authorization token and reports its scope.
/v2.0/authorizations/tokensvalidate and report scope in Swagger (opens in a new tab)The token to verify goes in the body as subject. The Authorization header holds your authentication token, or another valid authorization token.
curl --request POST \
--url 'https://vanguard.sandbox.waya.se/v2.0/authorizations/tokens' \
--header 'Authorization: Bearer <authentication-token>' \
--header 'Content-Type: application/json' \
--data '{ "subject": "<authorization-token>" }'
The token is good when valid is true and description.organizations holds at least one organization. description.expiresAt tells when it expires.
{
"valid": true,
"description": {
"expiresAt": "2026-11-02T08:56:28.891196Z",
"maxCacheTimeMs": 5000,
"apis": [
"INVOICE_API_3"
],
"organizations": [
{
"id": "ORG-123",
"name": "Example Client AB",
"accounts": [
{
"id": "DEF-456",
"name": "Example Client AB",
"user": null,
"credentials": null
}
]
}
]
},
"error": null
}
Renewal guidance
- Keep both tokens in memory, or in a cache shared by your instances, and reuse them. Do not sign a JWT or call Vanguard for every request.
- Renew the authorization token before it expires. Note when you got it, and authorize again well inside its 24 hours.
- When the authentication token expires, start over: sign a new JWT, get a new authentication token, and authorize your account again.
- As a safety net, treat a
401from the Invoice API as a sign that the authorization token is no longer valid. Get a new one and retry the request once.
First call
With an authorization token, list your invoices with GET /invoice. The base URL of the Invoice API in the sandbox is https://api.sandbox.waya.se/v4/rest.
Send the authorization token in the Authorization header:
curl --request GET \
--url 'https://api.sandbox.waya.se/v4/rest/invoice' \
--header 'Authorization: Bearer <authorization-token>'
A fresh sandbox has no invoices yet, so the answer is 200 with an empty data array.
The base URL itself is not an endpoint. A GET to it redirects to Swagger instead of returning your invoices, so always include /invoice.
If the call fails with 401 (319-2-5), the token was not accepted. Check that you send the authorization token, not the authentication token, and that it has not expired. All error codes are on the Errors page.
Once the call works, create your first invoice.