net/http da biblioteca padrão basta. Esta página traz um cliente pronto, para colar no seu projeto, que já faz o que importa: chave no cabeçalho, Idempotency-Key, novas tentativas só quando é seguro, listas por cursor e erros claros.
O SDK oficial de Go está a caminho. Quando sair, esta página passa a usar o pacote; o cliente abaixo continua funcionando, porque fala com a mesma API.
Rode só no servidor. A chave de API vai em todo pedido: nunca a coloque em front-end, app ou repositório. Guarde em variável de ambiente (
BUKIO_API_KEY).O cliente
Go 1.22 ou mais novo, sem dependências. Salve comobukiopay/client.go no seu projeto:
bukiopay/client.go
package bukiopay
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
const APIURL = "https://bukio-backend-production.up.railway.app"
// Error é o erro da API: use Code no seu sistema; Message vem em português.
type Error struct {
Status int
Code string
Message string
RequestID string
Details map[string]any
}
func (e *Error) Error() string { return fmt.Sprintf("bukiopay: %d %s: %s", e.Status, e.Code, e.Message) }
type Client struct {
APIKey string
MaxRetries int
HTTP *http.Client
}
// New cria o cliente. Sem chave, lê a variável BUKIO_API_KEY.
func New(apiKey string) *Client {
if apiKey == "" {
apiKey = os.Getenv("BUKIO_API_KEY")
}
return &Client{APIKey: apiKey, MaxRetries: 2, HTTP: &http.Client{Timeout: 60 * time.Second}}
}
// Request faz o pedido e lê a resposta em out. retrySafe diz se pode repetir em erro de rede ou 5xx:
// sim para consultas e para criar cobrança (com a mesma Idempotency-Key); nunca para saque, Pix
// enviado e estorno.
func (c *Client) Request(ctx context.Context, method, path string, query url.Values, body any, idempotencyKey string, retrySafe bool, out any) error {
var payload []byte
if body != nil {
var err error
if payload, err = json.Marshal(body); err != nil {
return err
}
}
u := APIURL + path
if len(query) > 0 {
u += "?" + query.Encode()
}
for attempt := 0; ; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := c.HTTP.Do(req)
if err != nil {
if retrySafe && attempt < c.MaxRetries {
time.Sleep(time.Duration(500<<attempt) * time.Millisecond)
continue
}
return err
}
data, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == http.StatusTooManyRequests && attempt < c.MaxRetries {
wait, _ := strconv.Atoi(res.Header.Get("Retry-After"))
time.Sleep(time.Duration(max(wait, 1)) * time.Second)
continue
}
if res.StatusCode >= 500 && retrySafe && attempt < c.MaxRetries {
time.Sleep(time.Duration(500<<attempt) * time.Millisecond)
continue
}
if res.StatusCode >= 400 {
var e struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details"`
} `json:"error"`
RequestID string `json:"requestId"`
}
_ = json.Unmarshal(data, &e)
return &Error{Status: res.StatusCode, Code: e.Error.Code, Message: e.Error.Message, RequestID: e.RequestID, Details: e.Error.Details}
}
if out == nil || len(data) == 0 {
return nil
}
return json.Unmarshal(data, out)
}
}
// Page é uma página de lista: items e o cursor da próxima (vazio quando acabou).
type Page[T any] struct {
Items []T `json:"items"`
NextCursor string `json:"nextCursor"`
}
// Each passa por todas as páginas de uma lista, buscando a próxima só quando precisa.
func Each[T any](ctx context.Context, c *Client, path string, query url.Values, fn func(T) error) error {
q := url.Values{}
for k, v := range query {
q[k] = v
}
for {
var page Page[T]
if err := c.Request(ctx, http.MethodGet, path, q, nil, "", true, &page); err != nil {
return err
}
for _, item := range page.Items {
if err := fn(item); err != nil {
return err
}
}
if page.NextCursor == "" {
return nil
}
q.Set("cursor", page.NextCursor)
}
}
Exemplos
- Pix
- Cartão
- Saque
- Listas
type Payment struct {
ID string `json:"id"`
Status string `json:"status"`
Amount int `json:"amount"`
CopyPaste string `json:"copyPaste"`
ExpiresAt string `json:"expiresAt"`
}
bukio := bukiopay.New("")
var cobranca Payment
err := bukio.Request(ctx, "POST", "/v1/payments", nil,
map[string]any{"amount": 15000, "description": "Pedido 1052"}, // R$ 150,00, sempre em centavos
"pedido-1052", // Idempotency-Key: o código do pedido no seu sistema
true, &cobranca)
fmt.Println(cobranca.ID, cobranca.CopyPaste) // BKY-... e o Pix Copia e Cola
expiresAt). Libere o pedido só no webhook payment.paid.var cobranca struct {
ID string `json:"id"`
PaymentURL string `json:"paymentUrl"`
}
err := bukio.Request(ctx, "POST", "/v1/payments", nil,
map[string]any{"amount": 15000, "method": "card", "description": "Pedido 1053"},
"pedido-1053", true, &cobranca)
// Mande o cliente para cobranca.PaymentURL (página segura de pagamento).
var saque struct {
ID string `json:"id"`
Status string `json:"status"`
}
err := bukio.Request(ctx, "POST", "/v1/withdrawals", nil, map[string]any{
"amount": 10000,
"destination": map[string]string{"keyType": "email", "key": "financeiro@exemplo.com"},
}, "saque-2026-11-05", false, &saque) // false: saque nunca é repetido sozinho
// saque.Status == "processing": confirme pelo webhook withdrawal.completed
err := bukiopay.Each(ctx, bukio, "/v1/payments", url.Values{"status": {"paid"}}, func(p Payment) error {
fmt.Println(p.ID, p.Amount)
return nil
})
Erros
var apiErr *bukiopay.Error
if errors.As(err, &apiErr) {
log.Println(apiErr.Status, apiErr.Code, apiErr.Message, apiErr.RequestID)
}
| Situação | O cliente tenta de novo? |
|---|---|
429 (passou de 100 pedidos por minuto) | Sim, esperando o Retry-After |
Erro de rede ou 5xx com retrySafe (consultas e criar cobrança) | Sim (a mesma Idempotency-Key evita cobrança duplicada) |
Saque, Pix enviado e estorno (retrySafe falso) | Nunca. Confira pela consulta ou pelo webhook antes de pedir de novo |
4xx | Não: corrija o pedido (veja Erros) |
Webhooks
Confira a assinaturaX-Bukio-Signature com o corpo cru, antes de ler o JSON:
bukiopay/webhook.go
package bukiopay
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
)
type WebhookEvent struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt string `json:"created_at"`
Data json.RawMessage `json:"data"`
}
var ErrInvalidWebhook = errors.New("bukiopay: aviso não é da WyzeCash")
// VerifyWebhook confere a assinatura (até 5 minutos) e devolve o evento.
func VerifyWebhook(body []byte, signature, secret string) (*WebhookEvent, error) {
var t int64
var sigs []string
for _, part := range strings.Split(signature, ",") {
k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
switch k {
case "t":
t, _ = strconv.ParseInt(v, 10, 64)
case "v1":
sigs = append(sigs, v)
}
}
if t == 0 || len(sigs) == 0 {
return nil, ErrInvalidWebhook
}
if d := time.Now().Unix() - t; d > 300 || d < -300 {
return nil, ErrInvalidWebhook
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
for _, s := range sigs {
if hmac.Equal([]byte(expected), []byte(s)) {
var event WebhookEvent
if err := json.Unmarshal(body, &event); err != nil {
return nil, ErrInvalidWebhook
}
return &event, nil
}
}
return nil, ErrInvalidWebhook
}
main.go
http.HandleFunc("POST /webhooks/bukiopay", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
event, err := bukiopay.VerifyWebhook(body, r.Header.Get("X-Bukio-Signature"), os.Getenv("BUKIO_WEBHOOK_SECRET"))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK) // responda logo; processe depois
if event.Type == "payment.paid" {
go liberarPedido(event.ID, event.Data) // use event.ID para não processar duas vezes
}
})
2xx em até 10 segundos. Veja Validando a assinatura e a lista de eventos.
Tempo real (WebSocket)
Sem endereço HTTPS para webhooks (bot, worker)? Receba os mesmos eventos pelo WebSocket. A biblioteca padrão não tem cliente WebSocket; este exemplo usagithub.com/coder/websocket:
go get github.com/coder/websocket
func ouvir(ctx context.Context) error {
header := http.Header{"Authorization": {"Bearer " + os.Getenv("BUKIO_API_KEY")}}
conn, _, err := websocket.Dial(ctx, "wss://bukio-backend-production.up.railway.app/v1/ws", &websocket.DialOptions{HTTPHeader: header})
if err != nil {
return err
}
defer conn.CloseNow()
for {
var msg struct {
ID string `json:"id"`
Event string `json:"event"`
Data json.RawMessage `json:"data"`
}
if err := wsjson.Read(ctx, conn, &msg); err != nil {
return err // reconecte esperando cada vez mais, mandando o último ID em since
}
switch {
case msg.Event == "ping":
wsjson.Write(ctx, conn, map[string]string{"action": "ping"})
case msg.Event == "authenticated":
wsjson.Write(ctx, conn, map[string]any{"action": "subscribe", "pattern": "payment.*"})
case msg.ID != "" && msg.Event == "payment.paid":
liberarPedido(msg.ID, msg.Data)
}
}
}
1008 quer dizer chave recusada: não reconecte com a mesma chave. Protocolo completo, since, erros e limites em WebSocket.
Próximos passos
- Sua primeira cobrança e Antes de ir para produção.
- Todas as rotas, com os campos: Referência da API.
- Convenções (centavos, datas,
Idempotency-Key, cursores): Convenções.