Authentication
Generate credentials, sign requests with Ed25519, and send your first authenticated call.
Every public API request must carry an Ed25519 envelope signature. Signing proves the request came from a holder of your private key, that it is recent, and that the body was not tampered with. There are no bearer tokens — the server never sees your signing key.
Credentials
From the dashboard (Settings → Dev space), enter a name and reason then click Create API Key. The dashboard generates an Ed25519 keypair in your browser, registers the public key, and shows you two values — copy both immediately:
| Env var | Description |
|---|---|
BSK_KEY_ID | Public identifier sent in the Bs-Key-Id header on every request. |
BSK_SIGNING_KEY | PKCS#8 DER private key encoded as standard base64. Used locally to sign — never transmitted. |
BSK_SIGNING_KEY is shown once and cannot be retrieved again. If you lose it, revoke the key and create a new one.
Request envelope
Every request must include these headers:
| Header | Value |
|---|---|
Bs-Key-Id | Value of BSK_KEY_ID |
Bs-Timestamp | Current Unix time in seconds |
Bs-Nonce | 16 random bytes encoded as standard base64 — unique per request |
Bs-Signature | Base64 Ed25519 signature over the signing string (see below) |
Content-Digest | sha-256=:<base64-sha256-of-body>: — required when a body is present, omit entirely for bodyless requests |
Signing string
Build the signing string by joining the following values with : (colon). Each variable-length field is preceded by its byte length as a decimal integer — this prevents injection attacks where a field value could contain the delimiter:
{len(Bs-Key-Id)}:{Bs-Key-Id}:{len(Bs-Timestamp)}:{Bs-Timestamp}:{len(Bs-Nonce)}:{Bs-Nonce}:{METHOD}:{len(path-with-query)}:{path-with-query}:{Content-Digest}{len(x)}is the UTF-8 byte length of the field as a decimal integer.{METHOD}is the HTTP method in uppercase (e.g.GET,POST) — no length prefix.{Content-Digest}is theContent-Digestheader value if the request has a body, or an empty string if it does not — no length prefix.
Sign the UTF-8 bytes of this string with BSK_SIGNING_KEY using Ed25519, then base64-encode the result.
Node.js example
import crypto from "crypto";
function signRequest(opts: {
method: string;
path: string;
body: Buffer | null;
keyId: string;
signingKeyBase64: string;
}) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomBytes(16).toString("base64");
let contentDigest = "";
if (opts.body && opts.body.length > 0) {
const hash = crypto.createHash("sha256").update(opts.body).digest("base64");
contentDigest = `sha-256=:${hash}:`;
}
// Length-prefixed fields prevent injection via values containing the delimiter.
const signingString = [
opts.keyId.length, opts.keyId,
timestamp.length, timestamp,
nonce.length, nonce,
opts.method.toUpperCase(),
opts.path.length, opts.path,
contentDigest,
].join(":");
const privateKey = crypto.createPrivateKey({
key: Buffer.from(opts.signingKeyBase64, "base64"),
format: "der",
type: "pkcs8",
});
const signature = crypto
.sign(null, Buffer.from(signingString, "utf8"), privateKey)
.toString("base64");
return {
"Bs-Key-Id": opts.keyId,
"Bs-Timestamp": timestamp,
"Bs-Nonce": nonce,
"Bs-Signature": signature,
...(contentDigest ? { "Content-Digest": contentDigest } : {}),
};
}Your first call
All endpoints are called with POST and a JSON body. Endpoints that take no input are called with no body — omit Content-Digest entirely, and the last segment of the signing string is an empty string:
const headers = signRequest({
method: "POST",
path: "/v1/account.balance.getMany",
body: null,
keyId: process.env.BSK_KEY_ID!,
signingKeyBase64: process.env.BSK_SIGNING_KEY!,
});
const response = await fetch("https://api.blacksheep.money/v1/account.balance.getMany", {
method: "POST",
headers,
});With an input, sign the exact body bytes you send:
const body = Buffer.from(JSON.stringify({ id: "1d2b8e7a-…" }));
const headers = signRequest({
method: "POST",
path: "/v1/transaction.get",
body,
keyId: process.env.BSK_KEY_ID!,
signingKeyBase64: process.env.BSK_SIGNING_KEY!,
});
const response = await fetch("https://api.blacksheep.money/v1/transaction.get", {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body,
});Common pitfalls
- Length prefixes — every variable-length field (key ID, timestamp, nonce, path) must be preceded by its byte length. Missing prefixes will cause
invalid_signature. - base64 not base64url —
Bs-Nonce,Bs-Signature, andContent-Digestall use standard base64 (+,/,=). base64url variants will fail verification. - Exact path — sign the full path including query string, exactly as sent. Any encoding difference breaks the signature.
- No leading/trailing whitespace — strip any whitespace from header values before signing.
- Clock skew —
Bs-Timestampmust be within 300 seconds of the server clock. Sync your clock with NTP. - Nonce uniqueness — generate 16 fresh random bytes per request. Reusing a nonce within the 10-minute window returns
replay_detected.
Error reference
All signature failures return HTTP 401 with the body { "error": "<code>" } (note: a flat string — endpoint-level errors use the richer { "error": { "code", "message" } } envelope instead):
| Error | Cause |
|---|---|
invalid_signature | Missing required headers, bad signature, unknown or revoked key ID, Content-Digest absent when body present, or body/digest mismatch |
stale_request | Bs-Timestamp is more than 300 seconds from server clock |
replay_detected | The same Bs-Key-Id + Bs-Nonce combination was used within the 10-minute window |
When debugging, verify each field of the signing string byte-for-byte against the order and format above. A 401 invalid_signature on an otherwise valid key almost always means a signing string construction error.