Signed requests
Required on POST /v1/chain-payouts, and on POST /v1/deposit-keys if you ever hand us a wrapped key. A bearer key alone asks for nothing on these routes: the request must also be signed by a key we registered and verified for your tenant.
Header
X-Bankcore-Request-Signature: v1,kid=<uuid>,ts=<unix seconds>,sig=<base64url>
Exactly these four fields, in this order, with no spaces. kid is lowercase. sig is base64url without padding.
What is signed
Scheme bankcore-request-v1: ECDSA P-256 / SHA-256, DER-encoded, over eight UTF-8 lines joined by LF, with no trailing LF:
bankcore-request-v1
<method> POST
<path> the URL path only, no query
<audience> the value we give you at onboarding
<kid> your key id, lowercase
<ts> Unix seconds, decimal
<Idempotency-Key> that header's value, exactly as sent
<sha256(body)> lowercase hex of the raw body bytes
- The body is hashed as the raw bytes we receive and parsed from those same bytes. There is no canonical JSON: sign the exact bytes you send.
tsmust be within ±300 seconds of our clock. Outside it:401 signature_expired.- A signature is valid only with its own
Idempotency-Key. A captured request replays only that key's stored answer. - When you retry, sign again with a fresh
ts, the sameIdempotency-Keyand the same body bytes.
Example
import base64, hashlib, json, time
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
private_key = serialization.load_pem_private_key(open("signing.pem", "rb").read(), None)
body = json.dumps(
{
"amount": {"amount": "500", "currency": "USDT_TRC20"},
"network": "tron",
"destination": {"address": "T...", "memo": None},
"reference": "payout-000123",
},
separators=(",", ":"),
).encode()
kid = "<your key id>"
ts = int(time.time())
idempotency_key = "payout-000123"
lines = [
"bankcore-request-v1",
"POST",
"/v1/chain-payouts",
"<audience>",
kid,
str(ts),
idempotency_key,
hashlib.sha256(body).hexdigest(),
]
der = private_key.sign("\n".join(lines).encode(), ec.ECDSA(hashes.SHA256()))
sig = base64.urlsafe_b64encode(der).rstrip(b"=").decode()
headers = {
"Authorization": "Bearer <your key>",
"Idempotency-Key": idempotency_key,
"X-Bankcore-Request-Signature": f"v1,kid={kid},ts={ts},sig={sig}",
"Content-Type": "application/json",
}
# send exactly `body` with these headers
Registering your key
- Generate a P-256 key pair where your payout code runs. The private key never leaves you.
- Send us the public key as a DER
SubjectPublicKeyInfo, base64. - We register and verify it and give you its
kid. Until then every signed call answers401 signature_invalid.
Before your first call, check your signer against the test vector we send you: a test key pair, a full signing input and the signature it must produce. That key pair is a test one; never register it.
Refusals
| Code | HTTP | |
|---|---|---|
signature_invalid | 401 | Missing or malformed header, a kid that is not a live key of yours, or a signature that does not verify. Nothing was done |
signature_expired | 401 | ts outside ±300 s. Sign again |
tenant_mismatch | 403 | The body names a tenant other than this key's. Nothing was recorded |