Fincore · Guides
Send money to a tokenized card
Encrypt the card number on your side, exchange it for a token, register the token as the destination instrument and send it Money Out without storing the card number.
Use this guide when you need to pay out to a card but do not want to store or send the card number. You encrypt the card number on your side, Monato stores it in its card vault and gives you a token. From then on you only use the token: you register it as the destination instrument and send Money Out to it.
The Money Out operation is the same one you use for CLABEs and debit cards. Only the first two steps are new.
Flow
| Step | Action | Operation | Save |
|---|---|---|---|
| 1 | Get a bearer token | POST /v1/clients/{clientId}/auth/credential-tokens (Authentication) |
Bearer token. |
| 2 | Encrypt the card number and tokenize it | Tokenize a card | token_id, first_6, last_4. |
| 3 | Register the token as the destination | Register instrument with card_token |
Destination instrument id. |
| 4 | Send Money Out | Create Money Out transaction | Transaction id, trackingId, initial status. |
| 5 | Reconcile the status | Status update webhook | Final transaction state. |
Keep only token_id, first_6 and last_4. Delete the card number after step 2 and never log it, nor the JSON you encrypt.
1. Get your encryption key
Monato gives you one public key per environment, with its kid (key name, for example stg-v1) and its SHA-256 fingerprint. The public key only encrypts. The private key that decrypts stays in Monato’s key service and is never exported. During a key rotation two kid values are active at the same time.
Store the public key as versioned configuration and keep your servers’ clocks synchronized (the request time must be within 30 seconds in the future).
2. Tokenize the card
Build this JSON for each card:
| Field | Rule |
|---|---|
pan |
Required. 15 to 19 digits. Must pass the Luhn check. |
tenant_id |
Required. Your client_id. It must match the client of the bearer token. |
nonce |
Required. 16 to 128 characters, new for every request. Use a UUID v4. |
iat |
Optional, recommended. Current time in epoch seconds. If you send it, it must be from 300 seconds in the past to 30 seconds in the future. |
holder_name |
Optional. Accepted and discarded. |
{
"pan": "4111111111111111",
"tenant_id": "<your client_id>",
"nonce": "3f6c1a52-8e0d-4b7a-9c21-5d4e7f8a9b10",
"iat": 1791916421
}Do not add any other field. Any undeclared field (for example a CVV) rejects the whole request with 400.
Encrypt the JSON as a compact JWE with alg = RSA-OAEP-256, enc = A256GCM and the kid in the protected header, and send it as the body of Tokenize a card:
curl -X POST https://apicore.stg.finch.lat/v1/clients/$CLIENT_ID/card-tokens \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/jose" \
--data "$JWE"{
"token_id": "9f1b2c3d-4e5f-4a6b-8c7d-1234567890ab",
"first_6": "411111",
"last_4": "1111"
}The response has Cache-Control: no-store. Every call creates a new token, even when the card was tokenized before. The JWE can be up to 8192 characters.
Your JWE library does the encryption in a few lines. You do not implement cryptography yourself.
import { readFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { CompactEncrypt, importSPKI } from 'jose';
const pem = readFileSync('monato-stg-v1.pem', 'utf8'); // public key from Monato
const kid = 'stg-v1'; // kid delivered with the key
const payload = { pan: '4111111111111111', tenant_id: '<your client_id>', nonce: randomUUID(), iat: Math.floor(Date.now() / 1000) };
const key = await importSPKI(pem, 'RSA-OAEP-256');
const jwe = await new CompactEncrypt(new TextEncoder().encode(JSON.stringify(payload)))
.setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM', kid })
.encrypt(key);
// jwe is the body of POST /v1/clients/{clientId}/card-tokensimport json, time, uuid
from jwcrypto import jwk, jwe
pem = open("monato-stg-v1.pem", "rb").read() # public key from Monato
kid = "stg-v1" # kid delivered with the key
payload = {"pan": "4111111111111111", "tenant_id": "<your client_id>",
"nonce": str(uuid.uuid4()), "iat": int(time.time())}
key = jwk.JWK.from_pem(pem)
token = jwe.JWE(json.dumps(payload).encode(),
protected={"alg": "RSA-OAEP-256", "enc": "A256GCM", "kid": kid})
token.add_recipient(key)
body = token.serialize(compact=True) # body of POST /v1/clients/{clientId}/card-tokensString pem = Files.readString(Path.of("monato-stg-v1.pem")); // public key from Monato
byte[] der = Base64.getMimeDecoder().decode(pem.replaceAll("-----(BEGIN|END) PUBLIC KEY-----", ""));
RSAPublicKey publicKey = (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(der));
String payload = new ObjectMapper().writeValueAsString(Map.of(
"pan", "4111111111111111", "tenant_id", "<your client_id>",
"nonce", UUID.randomUUID().toString(), "iat", Instant.now().getEpochSecond()));
JWEObject jwe = new JWEObject(
new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM).keyID("stg-v1").build(),
new Payload(payload));
jwe.encrypt(new RSAEncrypter(publicKey));
String body = jwe.serialize(); // body of POST /v1/clients/{clientId}/card-tokensTokenizer errors
| HTTP | code |
What to do |
|---|---|---|
400 |
SCHEMA_VALIDATION_ERROR |
Malformed JWE, unknown kid, payload that breaks the rules above (including an invalid card number or a tenant_id that is not yours). Fix the request; do not retry it as is. |
400 |
IAT_OUT_OF_WINDOW |
Check your clock and build a new JWE. |
401 |
UNAUTHORIZED |
Renew the bearer token. |
409 |
NONCE_REPLAY |
The nonce was already used. Build a new JWE with a new nonce. |
415 |
SCHEMA_VALIDATION_ERROR |
Send Content-Type: application/jose. |
503 |
INTERNAL_ERROR |
Temporary failure. Retry with backoff and a new JWE (new nonce and iat). |
SCHEMA_VALIDATION_ERROR deliberately does not say which check failed.
3. Register the token as the destination
Send a card_token object instead of debit_card or virtual_clabe. You do not send the card number or its first and last digits: Monato reads them from the vault.
| Field | Rule |
|---|---|
card_token.destination_bank_id |
Required. Bank UUID of the card issuer. It must match the card’s first six digits. |
card_token.token_id |
Required. The token_id from step 2, in UUID format. It must belong to your client. |
card_token.holder_name |
Required. Up to 40 characters. |
type |
Must be RECEIVER. A tokenized card can only receive. |
customer_id |
Optional. Send it when the card belongs to a Business Unit. |
curl -X POST https://apicore.stg.finch.lat/v1/clients/$CLIENT_ID/instruments \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"source_bank_id": "9d84b03a-28d1-4898-a69c-38824239e2b1",
"type": "RECEIVER",
"rfc": "XAXX010101000",
"alias": "Card ABC123",
"card_token": {
"destination_bank_id": "3054ff18-32a0-478d-b9fe-b5261f9a6e1f",
"token_id": "9f1b2c3d-4e5f-4a6b-8c7d-1234567890ab",
"holder_name": "Jane Doe"
}
}'{
"id": "5b0c6a1e-7d2f-4e8a-9c3b-2a1d0e9f8c7b",
"bankId": "3054ff18-32a0-478d-b9fe-b5261f9a6e1f",
"clientId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"ownerId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"alias": "Card ABC123",
"type": "RECEIVER",
"instrumentDetail": {
"tokenId": "9f1b2c3d-4e5f-4a6b-8c7d-1234567890ab",
"first6": "411111",
"last4": "1111",
"holderName": "Jane Doe"
},
"audit": {
"createdAt": "2026-10-01 16:12:01.084659-06:00",
"updatedAt": "2026-10-01 16:12:01.084659-06:00",
"deletedAt": "None",
"blockedAt": "None"
},
"rfc": "XAXX010101000"
}The response id is your destination_instrument_id. For a tokenized card, instrumentDetail returns tokenId, first6, last4 and holderName. It never returns a card number.
Registration fails with 400 for a malformed token_id, a type other than RECEIVER, an unsupported BIN or a destination bank that does not match the card, and with 404 if the token does not exist or belongs to another client. If the card vault is temporarily unavailable, no instrument is created; retry later.
4. Send Money Out
Use the instrument id as destination_instrument_id in Create Money Out transaction. Everything else works exactly as in Send money to a CLABE: the same request fields, Idempotency-Key, status webhook and errors.
curl -X POST https://apicore.stg.finch.lat/v1/transactions/money_out \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{
"client_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"source_instrument_id": "709448c3-7cbf-454d-a87e-feb23801269a",
"destination_instrument_id": "5b0c6a1e-7d2f-4e8a-9c3b-2a1d0e9f8c7b",
"transaction_request": {
"external_reference": "1234567",
"description": "Customer payout",
"amount": "250.00",
"currency": "MXN"
}
}'{
"id": "16811ee8-1ef9-4dd4-8d84-9c2df89cf302",
"bankId": "9d84b03a-28d1-4898-a69c-38824239e2b1",
"clientId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"externalReference": "1234567",
"trackingId": "20261001FINCHVLIKQ5SKUM",
"description": "Customer payout",
"amount": "250.00",
"currency": "MXN",
"category": "DEBIT_TRANS",
"subCategory": "SPEI_DEBIT",
"transactionStatus": "INITIALIZED",
"audit": {
"createdAt": "2026-10-01 16:20:12.408000-06:00",
"updatedAt": "2026-10-01 16:20:12.408000-06:00",
"deletedAt": "None",
"blockedAt": "None"
}
}The transaction starts as INITIALIZED. The final state arrives through the Status update webhook; see Transaction statuses.