If Boulevard's Admin API returns 401 even though your API key and secret are right, the problem is almost always how the token is built. Boulevard doesn't accept the key directly. Every request needs a freshly signed token.
Short answer:
payload = "blvd-admin-v1" + BUSINESS_ID + unix_timestamp_in_secondssignature = base64( HMAC-SHA256( payload, base64_decode(API_SECRET) ) )token = signature + payload(no separator)- Header:
Authorization: Basic base64( API_KEY + ":" + token )
We use this on every call in the Boulevard tracking pipeline described in our complete server-side guide.
Based on our build in 2026. Boulevard's developer documentation requires an account, so verify against it before shipping.
Before you start: getting credentials
Admin API access isn't automatic:
- Request API access through Boulevard's Developer Portal.
- Once approved, create and install a custom app for the business.
- You get an API key (the application ID, a UUID) and an API secret, which is base64-encoded. You also need the business ID (a UUID, without any
urn:prefix).
Store all three as server secrets. The secret signs every request, so treat it like a password.
The code
This works in Cloudflare Workers, Deno and modern Node (anything with Web Crypto):
const ADMIN_URL = 'https://dashboard.boulevard.io/api/2020-01/admin';
// Sandbox while testing: 'https://sandbox.joinblvd.com/api/2020-01/admin'
const b64ToBytes = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const bytesToB64 = (bytes) => btoa(String.fromCharCode(...bytes));
export async function buildBlvdAuthHeader(env) {
const { BLVD_BUSINESS_ID, BLVD_API_KEY, BLVD_API_SECRET } = env;
if (!BLVD_BUSINESS_ID || !BLVD_API_KEY || !BLVD_API_SECRET) throw new Error('Missing Boulevard credentials');
// 1. Payload: prefix + business ID + unix time in SECONDS
const ts = Math.floor(Date.now() / 1000);
const payload = `blvd-admin-v1${BLVD_BUSINESS_ID}${ts}`;
// 2. HMAC-SHA256 with the DECODED secret, then base64
const key = await crypto.subtle.importKey('raw', b64ToBytes(BLVD_API_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload));
const signature = bytesToB64(new Uint8Array(sig));
// 3. Token = signature immediately followed by the payload
const token = signature + payload;
// 4. Basic auth with the API key as username and the token as password
return 'Basic ' + btoa(`${BLVD_API_KEY}:${token}`);
}
export async function blvdQuery(env, query, variables = {}) {
const res = await fetch(env.BLVD_API_BASE_URL || ADMIN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: await buildBlvdAuthHeader(env) },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Boulevard HTTP ${res.status}: ${await res.text()}`);
const json = await res.json();
if (json.errors) throw new Error('Boulevard GraphQL: ' + JSON.stringify(json.errors)); // 200 can carry errors
return json.data;
}
A first call to prove it works
List the business's locations. It's harmless, and it gives you the location IDs you'll need for webhooks:
const data = await blvdQuery(env, `query { locations(first: 20) { edges { node { id name } } } }`);
console.log(data.locations.edges.map((e) => e.node));
If that returns your locations, authentication is done.
The mistakes that return 401
- Signing with the secret as text. The secret is base64. Decode it to bytes first, then use those bytes as the HMAC key.
- Milliseconds instead of seconds.
Date.now()is in milliseconds. Divide by 1000 and round down. - A separator in the token. No colon, space or dot between the signature and the payload.
- The
urn:prefix on the business ID. Use the bare UUID in the payload. - Reusing an old token. The timestamp is part of the token. Generate one per request.
- Production vs sandbox mismatch. Sandbox credentials don't work on the production endpoint, and the reverse.
GraphQL can fail with HTTP 200
Boulevard's GraphQL API returns errors in the response body. A request with a wrong field name comes back as HTTP 200 with an errors array. Check json.errors every time, as in the code above. Our most common ones were schema surprises, which are covered in getting real revenue out of Boulevard GraphQL.
Keeping credentials safe
- Keep the API key, secret and business ID as server secrets, never in frontend code.
- If you run one-off scripts with the credentials pasted in, put placeholders back before saving or committing.
- If a secret ever ends up in a shared doc or chat, rotate it.
Testing checklist
- Credentials are stored as server secrets.
- The
locationsquery returns your locations. - A query with a misspelled field is caught by your
errorscheck. - Sandbox and production use their own credentials.
Related reading: verifying Boulevard webhooks in a Cloudflare Worker.

