API Authentication

The IBANQ Client API uses OAuth 2.0 client credentials with private_key_jwt client authentication (RFC 7523), DPoP sender-constrained access tokens (RFC 9449), and HTTP Message Signatures (RFC 9421) on money-moving endpoints. No client secret ever crosses the wire. Everything is proven with keys you hold.

📘

Use your application ID at the token endpoint, not your IBANQ client ID

You hold two identifiers and they do different jobs. The application ID is the Auth0 application client ID: it identifies you to the token endpoint, and it is the value of the assertion's iss and sub claims (and of the optional client_id form field). The client ID UUID provided to you is the IBANQ identifier for your client record, and it belongs only in API request bodies, such as the clientId field on payees and payments. Requesting a token with your IBANQ client ID fails with 401 invalid_client.

Environments

SandboxProduction
Token endpointhttps://auth.sandbox.ibanq.com/oauth/tokenhttps://auth.ibanq.com/oauth/token
audience (API identifier)https://api.sandbox.ibanq.comhttps://api.ibanq.com
API base URLhttps://api.sandbox.ibanq.comhttps://api.ibanq.com
📘

Two hostnames, two jobs. Tokens come from the auth. hostname and API calls (including the audience value) use the api. hostname. The audience is matched literally against the registered identifier, so copy it exactly as shown, with no trailing slash.

The three keys

KeyTypeSignsRegistered with IBANQ?
Authentication keyRSA 2048-4096The client assertion (PS256)Yes. Email the public key to your account manager
DPoP keyEC P-256DPoP proofs (ES256)No. The public key travels inside each proof
Signing keyRSA 2048-4096Request signatures (rsa-pss-sha256)Yes. Email the public key and you receive its keyid back

All key pairs must be generated in your own infrastructure. A plain RSA key works for every algorithm here, because the algorithm is chosen at signing time and is not baked into the key.

Generating the keys

# Authentication key (RSA 3072) and its public half for registration
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out auth_private.pem
openssl pkey -in auth_private.pem -pubout -out auth_public.pem

# DPoP key (EC P-256). The public key is never registered
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -pkeyopt ec_param_enc:named_curve -out dpop_private.pem
openssl pkey -in dpop_private.pem -pubout -out dpop_public.pem

# Signing key (RSA 3072) and its public half for registration
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out signing_private.pem
openssl pkey -in signing_private.pem -pubout -out signing_public.pem

Generating keys without openssl. On macOS the built-in openssl is LibreSSL, which writes EC keys with explicit curve parameters that .NET cannot load. If you are on .NET you can generate all three keys natively and avoid the conversion step:

// Authentication and signing keys (RSA 3072, PKCS#8 PEM)
File.WriteAllText("auth_private.pem",    RSA.Create(3072).ExportPkcs8PrivateKeyPem());
File.WriteAllText("signing_private.pem", RSA.Create(3072).ExportPkcs8PrivateKeyPem());
// DPoP key (EC P-256, named curve)
File.WriteAllText("dpop_private.pem",    ECDsa.Create(ECCurve.NamedCurves.nistP256).ExportPkcs8PrivateKeyPem());

Export the public halves with ExportSubjectPublicKeyInfoPem() and send the authentication and signing public keys to your account manager as before.

Email auth_public.pem and signing_public.pem to your account manager. Keep every *_private.pem in your secure environment; private keys are never shared with IBANQ.

The keyid you use when signing requests is the RFC 7638 JWK thumbprint of your signing public key. It is returned to you at registration, and you can compute it yourself to confirm you are using the right key:

📘

PS256 by default. Authentication credentials are registered for PS256 (RSA-PSS). If your signing library or HSM cannot produce RSA-PSS signatures, RS256 can be registered instead; tell your account manager at onboarding. The assertion's algorithm must match the registered credential exactly. A mismatch fails with 401 invalid_client.

Step 1: Build the client assertion

A short-lived JWT signed with your authentication private key, algorithm PS256:

