Handoff is a standalone Go server that lets backend applications collect photos, signatures, and document scans from phone users. A backend creates a session via API, the user completes the action on a phone-friendly web UI served by Handoff, and the backend retrieves the result via polling or WebSocket.
Everything runs in a single binary with no external dependencies — sessions and files are stored in memory with configurable TTLs.
- Your backend creates a session via the REST API (or Go client library)
- You get back a URL and optionally generate a QR code
- The user opens the URL on their phone and completes the action
- Your backend receives the result via WebSocket push or polling
Backend Handoff Server Phone
│ │ │
├─ POST /api/v1/sessions ────►│ │
│◄── session URL + ID ────────┤ │
│ │ │
│ (display QR code) │◄── user scans QR ─────────┤
│ │── render action UI ──────►│
│◄── WebSocket: "opened" ─────┤ │
│◄── WebSocket: "completed" ──┤◄── submit result ─────────┤
│ │ │
├─ GET /api/v1/downloads/:id ►│ │
│◄── file data ───────────────┤ │
docker run -p 8080:8080 \
-e API_KEYS=my-secret-key \
-e BASE_URL=http://localhost:8080 \
ghcr.io/mxcd/handoff:latestgo build -o server ./cmd/server
API_KEYS=my-secret-key BASE_URL=http://localhost:8080 ./serverAll configuration is via environment variables. A .env file in the working directory is also loaded.
| Variable | Required | Default | Description |
|---|---|---|---|
API_KEYS |
Yes | — | Comma-separated list of API keys for authentication |
BASE_URL |
Yes | — | Public URL of the server, used to generate session URLs |
PORT |
No | 8080 |
HTTP port |
DEV |
No | false |
Enable development mode (colored log output) |
LOG_LEVEL |
No | info |
Log level (debug, info, warn, error) |
SESSION_TTL |
No | 30m |
How long a session stays active (Go duration string) |
RESULT_TTL |
No | 5m |
How long result files are available after completion |
SCAN_UPLOAD_MAX_BYTES |
No | 20971520 |
Max upload size per scan page (bytes) |
SCAN_MAX_PAGES |
No | 50 |
Max pages per scan session |
- photo — User takes a photo with their phone camera. Output formats:
jpg,png,pdf. - signature — User draws a signature on a touch-friendly pad. Output formats:
png,jpg,pdf,svg. - scan — User captures one or more document pages with perspective correction and multi-page assembly. Output formats:
pdf(assembled per document) orimages(individual pages). Supportssingleandmultidocument modes.
The pkg/handoff package provides a Go client with a fluent builder API, WebSocket event streaming with polling fallback, and automatic retries.
go get github.com/mxcd/handoffImport:
import "github.com/mxcd/handoff/pkg/handoff"client := handoff.NewClient("https://handoff.example.com", "my-api-key")session, err := client.NewSession().
WithAction(handoff.ActionTypePhoto).
WithOutputFormat(handoff.OutputFormatJPG).
WithIntro("Please take a photo of your ID card").
WithLogoURL("https://example.com/logo.png").
WithBrandingTitle("Acme Corp").
WithSessionTTL("10m").
WithResultTTL("5m").
Invoke(ctx)
if err != nil {
log.Fatal(err)
}
defer session.Close()
// Generate a QR code for the user to scan (returns a data:image/png;base64,... URL)
qrDataURL, err := session.GenerateQR()
// Block until the user completes the action
items, err := session.WaitForResult(ctx)
if err != nil {
log.Fatal(err)
}
// Download the photo
data, contentType, err := client.DownloadFile(ctx, items[0].DownloadID)session, err := client.NewSession().
WithAction(handoff.ActionTypeSignature).
WithOutputFormat(handoff.OutputFormatSVG).
Invoke(ctx)
if err != nil {
log.Fatal(err)
}
defer session.Close()
items, err := session.WaitForResult(ctx)session, err := client.NewSession().
WithAction(handoff.ActionTypeScan).
WithScanOutputFormat(handoff.ScanOutputFormatPDF).
WithDocumentMode(handoff.ScanDocumentModeMulti).
Invoke(ctx)
if err != nil {
log.Fatal(err)
}
defer session.Close()
scanResult, err := session.WaitForScanResult(ctx)
if err != nil {
log.Fatal(err)
}
// Each document has either a PDF URL or individual page image URLs
for _, doc := range scanResult.Documents {
if doc.PDFURL != "" {
data, _, err := client.DownloadFile(ctx, extractDownloadID(doc.PDFURL))
// ... save PDF
}
}Instead of blocking on WaitForResult, you can listen for real-time status updates:
session.OnEvent(func(evt handoff.Event) {
switch evt.Status {
case handoff.SessionStatusOpened:
fmt.Println("User opened the link")
case handoff.SessionStatusActionStarted:
fmt.Println("User started taking the photo")
case handoff.SessionStatusCompleted:
fmt.Println("Done!")
for _, item := range evt.Result {
fmt.Printf(" File: %s (%s)\n", item.Filename, item.ContentType)
}
}
})The client connects via WebSocket for instant updates. If the WebSocket connection fails after 3 reconnection attempts, it falls back to polling every 2 seconds.
info, err := client.GetSession(ctx, "session-uuid")
if err != nil {
if errors.Is(err, handoff.ErrNotFound) {
// session doesn't exist
}
if errors.Is(err, handoff.ErrSessionExpired) {
// session has expired
}
}
fmt.Printf("Status: %s\n", info.Status)The client returns sentinel errors that work with errors.Is:
var apiErr *handoff.APIError
if errors.As(err, &apiErr) {
fmt.Printf("HTTP %d: %s\n", apiErr.StatusCode, apiErr.Message)
}
errors.Is(err, handoff.ErrSessionExpired) // 410 Gone
errors.Is(err, handoff.ErrNotFound) // 404 Not Found
errors.Is(err, handoff.ErrUnauthorized) // 401 Unauthorized
errors.Is(err, handoff.ErrConflict) // 409 ConflictHTTP requests retry up to 3 times on 5xx or network errors with exponential backoff.
Sessions can include custom branding that is displayed at the top of all phone-facing pages (intro, action, success). This allows the consuming application to present its own identity to end users.
| Field | Type | Description |
|---|---|---|
logo_url |
string | URL to a logo image displayed at the top of session pages |
branding_title |
string | Custom heading text displayed below the logo |
custom_css |
string | Custom CSS injected into session pages for full style control |
All fields are optional and can be used independently.
Go client:
session, err := client.NewSession().
WithAction(handoff.ActionTypePhoto).
WithOutputFormat(handoff.OutputFormatJPG).
WithLogoURL("https://example.com/logo.png").
WithBrandingTitle("Acme Corp").
WithCustomCSS("body { background: #f5f5f5; } .btn-primary { background: #0066cc; }").
Invoke(ctx)REST API:
{
"action_type": "photo",
"output_format": "jpg",
"logo_url": "https://example.com/logo.png",
"branding_title": "Acme Corp",
"custom_css": "body { background: #f5f5f5; } .btn-primary { background: #0066cc; }"
}All API endpoints are under /api/v1 and require an X-API-Key header.
POST /api/v1/sessions
{
"action_type": "photo",
"output_format": "jpg",
"intro_text": "Take a photo of your ID",
"logo_url": "https://example.com/logo.png",
"branding_title": "Acme Corp",
"custom_css": "body { background: #f5f5f5; }",
"session_ttl": "30m",
"result_ttl": "5m"
}For scan sessions, output_format accepts pdf or images, and document_mode can be single (default) or multi.
Returns the full session object with id, url, and status.
GET /api/v1/sessions/:id
GET /api/v1/sessions/:id/result
Returns 202 Accepted while pending, 200 OK with result data when completed, or 410 Gone if expired.
GET /api/v1/downloads/:download_id
Returns the raw file with the appropriate Content-Type header.
GET /api/v1/sessions/:id/ws
Authenticate with X-API-Key header or api_key query parameter. Receives JSON messages:
{"type": "status_update", "session_id": "...", "status": "opened", "timestamp": "..."}{"type": "completed", "session_id": "...", "status": "completed", "data": [...], "timestamp": "..."}GET /api/v1/health → {"status": "ok"}
GET /api/v1/version → {"version": "v1.0.0", "commit": "abc1234"}
These endpoints do not require authentication.
Sessions progress through these states:
pending → opened → action_started → completed
↘ expired
- pending — Created, waiting for the user to open the URL
- opened — User opened the URL on their phone
- action_started — User began the action (camera opened, signature pad active, etc.)
- completed — Result submitted and available for download
- expired — Session TTL exceeded, no longer usable
After completion, result files remain available for the duration of RESULT_TTL. After that, downloads return 404.
# Run tests
go test -race ./...
# Lint
go vet ./...
# Hot-reload dev server (requires air)
just air
# Build with version info
go build -ldflags "-X github.com/mxcd/handoff/internal/util.Version=v1.0.0 \
-X github.com/mxcd/handoff/internal/util.Commit=$(git rev-parse --short HEAD)" \
-o server ./cmd/serverSee LICENSE for details.