const {
createHash,
createPrivateKey,
randomUUID,
sign,
} = require("node:crypto");
const baseUrl = requiredEnv("STABYL_BASE_URL").replace(/\/+$/, "");
const apiKey = requiredEnv("STABYL_API_KEY");
const signingKey = signingKeyFromSecret(
requiredEnv("STABYL_SIGNING_SECRET"),
);
function requiredEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
function signingKeyFromSecret(secret) {
const marker = "sig1_";
const markerIndex = secret.indexOf(marker);
if (markerIndex < 0) throw new Error("unsupported signing secret");
const seed = Buffer.from(
secret.slice(markerIndex + marker.length),
"base64url",
);
if (seed.length !== 32) throw new Error("invalid signing secret");
// RFC 8410 PKCS#8 prefix for a raw 32-byte Ed25519 private seed.
const pkcs8Prefix = Buffer.from(
"302e020100300506032b657004220420",
"hex",
);
return createPrivateKey({
key: Buffer.concat([pkcs8Prefix, seed]),
format: "der",
type: "pkcs8",
});
}
async function responseJson(response) {
const text = await response.text();
const payload = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(`Stabyl returned ${response.status}: ${text}`);
}
return payload;
}
async function signedPost(path, payload) {
// Serialize once. Hash, sign, and send these exact bytes.
const rawBody = Buffer.from(JSON.stringify(payload), "utf8");
const url = `${baseUrl}${path}`;
const parsedUrl = new URL(url);
const target = `${parsedUrl.pathname}${parsedUrl.search}`;
const timestamp = Date.now().toString();
const nonce = randomUUID();
const bodyHash = createHash("sha256").update(rawBody).digest("hex");
const canonical = [
"STABYL-SIGNATURE-V1",
timestamp,
nonce,
"POST",
target,
bodyHash,
].join("\n");
const signature = sign(null, Buffer.from(canonical, "utf8"), signingKey)
.toString("base64url");
return responseJson(await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": apiKey,
"X-Stabyl-Timestamp": timestamp,
"X-Stabyl-Nonce": nonce,
"X-Stabyl-Signature": signature,
},
body: rawBody,
}));
}
async function main() {
const intent = {
currency: "NGN",
items: [{
client_item_id: "beneficiary-a",
amount: "25000.00",
destination: {
type: "bank_account",
bank_code: "044",
bank_name: "Access Bank",
account_number: "0123456789",
account_name: "Example Recipient",
},
description: "Supplier settlement",
}],
};
const quote = await signedPost(
"/partner/wallets/withdrawal/quotes",
intent,
);
const available = quote.data.quotes.find(
(candidate) => candidate.status === "available",
);
if (!available) throw new Error("no available withdrawal route");
console.log("quote:", available);
// Generate this once for the business intent. Preserve it and the unchanged
// submitBody if an uncertain HTTP result must be retried.
const submitBody = {
...intent,
idempotency_key: randomUUID(),
};
const submitted = await signedPost(
"/partner/wallets/withdrawals",
submitBody,
);
console.log("submitted withdrawal:", submitted.data.id);
console.log(
"store this ID and wait for withdrawal.success or withdrawal.failed",
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});