Request signing
Every request is signed with an RSA private key that never leaves your servers. Orbita checks the signature against the public key you uploaded, so a leaked API key on its own can't be used, and a captured request can't be altered or replayed.
1. Create and upload a key pair
Generate a 2048-bit (or larger) RSA key pair once:
openssl genrsa -out private_key.pem 2048
openssl rsa -in private_key.pem -pubout -out public_key.pem- Keep
private_key.pemsecret. It never leaves your systems and Bluebulb never asks for it. - Your Primary Admin pastes the contents of
public_key.peminto the Orbita dashboard under Settings → API Keys → Signing key, confirming their password. - Bluebulb reviews the key and emails you when it's approved or rejected. Until it's approved, signed requests are rejected with
403.
2. Build the string to sign
For every request, join these four values with a newline (\n):
GET or POST./ — e.g. /api/rates?fromCurrency=usd&toCurrency=ngn. No scheme or host.X-Signature-Timestamp.POST
/api/conversions
1790150590512
3b4f9c0f8e…(64 hex characters)3. Sign it and send the headers
Sign the string with your private key using RSASSA-PKCS1-v1_5 with SHA-256, base64-encode the result, and send it with the timestamp:
<appId>.<secret>.timestampMs you signed.Signing helpers
Copy one of these into your integration. Every request sample in this reference uses them. Each has been checked against Orbita's own signature verification.
# Requires bash or zsh, openssl, and perl (macOS) or GNU date (Linux).
export ORBITA_BASE_URL="{baseUrl}" # https://api.bluebulb.co.uk or https://staging-api.bluebulb.co.uk
export ORBITA_API_KEY="<appId>.<secret>"
export ORBITA_PRIVATE_KEY_FILE="private_key.pem"
export ORBITA_TS_FILE="$HOME/.orbita_last_ts"
# Usage: read TS SIG < <(orbita_sign METHOD PATH_WITH_QUERY BODY)
orbita_sign() {
local method="$1" path_q="$2" body="$3"
# Millisecond timestamp: each signature is accepted once, so every call must
# use a later timestamp than the last -- even concurrent calls in the same
# millisecond. The last value is kept in a file (orbita_sign runs in a
# subshell), guarded by an mkdir lock, which is atomic and works without flock.
local ts last lock="$ORBITA_TS_FILE.lock"
until mkdir "$lock" 2>/dev/null; do sleep 0.01; done
ts=$(date +%s%3N 2>/dev/null)
case "$ts" in
*N*|"") ts=$(perl -MTime::HiRes=time -e 'printf("%d", time() * 1000)') ;;
esac
last=$(( $(cat "$ORBITA_TS_FILE" 2>/dev/null || echo 0) + 0 ))
if [ "$ts" -le "$last" ]; then ts=$((last + 1)); fi
echo "$ts" > "$ORBITA_TS_FILE"
rmdir "$lock"
local body_hash
body_hash=$(printf '%s' "$body" | openssl dgst -sha256 -r | cut -d' ' -f1)
local sig
sig=$(printf '%s\n%s\n%s\n%s' "$method" "$path_q" "$ts" "$body_hash" \
| openssl dgst -sha256 -sign "$ORBITA_PRIVATE_KEY_FILE" | base64 | tr -d '\n')
echo "$ts $sig"
}Rules Orbita enforces
X-Signature-Timestampmust be within 5 minutes of Orbita's clock, in either direction. Keep your servers synced with NTP.- Each signature is accepted once. Resending the same headers returns
403 Request signature already used. Because an identical request signed at the same millisecond produces the same signature, give every attempt — including concurrent retries — its own distinctX-Signature-Timestamp(e.g. never reuse the previous attempt's value; bump it by 1 ms if the clock hasn't moved), then sign again. - The key must be RSA, at least 2048 bits, PEM-encoded.
Signing multipart requests
Payout requests with a supporting document are sent as multipart/form-data. Sign the exact multipart body bytes, boundary included. The simplest way is to encode the form yourself, then sign and send those bytes:
// Offshore payout with a supporting document (Node 18+).
const form = new FormData();
form.append("customerReference", "INV-90872");
form.append("amount", "5000");
form.append("sourceCurrency", "usd");
form.append("destinationCurrency", "usd");
form.append("tradeType", "FX_OFFSHORE");
form.append("beneficiaryId", "b7d3e1f2-9a4c-4b6d-8e2f-1c3a5b7d9e0f");
form.append("file", new Blob([fs.readFileSync("invoice.pdf")], { type: "application/pdf" }), "invoice.pdf");
// Serialize the form once to get its exact bytes and boundary, then sign those bytes.
const encoded = new Request("http://localhost", { method: "POST", body: form });
const bodyBytes = Buffer.from(await encoded.arrayBuffer());
const contentType = encoded.headers.get("content-type"); // includes the boundary
const timestampMs = nextTimestampMs();
const bodyHash = crypto.createHash("sha256").update(bodyBytes).digest("hex");
const payload = ["POST", "/api/payouts", timestampMs, bodyHash].join("\n");
const signature = crypto.createSign("RSA-SHA256").update(payload).sign(PRIVATE_KEY, "base64");
const res = await fetch(BASE_URL + "/api/payouts", {
method: "POST",
headers: {
"x-api-key": API_KEY,
"X-Signature": signature,
"X-Signature-Timestamp": timestampMs,
"Content-Type": contentType,
},
body: bodyBytes,
});Troubleshooting
Responses to signed requests are signed by Orbita too — see the next page to verify them.
