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 varDescription
BSK_KEY_IDPublic identifier sent in the Bs-Key-Id header on every request.
BSK_SIGNING_KEYPKCS#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:

HeaderValue
Bs-Key-IdValue of BSK_KEY_ID
Bs-TimestampCurrent Unix time in seconds
Bs-Nonce16 random bytes encoded as standard base64 — unique per request
Bs-SignatureBase64 Ed25519 signature over the signing string (see below)
Content-Digestsha-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 the Content-Digest header 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 base64urlBs-Nonce, Bs-Signature, and Content-Digest all 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 skewBs-Timestamp must 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):

ErrorCause
invalid_signatureMissing required headers, bad signature, unknown or revoked key ID, Content-Digest absent when body present, or body/digest mismatch
stale_requestBs-Timestamp is more than 300 seconds from server clock
replay_detectedThe 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.