Pave Bank

Authentication

Pave Bank API Authentication

To access the Pave Bank API, use Bearer Token Authentication via the OAuth 2.0 Client Credentials Flow. Follow these steps:

Obtain your client_id and client_secret

Exchange them for an access token by making a POST request to: {baseUrl}/oauth2/token

Include the access token in the Authorization header of your API requests

Security Note

Keep your client_id and client_secret secure. Never expose them in public code. If compromised, rotate your credentials immediately.

Token Request Example

Make a POST request with the following body: grant_type=client_credentials&audience=developer

Use HTTP Basic Auth with your client_id and client_secret

curl --request POST \
  --url https://api.pavebank.com/oauth2/token \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --user 'CLIENT_ID:CLIENT_SECRET' \
  --data 'grant_type=client_credentials&audience=developer'

Authenticated API Call

curl -X GET "https://api.pavebank.com/v1/accounts" \
     -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Roles

Each API credential is assigned a role that controls what actions it can perform. The role is set when the credential is created.

RoleDescription
adminFull read and write access. Can perform all API operations including transfers, account creation, and webhook configuration.
viewerRead-only access. Can view accounts, balances, transactions, and other resources but cannot create or modify them.

Each API endpoint displays a role badge indicating the minimum role required. See individual endpoint pages for details.

If a viewer credential attempts a write operation, the API returns a PERMISSION_DENIED error with the message: "user does not have permission to perform this action".

Request Payload Signing

For mutating API requests (POST, PUT, PATCH, DELETE), Pave Bank supports request payload signing as a JSON Web Token (JWT). Signing provides request integrity, non-repudiation, and SCA compliance alongside your OAuth token.

The signed request carries one extra header — Pave-Bank-Request-Signature — which is a JWT covering the HTTP method, request URI, body hash, and replay-protection claims.

Verification Modes

Each legal entity operates in one of two modes:

ModeBehavior
PermissiveDefault. Requests succeed regardless of signing. Failures are recorded in the customer portal under Verification Logs so you can debug your integration.
EnforcedAll mutating requests must include a valid signature. Unsigned or invalid requests are rejected with 401 Unauthorized.

Permissive mode lets you integrate signing incrementally — verify your client implementation against real failures before flipping the switch.

Irreversible Signing Enforcement

Once your legal entity enables signing enforcement, it cannot be reversed. All mutating requests must be signed. Ensure all integrations are updated before enabling.

Setup

Generate a key pair using one of the supported algorithms (see below). Up to 2 active keys per credential.

Register the public key via the Pave Bank portal. You will receive a key ID (kid) — keep it; the JWT must reference it in its header.

Build a JWT carrying the request claims and sign it with your private key. Send it as Pave-Bank-Request-Signature.

Supported Algorithms

  • EdDSA (Ed25519) — recommended
  • RS256, RS384, RS512 (RSA ≥ 2048-bit)
  • PS256 (RSA-PSS, ≥ 2048-bit)

Pick one algorithm at registration time. The same RSA key cannot be reused for RS256 and PS256 — register two separate entries if you want both.

Signing Header

HeaderDescription
Pave-Bank-Request-SignatureThe signed JWT.

JWT Header

{
  "alg": "EdDSA",
  "typ": "JWT",
  "kid": "sha256:a1b2c3d4..."
}
FieldRequiredPurpose
algyesMust match the algorithm of the registered key.
typyesAlways "JWT".
kidyesKey ID returned at registration. Echo it back as-is.

JWT Payload

