Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Changelog

All notable changes to this SDK are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). A Go module carries no
version in `go.mod`; each version is released as a `vX.Y.Z` tag.

## [1.0.0] - Unreleased

**BREAKING.** Requires API contract 2.0.0.

### Added
- `Faces.CreateLivenessSession(ctx, collectionID)` returns a `LivenessSession`
(`SessionID`, ordered `Challenges` of type `blink`, `turn_left` or
`turn_right`, `ExpiresAt`).
- `Faces.CompleteLivenessSession(ctx, collectionID, sessionID, frames, mirrored)`
returns a `LivenessSessionResult`: the active liveness verdict, `Steps`
(type and passed) and, on a pass, `LivenessToken` and `LivenessTokenExpiresAt`.
It is retried only on 429, never on a network error or a 5xx such as 503,
since the session is used up by then.
- The error code `LIVENESS_SESSION_INVALID` (422), returned as `*APIError`.

### Changed
- Pinned API contract 2.0.0 (`ContractVersion`, `CONTRACT_VERSION`,
`contract/openapi-2.0.0.json`).

### Removed
- **BREAKING:** `ActiveLivenessResult.LivenessToken` and
`ActiveLivenessResult.LivenessTokenExpiresAt`. The stateless active liveness
check no longer issues a token.

### Migration
`ActiveLiveness` no longer returns a token. Create a session, show its
challenges, complete it with the frames:

```go
session, err := client.Faces.CreateLivenessSession(ctx, collID)
// show session.Challenges in order and capture frames while the person performs them
result, err := client.Faces.CompleteLivenessSession(ctx, collID, session.SessionID, frames, mirrored)
// enrol with RegisterInput{LivenessToken: result.LivenessToken} when result.IsLive
```

Keep `ActiveLiveness` only where a verdict without a token is enough.
2 changes: 1 addition & 1 deletion CONTRACT_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.0
2.0.0
71 changes: 59 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ go get github.com/livexface/livexface-go

Requires Go 1.22 or later.

Validated against API contract 1.0.0 (`/openapi.json` `info.version`), exposed as `livexface.ContractVersion`. The test suite calls every client method against the pinned contract in `contract/` and fails if a method or path is missing from it or a required field is not sent; to move to a new contract, copy the release asset `openapi-<version>.json` into `contract/` and update `CONTRACT_VERSION` and the `ContractVersion` constant.
This is the 1.0.0 release (see [CHANGELOG.md](CHANGELOG.md)); a Go module has no version in `go.mod`, so the version is the `v1.0.0` release tag.

Validated against API contract 2.0.0 (`/openapi.json` `info.version`), exposed as `livexface.ContractVersion`. The test suite calls every client method against the pinned contract in `contract/` and fails if a method or path is missing from it or a required field is not sent; to move to a new contract, copy the release asset `openapi-<version>.json` into `contract/` and update `CONTRACT_VERSION` and the `ContractVersion` constant.

## Quick Start

