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
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:
RSA is also accepted (PS256, minimum 2048 bits) if the key already lives
in an HSM:
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:
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):
Example for a PIX:
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:
jti is optional and exists to correlate logs. What is signed is
base64url(protected) + "." + base64url(canonicalString).
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
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”.
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):
Canonical string (LF between lines, no CRLF, no trailing newline):
JWS:
The signature is 64 bytes (R||S). If your library emits DER, it will not match.
Common mistakes
- CRLF instead of LF in the canonical string.
- Path without the query string that went on the request.
- Timestamp in milliseconds.
- Hash of empty as an empty string, instead of the SHA-256 of zero bytes.
- ES256 signature in DER (
request_signature_invalid). - Clock outside 300s (
request_timestamp_skew) — enable NTP. kidstill in the 18h grace or already retired (unknown_kid).- 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-Keyis required and enters the canonical string — it is the nonce. Repeating returns the original result. - On read, repeating returns the same read.
Detail: Idempotency.