A terminal proves who it is with its own client certificate, whose private key it made in its secure hardware and can never export. It gets its first one by pairing: it exchanges a Pairing Code for a certificate at POST /v1/terminal_pairings. This is for the app running on the terminal: a merchant's server never calls it.
A Pairing Code is issued by the platform's operations team for one terminal that is assigned to a store, and given to whoever is setting it up. It looks like ABCDE-FGH12, works once and only for that terminal's serial, and expires after a time the platform sets, 30 minutes by default. Issuing another code for the same terminal cancels the first.
How it goes
Section titled “How it goes”How a terminal is paired, between Operations team, Installer, Terminal app and API.
- Operations team to Installer: Pairing Code
- Installer to Terminal app: types in the code
- Terminal app, step 1: challenge = SHA-256(code)
- Terminal app, step 2: new key in the Keystore, attested with challenge
- Terminal app, step 3: CSR signed by that key
- Terminal app to API, step 4: POST /v1/terminal_pairings
- API to Terminal app: 201 certificate, chain
- Work out the attestation challenge from the code. Take the code as typed, upper-case it and remove its dashes and spaces, then take the SHA-256 digest of those ASCII characters: the raw 32 bytes, not hex. For
abcde-fgh12that is SHA-256 of the ten bytesABCDEFGH12. The challenge binds the key to this code, so a key made for another pairing is refused. - Generate a key in the hardware-backed Android Keystore, in its Trusted Execution Environment or StrongBox, with key attestation on and the challenge from step 1 as its attestation challenge. Use an EC P-256 key; an RSA key of 2048 bits or more is also accepted. A key generated in software is refused.
- Make a certificate signing request (PKCS#10, PEM) signed by that key. Its subject is ignored: the certificate names the terminal by its id and serial.
- Send the serial, the code, the CSR and the key's attestation certificate chain to
POST /v1/terminal_pairings. The chain is the one the Keystore returns for the key, each certificate as standard base64 of its DER, leaf first. It must lead to a hardware attestation root, with or without the root itself on the end.
The attestation challenge and the key
Section titled “The attestation challenge and the key”In Kotlin, with Android's KeyGenParameterSpec. This is illustrative: adapt it to your app, its error handling and your TLS client's needs.
import android.security.keystore.KeyGenParameterSpecimport android.security.keystore.KeyPropertiesimport android.util.Base64import java.security.KeyPairGeneratorimport java.security.KeyStoreimport java.security.MessageDigestimport java.security.spec.ECGenParameterSpec
// 1. The challenge: SHA-256 of the code, upper case, without dashes or spaces.val typed = "abcde-fgh12"val normalised = typed.uppercase().replace("-", "").replace(" ", "") // "ABCDEFGH12"val challenge = MessageDigest.getInstance("SHA-256") .digest(normalised.toByteArray(Charsets.US_ASCII)) // 32 bytes
// 2. A non-exportable EC P-256 key in the Keystore, attested with that challenge.val alias = "terminal-credential"val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) .setDigests(KeyProperties.DIGEST_SHA256) .setAttestationChallenge(challenge) // .setIsStrongBoxBacked(true) // where the device has StrongBox .build()KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore").run { initialize(spec) generateKeyPair()}
// The attestation chain, leaf first, as standard base64 DER.val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }val attestationChain = keyStore.getCertificateChain(alias) .map { Base64.encodeToString(it.encoded, Base64.NO_WRAP) }
// 3. The CSR: Android has no builder of its own, so use a library such as Bouncy Castle's// PKCS10CertificationRequestBuilder, signing with keyStore.getKey(alias, null), and// encode the request as PEM.The request
Section titled “The request”No API key and no Access Token: the terminal has no credential yet. The call goes to the terminal host, terminal.api.example.com.
curl https://terminal.api.example.com/v1/terminal_pairings \ -H "Content-Type: application/json" \ -d '{ "serial": "TX00000001", "pairing_code": "ABCDE-FGH12", "csr": "-----BEGIN CERTIFICATE REQUEST-----\nMIIBDzCBtgIBADAUMRIwEAYD...\n-----END CERTIFICATE REQUEST-----\n", "attestation_chain": ["MIICyjCCAnCgAwIBAgIBATAK...", "MIICMDCCAbegAwIBAgIK...", "MIIFHDCCAwSgAwIBAgIJ..."] }'{ "object": "terminal_pairing", "terminal_id": "3f6c2a9e-8b41-4d7a-9e25-0c1b7d4f8a63", "certificate": "-----BEGIN CERTIFICATE-----\nMIIB9TCCAZugAwIBAgIU...\n-----END CERTIFICATE-----\n", "chain": "-----BEGIN CERTIFICATE-----\nMIIBvjCCAWSgAwIBAgIU...\n-----END CERTIFICATE-----\n", "expires_at": 1790999400, "livemode": true}certificate is the terminal's client certificate, valid for up to 7 days; expires_at says when it ends, and is the one to go by. chain holds the certificates above it, its issuer first. Keep them with the key: the terminal presents the certificate, over mutual TLS, on its calls from then on, such as recording a cash payment. Renew it before it expires, over the current certificate and with no Pairing Code: see Renewing a certificate. The answer is sent with Cache-Control: no-store.
Retrying, and pairing again
Section titled “Retrying, and pairing again”- If the answer was lost, send the same request again, with the same code and the same key, while the code has not expired: you get the same certificate back. Do not generate a new key for a retry; a new key with a code already used is refused.
- Five wrong codes for a terminal spend its outstanding code. Ask the operations team for a new one, and generate a new key for it, since the challenge changes with the code.
- Pairing a terminal that is already paired replaces its certificate: the earlier one stops being accepted.
When a terminal is refused
Section titled “When a terminal is refused”A paired terminal presents its certificate over mutual TLS on every call, and the certificate is checked on every call, not only when the connection is made. GET /v1/terminal/me answers who the certificate says the terminal is, so the app can check it is paired.
A call is refused with 401 terminal_credential_refused when the certificate does not count:
- it is unknown: no terminal was paired with it;
- the operations team revoked the terminal's credential, as for a terminal that was lost or stolen, which takes effect on its very next call, whatever the certificate's expiry says;
- the terminal was paired again, so an earlier certificate was replaced;
- the certificate expired;
- the terminal was moved to another store or merchant, or is no longer assigned to one. A terminal moved to another store or merchant is paired again;
- the operations team suspended the terminal: it is refused until they reinstate it, and then the same certificate is honoured again, unless it has expired meanwhile;
- the terminal was retired: it is out of service.
None of these is fixed by retrying. Except for a suspension or a retirement, the terminal is paired again, with a new Pairing Code from the operations team and a new key.
Errors
Section titled “Errors”In the usual error envelope. Every refused pairing is the same 401 pairing_refused, on purpose, so a request reveals nothing about which check failed.
| Status | code | When |
|---|---|---|
| 401 | pairing_refused | The code is wrong, spent, expired or cancelled; the serial is not the code's terminal's; the attestation chain does not lead to a trusted root; the key is not hardware-backed; the attested key is not the CSR's; or the challenge is not the code's. Check the code and how the challenge is worked out; if the code may be spent or expired, ask the operations team for a new one. |
| 400 | invalid_parameter | A field is missing or not readable. param names a missing field, or is request when a CSR is not PEM or a chain entry is not base64. |
| 429 | rate_limited | Too many pairing requests from this address. Wait for Retry-After seconds. |
| 503 | service_unavailable | Pairing could not be completed just now, and it is not known whether the certificate was issued. Retry shortly with the same request: the same code and key return the same certificate if it was, so never make a new key for the retry. |
Next: a terminal session
Section titled “Next: a terminal session”A paired terminal opens a terminal session when a cashier starts work. The session is opened by the terminal itself, over mutual TLS with its certificate, and never by your server. And once a day it renews its certificate.