Signed checkout forms
Your backend signs the charge with the app's secret key and renders it as a form; the customer's browser posts it to /connect and the broker redirects straight to the gateway. No API call, nothing to store first.
API or signed form
- 1API only (recommended): POST /connect from your backend with Authorization: Bearer, get redirect_url in JSON and redirect yourself. You get the id before the customer leaves.
- 2API + signed forms (the default Checkout flow of an app): also render a signed form. The broker creates the payment or subscription when the browser posts it and answers 303 to the checkout.
The fields
The same fields as POST /connect, flattened: app (the app's public key, pk_app_…), amount, currency, method (optional), return_to, customer[external_ref], customer[email]… , metadata[any key] (optional), plus expires and signature.
expires is a Unix time in seconds, at most one hour ahead. Sign right before sending the customer, e.g. now + 15 minutes. The signature is also the idempotency key: posting the same form twice goes to the same checkout.
The signature
- 1Take every field except signature.
- 2Flatten nested ones as customer[email], metadata[order_id].
- 3Sort them by key, byte by byte.
- 4Percent-encode each key and each value per RFC 3986: only A–Z a–z 0–9 - _ . ~ stay as they are, a space is %20, hex digits in uppercase.
- 5Join key=value pairs with &.
- 6signature = lowercase hex HMAC-SHA256 of that string, keyed with the app's secret key.
Only the canonical string needs this exact encoding. The form sends the raw values; the browser encodes them and the broker decodes them before checking.
Sign it
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)) }
The form
Render the signed fields as hidden inputs of a form that POSTs to /connect (no Authorization header). The amount and the customer stay out of the URL and the browser history. The broker answers 303 to the 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>
Check your code
Sign these fields with the key sk_app_test. Your canonical string and signature must match exactly:
{ "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
When the broker refuses
- 1403: unknown or paused app, an app on the API only flow, or a wrong signature.
- 2410: expires is in the past. Render the form again with a fresh signature.
- 3400: expires missing, not a number, or more than one hour ahead.
- 4422: return_to not in the app's allowed hosts, or a charge the broker cannot create (amount, currency…).
The customer sees a short page with a Back button to return_to when it is allowed; the reason stays out of it.