Authentication & Access Control
Every /v1 request is authenticated by API Key. Keys can additionally be restricted by scope, by source IP and by request signature.
"Heimdall knows the name, the hour and the errand of every traveler who steps onto the bridge. Those who cannot say all three do not cross."
API Key
Send your key in the X-Bifrost-Invoke header on every request. There is no login step and no token to refresh: the key is the credential.
curl -X GET "https://bridge.bifrostcrypto.com/v1/balances" \
-H "X-Bifrost-Invoke: YOUR_API_KEY"Access is re-checked on every request. Revoking a key, letting it expire, or having API access disabled for the account takes effect on the very next request, without waiting for any cache to expire.
- Missing header: 401 api_key_missing. Unknown, revoked or expired key: 401 api_key_invalid.
- API access disabled for the account: 403 api_access_denied.
- Account e-mail not verified: 403 email_not_verified. Verify the e-mail in the panel; the key starts working on the next request.
- Suspended account: the keys are kept but answer 401 api_key_invalid while the suspension lasts, and work again when the account is reactivated.
Scopes
A scope is a permission to reach a group of endpoints. When you create a key you can pick which scopes it carries, so a key used only to read balances cannot create a payout even if it leaks.
A key created without a scope list is read-only: payout:read, payin:read, balances:read, statements:read and prices:read. It has no write scope and no fixed-wallet scope.
| Scope | Grants access to |
|---|---|
payout:read | List payouts and check the status of a payout |
payout:write | Create payouts |
payin:read | List payins and check the status of a payin |
payin:write | Create payins |
balances:read | Read account balances |
statements:read | Read statements, summaries, accepted tokens and accepted fiats |
prices:read | Read quotes and the currency catalog |
wallets:read | List fixed wallets and read their configuration |
wallets:write | Create fixed wallets and change their configuration |
Calling an endpoint outside the key's scopes returns 403 stating which scopes would work, so the fix is visible without guesswork. Some endpoints accept more than one, and holding any of them is enough:
{
"error": {
"code": "scope_denied",
"message": "This API key does not have permission for this endpoint",
"details": {
"required_scopes": ["payout:write"],
"granted_scopes": ["balances:read", "payin:read"]
}
}
}On keys carrying a write scope (payout:write, payin:write, wallets:write) it is worth adding a second layer on top of the scope: an IP whitelist if your integration calls from a fixed address, or request signing if it does not. Neither is mandatory, but both exist because a credential that moves funds deserves more than a static secret.
IP Whitelist
The whitelist is optional, on any scope. Without it the key is accepted from any source, and nothing you already run needs to change. It is an extra layer for callers with a fixed address; if your integration has no fixed IP, use require_signature, which protects a leaked key without depending on the source.
A key can be restricted to a set of source addresses. Individual addresses and CIDR ranges are both accepted, in IPv4 and IPv6:
["203.0.113.10", "198.51.100.0/24", "2001:db8::/32"]A request arrives from one address family at a time. If your infrastructure reaches us over both IPv4 and IPv6, declare both, because an entry for one family does not authorize the other. Requests from outside the list are refused with 403 ip_not_authorized.
Request Signature (HMAC)
Signing is optional and is turned on per key, at creation time. Keys that do not require it keep authenticating with X-Bifrost-Invoke alone, with no extra headers, and nothing you already run is affected.
Optional per key. The API Key alone proves that the caller knows a static secret, but it does not prove the request is recent, that the body is intact, or that the call has not already happened. With signing enabled, each request carries a proof valid once and only for a short window.
| Header | Description |
|---|---|
X-Bifrost-Timestamp | Unix time in whole seconds, digits only, when the request was built. Refused if it differs from server time by more than 300 seconds. |
X-Bifrost-Nonce | Value unique per request, 16 to 80 characters from A-Z, a-z, 0-9, underscore and hyphen. Accepted once per key. |
X-Bifrost-Signature | HMAC-SHA256 of the canonical string, keyed by your signing secret, in hexadecimal. Send it in lowercase; uppercase is also accepted. |
The canonical string
Build these six fields joined by a single newline character, in this exact order:
METHOD
PATH
QUERY
TIMESTAMP
NONCE
SHA256_HEX(BODY)METHOD is uppercase. PATH is the path exactly as it appears in your request line, with its leading slash and the /v1 prefix (for example /v1/check-payout/f47ac10b-58cc-4372-a567-0e02b2c3d479): it is not URL-decoded and a trailing slash is kept. QUERY is the raw query string as sent, with no leading question mark, not sorted and not re-encoded, and is empty when there are no parameters. The last field is the hex SHA-256 of the exact body bytes you send. For requests without a body, hash the empty string, which yields e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
Signing a request
const crypto = require("crypto");
const secret = process.env.BIFROST_SIGNING_SECRET;
const method = "POST";
const path = "/v1/payout";
const query = "";
const body = JSON.stringify({ /* ... */ });
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomBytes(16).toString("hex");
const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
const canonical = [method, path, query, timestamp, nonce, bodyHash].join("\n");
const signature = crypto
.createHmac("sha256", secret)
.update(canonical)
.digest("hex");
await fetch("https://bridge.bifrostcrypto.com" + path, {
method,
headers: {
"X-Bifrost-Invoke": process.env.BIFROST_API_KEY,
"X-Bifrost-Timestamp": timestamp,
"X-Bifrost-Nonce": nonce,
"X-Bifrost-Signature": signature,
"Content-Type": "application/json",
},
body,
});What to keep in mind
- The signing secret is shown once, when the key is created. It is never returned again, so store it before closing the panel.
- The secret is separate from the API Key. Both are needed: the key identifies you, the signature proves the request.
- Hash the same bytes you actually transmit. Serializing the body twice, or reserializing after signing, changes the digest and invalidates the signature.
- Each nonce is accepted once per key. Replaying a request verbatim returns signature_replayed even when everything else is correct.
- Clock drift is the most common cause of signature_timestamp_out_of_range. Keep your servers on NTP.
Rate Limits
Each key has its own quota over a fixed window aligned to the clock: by default 500 requests per 60 seconds, set when the key is created. Responses carry the current state, so you can pace your usage instead of discovering the limit by hitting it:
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 487
X-RateLimit-Reset: 1760054460- X-RateLimit-Limit is the quota, X-RateLimit-Remaining what is left in the window, and X-RateLimit-Reset the Unix time when the window resets.
- The headers come on every response that passed authentication and the scope check, including 4xx and 5xx answers from the endpoint.
- They are absent on authentication and scope refusals, and on keys configured without a quota, which are not rate limited.
Past the quota, requests return 429 rate_limit_exceeded with a Retry-After header until the window resets. There is no extra penalty:
HTTP/1.1 429 Too Many Requests
Retry-After: 24
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1760054460
{
"error": {
"code": "rate_limit_exceeded",
"message": "You have exceeded the allowed number of requests",
"details": {
"limit": 500,
"window_seconds": 60,
"retry_after_seconds": 24,
"retry_after_formatted": "24 seconds"
}
}
}Repeated authentication failures
Independently of the per-key quota, an IP address that keeps failing authentication is blocked for a while. While blocked it receives 429 too_many_failed_authentications with a Retry-After header: wait that many seconds before trying again. Successful requests never count toward this block.
{
"error": {
"code": "too_many_failed_authentications",
"message": "Too many failed authentication attempts from this address",
"details": {
"retry_after_seconds": 900,
"retry_after_formatted": "15 minutes"
}
}
}