Go
Integra Constaia en Go con net/http y la librería estándar, subida multipart, structs tipados, reintentos 429 y verificación HMAC de webhooks.
Esta guía usa solo la librería estándar de Go: un cliente pequeño para POST /v1/analyze, un handler que recibe el
fichero del usuario y lo reenvía, y un handler de webhooks con la firma verificada.
SDK oficial de Go: próximamente
Todavía no hay SDK oficial para Go. Aquí llamas a la API REST directamente. Si prefieres un cliente generado, la
especificación OpenAPI está en https://api.constaia.com/openapi.json (por ejemplo, con oapi-codegen).
Requisitos
- Go 1.22 o superior (usa los patrones
"POST /ruta"dehttp.ServeMux). - Una clave de test
ck_test_…del panel (ver Autenticación). - El secreto
whsec_…de un endpoint de webhook (ver Webhooks).
Instalación
No hay dependencias externas:
mkdir constaia-go && cd constaia-go
go mod init example.com/constaia-goCONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...La clave se queda en el servidor. Nunca la incluyas en un binario que distribuyas ni en una app móvil.
Cliente
Structs con solo la parte del análisis que usas, subida multipart/form-data con los campos file y options
(JSON en texto), una Idempotency-Key por operación y reintento de los 429 respetando Retry-After con la misma
clave, así que no hay doble cobro.
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
"time"
)
const (
baseURL = "https://api.constaia.com"
maxFileSize = 20 << 20
maxRetries = 2
)
type Reason struct {
Code string `json:"code"`
Severity string `json:"severity"`
Message string `json:"message"`
}
type Verdict struct {
Expected []string `json:"expected"`
Match bool `json:"match"`
Status string `json:"status"`
Reasons []Reason `json:"reasons"`
}
type Document struct {
Type string `json:"type"`
Label string `json:"label"`
Confidence float64 `json:"confidence"`
}
type Field struct {
Value any `json:"value"`
Confidence *float64 `json:"confidence"`
Validated *bool `json:"validated"`
}
type AnalysisError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type Analysis struct {
ID string `json:"id"`
Status string `json:"status"`
Livemode bool `json:"livemode"`
CreatedAt time.Time `json:"created_at"`
Document *Document `json:"document"`
Verdict *Verdict `json:"verdict"`
Fields map[string]Field `json:"fields"`
Warnings []string `json:"warnings"`
Metadata map[string]string `json:"metadata"`
Error *AnalysisError `json:"error"`
}
type Holder struct {
FullName string `json:"full_name,omitempty"`
}
type Checks struct {
NotExpired bool `json:"not_expired"`
Holder *Holder `json:"holder,omitempty"`
}
type AnalyzeOptions struct {
Expect []string `json:"expect"`
Checks *Checks `json:"checks,omitempty"`
Storage string `json:"storage,omitempty"`
Async bool `json:"async,omitempty"`
Language string `json:"language,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type APIError struct {
StatusCode int `json:"-"`
RetryAfter time.Duration `json:"-"`
Type string `json:"type"`
Code string `json:"code"`
Message string `json:"message"`
Param string `json:"param"`
RequestID string `json:"request_id"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("constaia: %d %s: %s (request_id=%s)", e.StatusCode, e.Code, e.Message, e.RequestID)
}
type Client struct {
apiKey string
http *http.Client
}
func NewClient(apiKey string) *Client {
// El análisis síncrono puede tardar hasta 30 s antes de devolver 202.
return &Client{apiKey: apiKey, http: &http.Client{Timeout: 60 * time.Second}}
}
func (c *Client) Analyze(ctx context.Context, filename string, data []byte, opts AnalyzeOptions) (*Analysis, error) {
optionsJSON, err := json.Marshal(opts)
if err != nil {
return nil, err
}
idempotencyKey := newUUID()
for attempt := 0; ; attempt++ {
body, contentType, err := multipartBody(filename, data, optionsJSON)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/analyze", body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("Idempotency-Key", idempotencyKey)
var analysis Analysis
err = c.do(req, &analysis)
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests && attempt < maxRetries {
wait := apiErr.RetryAfter
if wait <= 0 {
wait = time.Second
}
select {
case <-time.After(wait):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if err != nil {
return nil, err
}
return &analysis, nil
}
}
func (c *Client) GetAnalysis(ctx context.Context, id string) (*Analysis, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/analyses/"+id, nil)
if err != nil {
return nil, err
}
var analysis Analysis
if err := c.do(req, &analysis); err != nil {
return nil, err
}
return &analysis, nil
}
func (c *Client) do(req *http.Request, out any) error {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return json.Unmarshal(raw, out)
}
var envelope struct {
Error APIError `json:"error"`
}
_ = json.Unmarshal(raw, &envelope)
apiErr := &envelope.Error
apiErr.StatusCode = resp.StatusCode
if apiErr.RequestID == "" {
apiErr.RequestID = resp.Header.Get("X-Request-Id")
}
if secs, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
apiErr.RetryAfter = time.Duration(secs) * time.Second
}
return apiErr
}
func multipartBody(filename string, data, optionsJSON []byte) (*bytes.Buffer, string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("file", filename)
if err != nil {
return nil, "", err
}
if _, err := part.Write(data); err != nil {
return nil, "", err
}
if err := w.WriteField("options", string(optionsJSON)); err != nil {
return nil, "", err
}
if err := w.Close(); err != nil {
return nil, "", err
}
return &buf, w.FormDataContentType(), nil
}
func newUUID() string {
var b [16]byte
_, _ = rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}CreateFormFile envía la parte como application/octet-stream; no importa, porque la API detecta el tipo real
(JPEG, PNG, WEBP, HEIC o PDF) por su contenido.
Handler que recibe el documento
r.FormFile lee el fichero del usuario y el handler lo reenvía. Conserva header.Filename: en modo test el resultado
depende del nombre. Tu backend decide expect y checks; no los aceptes tal cual del cliente.
package main
import (
"context"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"time"
)
type server struct {
constaia *Client
webhookSecret string
}
func main() {
apiKey := os.Getenv("CONSTAIA_API_KEY")
if apiKey == "" {
log.Fatal("falta CONSTAIA_API_KEY")
}
s := &server{constaia: NewClient(apiKey), webhookSecret: os.Getenv("CONSTAIA_WEBHOOK_SECRET")}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/verify", s.verify)
mux.HandleFunc("POST /webhooks/constaia", s.webhook)
log.Println("escuchando en :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
func (s *server) verify(w http.ResponseWriter, r *http.Request) {
// Añade aquí tu autenticación: cada análisis consume créditos.
r.Body = http.MaxBytesReader(w, r.Body, maxFileSize+(1<<20))
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "Falta el documento o supera 20 MB.")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil || len(data) == 0 {
writeError(w, http.StatusBadRequest, "No se pudo leer el documento.")
return
}
if len(data) > maxFileSize {
writeError(w, http.StatusRequestEntityTooLarge, "Máximo 20 MB.")
return
}
checks := &Checks{NotExpired: true}
if name := r.FormValue("full_name"); name != "" {
checks.Holder = &Holder{FullName: name}
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
analysis, err := s.constaia.Analyze(ctx, header.Filename, data, AnalyzeOptions{
Expect: []string{"es_dni"},
Checks: checks,
Storage: "none",
Language: "es",
})
if err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) {
log.Printf("constaia %d %s request_id=%s", apiErr.StatusCode, apiErr.Code, apiErr.RequestID)
if apiErr.Type == "invalid_request" {
writeError(w, http.StatusUnprocessableEntity, apiErr.Message)
return
}
} else {
log.Printf("constaia: %v", err)
}
writeError(w, http.StatusBadGateway, "No se pudo verificar el documento. Inténtalo de nuevo.")
return
}
if analysis.Status != "completed" {
// 202: el resultado llegará por webhook (o consulta GetAnalysis).
writeJSON(w, http.StatusAccepted, map[string]string{"id": analysis.ID, "status": analysis.Status})
return
}
resp := map[string]any{"id": analysis.ID, "warnings": analysis.Warnings}
if v := analysis.Verdict; v != nil {
messages := make([]string, 0, len(v.Reasons))
for _, reason := range v.Reasons {
messages = append(messages, reason.Message)
}
resp["status"] = v.Status
resp["reasons"] = messages
}
writeJSON(w, http.StatusOK, resp)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]any{"error": map[string]string{"message": message}})
}analysis.Verdict.Status vale:
- Válido el tipo coincide y no hay avisos: acepta.
- No válido alguna razón con severidad
error(tipo distinto, caducado, titular distinto…): rechaza. - Revisar alguna razón con severidad
warning: revisión humana.
Los Code de las razones son estables; los Message vienen traducidos según language. Para leer un campo:
analysis.Fields["document_number"].Value. Más en Veredictos.
200 frente a 202
El análisis síncrono espera hasta 30 s. Si termina, recibes 200 con status: "completed"; si no, 202 con
status: "queued" o "processing" y el resultado llega por webhook. Con Async: true la API responde 202 al
momento. El handler anterior distingue ambos casos por analysis.Status.
Webhook con firma verificada
Lee el cuerpo crudo con io.ReadAll antes de decodificarlo: la firma se calcula sobre los bytes exactos.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
)
const webhookTolerance = 300 // segundos
var errInvalidSignature = errors.New("constaia: invalid webhook signature")
func verifyWebhook(secret string, h http.Header, body []byte, now time.Time) error {
id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
if id == "" || ts == "" || sigs == "" {
return errInvalidSignature
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return errInvalidSignature
}
if d := now.Unix() - sec; d > webhookTolerance || d < -webhookTolerance {
return errInvalidSignature
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return err
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + ts + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, part := range strings.Fields(sigs) {
version, sig, ok := strings.Cut(part, ",")
if !ok || version != "v1" {
continue
}
got, err := base64.StdEncoding.DecodeString(sig)
if err == nil && hmac.Equal(got, expected) {
return nil
}
}
return errInvalidSignature
}
type webhookEvent struct {
Type string `json:"type"`
CreatedAt string `json:"created_at"`
Data json.RawMessage `json:"data"`
}
func (s *server) webhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if err := verifyWebhook(s.webhookSecret, r.Header, body, time.Now()); err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
var event webhookEvent
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
// webhook-id es estable entre reintentos: deduplica con él en tu base de datos.
msgID := r.Header.Get("webhook-id")
switch event.Type {
case "analysis.completed", "analysis.review_required", "analysis.failed":
var analysis Analysis
if err := json.Unmarshal(event.Data, &analysis); err == nil {
status := ""
if analysis.Verdict != nil {
status = analysis.Verdict.Status
}
// Encola el trabajo pesado; responde 2xx en menos de 15 s.
log.Printf("%s %s %s verdict=%s", msgID, event.Type, analysis.ID, status)
}
}
w.WriteHeader(http.StatusNoContent)
}analysis.review_required llega además de analysis.completed cuando el veredicto es review. Los eventos de
prueba del panel llegan con type: "test" y el switch los ignora.
Probar en modo test
export $(cat .env | xargs) && go run .Con una clave ck_test_… no se gastan créditos y el resultado depende del nombre del fichero (tiene que ser una
imagen o un PDF real: renombra cualquier foto).
curl -F "file=@dni_valid.jpg" -F "full_name=María García López" http://localhost:8080/api/verify
curl -F "file=@dni_expired.jpg" http://localhost:8080/api/verify
curl -F "file=@blurry.jpg" http://localhost:8080/api/verify
curl -F "file=@dni_valid.jpg" -F "full_name=Juan Pérez" http://localhost:8080/api/verify| Fichero | Veredicto | Motivo |
|---|---|---|
dni_valid.jpg | valid | "Vigente hasta el 12/03/2031." |
dni_expired.jpg | invalid | not_expired con severidad error: "Caducado el 15/06/2020." |
blurry.jpg | review | low_quality con severidad warning; avisos blurry y low_quality |
dni_valid.jpg con full_name=Juan Pérez | invalid | holder con severidad error |
Más ficheros en Modo test.
Errores
Las respuestas de error tienen la forma { "error": { "type", "code", "message", "param", "request_id" } }, que el
cliente convierte en *APIError. Decide por Type y Code, nunca por Message, y registra RequestID. Tabla
completa en Errores.
Checklist de producción
- Clave
ck_live_…(requiere email verificado) en tu gestor de secretos, nunca en el repositorio. - Autenticación y límite por usuario en tu endpoint: cada análisis consume créditos.
http.MaxBytesReadery límite de 20 MB antes de llamar a la API.- Una
Idempotency-Keypor operación lógica, reutilizada en los reintentos (Idempotencia). - Limita la concurrencia hacia Constaia (2 req/s por clave en el plan gratuito, 10 en el de pago), por ejemplo con un semáforo o
golang.org/x/sync/semaphore(Límites). - Webhook registrado para los
202y los análisisasync; deduplica porwebhook-idy responde rápido. - Revisa
storageykeep_resultsen Almacenamiento y privacidad.
Siguientes pasos
Ruby on Rails
Valida DNI y otros documentos desde Rails 7/8 con Faraday multipart, un service object, ActiveJob y webhooks de Constaia verificados con HMAC.
Spring Boot
Valida documentos con Constaia desde Spring Boot 3 y Java 21 usando RestClient multipart, records, manejo de errores y webhooks HMAC verificados.