Request signing

Every call to https://client.api.corpx.com/v1/** carries, besides the token, a signature made with the private key that stayed on your server.

The OAuth token is a bearer: whoever copies it from a log or a proxy can use it until it expires. The signature fixes that because the private key does not travel. A leaked token without it cannot move money.

Key rotation and the IP allowlist live in Keys and IPs.

Why the host is different

CredentialHostSignature
Issued by the bank for your accounthttps://client.api.corpx.comOn every call
BaaS integrator (many accounts)https://tenant.api.corpx.comNo

Using the account credential on the old host returns 403 signed_host_required. The old host caches the authorisation decision for 300s; a cached decision cannot depend on that request’s signature. The signed host verifies every call.

The converse is also true: the signed host only exposes /v1/** and refuses any request without the signing headers before looking at the token (403 signature_required).

Step 1: the key pair

ECDSA P-256 (ES256) is recommended — short key, short signature, supported in every language:

# Private: STAYS ON YOUR SERVER. Never send this file.
openssl ecparam -genkey -name prime256v1 -noout -out corpx-signing.key
# Public: this is what the bank registered (or what you register on rotation).
openssl ec -in corpx-signing.key -pubout -out corpx-signing.pub

RSA is also accepted (PS256, minimum 2048 bits) if the key already lives in an HSM:

openssl genrsa -out corpx-signing.key 3072
openssl rsa -in corpx-signing.key -pubout -out corpx-signing.pub

publicKeyPem is the contents of the .pub file — a -----BEGIN PUBLIC KEY----- block. Sending the private key returns 422 invalid_public_key. Treat that key as compromised and generate another.

The kid

Each key gets a kid derived from the SHA-256 of the public key DER, truncated to 16 bytes (32 hex). Derived — not random — so you can check offline that you registered the right key:

openssl pkey -pubin -in corpx-signing.pub -outform DER | shasum -a 256 | cut -c1-32

The value must match the kid the API returned.

Step 2: the canonical string

Five fields, in this order, separated by \n (LF, not CRLF):

METHOD \n PATH?QUERY \n TIMESTAMP \n IDEMPOTENCY_KEY_OR_EMPTY \n X_CONTENT_SHA256
FieldRule
METHODUppercase: POST, GET, PUT, DELETE
PATH?QUERYPath as sent, with the query if any. No host
TIMESTAMPSame value as X-Request-Timestamp: unix seconds
IDEMPOTENCY_KEY_OR_EMPTYIdempotency-Key value; empty string when the route does not use one
X_CONTENT_SHA256SHA-256 of the body as lowercase hex. Empty body = hash of empty, not an empty string

Example for a PIX:

POST
/v1/accounts/acc-123/pix/payments
1789412400
4f1e3b7a-9d2c-4a11-8f55-2b0c6a7d1e90
b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c

The empty-body hash is always e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. Use it on GET and DELETE.

Step 3: sign

The signature is a compact JWS with detached payload (RFC 7797): <protected>..<signature> — note the two consecutive dots.

The protected header is:

{ "alg": "ES256", "kid": "YOUR_KID", "jti": "per-request-uuid" }

jti is optional and exists to correlate logs. What is signed is base64url(protected) + "." + base64url(canonicalString).

const crypto = require('node:crypto');
function sign(privateKeyPem, kid, canonical) {
const b64 = (buf) => Buffer.from(buf).toString('base64url');
const protected_ = b64(JSON.stringify({ alg: 'ES256', kid, jti: crypto.randomUUID() }));
const input = `${protected_}.${b64(canonical)}`;
const signature = crypto.sign('sha256', Buffer.from(input), {
key: privateKeyPem,
// dsaEncoding is required: without it Node emits DER and the API refuses.
dsaEncoding: 'ieee-p1363',
});
return `${protected_}..${b64(signature)}`;
}
function contentSha256(body) {
return require('node:crypto').createHash('sha256').update(body).digest('hex');
}
function canonicalString({ method, path, timestamp, idempotencyKey = '', body = '' }) {
return [method, path, String(timestamp), idempotencyKey, contentSha256(body)].join('\n');
}
ES256 is R\|\|S, not DER

The signature must be exactly 64 bytes (R and S of 32 bytes each). Most libraries emit DER by default and the API refuses with request_signature_invalid. In Node it is dsaEncoding: 'ieee-p1363'; in Go, build R||S from ecdsa.Sign; in Python, use utils.decode_dss_signature and concatenate.

Step 4: send

curl -X POST "https://client.api.corpx.com/v1/accounts/$ACCOUNT_ID/pix/out" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Tenant-Id: $TENANT_ID" \
-H "Idempotency-Key: 4f1e3b7a-9d2c-4a11-8f55-2b0c6a7d1e90" \
-H "X-Request-Timestamp: 1789412400" \
-H "X-Content-SHA256: 612612d208fb618eb2b007d2a7f8d7a1cfb511532389298f1cc33322c3094bcc" \
-H "X-Request-Signature: $SIG" \
-H "Content-Type: application/json" \
-d '{"amount":100.00,"keyType":"CPF","key":"12345678901"}'
HeaderRequiredDescription
X-Request-TimestampYesUnix seconds. 300s tolerance
X-Content-SHA256YesSHA-256 of the body as lowercase hex
X-Request-SignatureYesThe detached JWS from step 3

The body hash travels in the header — the edge verifies the signature without re-reading the body. The application checks that the received body matches the signed hash after the edge. A mismatch returns 400 body_hash_mismatch.

Test without moving money

POST /v1/security/signature/verify returns the canonical string we built, the hash we expected and the result. It always answers 200 (except a malformed body), including when the signature is invalid: a 401 here would be indistinguishable from “token expired”.

curl -X POST "https://client.api.corpx.com/v1/security/signature/verify" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Tenant-Id: $TENANT_ID" \
-H "X-Request-Timestamp: $TS" \
-H "X-Content-SHA256: $SHA" \
-H "X-Request-Signature: $SIG" \
-H "Content-Type: application/json" \
-d '{
"method": "POST",
"path": "/v1/accounts/acc-123/pix/payments",
"timestamp": "1789412400",
"idempotencyKey": "4f1e3b7a-9d2c-4a11-8f55-2b0c6a7d1e90",
"body": "{\"amount\":1000}",
"signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6Ii4uLiJ9..MEUCIQ..."
}'
{
"valid": true,
"kid": "9f2a...",
"alg": "ES256",
"canonicalString": "POST\n/v1/accounts/acc-123/pix/payments\n1789412400\n...",
"expectedContentSha256": "b5bb9d80...",
"signedHost": "client.api.corpx.com",
"maxSkewSeconds": 300,
"usableKids": ["9f2a..."]
}

When valid is false, reason carries the same code the real request would return. Compare canonicalString character by character.

Test vector

Use these values to validate the implementation offline, without a credential. The JWS below verifies against the public key with dsaEncoding: 'ieee-p1363'.

Public key (SPKI PEM):

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE6VuQL7n18jc/8dHENPXeHZrdCVPu
q2j496awbvsDhxWTVj3hBScPI9MioPmlfS9nNUo9MJhTDNMfVRUALXXrfg==
-----END PUBLIC KEY-----
FieldValue
kidcfe8291443215153e11b76a7a533dd3c
jti00000000-0000-4000-8000-000000000001
Body{"amount":1000}
X-Content-SHA256612612d208fb618eb2b007d2a7f8d7a1cfb511532389298f1cc33322c3094bcc

Canonical string (LF between lines, no CRLF, no trailing newline):

POST
/v1/accounts/acc-123/pix/payments
1789412400
4f1e3b7a-9d2c-4a11-8f55-2b0c6a7d1e90
612612d208fb618eb2b007d2a7f8d7a1cfb511532389298f1cc33322c3094bcc

JWS:

eyJhbGciOiJFUzI1NiIsImtpZCI6ImNmZTgyOTE0NDMyMTUxNTNlMTFiNzZhN2E1MzNkZDNjIiwianRpIjoiMDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIn0..hN37T9veyWLyXnEvXqnPwFIsD1GzgKfoY_bHzkc3rx3-MZCNhVaIVq1W_P77g8Dfw6IJ09wBddu6bhNQkGulvQ

The signature is 64 bytes (R||S). If your library emits DER, it will not match.

Common mistakes

  1. CRLF instead of LF in the canonical string.
  2. Path without the query string that went on the request.
  3. Timestamp in milliseconds.
  4. Hash of empty as an empty string, instead of the SHA-256 of zero bytes.
  5. ES256 signature in DER (request_signature_invalid).
  6. Clock outside 300s (request_timestamp_skew) — enable NTP.
  7. kid still in the 18h grace or already retired (unknown_kid).
  8. Credential on the wrong host (signed_host_required).

Full table in Errors.

Replay

A signed request may be repeated inside the 300s window. That is accepted on purpose:

  • On mutation, Idempotency-Key is required and enters the canonical string — it is the nonce. Repeating returns the original result.
  • On read, repeating returns the same read.

Detail: Idempotency.