No Ruby on Rails, a API Pix se integra sem gem extra: o Net::HTTP da biblioteca padrão cria a cobrança, uma view ERB mostra o QR Code e o copia e cola, e um controller recebe o webhook que confirma o pagamento. O código segue a documentação da API, usa Rails 7 ou superior e a base https://app.xflowpayments.com/api/v1. O módulo do cliente não depende do Rails e roda num script Ruby comum. A visão geral está no guia da API Pix.
O que você precisa
- Uma chave gerada no painel, em Desenvolvedores > Chaves de API. A chave pública (
pk_live_...) fica sempre visível. O segredo (sk_live_...) aparece uma única vez. - Para testar, uma chave do ambiente Teste (
pk_test_...esk_test_...), que pode ser criada antes da aprovação do cadastro. - As variáveis de ambiente
XFLOW_PUBLIC_KEY,XFLOW_SECRET_KEYeXFLOW_WEBHOOK_SECRET. Se preferir as credentials do Rails, troqueENV.fetchporRails.application.credentials.dig(:xflow, ...).
O segredo não vai para o repositório nem para o JavaScript da página.
Um cliente com Net::HTTP
A autenticação é HTTP Basic: usuário é a chave pública, senha é o segredo. Valores vão em centavos, como inteiros.
# app/services/xflow.rb
require "net/http"
require "json"
module Xflow
BASE = "https://app.xflowpayments.com/api/v1"
METODOS = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.freeze
class Erro < StandardError
attr_reader :status, :codigo, :request_id, :retry_after
def initialize(resposta)
corpo = resposta.body.to_s.empty? ? {} : JSON.parse(resposta.body)
@status = resposta.code.to_i
@codigo = corpo["error"]
@request_id = corpo["request_id"]
@retry_after = resposta["retry-after"].to_i
super("#{@status} #{@codigo} (#{@request_id})")
end
end
def self.chamar(metodo, caminho, corpo: nil, idempotency_key: nil)
uri = URI(BASE + caminho)
req = METODOS.fetch(metodo).new(uri)
req.basic_auth(ENV.fetch("XFLOW_PUBLIC_KEY"), ENV.fetch("XFLOW_SECRET_KEY"))
req["Idempotency-Key"] = idempotency_key if idempotency_key
if corpo
req["Content-Type"] = "application/json"
req.body = JSON.generate(corpo)
end
resposta = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
http.request(req)
end
raise Erro, resposta unless resposta.is_a?(Net::HTTPSuccess)
resposta.body.to_s.empty? ? {} : JSON.parse(resposta.body)
end
endToda resposta traz um request_id (req_...), e a mensagem do Xflow::Erro já o inclui. Com ele você acha a chamada em Integrações > Logs da API, que guarda cada requisição autenticada por 30 dias.
Criar a cobrança
# config/routes.rb
post "pedidos/:pedido_id/pix", to: "pagamentos#create"
post "webhooks/xflow", to: "xflow_webhooks#create"# app/controllers/pagamentos_controller.rb
class PagamentosController < ApplicationController
def create
pedido = current_user.pedidos.find(params[:pedido_id]) # só pedidos do usuário logado
@cobranca = Xflow.chamar(
"POST", "/charges",
corpo: {
amountCents: pedido.total_centavos, # inteiro: R$ 19,90 = 1990
customer: { name: pedido.cliente_nome, email: pedido.cliente_email, document: pedido.cliente_documento },
description: "Pedido ##{pedido.id}",
external_reference: "pedido-#{pedido.id}"
},
idempotency_key: "pedido-#{pedido.id}"
)
pedido.update!(xflow_charge_id: @cobranca["id"])
render :pix
end
endAjuste current_user e os campos ao seu app. O valor vem do banco, nunca de params. A Idempotency-Key por pedido faz uma nova tentativa devolver a mesma cobrança em vez de gerar outro Pix. A mesma chave com outro corpo dá 409 idempotency_conflict. O porquê está em idempotência em API de pagamento. O external_reference (até 120 caracteres) volta em toda resposta e webhook.
Mostrar o QR Code na view
pix.qrCodeBase64 é um PNG em data-URL, pronto para o src. O escape do ERB não altera esse texto:
<%# app/views/pagamentos/pix.html.erb %>
<img src="<%= @cobranca.dig("pix", "qrCodeBase64") %>" alt="QR Code Pix" width="240" height="240">
<textarea readonly><%= @cobranca.dig("pix", "copyPaste") %></textarea>pix.copyPaste é o código BR Code que o pagador cola no app do banco: repasse sem alterar. Os dois campos vêm na criação e em GET /api/v1/charges/{id}, mas não na listagem.
Receber o webhook
Cadastre a URL em Integrações > Webhooks. Ela precisa ser https:// e pública. Cada entrega é um POST JSON com os headers x-webhook-event e x-webhook-signature: sha256=..., o HMAC-SHA256 do corpo cru com o segredo do endpoint.
# app/controllers/xflow_webhooks_controller.rb
class XflowWebhooksController < ActionController::Base
skip_forgery_protection
def create
corpo = request.raw_post # bytes crus, antes de qualquer parse
esperada = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("XFLOW_WEBHOOK_SECRET"), corpo)
recebida = request.headers["X-Webhook-Signature"].to_s
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(esperada, recebida)
evento = JSON.parse(corpo)
if evento["event"] == "transaction.paid" && evento.dig("data", "livemode") == true
ProcessarPagamentoJob.perform_later(evento["data"])
end
head :ok
end
endPor que cada parte está assim:
- Herda de
ActionController::Base, e não doApplicationController, para não carregar obefore_actionde login do app. Oskip_forgery_protectiondesliga o CSRF só aqui. request.raw_post, e nãoparams. O Rails converte o JSON emparams, mas o HMAC precisa dos bytes exatos.secure_comparecompara em tempo constante. Não use==para assinatura.head :okconfirma a entrega. Sem 2xx, a xFlow reenvia até 3 vezes.livemode: falsemarca evento de teste.
ActiveJob opcional
O job deixa a resposta rápida:
# app/jobs/processar_pagamento_job.rb
class ProcessarPagamentoJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: 30.seconds, attempts: 5
def perform(data)
# só muda pedido ainda não pago: o mesmo evento pode chegar duas vezes
Pedido.where(referencia: data["external_reference"])
.where.not(status: "pago")
.update_all(status: "pago", end_to_end: data.dig("pix", "endToEnd"), updated_at: Time.current)
end
endDepois do head :ok, a xFlow considera o evento entregue. Se o job falhar, quem tenta de novo é a sua fila, com o retry_on. Apps novos do Rails 8 já vêm com o Solid Queue. Sem fila, chame o mesmo código direto no controller. Os outros eventos estão em webhook Pix.
Testar no sandbox
Com as chaves de teste nas variáveis, rode no rails console:
teste = Xflow.chamar("POST", "/charges", corpo: {
amountCents: 1990,
customer: { name: "Cliente", email: "c@exemplo.com", document: "00000000000" }
})
Xflow.chamar("POST", "/test/charges/#{teste["id"]}/pay")
Xflow.chamar("GET", "/charges/#{teste["id"]}").values_at("status", "livemode") # => ["paid", false]A cobrança vira paid, a taxa sai das taxas reais da sua conta e o webhook chega com livemode: false. O pix.copyPaste de teste não pode ser pago, e a simulação com chave de produção responde 403 test_mode_only. Para receber o evento no ambiente de desenvolvimento por um túnel HTTPS, inclua o domínio do túnel em config.hosts, senão o Rails bloqueia a requisição. O passo a passo está em como testar webhook localmente e em sandbox da API Pix.
A rota do webhook se testa com um corpo assinado por você:
# test/integration/xflow_webhook_test.rb
require "test_helper"
class XflowWebhookTest < ActionDispatch::IntegrationTest
test "aceita webhook assinado" do
ENV["XFLOW_WEBHOOK_SECRET"] = "segredo-de-teste"
corpo = { event: "transaction.pending", data: { livemode: false } }.to_json
assinatura = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", "segredo-de-teste", corpo)
post "/webhooks/xflow", params: corpo,
headers: { "CONTENT_TYPE" => "application/json", "X-Webhook-Signature" => assinatura }
assert_response :ok
end
endSe a assinatura não bater em produção, compare os valores no validador de assinatura de webhook.
Erros e limite de requisições
Um rescue_from registra o request_id e devolve uma mensagem simples:
class ApplicationController < ActionController::Base
rescue_from Xflow::Erro do |erro|
Rails.logger.warn("xFlow: #{erro.message}") # status, código e request_id
response.set_header("Retry-After", erro.retry_after.to_s) if erro.status == 429
render plain: "Não foi possível gerar o Pix agora.", status: erro.status == 429 ? :service_unavailable : :bad_gateway
end
endOs códigos mais comuns:
401 unauthorized: chave inválida ou revogada, ou IP fora da allowlist.400 validation: corpo inválido ou valor fora do mínimo ou do máximo da conta.403 account_inactive: conta em análise ou bloqueada. Siga com a chave de teste.429 rate_limited: o limite é por conta. Num job, useretry_job(wait: erro.retry_after.seconds)e repita com a mesmaIdempotency-Key. Toda resposta autenticada trazx-ratelimit-remaining.502 charge_failed: falha ao gerar a cobrança no provedor. Repita com backoff.
A tabela completa está em erros da API Pix.
Antes de ir para produção
- Troque as chaves de teste por
pk_live_/sk_live_só nas variáveis (ou credentials) de produção. - Cadastre a URL de produção do webhook e guarde o segredo do endpoint.
- Confirme que o worker do ActiveJob está rodando.
- Mantenha o filtro de
livemodee oupdate_allcondicional. - Registre o
request_iddos erros e, se quiser, ative a allowlist de IP em Chaves de API.
Prefira o webhook ao polling de GET /api/v1/charges/{id}. A página Desenvolvedores reúne chaves, logs e os guias de outras linguagens.
Perguntas frequentes
Por que o webhook Pix dá erro de CSRF no Rails?
Porque controllers que herdam de ActionController::Base exigem o token de autenticidade em todo POST, e a xFlow não envia esse token. Use skip_forgery_protection no controller do webhook. Em apps só de API, com ActionController::API, essa verificação não existe.
Preciso de gem para usar a API Pix no Rails?
Não. O Net::HTTP e o JSON da biblioteca padrão do Ruby fazem as chamadas, com basic_auth para a chave pública e o segredo. A API da xFlow é REST, com valores em centavos.
Por que a assinatura do webhook não confere no Rails?
Porque o HMAC foi calculado sobre params ou sobre um JSON gerado de novo, e não sobre os bytes recebidos. Use request.raw_post, calcule OpenSSL::HMAC.hexdigest("SHA256", segredo, corpo), prefixe com sha256= e compare com secure_compare.
Como testar a API Pix no Rails sem pagar?
Com chaves de teste (pk_test_/sk_test_), crie a cobrança no rails console e chame POST /api/v1/test/charges/{id}/pay. A cobrança vira paid e o webhook transaction.paid chega com livemode: false.