Expand Down Expand Up @@ -93,40 +95,76 @@ client := livexface.New(
| `Faces.Verify(ctx, collectionID, VerifyInput)` | 1:1 verification against a stored face |
| `Faces.Identify(ctx, collectionID, IdentifyInput)` | 1:N search — return top-K matches |
| `Faces.Liveness(ctx, collectionID, image, filename)` | Passive liveness detection |
| `Faces.ActiveLiveness(ctx, collectionID, []LivenessFrame)` | Active liveness over 5–50 frames; returns a liveness token when passed |
| `Faces.ActiveLiveness(ctx, collectionID, []LivenessFrame)` | Stateless active liveness over 5–50 frames; a verdict only, no token |
| `Faces.CreateLivenessSession(ctx, collectionID)` | Start a liveness session; returns the steps to perform and its expiry |
| `Faces.CompleteLivenessSession(ctx, collectionID, sessionID, []LivenessFrame, mirrored)` | Submit 5–50 frames for a session; returns the verdict, per-step results and, on a pass, a liveness token |
| `Faces.Compare(ctx, CompareInput)` | Compare two images without enrolling |
| `Faces.BatchRegister(ctx, collectionID, []BatchItem, ...CallOption)` | Enroll up to 20 faces in one request |
| `Faces.BatchRegisterAsync(ctx, collectionID, []BatchItem, ...CallOption)` | Queue up to 100 faces; returns a job to poll with `GetBatchJob` |

### Liveness-gated enrolment

Collections that require liveness reject enrolment without a token from a
passed active liveness check (`LIVENESS_TOKEN_REQUIRED`). Tokens are
single-use, expire after 5 minutes, and are bound to the organization and
collection. A token that is expired, reused or for another collection returns
`LIVENESS_TOKEN_INVALID`; a token whose face does not match the enrolled image
returns `LIVENESS_FACE_MISMATCH`.
passed liveness session (`LIVENESS_TOKEN_REQUIRED`). The server picks the steps
of each session (one blink and one or two head turns, in random order), so
frames prepared in advance from a photo do not pass. `ActiveLiveness` is a
stateless verdict and issues no token.

1. Create a session. It expires 60 seconds later by default.
2. Show its challenges to the person in order. `turn_left` and `turn_right`
are the person's own left and right.
3. Capture 5–50 frames while they perform the steps.
4. Complete the session with the frames, once. Pass `mirrored: true` when the
frames are horizontally mirrored, as a selfie preview is.
5. Enrol with the token.

```go
session, err := client.Faces.CreateLivenessSession(ctx, collID)
if err != nil {
log.Fatal(err)
}
prompts := map[livexface.LivenessStepType]string{
livexface.LivenessStepBlink: "Blink",
livexface.LivenessStepTurnLeft: "Turn your head to your left",
livexface.LivenessStepTurnRight: "Turn your head to your right",
}
for _, ch := range session.Challenges {
showPrompt(prompts[ch.Type]) // your UI, while the camera captures frames
}

frames := make([]livexface.LivenessFrame, 0, len(jpegFrames))
for _, f := range jpegFrames { // at least 5 frames captured while the user blinks and turns
for _, f := range jpegFrames {
frames = append(frames, livexface.LivenessFrame{Image: f})
}
check, err := client.Faces.ActiveLiveness(ctx, collID, frames)
result, err := client.Faces.CompleteLivenessSession(ctx, collID, session.SessionID, frames, false)
if err != nil {
log.Fatal(err)
log.Fatal(err) // on LIVENESS_SESSION_INVALID or SERVICE_BUSY, create a new session
}
if !check.IsLive {
if !result.IsLive {
for _, st := range result.Steps {
fmt.Printf("%s passed=%v\n", st.Type, st.Passed)
}
log.Fatal("liveness check failed")
}

face, err := client.Faces.Register(ctx, collID, livexface.RegisterInput{
ExternalID: "user_42",
Image: jpegFrames[0],
LivenessToken: check.LivenessToken,
LivenessToken: result.LivenessToken,
})
```

A session is judged at most once. Any submission uses it up except one with
fewer than 5 frames (`IMAGE_REQUIRED`, 400), which you may resubmit. A session
that is unknown, expired, already submitted or for another collection fails with
`LIVENESS_SESSION_INVALID` (422); create a new session. A pass whose token the
server could not store has `IsLive` true and an empty `LivenessToken`.

Liveness tokens are single-use, expire after 5 minutes and are bound to the
organization and collection. A token that is expired, reused or for another
collection returns `LIVENESS_TOKEN_INVALID`; a token whose face does not match
the enrolled image returns `LIVENESS_FACE_MISMATCH`.

`BatchItem` has the same optional `LivenessToken` field for `BatchRegister`
and `BatchRegisterAsync`.

Expand Down Expand Up @@ -157,6 +195,10 @@ if err != nil {
}
```

`CompleteLivenessSession` fails with `LIVENESS_SESSION_INVALID` (HTTP 422)
when the session is unknown, expired, already submitted or bound to another
collection. Check `apiErr.Code` and start a new session.

## Idempotent requests

`Register`, `BatchRegister` and `BatchRegisterAsync` accept
Expand Down Expand Up @@ -187,6 +229,11 @@ first:
and for calls with an idempotency key, never for other POSTs such as
`Identify` or `Verify`.
- Other 4xx responses are never retried.
- `CompleteLivenessSession` is retried only on 429, which the server sends
before it touches the session. It is not retried on a network error or any
5xx, including 503 `SERVICE_BUSY`: the server uses the session up before the
engine can answer busy, so a retry would only fail with
`LIVENESS_SESSION_INVALID` and hide the cause. On a 503, create a new session.
- The enrolment methods send the same idempotency key on every attempt, and
generate one when you pass none.

Expand Down
98 changes: 84 additions & 14 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const defaultBaseURL = "https://api.livexface.com/api/v1"
// ContractVersion is the API contract version (info.version of /openapi.json)
// this SDK release is validated against. The contract is pinned in
// contract/openapi-<version>.json and checked by the test suite.
const ContractVersion = "1.0.0"
const ContractVersion = "2.0.0"

// ─── Error ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -96,6 +96,8 @@ func WithHTTPClient(hc *http.Client) Option {
// - network errors and other 5xx responses are retried only for GET, PATCH
// and DELETE requests and for requests that carry an idempotency key;
// - other 4xx responses are never retried;
// - CompleteLivenessSession is retried only on 429: the server uses the
// session up before it can answer 503;
// - Register, BatchRegister and BatchRegisterAsync send one idempotency key
// on every attempt of a call, generating one when the caller gave none.
//
Expand Down Expand Up @@ -195,6 +197,13 @@ type apiEnvelope struct {
// response it returns a *APIError. body is sent whole on every attempt; a
// non-empty idempotencyKey is sent as the Idempotency-Key header.
func (c *Client) do(ctx context.Context, method, path string, body []byte, contentType, idempotencyKey string) (json.RawMessage, error) {
return c.doRetry(ctx, method, path, body, contentType, idempotencyKey, true)
}

// doRetry is do with a choice about 503: retryBusy false leaves a 503 to the
// rules for other 5xx, for a request the server may have acted on before
// answering 503 (completing a liveness session uses the session up first).
func (c *Client) doRetry(ctx context.Context, method, path string, body []byte, contentType, idempotencyKey string, retryBusy bool) (json.RawMessage, error) {
for attempt := 0; ; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
Expand All @@ -214,7 +223,7 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte, conte
}
safe := idempotencyKey != "" || method == http.MethodGet ||
method == http.MethodPatch || method == http.MethodDelete
delay, ok := c.retryDelay(err, attempt, safe)
delay, ok := c.retryDelay(err, attempt, safe, retryBusy)
if !ok {
return nil, err
}
Expand Down Expand Up @@ -288,11 +297,12 @@ func (c *Client) send(req *http.Request) (json.RawMessage, error) {
// retryDelay reports whether a failed attempt may be retried and how long to
// wait first. safe means repeating the request is harmless: a read, a
// metadata update, a deletion or a request with an idempotency key.
func (c *Client) retryDelay(err error, attempt int, safe bool) (time.Duration, bool) {
// retryBusy false treats 503 like any other 5xx.
func (c *Client) retryDelay(err error, attempt int, safe, retryBusy bool) (time.Duration, bool) {
var apiErr *APIError
if errors.As(err, &apiErr) {
switch s := apiErr.StatusCode; {
case s == http.StatusTooManyRequests || s == http.StatusServiceUnavailable:
case s == http.StatusTooManyRequests || (s == http.StatusServiceUnavailable && retryBusy):
if ra := apiErr.RetryAfter; ra != nil {
if float64(*ra) >= c.maxRetryDelay.Seconds() {
return c.maxRetryDelay, true
Expand Down Expand Up @@ -570,22 +580,28 @@ func (r *FacesResource) Liveness(ctx context.Context, collectionID string, image
return &result, nil
}

// ActiveLiveness runs an active liveness check (blink, head turn and passive
// anti-spoofing) over a sequence of 5 to 50 frames. When the check passes, the
// result carries a single-use LivenessToken (valid for 5 minutes, bound to the
// organization and collection) that can be passed to Register or BatchItem to
// enrol into a collection that requires liveness.
func (r *FacesResource) ActiveLiveness(ctx context.Context, collectionID string, frames []LivenessFrame) (*ActiveLivenessResult, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)

// writeFrames adds frames as the parts frame_0, frame_1, ….
func writeFrames(mw *multipart.Writer, frames []LivenessFrame) error {
for i, frame := range frames {
field := fmt.Sprintf("frame_%d", i)
fname := imageFilename(frame.Filename, field+".jpg")
if err := writeImagePart(mw, field, fname, frame.Image); err != nil {
return nil, fmt.Errorf("livexface: write %s: %w", field, err)
return fmt.Errorf("livexface: write %s: %w", field, err)
}
}
return nil
}

// ActiveLiveness runs a stateless active liveness check (blink, head turn and
// passive anti-spoofing) over a sequence of 5 to 50 frames. It returns a
// verdict only and issues no liveness token; to enrol into a collection that
// requires liveness, use CreateLivenessSession and CompleteLivenessSession.
func (r *FacesResource) ActiveLiveness(ctx context.Context, collectionID string, frames []LivenessFrame) (*ActiveLivenessResult, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
if err := writeFrames(mw, frames); err != nil {
return nil, err
}
mw.Close()

data, err := r.c.do(ctx, http.MethodPost,
Expand All @@ -601,6 +617,60 @@ func (r *FacesResource) ActiveLiveness(ctx context.Context, collectionID string,
return &result, nil
}

// CreateLivenessSession starts a liveness session bound to the collection.
// The server picks the steps (one blink and one or two head turns, in random
// order); show them to the person in order, capture frames while they perform
// them, and pass the frames to CompleteLivenessSession before ExpiresAt
// (60 seconds by default).
func (r *FacesResource) CreateLivenessSession(ctx context.Context, collectionID string) (*LivenessSession, error) {
data, err := r.c.do(ctx, http.MethodPost,
"/collections/"+collectionID+"/liveness-sessions", nil, "", "")
if err != nil {
return nil, err
}
var session LivenessSession
if err := json.Unmarshal(data, &session); err != nil {
return nil, fmt.Errorf("livexface: decode liveness session: %w", err)
}
return &session, nil
}

// CompleteLivenessSession submits 5 to 50 frames for a liveness session. Set
// mirrored when the frames are horizontally mirrored, as a selfie preview is.
// The session passes only when every step appears in the frames in order; a
// pass carries a single-use LivenessToken (valid for 5 minutes, bound to the
// collection) for Register or BatchItem.
//
// A session is judged at most once: any submission other than one with fewer
// than 5 frames (IMAGE_REQUIRED) uses it up. A reused, expired or unknown
// session fails with LIVENESS_SESSION_INVALID (422). The call is therefore
// never retried automatically except on 429, which the server answers before
// touching the session; after a network error or a 5xx such as SERVICE_BUSY,
// create a new session.
func (r *FacesResource) CompleteLivenessSession(ctx context.Context, collectionID, sessionID string, frames []LivenessFrame, mirrored bool) (*LivenessSessionResult, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
if err := writeFrames(mw, frames); err != nil {
return nil, err
}
if err := mw.WriteField("mirrored", strconv.FormatBool(mirrored)); err != nil {
return nil, err
}
mw.Close()

data, err := r.c.doRetry(ctx, http.MethodPost,
"/collections/"+collectionID+"/liveness-sessions/"+sessionID,
buf.Bytes(), mw.FormDataContentType(), "", false)
if err != nil {
return nil, err
}
var result LivenessSessionResult
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("livexface: decode liveness session result: %w", err)
}
return &result, nil
}

// Compare performs a pairwise comparison of two face images without enrolling
// either into a collection.
func (r *FacesResource) Compare(ctx context.Context, input CompareInput) (*VerifyResult, error) {
Expand Down
Loading
Loading