{
  "iss": "<your_oauth_client_id>",
  "iat": 1746316800,
  "exp": 1746317100,
  "jti": "01HXJ7K3QZ8R9N2YV5T6M4WBPC",
  "method": "POST",
  "uri": "/v1/transfer/account",
  "body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
ClaimRequiredPurpose
issyesYour OAuth client ID. Must equal the bearer token's client.
iatyesIssued-at (Unix seconds).
expyesExpiry (Unix seconds). Lifetime ≤ 5 minutes.
jtiyesUnique nonce (UUID, ULID, or any unique string ≤ 128 chars).
methodyesHTTP method, uppercase. Must match the actual request.
uriyesRequest URI exactly as sent (no normalization). Must match the request.
body_hashyesLowercase hex SHA-256 of the raw request body bytes.

Signed Request Example

curl -X POST "https://api.pavebank.com/developer-api/v1/transfer/account" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: txn_abc123" \
  -H "Pave-Bank-Request-Signature: <JWT>" \
  -d '{"source_iban":"GE00PV0000000000000001","destination_iban":"GE00PV0000000000000002","amount":{"value":"1000.00","currency":"GBP"},"description":"Invoice 2026-001"}'

Important: Raw Body Hash

The body_hash claim must be the SHA-256 hex digest of the exact raw request body bytes sent over the wire.

Do not parse the JSON and re-serialize it before hashing — any transformation (added whitespace, key reordering, number formatting) will change the byte representation and cause a hash mismatch.

// ✅ Correct: hash the raw bytes you send
body_bytes := []byte(`{"source_iban":"GE00PV..."}`)
body_hash  := sha256(body_bytes)

// ❌ Wrong: parsing and re-serializing may alter bytes
parsed := JSON.parse(body_bytes)
body_hash := sha256(JSON.stringify(parsed))  // may produce different whitespace or key order

Implementation Examples

Key management

Key generation is not provided in these examples. Register the public key via the Pave Bank portal to obtain a kid, and keep the private key in secure storage.

package main

import (
    "crypto/ed25519"
    "crypto/sha256"
    "crypto/x509"
    "encoding/hex"
    "encoding/pem"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"

    "github.com/golang-jwt/jwt/v5"
    "github.com/google/uuid"
)

func hashBody(body []byte) string {
    sum := sha256.Sum256(body)
    return hex.EncodeToString(sum[:])
}

func main() {
    // Load the private key (PEM-encoded) from secure storage.
    privPEM, _ := os.ReadFile("/path/to/private_key.pem")
    block, _ := pem.Decode(privPEM)
    parsed, _ := x509.ParsePKCS8PrivateKey(block.Bytes)
    priv := parsed.(ed25519.PrivateKey)

    // kid returned at key registration time.
    kid := "sha256:..."
    clientID := "<your_oauth_client_id>"

    // Build the JWT for the request
    body := []byte(`{"source_iban":"GE00PV0000000000000001","destination_iban":"GE00PV0000000000000002","amount":{"value":"1000.00","currency":"GBP"},"description":"Invoice 2026-001"}`)

    now := time.Now()
    claims := jwt.MapClaims{
        "iss":       clientID,
        "iat":       now.Unix(),
        "exp":       now.Add(2 * time.Minute).Unix(),
        "jti":       uuid.NewString(),
        "method":    "POST",
        "uri":       "/v1/transfer/account",
        "body_hash": hashBody(body),
    }

    token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
    token.Header["kid"] = kid

    signed, _ := token.SignedString(priv)

    req, _ := http.NewRequest("POST",
        "https://api.pavebank.com/developer-api/v1/transfer/account",
        nil) // attach body via http.NewRequestWithContext or NewReader in real code
    req.Header.Set("Authorization", "Bearer <oauth_token>")
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Idempotency-Key", "txn_abc123")
    req.Header.Set("Pave-Bank-Request-Signature", signed)

    resp, _ := http.DefaultClient.Do(req)
    defer resp.Body.Close()
    respBody, _ := io.ReadAll(resp.Body)
    fmt.Printf("Status: %d\nBody: %s\n", resp.StatusCode, string(respBody))
}

Verification Reason Codes

In enforced mode, a non-passing verification returns 401 Unauthorized with the reason code as the error message. In permissive mode, the request succeeds but the failure is recorded in Verification Logs (see below) with the same reason code.

ReasonDescription
missingPave-Bank-Request-Signature header was not provided
malformedJWT could not be parsed
nonce_missingjti claim missing
nonce_malformedjti claim too long (>128 chars)
body_hash_mismatchbody_hash claim does not match the actual request body
timestamp_skewiat / exp outside the allowed window
method_mismatchmethod claim does not match the HTTP method
uri_mismatchuri claim does not match the request URI
algorithm_mismatchJWT alg does not match the registered key's algorithm
signature_mismatchThe signature did not verify against the public key
replay_detectedThe jti was already used
expiredexp missing or lifetime > 5 minutes

Response Headers (permissive mode)

In permissive mode, every response to a mutating request carries headers describing the signature verification outcome. Use these to confirm your signing implementation before enabling enforcement.

HeaderValuesDescription
Pave-Bank-Signature-Verificationpassed, failedWhether the signature verified successfully.
Pave-Bank-Signature-Reasonreason code (see table above)Set only when verification failed.
Pave-Bank-Signature-ModepermissiveSet only when verification failed.

Successful verification example:

HTTP/1.1 200 OK
Pave-Bank-Signature-Verification: passed

Failed verification example (request still goes through):

HTTP/1.1 200 OK
Pave-Bank-Signature-Verification: failed
Pave-Bank-Signature-Reason: body_hash_mismatch
Pave-Bank-Signature-Mode: permissive

In enforced mode

Failed requests are rejected with 401 Unauthorized and the reason is returned in the response body, not as a header.

Verification Logs

You can review verification failures for any developer API credential in the customer portal under Settings → Developer API → [credential] → Verification Logs.

The page lists every failed attempt with the request path, signing key used, JWT algorithm, failure reason, and timestamp. The timestamp column defaults to your local timezone and can be toggled to UTC from the column header. You can filter by signing key, date range, request path, and failure reason — useful when iterating in permissive mode before enforcement.

Key Rotation

To rotate with zero downtime: register a new key (max 2 active), switch your signing code, then revoke the old key.