Constaia
Integraciones

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" de http.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-go
.env
CONSTAIA_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.

constaia.go
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.

main.go
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.

webhook.go
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
FicheroVeredictoMotivo
dni_valid.jpgvalid"Vigente hasta el 12/03/2031."
dni_expired.jpginvalidnot_expired con severidad error: "Caducado el 15/06/2020."
blurry.jpgreviewlow_quality con severidad warning; avisos blurry y low_quality
dni_valid.jpg con full_name=Juan Pérezinvalidholder 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.MaxBytesReader y límite de 20 MB antes de llamar a la API.
  • Una Idempotency-Key por 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 202 y los análisis async; deduplica por webhook-id y responde rápido.
  • Revisa storage y keep_results en Almacenamiento y privacidad.

Siguientes pasos

En esta página