Go
Integrate Constaia in Go with net/http and the standard library: multipart upload, typed structs, 429 retries and HMAC webhook verification.
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.ServeMuxpatterns 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-goCONSTAIA_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.
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.
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.
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| File | Verdict | Reason |
|---|---|---|
dni_valid.jpg | valid | "Valid until 12/03/2031." |
dni_expired.jpg | invalid | not_expired with severity error |
blurry.jpg | review | low_quality with severity warning; warnings blurry and low_quality |
dni_valid.jpg with full_name=Juan Pérez | invalid | holder 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.MaxBytesReaderand a 20 MB limit before calling the API.- One
Idempotency-Keyper 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
202responses andasyncanalyses; dedupe onwebhook-idand answer fast. - Review
storageandkeep_resultsin Storage and privacy.
Next steps
Ruby on Rails
Validate Spanish IDs and other documents from Rails 7/8 with Faraday multipart, a service object, ActiveJob and HMAC-verified Constaia webhooks.
Spring Boot
Validate documents with Constaia from Spring Boot 3 and Java 21 using RestClient multipart, records, error handling and HMAC-verified webhooks.