Tokenização
Como zhex.js Elements transforma PAN/CVV em tok_* sem nunca tocar seu servidor — escopo PCI-DSS SAQ-A, single-use, e o caminho para PaymentMethod reutilizável.
Tokenização é o que separa "alguém que aceita cartão" de "negócio que fala com banco". Sem hosted fields, você cai em PCI-DSS SAQ-D (auditoria pesada, ASV scan trimestral, política de segregação). Com hosted fields, você fica em SAQ-A — o escopo mínimo, ~10 perguntas anuais.
A diferença prática: SAQ-A te livra de quase todo overhead de compliance. É a mesma posição que Stripe, Adyen, Braintree dão aos seus clientes.
A regra de ouro
PAN e CVV nunca podem tocar seu servidor. Eles vão direto do navegador do cliente para prometheus.zhex.io, via um iframe servido por js.zhex.io. Você recebe de volta apenas um Token (tok_*) — uma string opaca, single-use, válida por 15 minutos.
Renderizar Elements no front
O SDK é distribuído como pacote npm. Instale, importe e monte o iframe — não há bundle global via <script> hoje (a versão CDN entra numa minor próxima):
npm install @zhexio/zhex-jsimport { Zhex } from '@zhexio/zhex-js';
const zhex = Zhex('zk_live_...'); // publishable key
const elements = zhex.elements({
appearance: {
theme: 'dark',
variables: { colorPrimary: '#5C7DFA' },
},
});
const card = elements.create('card');
card.mount('#card-element');
document
.getElementById('payment-form')!
.addEventListener('submit', async (e) => {
e.preventDefault();
const result = await zhex.createToken(card);
if ('error' in result) {
alert(result.error.message);
return;
}
// tok_* vai pro seu servidor por fetch normal
const res = await fetch('/api/charge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: result.token.id }),
});
const intent = await res.json();
// O intent retornado pelo seu /api/charge já tem status atualizado.
// Em cartão sem 3DS: 'succeeded' direto. Com 3DS, o fluxo é orquestrado
// pelo checkout hosted da Zhex hoje.
});A zk_live_* (publishable) pode aparecer no front — ela só consegue criar Token. Não roda charge, não vê dados sensíveis, não lista clientes.
Customizar a aparência
Todo input e label dentro do iframe é controlado por CSS variables que você passa via appearance.variables. Cada chave em camelCase vira uma variável --zhex-<kebab-case> aplicada no <html> do iframe — então o estilo persiste mesmo durante re-renders e nada do seu CSS externo precisa atravessar a fronteira de origem.
Duas camadas:
- Tokens semânticos — afetam todo o iframe (texto, borda, foco, fundo).
- Overrides por campo — controle cirúrgico nos inputs quando os tokens são amplos demais.
const elements = zhex.elements({
appearance: {
theme: 'light', // ou 'dark'
variables: {
// Tokens semânticos
fontFamily: '"Inter", system-ui, sans-serif',
fontSize: '15px',
colorPrimary: '#f97316', // foco, brand
colorText: '#0a0a0a',
colorMuted: '#a3a3a3', // placeholders, hints
colorDanger: '#dc2626', // borda de erro
colorBorder: '#e5e5e5',
colorBackground: '#ffffff',
borderRadius: '12px',
// Overrides por campo
inputBackground: '#fafafa',
inputBorderColor: '#e5e5e5',
inputBorderColorFocus: '#f97316',
inputBorderColorError: '#dc2626',
inputBorderRadius: '16px',
inputBorderWidth: '1px',
inputPaddingX: '16px',
inputPaddingY: '0px',
inputHeight: '52px',
inputFontSize: '15px',
inputFontWeight: '400',
inputColor: '#0a0a0a',
inputPlaceholderColor: '#a3a3a3',
inputBoxShadow: 'none',
inputBoxShadowFocus: '0 0 0 4px rgba(249,115,22,0.12)',
fieldGap: '10px',
brandGlyphSize: '32px',
},
},
});Tokens semânticos
| Variável | CSS var gerada | Default | Uso |
|---|---|---|---|
fontFamily | --zhex-font-family | system-ui stack | Família tipográfica do iframe |
fontSize | --zhex-font-size | 15px | Tamanho base — input herda quando inputFontSize não vem |
colorPrimary | --zhex-color-primary | tom azul Zhex | Estado de foco do input + brand |
colorText | --zhex-color-text | preto | Texto digitado, label |
colorMuted | --zhex-color-muted | cinza médio | Placeholder, hints |
colorDanger | --zhex-color-danger | vermelho | Borda de erro, mensagem de erro |
colorBorder | --zhex-color-border | cinza claro | Borda do input em estado idle |
colorBackground | --zhex-color-background | branco | Fundo do iframe |
borderRadius | --zhex-border-radius | 12px | Raio padrão (input herda quando inputBorderRadius não vem) |
Overrides por campo
| Variável | CSS var gerada | Aplicado em |
|---|---|---|
inputBackground | --zhex-input-background | Fundo dos 3 inputs |
inputBorderColor | --zhex-input-border-color | Borda idle |
inputBorderColorFocus | --zhex-input-border-color-focus | Borda em :focus |
inputBorderColorError | --zhex-input-border-color-error | Borda quando há erro de validação |
inputBorderWidth | --zhex-input-border-width | Espessura da borda |
inputBorderRadius | --zhex-input-border-radius | Raio do input (override de borderRadius) |
inputPaddingX | --zhex-input-padding-x | Padding horizontal |
inputPaddingY | --zhex-input-padding-y | Padding vertical |
inputHeight | --zhex-input-height | Altura fixa do input |
inputFontSize | --zhex-input-font-size | Tamanho da fonte digitada |
inputFontWeight | --zhex-input-font-weight | Peso da fonte digitada |
inputColor | --zhex-input-color | Cor do texto digitado |
inputPlaceholderColor | --zhex-input-placeholder-color | Cor do placeholder |
inputBoxShadow | --zhex-input-box-shadow | Sombra idle |
inputBoxShadowFocus | --zhex-input-box-shadow-focus | Sombra em :focus (use pra fazer "ring") |
fieldGap | --zhex-field-gap | Gap entre número, expiry e CVC |
brandGlyphSize | --zhex-brand-glyph-size | Tamanho do logo da bandeira (Visa, Mastercard…) |
Atualizar tema em runtime
elements.update() reaplica appearance sem desmontar o iframe — útil pra um toggle de dark mode no checkout:
const card = elements.create('card');
card.mount('#card');
// usuário clica num switch...
elements.update({
appearance: {
theme: 'dark',
variables: { colorBackground: '#0a0a0a', colorText: '#fafafa' },
},
});Tudo que não vem fica no default
Você não precisa setar todas as variáveis — qualquer chave omitida cai pro default do iframe (que tenta combinar luz/escuro automaticamente via theme). Comece com 2-3 cores e ajuste o que destoar do seu checkout.
Por que CSS-in-JS externo não funciona
O iframe vive em js.zhex.io, separado do seu domínio por same-origin policy. Tailwind, styled-components, MUI, etc. do seu app não vazam pra dentro. A única ponte é appearance.variables — qualquer estilo que você queira aplicar tem que passar por essas chaves. É exatamente o que mantém o escopo PCI-DSS em SAQ-A: nem CSS nem JS seu toca os campos onde o cartão é digitado.
Allowlist de origens
zk_* no front exige que o domínio da página seja registrado previamente como origem confiável. Sem isso, POST /v1/tokens retorna 403 e o iframe propaga error.code = "origin_not_allowed".
Registre cada domínio que vai hospedar o iframe (incluindo staging.* e localhost em test mode) via dashboard ou pela API:
curl -X POST https://prometheus.zhex.io/merchant-origins \
-H "Authorization: Bearer ${ZHEX_DASHBOARD_JWT}" \
-H "Content-Type: application/json" \
-d '{
"origin": "https://checkout.suaempresa.com",
"livemode": true
}'Por baixo dos panos: o iframe pina o parentOrigin no primeiro postMessage(zhex:init) e estampa Zhex-Parent-Origin: <origem-pinada> em cada POST /v1/tokens. A API só confia nesse header quando a requisição vem de um iframe da Zhex (js.zhex.io); de qualquer outra origem, ela cai no Origin do navegador. Os dois caminhos batem na mesma allowlist do merchant.
Test mode usa allowlist própria
Origens registradas em livemode: false só liberam tokens em zk_test_*, e vice-versa. Você não vai conseguir testar com localhost em produção mesmo que adicione o domínio — é proposital.
Anatomia de um Token
O que zhex.createToken() resolve no front é um subset seguro do recurso server-side — sem client_ip, sem fingerprint, só o que a UI precisa para mostrar o cartão e o que você precisa para cobrar:
{
"id": "tok_a3f29e7b1c4d8052f3a0b8e9c2d1f4a6",
"type": "CARD",
"livemode": false,
"card": {
"brand": "visa",
"last4": "4242",
"expMonth": 12,
"expYear": 2027,
"holderName": "MARIA SILVA"
},
"used": false,
"expiresAt": "2026-04-26T14:30:00.000Z",
"createdAt": "2026-04-26T14:15:00.000Z"
}Use o que está visível:
card.last4+card.brandpara mostrar "Visa terminando em 4242" no checkout.card.holderNamepara confirmar o nome do titular (nullquando não fornecido).expiresAt(ISO) para sumir com o token da UI assim que o TTL acabar.livemodecomo sanity-check defensivo no servidor — token de test em rota live é sintoma de chave trocada.
Não salve tok_* no seu banco. Ele é single-use e expira em 15 minutos. Para reutilizar em cobranças futuras, suba para PaymentMethod (próxima seção).
Token → PaymentMethod (reutilização)
Endpoint disponível
POST /v1/payment_methods está no ar. Aceita só tok_* produzido pelo zhex.js — dados de cartão crus nunca trafegam por essa rota. O token é consumido atomicamente: uma segunda chamada com o mesmo tok_* sempre falha com token_unusable.
PaymentMethod (pm_*) é o que você associa a um Customer para cobrar de novo sem pedir o cartão. Cria-se a partir de um Token:
import { randomUUID } from 'node:crypto';
const pmRes = await fetch('https://prometheus.zhex.io/v1/payment_methods', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ZHEX_SECRET_KEY}`,
'Idempotency-Key': randomUUID(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'card',
token: 'tok_1NJsVxLkdI...',
customer: 'cus_abc',
}),
});
const pm = await pmRes.json();
// { id: "pm_1NJtY7LkdI...", card: { brand, last4, ... }, customer: "cus_abc" }A confirmação na API V1 aceita tanto tok_* fresco (primeira cobrança ou cliente quer re-tokenizar) quanto pm_* salvo (cobrança recorrente, retry de billing, "1 clique" com cartão guardado).
// Primeira compra — cliente preenche cartão no iframe, servidor recebe tok_*
const intent = await zhex.paymentIntents.create({
amount: 9700,
currency: 'brl',
customer: 'cus_abc',
});
await zhex.paymentIntents.confirm(intent.id, {
payment_method: 'tok_…', // do zhex.js
});
// Próxima cobrança recorrente — sem nova tokenização
await zhex.paymentIntents.confirm(nextIntent.id, {
payment_method: 'pm_…', // pm_* salvo no customer
off_session: true, // sinaliza ao emissor que o cliente está ausente
});off_session: true avisa o emissor que o cardholder não está presente (renovação de assinatura, retentativa em background). 3DS pode ser exigido mesmo assim em cartões high-risk — nesses casos, o intent volta em requires_action e você notifica o cliente para autorizar.
A Zhex valida que o pm_* pertence ao mesmo customer do intent e à mesma partição (livemode); cross-customer ou cross-mode retornam payment_method_not_found (mesma mensagem que id inexistente — sem leak).
Token vs PaymentMethod
Token (tok_*) | PaymentMethod (pm_*) | |
|---|---|---|
| Tempo de vida | 15 min | Permanente (até deletado) |
| Uso | 1× | Múltiplos charges |
| Onde nasce | Front, via zhex.js | Servidor, a partir de tok_* |
Anexado a Customer? | Não | Sim, sempre |
| Bom para | Compra one-shot | Recurring, salvar cartão |
Regra: se o cliente vai cobrar de novo algum dia, suba para pm_*. Se é cobrança única e ele provavelmente nem volta, fica em tok_* — menos modelo no seu DB.
Tokenização em outros métodos
Hoje só card é tokenizado pelo iframe — todos os IDs ficam no formato genérico tok_<32 hex>. Os tipos abaixo são parte do roadmap; quando entrarem, o type no payload distingue o método (não o prefixo do ID):
| Tipo | Status | Vida útil prevista |
|---|---|---|
CARD | Disponível | 15 min |
bank_account (US ACH) | Roadmap | 15 min |
pix_key | Roadmap | 15 min |
pix_automatic_authorization | Roadmap | Até revogada |
Para PIX one-shot e boleto, não há tokenização — o método é gerado server-side junto com o PaymentIntent.
Apple Pay e Google Pay
Roadmap
Carteiras digitais ainda não estão expostas pelo SDK público. O snippet abaixo é o contrato planejado — acompanhe o changelog do @zhexio/zhex-js para saber quando zhex.paymentRequest() entra em produção.
zhex.js Elements vai aceitar pagamento por carteiras digitais. O fluxo previsto:
const paymentRequest = zhex.paymentRequest({
country: 'BR',
currency: 'brl',
total: { label: 'Curso Node Avançado', amount: 49700 },
});
const elements = zhex.elements();
const prButton = elements.create('paymentRequestButton', {
paymentRequest,
});
// só monta se o browser/device suportar
const canMakePayment = await paymentRequest.canMakePayment();
if (canMakePayment) {
prButton.mount('#payment-request-button');
}
paymentRequest.on('token', async ({ token, complete }) => {
const res = await fetch('/api/charge', {
method: 'POST',
body: JSON.stringify({ token: token.id }),
});
if (res.ok) complete('success');
else complete('fail');
});A carteira digital faz 3DS implícito — biometric do device serve como autenticação. Liability shift automático, fraude próxima de zero.
Erros comuns na tokenização
zhex.createToken() retorna { error: ElementError } quando algo falha. Os code possíveis são fixos e estão listados aqui:
code | Causa | Como tratar no front |
|---|---|---|
incomplete | Algum campo (número, validade ou CVV) ainda não foi preenchido | Não submeter — o iframe emite change com complete: true quando está pronto |
invalid_card_number | Luhn check falhou no número digitado | Mostrar inline ao lado do campo |
invalid_expiry | Validade fora do range ou no passado | Idem |
invalid_cvc | CVV com tamanho errado para a bandeira detectada | Idem |
unsupported_card | Bandeira detectada que a Zhex não tokeniza (ex: pré-pago restrito) | Pedir outro cartão |
card_declined | BIN bloqueado pelo emissor (raro nesta etapa) | Pedir outro cartão |
origin_not_allowed | Domínio da página não está na allowlist do merchant | Erro de configuração — registre o domínio (ver Allowlist de origens) |
rate_limited | Cliente passou do limite de tokens por minuto | Backoff — provavelmente bot/scrape, raramente uso legítimo |
network_error | Iframe não conseguiu falar com prometheus.zhex.io | Retry curto (1-2 tentativas), depois fallback |
unknown_error | Falha inesperada do iframe ou da API | Logue o erro com contexto e abra ticket |
O iframe já valida incomplete, invalid_card_number, invalid_expiry e invalid_cvc localmente antes de bater na API — você só vê esses códigos se forçar submit antes do estado ficar complete.
CSP (Content Security Policy)
Se sua aplicação tem CSP estrito, libere os domínios da Zhex:
Content-Security-Policy:
script-src 'self' https://js.zhex.io;
frame-src https://js.zhex.io;
connect-src 'self' https://prometheus.zhex.io;Sem frame-src https://js.zhex.io, o navegador bloqueia a carga do iframe e card.mount() falha silenciosamente — o <div> fica vazio e nenhum evento change chega. Em ambientes com Cloudflare/CSP automático, o caminho mais comum é adicionar js.zhex.io à allowlist. As entradas de script-src e connect-src são preventivas para quando o bundle CDN e métodos diretos como zhex.confirmPayment() forem liberados.
Por que isso importa
Sem tokenização hosted, você teria que:
- Cumprir 80+ controles PCI-DSS SAQ-D (firewall, key rotation, segregação de rede).
- Contratar ASV (Approved Scanning Vendor) — scan trimestral pago.
- QSA (Qualified Security Assessor) — auditoria anual cara para volumes acima de 6M tx/ano.
- Cifrar PAN em repouso (
AES-256minimum) e em trânsito. - Logging segregado, retenção de 12 meses, immutable.
- Treinamento de pessoal anual.
Com zhex.js, você cumpre 9 controles SAQ-A, todos baseados em "não receber PAN/CVV no servidor". É a diferença entre dois dias de papelada/ano e uma equipe de compliance dedicada.
Próximos passos
PaymentIntent
Como tok_* vira cobrança real
Test mode
Cartões 4242, 3DS forçado, declines
Chaves de API
Publishable, secret, restricted
Atualizado em