ClaimValue
iss / subYour application ID
audThe token endpoint URL you are calling (e.g. https://auth.sandbox.ibanq.com/oauth/token)
jtiA random unique value (single-use)
iat / expNow / now + 60 seconds

Step 2: Request the access token

POST to the token endpoint, application/x-www-form-urlencoded, with a DPoP proof header (see Step 3; no ath claim yet, since there is no token yet):

FieldValue
grant_typeclient_credentials
client_assertion_typeurn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertionThe JWT from Step 1
audienceRequired. The environment's API identifier from the table above, matched literally, no trailing slash. This is not the token URL
scopeNot usable. Auth0 returns every scope delegated to your application regardless of what you send here, as the field applies to other connection types, so we recommend leaving it blank

client_id as a form field is optional. Your identity is carried by the assertion's iss and sub claims. If you do send it, its value is your application ID. You should not send your IBANQ client Id (this causes some confusion as the application Id is a client Id from Auth0's perspective).

body: new URLSearchParams({
  grant_type: 'client_credentials',
  audience,
  client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
  client_assertion: await createClientAssertion(applicationId, tokenUrl),
  scope: '',
}),
data={
    "grant_type": "client_credentials",
    "audience": audience,
    "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    "client_assertion": create_client_assertion(application_id, token_url),
    "scope": "",
}
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
    ["grant_type"] = "client_credentials",
    ["audience"] = audience,
    ["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    ["client_assertion"] = CreateClientAssertion(applicationId, tokenUrl),
    ["scope"] = ""
})

The response contains "token_type": "DPoP" (not Bearer) and an access token bound to your DPoP key via its cnf.jkt claim. Read expires_in and request a new token shortly before it expires rather than assuming a lifetime.

Token endpoint failures:

ResponseCause
403 access_denied: "No audience parameter was provided"The audience form field is missing
403 access_denied: "Service not enabled within domain: "The audience string doesn't exactly match a registered identifier (check for a stray trailing slash)
401 invalid_clientThe assertion's algorithm doesn't match the registered credential (PS256 vs RS256), or the assertion is invalid or expired
401 invalid_client before onboarding completesUntil your authentication public key is registered and your application id exists, the token endpoint returns 401 invalid_client for every fault, including a missing audience or a missing DPoP header. Nothing about your request can be validated before onboarding completes, so re-check the whole request once you have your application id.

Step 3: DPoP proofs

Every request, token requests and API calls alike, carries a DPoP header: a fresh ES256 JWT signed with your DPoP key.

Header: typ: "dpop+jwt", alg: "ES256", and jwk containing your DPoP public key (kty, crv, x, y only; never include the private d parameter).

Claims:

ClaimValue
htmThe HTTP method, uppercase
htuThe request URL: scheme + host + path only, no query string or fragment
jtiUnique per proof. Proofs are single-use and replays are rejected
iatCurrent time. Generate the proof immediately before sending
athAPI calls only: base64url(SHA-256(access token))

API calls send two headers:

Authorization: DPoP <access token>
DPoP: <fresh proof for this method and URL, including ath>

Step 4: Request signing (money-moving endpoints)

Endpoints under the payments:write and fx:trade scopes require an RFC 9421 HTTP Message Signature with an RFC 9530 Content-Digest. Read endpoints, payee management, and account name verifications do not.

Three headers:

Content-Digest: sha-256=:<standard-base64(SHA256(exact body bytes))>:
Signature-Input: sig1=("@method" "@target-uri" "content-digest" "content-type");created=<unix-seconds>;keyid="<your keyid>";alg="rsa-pss-sha256"
Signature: sig1=:<standard-base64(signature bytes)>:

The signature base. Build this exact string (lines joined with \n, no trailing newline) and sign it with RSA-PSS/SHA-256 (salt length 32, the .NET default):

"@method": POST
"@target-uri": https://api.sandbox.ibanq.com/v1/outbound-payments
"content-digest": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
"content-type": application/json
"@signature-params": ("@method" "@target-uri" "content-digest" "content-type");created=1756500000;keyid="abc123";alg="rsa-pss-sha256"

