Autenticación y firma de solicitudes
Firma las solicitudes a la API con HMAC-SHA256 usando el UUID de tu proyecto y tu API key.
Toda solicitud a la API (excepto los webhooks entrantes) debe incluir el UUID de tu proyecto y una firma de la solicitud. La firma demuestra que la solicitud proviene de ti y que nadie la modificó en el camino.
API keys
2328.io utiliza dos claves que comparten el mismo algoritmo de firma pero cubren distintos endpoints:
| Clave | Se utiliza para |
|---|---|
| API key | Pagos, monederos estáticos, saldo, tipos de cambio y verificación de webhooks de pago / monedero estático |
| Payout API key | Todos los endpoints /v1/payout/* y la verificación de webhooks de retiro |
Ambas claves se gestionan en la configuración de tu proyecto en 2328.io. Los ejemplos siguientes mencionan "API key" de forma genérica — sustitúyela por la correcta según el endpoint que llames.
Nunca mezcles las dos claves: firmar una solicitud de retiro con la API key habitual (o una solicitud de pago con la clave de retiro) devuelve un error de firma.
Headers requeridos
| Header | Tipo | Requerido | Descripción |
|---|---|---|---|
Content-Type | string | sí | Siempre application/json |
project | string | sí | UUID de tu proyecto |
sign | string | sí | Firma HMAC-SHA256 de la solicitud, calculada con tu API key |
User-Agent | string | sí | Identifica tu aplicación (p. ej. MyShop/1.4 (+https://myshop.example)). Las solicitudes sin User-Agent pueden ser bloqueadas. |
Cómo funciona la firma
Piensa en la firma como una huella del cuerpo de la solicitud. Se construye así:
- Serializa el cuerpo a JSON (compacto — sin espacios en blanco adicionales).
- Codifica ese JSON en Base64. Este paso normaliza la entrada entre lenguajes — una vez convertido a ASCII plano, todos los lenguajes producen los mismos bytes para HMAC.
- Calcula HMAC-SHA256 sobre la cadena Base64 usando tu API key, y luego convierte el resultado a hexadecimal en minúsculas.
Para GET y otros tipos de solicitud sin cuerpo, firma una cadena vacía en lugar del JSON.
La firma de la cadena vacía es constante para una API key dada. Puedes cachearla si haces muchas llamadas GET.
Implementaciones
<?php
function apiSign(array $data, string $apiKey): string {
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$base64 = base64_encode($json);
return hash_hmac('sha256', $base64, $apiKey);
}import crypto from "crypto";
export function apiSign(data, apiKey) {
const json = JSON.stringify(data);
const base64 = Buffer.from(json).toString("base64");
return crypto.createHmac("sha256", apiKey).update(base64).digest("hex");
}import { createHmac } from "crypto";
export function apiSign(data: object, apiKey: string): string {
const json = JSON.stringify(data);
const base64 = Buffer.from(json).toString("base64");
return createHmac("sha256", apiKey).update(base64).digest("hex");
}import json
import hmac
import hashlib
import base64
def api_sign(data: dict, api_key: str) -> str:
# ensure_ascii=False keeps non-ASCII characters (Cyrillic, Chinese, …)
# as-is. Without it, Python escapes them to \uXXXX and the signature
# diverges from PHP / Node / Go.
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
b64 = base64.b64encode(body.encode("utf-8")).decode()
return hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest()package sign
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
)
func ApiSign(data any, apiKey string) (string, error) {
// json.Encoder with SetEscapeHTML(false) — without it, Go escapes <, >, &
// to \u003c etc., which breaks compatibility with PHP / Node / Python.
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(data); err != nil {
return "", err
}
// Encoder appends a trailing newline — drop it.
body := bytes.TrimRight(buf.Bytes(), "\n")
b64 := base64.StdEncoding.EncodeToString(body)
h := hmac.New(sha256.New, []byte(apiKey))
h.Write([]byte(b64))
return hex.EncodeToString(h.Sum(nil)), nil
}Solicitudes sin cuerpo (GET)
Para solicitudes con cuerpo vacío (por ejemplo GET /v1/payout/status/{uuid}), firma una cadena vacía:
SIGN=$(printf '' | openssl dgst -sha256 -hmac "$API_KEY" -hex | awk '{print $NF}')$sign = hash_hmac('sha256', base64_encode(''), $apiKey);import { createHmac } from "crypto";
const sign = createHmac("sha256", apiKey)
.update(Buffer.from("").toString("base64"))
.digest("hex");import { createHmac } from "crypto";
const sign: string = createHmac("sha256", apiKey)
.update(Buffer.from("").toString("base64"))
.digest("hex");import hmac
import hashlib
import base64
sign = hmac.new(
api_key.encode(),
base64.b64encode(b"").decode().encode(),
hashlib.sha256,
).hexdigest()package sign
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
func EmptyBodySign(apiKey string) string {
b64 := base64.StdEncoding.EncodeToString([]byte(""))
h := hmac.New(sha256.New, []byte(apiKey))
h.Write([]byte(b64))
return hex.EncodeToString(h.Sum(nil))
}Ejemplo completo de solicitud
curl -X POST https://api.2328.io/api/v1/payment \
-H "Content-Type: application/json" \
-H "User-Agent: MyShop/1.0 (+https://myshop.example)" \
-H "project: YOUR_PROJECT_UUID" \
-H "sign: YOUR_HMAC_SIGNATURE" \
-d '{"amount":"100.00","currency":"USD","order_id":"ORDER-123"}'<?php
function apiSign(string $body, string $apiKey): string {
return hash_hmac('sha256', base64_encode($body), $apiKey);
}
$project = 'YOUR_PROJECT_UUID';
$apiKey = 'YOUR_API_KEY';
$data = [
'amount' => '100.00',
'currency' => 'USD',
'order_id' => 'ORDER-123',
];
$body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$sign = apiSign($body, $apiKey);
$ch = curl_init('https://api.2328.io/api/v1/payment');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'User-Agent: MyShop/1.0 (+https://myshop.example)',
"project: $project",
"sign: $sign",
],
]);
$response = json_decode(curl_exec($ch), true);import { createHmac } from "crypto";
function apiSign(body, apiKey) {
const base64 = Buffer.from(body, "utf8").toString("base64");
return createHmac("sha256", apiKey).update(base64).digest("hex");
}
const data = {
amount: "100.00",
currency: "USD",
order_id: "ORDER-123",
};
const body = JSON.stringify(data);
const sign = apiSign(body, process.env.API_KEY);
const res = await fetch("https://api.2328.io/api/v1/payment", {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "MyShop/1.0 (+https://myshop.example)",
project: process.env.PROJECT_UUID,
sign,
},
body,
});
const json = await res.json();import { createHmac } from "crypto";
function apiSign(body: string, apiKey: string): string {
const base64 = Buffer.from(body, "utf8").toString("base64");
return createHmac("sha256", apiKey).update(base64).digest("hex");
}
type CreatePaymentBody = {
amount: string;
currency: string;
order_id: string;
};
type CreatePaymentResponse = { state: number; result: unknown };
const data: CreatePaymentBody = {
amount: "100.00",
currency: "USD",
order_id: "ORDER-123",
};
const body = JSON.stringify(data);
const sign = apiSign(body, process.env.API_KEY!);
const res = await fetch("https://api.2328.io/api/v1/payment", {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "MyShop/1.0 (+https://myshop.example)",
project: process.env.PROJECT_UUID!,
sign,
},
body,
});
const json = (await res.json()) as CreatePaymentResponse;import json
import hmac
import hashlib
import base64
import httpx
def api_sign(body: str, api_key: str) -> str:
b64 = base64.b64encode(body.encode("utf-8")).decode()
return hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest()
data = {
"amount": "100.00",
"currency": "USD",
"order_id": "ORDER-123",
}
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
sign = api_sign(body, API_KEY)
r = httpx.post(
"https://api.2328.io/api/v1/payment",
headers={
"Content-Type": "application/json",
"User-Agent": "MyShop/1.0 (+https://myshop.example)",
"project": PROJECT_UUID,
"sign": sign,
},
content=body.encode("utf-8"),
)
response = r.json()package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
)
func ApiSign(body []byte, apiKey string) string {
b64 := base64.StdEncoding.EncodeToString(body)
h := hmac.New(sha256.New, []byte(apiKey))
h.Write([]byte(b64))
return hex.EncodeToString(h.Sum(nil))
}
func marshalCanonical(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
func main() {
data := struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
OrderID string `json:"order_id"`
}{
Amount: "100.00",
Currency: "USD",
OrderID: "ORDER-123",
}
body, err := marshalCanonical(data)
if err != nil {
panic(err)
}
sign := ApiSign(body, apiKey)
req, _ := http.NewRequest("POST",
"https://api.2328.io/api/v1/payment",
bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "MyShop/1.0 (+https://myshop.example)")
req.Header.Set("project", projectUUID)
req.Header.Set("sign", sign)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
}Nunca expongas tu API key en código del lado del cliente. Firma las solicitudes en tu backend. Una API key filtrada le da a cualquiera acceso completo a tu cuenta de comerciante.
Verificación de firmas de webhook
Cuando 2328.io te envía un webhook, el mismo algoritmo se aplica a la inversa:
- Extrae el campo
signdel payload. - Codifica los campos restantes a JSON (compacto, sin espacios en blanco).
- Codifica esa cadena en Base64.
- Calcula
HMAC-SHA256con la clave correspondiente. - Compáralo con el
signrecibido usando una comparación de tiempo constante (hash_equals,crypto.timingSafeEqual,hmac.compare_digest,subtle.ConstantTimeCompare,OpenSSL.fixed_length_secure_compare).
La clave de firma depende del origen del webhook:
| Webhook | Clave para verificar |
|---|---|
Webhooks de pago / monedero estático (/v1/payment, /v1/static-wallet) | API key |
Webhooks de retiro (/v1/payout) | Payout API key |
Errores comunes de verificación. Tu codificador JSON debe producir exactamente los mismos bytes que produjo el emisor — de lo contrario el Base64 difiere y la firma no coincidirá.
- Go: usa
json.NewEncoderconSetEscapeHTML(false). Eljson.Marshalpor defecto escapa<,>,&a<y rompe la firma. - Python: pasa
ensure_ascii=Falseajson.dumps. Sin esto, los caracteres no ASCII (cirílico, chino, …) se escapan a\uXXXX. - JSON compacto: sin espacios en blanco entre campos (
separators=(",", ":")en Python). - Orden de campos (Go): un
map[string]anysimple aleatoriza las claves al recodificar. Usajson.RawMessage, una struct ordenada o eliminasignde los bytes originales.
Si la verificación sigue fallando, ejecuta apiSign sobre el payload tú mismo — debe producir la misma cadena hexadecimal que el sign recibido.
Una firma válida no previene reenvíos. Solo prueba que el webhook vino de 2328.io — no impide que un atacante vuelva a publicar un webhook capturado más tarde. Comprueba siempre la idempotencia por uuid (o txid para wallets estáticos) antes de acreditar fondos. Rechaza con HTTP 401 si la firma falta o es incorrecta.
Los ejemplos de código completos están en Webhook Notifications. El manejo de reintentos y las reglas de idempotencia están en Buenas prácticas.