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

  1. 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.
  2. 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

  1. 1Pegue todos os campos, menos signature.
  2. 2Achate os aninhados como customer[email], metadata[order_id].
  3. 3Ordene pela key, byte a byte.
  4. 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.
  5. 5Junte os pares key=value com &.
  6. 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

Ruby
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"]) ] ]
Node
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)]]
Python
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"]))]
Go
// 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.

HTML
<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:

Campos
{
  "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"
}
String canônica
amount=49.90&app=pk_app_test&currency=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
Signature
bef52b640a81a802c3d31cc9475362aa9d9e82ce283c47b9c1f9689d555961d7

Quando o broker recusa

  1. 1403: app desconhecido ou pausado, app no fluxo Só API, ou signature errada.
  2. 2410: expires já passou. Renderize o form de novo com uma signature nova.
  3. 3400: expires ausente, não numérico ou mais de uma hora à frente.
  4. 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.

Tem certeza?

Atalhos de teclado

⌘ KBuscar e navegar⌘ JAbrir e fechar o Workbenchg hIníciog pPagamentosg cClientesg sSubscriptionsg aAppsg gGateways?Esta listaEscFechar