Visa sandbox connectivity
Live checks against the Visa Developer sandbox for the project **Prokura Network Agent Enabler** (Visa Intelligent Commerce and Trusted Agent Protocol in Sandbox). They run on your machine, never in CI.
Setup
Two ways Visa authenticates
Visa uses one scheme per API, never both on the same call:
| API | Auth | Client certificate |
| --------------------------------------------------------------- | ----------------------------------------------------------- | ------------------ |
| `GET /vdp/helloworld` | Two-way SSL + Basic auth (`VISA_USER_ID` / `VISA_PASSWORD`) | yes |
| VIC (`/vacp/v1/...`), Trusted Agent Protocol, Merchant Registry | X-Pay token + `apikey` query parameter, MLE for VIC | **no** |
The client (`packages/adapters/visa/src/client.ts`) picks the scheme per call (`auth: "mtls" | "xpay"`). If an X-Pay endpoint receives a client certificate, Visa resets the connection (`ECONNRESET`); the scripts explain that as "probably needs X-Pay or isn't enabled on the project", not as a certificate problem.
X-Pay token (as in Visa's reference client, github.com/visa/ai `packages/api-client/src/x-pay-token.ts`): `xv2:{timestamp}:{hex HMAC-SHA256(shared secret, timestamp + resource_path + query_string + body)}`, sent as the `x-pay-token` header, with `apikey={VISA_XPAY_API_KEY}` in the query. `resource_path` is the path without its first segment, the context path (`/vacp/v1/cards` → `v1/cards`, `/v1/trustedAgentProtocol` → `trustedAgentProtocol`; verified live: keeping the `v1/` gets `401 9159`). VTS is the exception and signs the full path, the query string includes `apikey`, and the body is the exact JSON sent (for MLE calls, the `{"encData": …}` envelope).
1. Add the APIs to the project
In the Visa Developer dashboard, open the project → **Sandbox** → **Add API** and add:
- **Visa Intelligent Commerce** (VIC: cards, instructions, credentials, confirmations); - **Trusted Agent Protocol**; - **Visa Merchant Registry Service**.
An API that is not added answers `403`, or resets the connection.
2. X-Pay credentials (API key and shared secret)
On the project's **Credentials** page, under X-Pay, Visa asks for a public key and shows the X-Pay values encrypted with it. The **API key** is shown in plain text; the **shared secret** comes back encrypted (a 344-character base64 string, i.e. an RSA-2048 ciphertext). Keep them apart: the API key is not the decrypted secret. The scripts refuse to run with an encrypted-looking value, because Visa would answer `401 9201 Token Validation Failed`.
openssl genrsa -out secrets/visa/xpay-decrypt-key.pem 2048 openssl rsa -in secrets/visa/xpay-decrypt-key.pem -pubout -out secrets/visa/xpay-decrypt-pub.pem # Visa wants the SubjectPublicKeyInfo as ONE line of base64: no BEGIN/END header or footer, no newlines. grep -v -- '-----' secrets/visa/xpay-decrypt-pub.pem | tr -d '\n'
Paste that line into the dashboard. Save each encrypted value to a file (not your shell history) and decrypt it with **RSA-OAEP, SHA-256** (both the OAEP digest and MGF1; OpenSSL's default is SHA-1, which fails with "oaep decoding error"):
base64 -d -i secrets/visa/xpay-value.b64 \
| openssl pkeyutl -decrypt -inkey secrets/visa/xpay-decrypt-key.pem \
-pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 -pkeyopt rsa_mgf1_md:sha256Put the API key in `VISA_XPAY_API_KEY`, the decrypted secret in `VISA_XPAY_SHARED_SECRET`, and delete the `.b64` file.
**Quote the shared secret in `.env`.** Visa secrets often contain `#`, and an unquoted `#` starts a comment in `.env` (Node's `--env-file`, dotenv, docker compose), silently cutting the secret short; Visa then answers `401 9201 Token Validation Failed`. Write it as `VISA_XPAY_SHARED_SECRET="…"`.
3. Files and settings
1. Put the project's files in `secrets/visa/` (git-ignored; a test fails if anything under `secrets/` is tracked):
| File | What | | --------------------- | ------------------------------------------------------------------- | | `cert.pem`, `key.pem` | Two-Way SSL (mTLS) client certificate and key (Hello World only) | | `ca-bundle.pem` | Visa Sandbox Root CA + Sandbox Issuing CA + DigiCert Global Root G2 | | `mle-key.pem` | Our Message Level Encryption private key (decrypts responses) | | `mle-server-cert.pem` | Visa's MLE server certificate (encrypts requests) | | `xpay-decrypt-*.pem` | Key pair used once to decrypt the X-Pay shared secret |
2. Fill the `VISA_*` settings in `.env` (see `.env.example`; the paths there already point to `secrets/visa/`). `VISA_USER_ID` and `VISA_PASSWORD` are the Two-Way SSL credentials of the project; `VISA_MLE_KEY_ID` is the MLE key id shown on the project's credentials page; `VISA_XPAY_API_KEY` and `VISA_XPAY_SHARED_SECRET` come from step 2.
3. Every live script needs `VISA_LIVE=1`. `VISA_DEBUG=1` additionally prints the request/response log (redacted: no Authorization, keyId, X-Pay token, API key, JWE bodies or card fields).
PEM newline pitfall
PEM files copied out of a browser or a JSON field often lose their newlines: a whole certificate on one line, or two certificates glued as `-----END CERTIFICATE----------BEGIN CERTIFICATE-----`. OpenSSL then reads only the first certificate of a bundle, or none, and the handshake fails with "unable to get issuer certificate" even though the right CA is in the file. The client normalises every PEM it loads (`packages/adapters/visa/src/pem.ts`: split on BEGIN/END, rewrap the base64 at 64 columns, rejoin with newlines) and adds the CA bundle to Node's default roots instead of replacing them. To fix a file by hand, put each `-----BEGIN …-----` and `-----END …-----` on its own line.
VISA_LIVE=1 pnpm visa:hello VISA_LIVE=1 pnpm visa:vic-check VISA_LIVE=1 pnpm visa:tap-check
`pnpm visa:hello`
Calls `GET /vdp/helloworld` over two-way SSL with Basic auth.
Expected output:
Visa sandbox · Hello World (two-way SSL) endpoint https://sandbox.api.visa.com/vdp/helloworld project 4cbae454-304c-409b-9023-a4b4e2e912f0 status 200 message helloworld timestamp 2026-11-10T12:00:00 correlation <x-correlation-id from Visa> request id <our uuid> duration 350 ms ✓ Connected to the Visa sandbox.
Quote the correlation id when you contact Visa support.
Common failures
| Message | Cause | Fix |
| -------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `Missing Visa settings: VISA_…` | A required setting is empty | Fill it in `.env` |
| `File not found for VISA_KEY_PATH` | A path in `.env` does not exist | Check the file name under `secrets/visa/` |
| `TLS: Visa's server certificate is not trusted` | `VISA_CA_PATH` missing or wrong | Use the CA bundle with Sandbox Root CA, Sandbox Issuing CA and DigiCert Global Root G2 |
| `TLS handshake failed … did not accept the client certificate` | Wrong cert/key pair, a certificate from another project, or an expired one | Download the project's Two-Way SSL certificate again; the key must be the one the CSR was made with |
| `Visa reset the connection … probably needs X-Pay` | Two-way SSL sent to an X-Pay API, or the API is not added to the project | Use `auth: "xpay"` for that path; Sandbox → Add API |
| `401 … 9124` | User id / password missing or wrong | Copy them from the project's Two-Way SSL credentials (they differ from the dashboard login) |
| `403` | Authenticated but the API is not enabled for the project | Sandbox → Add API in the Visa Developer dashboard |
| `Network: cannot reach …` | No route to `sandbox.api.visa.com:443` | Corporate proxies that inspect TLS break mTLS; run from a network without TLS interception |
The build environment of this repository could not reach Visa (outbound TLS goes through an intercepting proxy, which cannot carry a client certificate), so these scripts were exercised against a mock sandbox in tests and must be run locally against the real one.
`pnpm visa:vic-check`
Runs the Visa Intelligent Commerce v1 lifecycle, as in Visa's reference workflow (github.com/visa/ai `apps/vic-api-examples/workflows/retrieve-credentials-confirmations.ts`, payloads from `apps/shared-utils`). Every call uses X-Pay token auth and Message Level Encryption (`keyId` header; requests encrypted to `mle-server-cert.pem`, responses decrypted with `mle-key.pem`). Each step prints Visa's correlation id and our request id; the script fails (exit code 1) at the first step Visa rejects.
| Step | Call | Payload (`packages/adapters/visa/src/vic.ts`) |
| --------------- | ----------------------------------------------- | -------------------------------------------------------------------------------- |
| 1. enroll card | `POST /vacp/v1/cards` | consumer, app instance, `enrollmentReferenceData` (VTS token reference), consent |
| 2. instruction | `POST /vacp/v1/instructions` | consumer id, token id, device assurance data, a mandate (Best Buy, 800 USD cap) |
| 3. credentials | `POST /vacp/v1/instructions/{id}/credentials` | token id, instruction id, a 710 USD purchase || 4. confirmation | `POST /vacp/v1/instructions/{id}/confirmations` | approved payment, order and shipping data for the same transaction reference |The fixed values (app instance, assurance data, consumer country, merchant, address) are the sandbox test values from visa/ai `apps/shared-utils/constants.ts` and the payload builders. Settings, named as in Visa's reference client:
- **Required:** `VISA_EXTERNAL_CLIENT_ID` and `VISA_EXTERNAL_APP_ID`, the `client` object issued during VIC onboarding. Without them Visa answers `400 client: must not be null`, so the script stops first (exit code 3). - **Optional:** `VISA_CONSUMER_ID` (at most 50 characters), `VISA_ENROLLMENT_REFERENCE_ID` (the VTS `vProvisionedTokenId`), `VISA_AUTHORIZATION`, `VISA_RELATIONSHIP_ID`. Without the first two the script sends generated UUIDs and says so.
Visa's VIC error responses are MLE-encrypted too; the client decrypts them and prints Visa's `message` (for example `consumer.consumerId: size must be between 0 and 50`).
Payment credentials in the response are never printed or stored: the script lists the response's field names only.
Visa sandbox · Visa Intelligent Commerce v1 (X-Pay + MLE)
project 4cbae454-304c-409b-9023-a4b4e2e912f0
auth X-Pay token, MLE key id configured
…
1. enroll card 200 · status <status> · correlation <id> · request <uuid>
2. instruction 200 · status <status> · correlation <id> · request <uuid>
instructionId <id>
3. credentials 200 · status <status> · correlation <id> · request <uuid>
fields … (values not shown)
4. confirmation 200 · status <status> · correlation <id> · request <uuid>
✓ VIC v1 lifecycle completed in the sandbox.Production: VTS onboarding
The sandbox check does not onboard a token requestor. In production (and certification) the agent is first onboarded with Visa Token Service as a token requestor (`tokenRequestorType: AI_AGENT`, `eComClientSetting: Ecom_Enabler`, Agent Enabler model) and the card is tokenised through VTS (`POST /vts/provisionedTokens` plus the step-up/OTP flow). The resulting `vProvisionedTokenId` is the `enrollmentReferenceId` of step 1. VTS uses its own base URL (`https://cert.api.visa.com` in certification) and X-Pay credentials, and its X-Pay resource path is the full path after the domain. This is a production onboarding step, not part of `visa:vic-check`.
`pnpm visa:tap-check`
1. **Local check.** Signs a sample request with the agent's Ed25519 key per the Trusted Agent Protocol (RFC 9421, `packages/adapters/tap`) in both profiles (`rfc9421` and `visa-sample`) and verifies it with our verifier. The `visa-sample` profile is also tested against Visa's own sample verifier (`packages/adapters/tap/src/fixtures`). The agent key is `.data/demo-agent-key` (created by the demos), or `TAP_AGENT_KEY_FILE`. 2. **Trusted Agent Protocol.** `GET {VISA_TAP_PATH}` (default `/v1/trustedAgentProtocol`, as spelled in Visa's API reference, developer.visa.com/api-reference/trusted-agent-protocol.md) with X-Pay auth. `VISA_TAP_QUERY` adds query parameters, `VISA_TAP_XPAY_RESOURCE_PATH` overrides the signed resource path, and `VISA_TAP_SIGN=1` also signs the request with the agent key (RFC 9421, `agent-browser-auth`). Live status (2026-11-10): the X-Pay token is accepted, but Visa answers `500 9002 Internal server error` for both spellings, with and without RFC 9421 signature headers; this needs Visa (see docs/status/2026-11-10-system-check.md for correlation ids). 3. **Visa Merchant Registry Service.** `{VISA_VMRS_METHOD} {VISA_VMRS_PATH}` with X-Pay auth (`VISA_VMRS_QUERY` for parameters, `VISA_VMRS_MLE=1` if MLE is mandatory). Visa's API reference lists the read-only GETs `/vmrs/v1/merchants/{merchantId}`, `/vmrs/v1/merchants/{merchantId}/keys` and `/vmrs/v1/merchants/{merchantId}/keys/{keyId}`, so a lookup needs a registered merchant id; for an unknown id Visa answers `404 {"detail":"The requested merchant was not found"}`, which proves auth and routing. Skipped (exit code 3) when `VISA_VMRS_PATH` is not set.
The paths come from the markdown export of Visa's API reference (`developer.visa.com/api-reference/trusted-agent-protocol.md`), which documents both APIs; the HTML reference page and the visa/ai repository do not list them.
Exit codes: `0` all calls accepted, `1` a call rejected or an error, `3` Merchant Registry not configured.
The `visa-sandbox` rail in the rails demo
`pnpm demo:rails` step (d) pays €25 to `merchant.example` on the rail `visa-sandbox`. The intent goes through the same gate and evidence chain as card, SEPA and x402; the gate is unchanged. After `allow`, the API's `VisaSandboxRail`:
- without `VISA_ENROLLMENT_REFERENCE_ID` and `VISA_TEST_INSTRUCTION_ID`: calls Hello World and records `rail_ref = vdp:<correlation id>`, `rail_status = sandbox_reachable_no_token`; - with both: calls `POST /vacp/v1/instructions/{id}/credentials` (X-Pay + MLE) for the allowed amount and records `rail_ref = vic:<correlation id>` and Visa's status; the credentials themselves are never stored or returned; - if Visa rejects the call: records `rail_ref = visa:<correlation id>`, `rail_status = rejected_<http status>`.
The API enables the rail only with `VISA_LIVE=1` and all `VISA_*` settings; otherwise step (d) is still decided and recorded, with the execution marked "rail visa-sandbox is not configured". In docker compose the API mounts `secrets/` read-only at `/app/secrets`, so the relative paths in `.env` work unchanged:
VISA_LIVE=1 docker compose up -d # or set VISA_LIVE=1 in .env; compose passes it to the API pnpm demo:rails