Rules the server enforces:

  • All four components, in exactly that order. Only rsa-pss-sha256 is supported.
  • @method is uppercase. @target-uri is the full public URL including any query string. Note this differs from the DPoP htu, which excludes it.
  • content-type must byte-match the header you send. Send exactly application/json. A ; charset=utf-8 suffix breaks the signature.
  • Content-Digest and the signature must be computed from the exact final body bytes. Serialise once and send those bytes.
  • created must be within 60 seconds of server time (30 seconds future tolerance). Generate the signature immediately before sending and never pre-compute it.
  • keyid is the RFC 7638 JWK thumbprint of your signing public key (base64url of the SHA-256 of its canonical JWK). It is returned to you at registration, and you can compute it yourself to verify.
  • Both base64 values are standard base64 (not base64url), wrapped in colons.

Error responses

Errors are returned as problem details:

{ "title": "Bad Request", "status": 400, "detail": "<reason>", "errors": ["<reason>"] }
StatusDetail / causeAction
401: "Access token is invalid"Token expired, or its aud or issuer doesn't match this environmentRequest a new token and check you're using the right environment's endpoints
401: DPoP proof failures (htu mismatch, replay, key mismatch, stale iat)The proof doesn't match this exact request or was reusedGenerate a fresh proof per request and verify htm/htu (no query string in htu)
400: "Request signature verification failed: reason"Signature or digest failed. Reasons include missing_content_digest, invalid_content_digest, missing_component_<name>, created_too_old, created_in_future, unsupported_algorithm, unknown_keyid, expired_key, invalid_signature_format, invalid_signatureRecompute the digest and signature base from the exact final request; check keyid and clock
403 insufficient_scopeYour token lacks the required scopeRequest the scope from your account manager
409Duplicate request, or an idempotencyKey reused with different detailsExpected on safe retries. An identical retry returns 200 with the original id; a 409 body carries existingPaymentId

Idempotency

There is no idempotency header. Outbound payments and internal payments take a caller-generated idempotencyKey in the request body, up to 100 characters. Retrying with the same key and identical details returns 200 with the original payment's id; reusing a key with different details returns 409, whose body carries existingPaymentId (or existingInternalPaymentId on internal payments). FX trades use the internalReference field the same way: one trade per reference. See Idempotency for the full rules, including the copyNumber field.

Key rotation

  • Authentication key: email the new public key; two may be registered simultaneously. Switch your client over, then ask for the old key to be retired.
  • Signing key: email the new public key and receive its new keyid; begin signing with it, then ask for the old keyid to be retired.
  • DPoP key: rotate freely, it isn't registered. Keep the old key available for the lifetime of any tokens still bound to it.

Troubleshooting

SymptomLikely cause
403 "Service not enabled within domain"audience doesn't literally match, usually a trailing-slash difference
401 invalid_client at the token endpointAssertion algorithm doesn't match the registered credential (PS256 by default), or the assertion's iss/sub carries the client ID instead of the application ID
401 htu mismatch on a URL with query parametersThe DPoP htu must exclude the query string; the signed @target-uri must include it
Signature fails only for some requestsContent-Type charset suffix, re-serialised body bytes, or a pre-computed created outside the 60-second window
Signing errors (400) on an endpoint you didn't expect to signCheck the path. Requests to unmatched routes are signature-enforced, so a typo in the URL surfaces as a signing error rather than a 404
PlatformNotSupportedException: Only named curves are supported on this platform (.NET on macOS)The DPoP key was generated with explicit curve parameters instead of the prime256v1 curve OID. macOS's built-in openssl is LibreSSL,
which does this by default. Re-encode without changing the key: openssl ec -in dpop_private.pem -param_enc named_curve | openssl pkcs8 -topk8 -nocrypt -out dpop_private_named.pem dpop_private_named.pem. The public point is unchanged, so the JWK thumbprint and your keyid stay the same. LibreSSL cannot perform
this conversion, install OpenSSL first brew install openssl).