Signed checkout forms
Seu backend assina a cobrança com a secret key do app e renderiza como um form; o navegador do cliente envia para o /connect e o broker redireciona direto para o gateway. Sem chamada de API e sem nada para guardar antes.
API ou signed form
- 1Só API (recomendado): faça POST /connect do seu backend com Authorization: Bearer, receba a redirect_url no JSON e redirecione você mesmo. Você tem o id antes de o cliente sair.
- 2API + signed forms (o Fluxo de checkout padrão de um app): renderize também um form assinado. O broker cria o pagamento ou a subscription quando o navegador envia o form e responde 303 para o checkout.
Os campos
Os mesmos campos do POST /connect, achatados: app (a public key do app, pk_app_…), amount, currency, method (opcional), return_to, customer[external_ref], customer[email]… , metadata[qualquer key] (opcional), mais expires e signature.
expires é um Unix time em segundos, no máximo uma hora à frente. Assine logo antes de enviar o cliente, ex.: agora + 15 minutos. A signature também é a idempotency key: enviar o mesmo form duas vezes leva ao mesmo checkout.
A signature
- 1Pegue todos os campos, menos signature.
- 2Achate os aninhados como customer[email], metadata[order_id].
- 3Ordene pela key, byte a byte.
- 4Faça percent-encoding de cada key e de cada valor conforme a RFC 3986: só A–Z a–z 0–9 - _ . ~ ficam como estão, espaço vira %20, dígitos hex em maiúsculas.
- 5Junte os pares key=value com &.
- 6signature = HMAC-SHA256 dessa string em hex minúsculo, com a secret key do app.
Só a string canônica precisa dessa codificação exata. O form envia os valores crus; o navegador codifica e o broker decodifica antes de conferir.
Assine
require "erb" require "openssl" def pay_flatten(params) params.flat_map do |key, value| value.is_a?(Hash) ? value.map { |k, v| [ "#{key}[#{k}]", v.to_s ] } : [ [ key.to_s, value.to_s ] ] end end def pay_signature(params, secret) canonical = pay_flatten(params) .sort_by { |key, _| key.b } .map { |key, value| "#{ERB::Util.url_encode(key)}=#{ERB::Util.url_encode(value)}" } .join("&") OpenSSL::HMAC.hexdigest("SHA256", secret, canonical) end params = { "app" => "pk_app_…", "amount" => "49.90", "currency" => "BRL", "customer" => { "external_ref" => current_user.id.to_s, "email" => current_user.email }, "return_to" => "https://yourapp.com/billing", "expires" => (Time.now.to_i + 900).to_s, } # hidden inputs of the form below: name = key, value = value @pay_fields = pay_flatten(params) + [ [ "signature", pay_signature(params, ENV["PAY_SECRET_KEY"]) ] ]
import crypto from "node:crypto" // encodeURIComponent leaves ! ' ( ) * as they are; RFC 3986 escapes them const encode = (value) => encodeURIComponent(value) .replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()) const flatten = (params) => Object.entries(params).flatMap(([key, value]) => typeof value === "object" && value !== null ? Object.entries(value).map(([k, v]) => [`${key}[${k}]`, String(v)]) : [[key, String(value)]]) function paySignature(params, secret) { const canonical = flatten(params) .sort(([a], [b]) => Buffer.compare(Buffer.from(a), Buffer.from(b))) .map(([key, value]) => `${encode(key)}=${encode(value)}`) .join("&") return crypto.createHmac("sha256", secret).update(canonical).digest("hex") } // hidden inputs of the form below: name = key, value = value const fields = [...flatten(params), ["signature", paySignature(params, process.env.PAY_SECRET_KEY)]]
import hashlib import hmac import os from urllib.parse import quote def flatten(params): pairs = [] for key, value in params.items(): if isinstance(value, dict): pairs += [(f"{key}[{k}]", str(v)) for k, v in value.items()] else: pairs.append((key, str(value))) return pairs def pay_signature(params, secret): pairs = sorted(flatten(params), key=lambda pair: pair[0].encode()) canonical = "&".join(f"{quote(k, safe='~')}={quote(v, safe='~')}" for k, v in pairs) return hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest() # hidden inputs of the form below: name = key, value = value fields = flatten(params) + [("signature", pay_signature(params, os.environ["PAY_SECRET_KEY"]))]
// params holds flat keys: "customer[email]", "metadata[order_id]"… func encode(s string) string { return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") } func PaySignature(params map[string]string, secret string) string { keys := make([]string, 0, len(params)) for key := range params { keys = append(keys, key) } sort.Strings(keys) pairs := make([]string, len(keys)) for i, key := range keys { pairs[i] = encode(key) + "=" + encode(params[key]) } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(strings.Join(pairs, "&"))) return hex.EncodeToString(mac.Sum(nil)) }
O form
Renderize os campos assinados como inputs hidden de um form que faz POST para o /connect (sem header Authorization). O valor e o cliente ficam fora da URL e do histórico do navegador. O broker responde 303 para o checkout.
<form method="post" action="https://pay.contagorda.com/connect"> <input type="hidden" name="app" value="pk_app_…"> <input type="hidden" name="amount" value="49.90"> <input type="hidden" name="currency" value="BRL"> <input type="hidden" name="customer[external_ref]" value="user_42"> <input type="hidden" name="metadata[order_id]" value="1042"> <input type="hidden" name="return_to" value="https://yourapp.com/billing"> <input type="hidden" name="expires" value="<now + 15 min>"> <input type="hidden" name="signature" value="<hmac-sha256>"> <button type="submit">Pay</button> </form>
Confira seu código
Assine estes campos com a key sk_app_test. Sua string canônica e sua signature precisam ser idênticas:
{ "app": "pk_app_test", "amount": "49.90", "currency": "BRL", "return_to": "https://app.example.com/billing?tab=plans", "customer": { "external_ref": "user_42", "name": "Ana Conceição", "email": "[email protected]" }, "expires": "1767225600" }
amount=49.90&app=pk_app_test¤cy=BRL&customer%5Bemail%5D=ana%40example.com&customer%5Bexternal_ref%5D=user_42&customer%5Bname%5D=Ana%20Concei%C3%A7%C3%A3o&expires=1767225600&return_to=https%3A%2F%2Fapp.example.com%2Fbilling%3Ftab%3Dplans
bef52b640a81a802c3d31cc9475362aa9d9e82ce283c47b9c1f9689d555961d7
Quando o broker recusa
- 1403: app desconhecido ou pausado, app no fluxo Só API, ou signature errada.
- 2410: expires já passou. Renderize o form de novo com uma signature nova.
- 3400: expires ausente, não numérico ou mais de uma hora à frente.
- 4422: return_to fora dos hosts permitidos do app, ou uma cobrança que o broker não consegue criar (amount, currency…).
O cliente vê uma página curta com um botão Voltar para o return_to, quando ele é permitido; o motivo não aparece.