Constaia
Integrations

Go

Integrate Constaia in Go with net/http and the standard library: multipart upload, typed structs, 429 retries and HMAC webhook verification.

Cette page n'est pas encore traduite dans votre langue. Voici la version anglaise.

This guide uses only the Go standard library: a small client for POST /v1/analyze, a handler that receives the user's file and forwards it, and a webhook handler with a verified signature.

Official Go SDK: coming soon

There is no official Go SDK yet. Here you call the REST API directly. If you prefer a generated client, the OpenAPI spec is at https://api.constaia.com/openapi.json (for example, with oapi-codegen).

Requirements

  • Go 1.22 or later (uses http.ServeMux patterns like "POST /path").
  • A test key ck_test_… from the dashboard (see Authentication).
  • The whsec_… secret of a webhook endpoint (see Webhooks).

Installation

No external dependencies:

mkdir constaia-go && cd constaia-go
go mod init example.com/constaia-go
.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

The key stays on the server. Never embed it in a binary you distribute or in a mobile app.

Client

Structs with only the part of the analysis you use, a multipart/form-data upload with the file and options fields (JSON as text), one Idempotency-Key per operation and 429 retries honouring Retry-After with the same key, so there is no double charge.

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 {
	// A synchronous analysis can take up to 30 s before returning 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 sends the part as application/octet-stream; that's fine, because the API detects the real type (JPEG, PNG, WEBP, HEIC or PDF) from the content.

Handler that receives the document

r.FormFile reads the user's file and the handler forwards it. Keep header.Filename: in test mode the result depends on the name. Your backend decides expect and checks; don't take them as-is from the client.

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("CONSTAIA_API_KEY is missing")
	}
	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("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

func (s *server) verify(w http.ResponseWriter, r *http.Request) {
	// Add your authentication here: every analysis spends credits.
	r.Body = http.MaxBytesReader(w, r.Body, maxFileSize+(1<<20))
	file, header, err := r.FormFile("file")
	if err != nil {
		writeError(w, http.StatusBadRequest, "The document is missing or larger than 20 MB.")
		return
	}
	defer file.Close()

	data, err := io.ReadAll(file)
	if err != nil || len(data) == 0 {
		writeError(w, http.StatusBadRequest, "The document could not be read.")
		return
	}
	if len(data) > maxFileSize {
		writeError(w, http.StatusRequestEntityTooLarge, "Max 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: "en",
	})
	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, "The document could not be verified. Please try again.")
		return
	}

	if analysis.Status != "completed" {
		// 202: the result will arrive by webhook (or call 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 is:

  • Válido the type matches and there are no warnings: accept.
  • No válido some reason with severity error (wrong type, expired, holder mismatch…): reject.
  • Revisar some reason with severity warning: human review.

Reason Code values are stable; Message values are localised according to language. To read a field: analysis.Fields["document_number"].Value. More in Verdicts.

200 vs 202

A synchronous analysis waits up to 30 s. If it finishes, you get 200 with status: "completed"; if not, 202 with status: "queued" or "processing" and the result arrives by webhook. With Async: true the API answers 202 right away. The handler above tells both cases apart by analysis.Status.

Webhook with verified signature

Read the raw body with io.ReadAll before decoding it: the signature is computed over the exact bytes.

webhook.go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"errors"
	"io"
	"log"
	"net/http"
	"strconv"
	"strings"
	"time"
)

const webhookTolerance = 300 // seconds

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 is stable across retries: dedupe on it in your database.
	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
			}
			// Queue heavy work; answer 2xx within 15 s.
			log.Printf("%s %s %s verdict=%s", msgID, event.Type, analysis.ID, status)
		}
	}
	w.WriteHeader(http.StatusNoContent)
}

analysis.review_required is sent in addition to analysis.completed when the verdict is review. Test events from the dashboard arrive with type: "test" and the switch ignores them.

Test mode

export $(cat .env | xargs) && go run .

With a ck_test_… key no credits are spent and the result depends on the filename (it must be a real image or PDF: rename any photo).

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
FileVerdictReason
dni_valid.jpgvalid"Valid until 12/03/2031."
dni_expired.jpginvalidnot_expired with severity error
blurry.jpgreviewlow_quality with severity warning; warnings blurry and low_quality
dni_valid.jpg with full_name=Juan Pérezinvalidholder with severity error

More files in Test mode.

Errors

Error responses have the shape { "error": { "type", "code", "message", "param", "request_id" } }, which the client turns into *APIError. Branch on Type and Code, never on Message, and log RequestID. Full table in Errors.

Production checklist

  • A ck_live_… key (requires a verified email) in your secret manager, never in the repository.
  • Authentication and a per-user limit on your endpoint: every analysis spends credits.
  • http.MaxBytesReader and a 20 MB limit before calling the API.
  • One Idempotency-Key per logical operation, reused on retries (Idempotency).
  • Limit concurrency towards Constaia (2 req/s per key on the free plan, 10 on paid), for example with a semaphore or golang.org/x/sync/semaphore (Rate limits).
  • A webhook registered for 202 responses and async analyses; dedupe on webhook-id and answer fast.
  • Review storage and keep_results in Storage and privacy.

Next steps

Sur cette page