Skip to content

@ab-ecosystem/ai-photo-detector

0.1.0PublicAI vision · Event photos

Upload an event’s photos, let guests find themselves with a selfie, and deliver the matches over WhatsApp. One PhotoDetector client wraps the whole REST API — galleries and events, bulk photo upload, face search, themes, analytics, guests, WhatsApp channels and broadcasts, campaigns, custom domains, and a public surface your guests hit without an owner token.

$ npm install @ab-ecosystem/ai-photo-detector
View on npm

@ab-ecosystem/ai-photo-detector

Official Node.js and TypeScript SDK for the Photo Detector API — AI-powered face recognition photo search for event photography.

Upload an event's photos, let guests find themselves with a selfie, and deliver the matches over WhatsApp. This package wraps the entire Photo Detector REST API in a typed, dependency-free client that works in both ESM and CommonJS projects.

npm version npm downloads types included node >=18 license MIT

Why this SDK

  • Typed end to end — every request and response mirrors the API's own schemas, so your editor knows the shape of a gallery, a face match and a broadcast job.
  • Zero runtime dependencies — built on Node 18's native fetch, FormData and Blob. Nothing to audit, nothing to patch.
  • Dual ESM and CommonJS builds — import and require both work, with correct type declarations for each.
  • One-call photo upload — photos.upload() handles the presign → S3 → confirm handshake, in parallel, with progress callbacks.
  • Typed errors — catch NotFoundError or RateLimitError directly instead of inspecting status codes.
  • Automatic retries — 408, 429 and 5xx responses back off exponentially and honour Retry-After.
  • Cancellable — per-call timeout and AbortSignal on every method.

Table of contents

Requirements

  • Node.js 18 or newer. The SDK uses the built-in fetch, FormData, Blob and AbortController, all of which landed in Node 18. No polyfill needed.
  • A Photo Detector API deployment and an SSO access token for owner endpoints.

Installation

npm install @ab-ecosystem/ai-photo-detector
pnpm add @ab-ecosystem/ai-photo-detector
yarn add @ab-ecosystem/ai-photo-detector

Quick start

import { PhotoDetector } from "@ab-ecosystem/ai-photo-detector";

const client = new PhotoDetector({
  baseUrl: "https://your-api.example.com",
  accessToken: process.env.PHOTO_DETECTOR_ACCESS_TOKEN,
});

// 1. Create a gallery and an event inside it
const gallery = await client.galleries.create({ name: "Wedding 2025" });

const event = await client.events.create(gallery.id, {
  name: "Ceremony",
  location: "Mumbai",
  event_date: "2025-12-31T18:00:00",
});

// 2. Upload photos — presign, upload to storage and confirm in one call
await client.photos.upload(gallery.id, ["./photo1.jpg", "./photo2.jpg"], {
  subGalleryId: event.id,
  onProgress: ({ completed, total }) => console.log(`${completed}/${total}`),
});

// 3. Find every photo containing a given face
const { results } = await client.search.search("./selfie.jpg", {
  galleryId: gallery.id,
});

for (const match of results) {
  console.log(match.photo_url, match.similarity);
}

CommonJS works the same way:

const { PhotoDetector } = require("@ab-ecosystem/ai-photo-detector");

Configuration

Client options

const client = new PhotoDetector({
  baseUrl: "https://your-api.example.com",
  accessToken: () => tokenStore.current(),
  timeout: 60_000,
  maxRetries: 3,
});
OptionTypeDefaultDescription
baseUrlstring$PHOTO_DETECTOR_BASE_URLRoot URL of the backend. Required — the constructor throws without it.
accessTokenstring | () => string | Promise<string>$PHOTO_DETECTOR_ACCESS_TOKENSSO bearer token for owner endpoints. A function is re-invoked per request, which suits rotating tokens.
guestTokenstring | () => string | Promise<string>—Guest JWT sent as X-Guest-Token. Individual public.* calls can override it.
timeoutnumber30000Per-request timeout in milliseconds.
maxRetriesnumber2Retries on 408, 429 and 5xx, plus network failures.
headersRecord<string, string>—Extra headers merged into every request.
fetchFetchLikeglobal fetchOverride for tests, proxies or a custom agent.

Environment variables

baseUrl and accessToken fall back to environment variables, so in a configured environment new PhotoDetector() is enough:

VariableMaps to
PHOTO_DETECTOR_BASE_URLbaseUrl
PHOTO_DETECTOR_ACCESS_TOKENaccessToken

Per-call options

Every resource method takes an optional final argument:

const controller = new AbortController();

const photos = client.photos.list(
  galleryId,
  { limit: 100 },
  { signal: controller.signal, timeout: 5_000, maxRetries: 0 },
);

controller.abort();
OptionTypeDescription
signalAbortSignalCancel the request. An abort you request yourself is never retried.
timeoutnumberOverrides the client timeout for this call.
maxRetriesnumberOverrides the client retry count for this call.
headersRecord<string, string | undefined>Merged over the client headers. An explicit undefined removes a header.

Authentication

There are two independent identities, and a single client can hold both.

Owner — an SSO bearer token sent as Authorization: Bearer <token>. Required for galleries, events, photos, search, themes, analytics, guests, WhatsApp and custom domains.

Guest — a JWT minted by OTP verification and sent as X-Guest-Token. Used by public.getSelfie, public.uploadSelfie and public.searchEvent. Valid for 30 days.

client.setAccessToken("new-owner-token");
client.setGuestToken("guest-jwt");

Guest methods also accept a token inline, which is the right choice on a server handling many guests at once:

await client.public.searchEvent("my-event", { useStored: true }, { guestToken });

Everything under client.public except those three methods needs no token at all.

API reference

Galleries

A gallery is the top-level container owned by one account. client.galleries:

const gallery = await client.galleries.create({
  name: "My Gallery",
  description: "Summer season",
  allow_global_search: false,
});

const page = await client.galleries.list({ page: 1, limit: 20 });
// -> { data, total, page, limit, total_pages }

await client.galleries.get(gallery.id);
await client.galleries.update(gallery.id, { name: "New Name" });
await client.galleries.delete(gallery.id);

const { cover_image } = await client.galleries.uploadCover(gallery.id, "./cover.jpg");

allow_global_search widens guest face search beyond a single event: a search run on any gallery with the flag set also scans every other gallery of the same owner that has it set.

Events

An event is a dated sub-collection of photos inside a gallery, and the unit the public pages are built around. client.events:

await client.events.create(galleryId, {
  name: "Reception",
  description: "Evening session",
  event_date: "2025-12-31T18:00:00",   // string or Date
  location: "Delhi",
  category: "wedding",
});

await client.events.list(galleryId);
await client.events.get(galleryId, eventId);
await client.events.update(galleryId, eventId, { visibility: "public" });
await client.events.delete(galleryId, eventId);

visibility controls what guests can reach:

ValuePublic pageFull photo listFace search
publicyesyesyes
privateyesno (403)yes
draftno (403)nono

Photos

Upload in one call

photos.upload() runs the whole flow — ask for presigned URLs, PUT each file straight to storage, then confirm so the server queues face detection.

const photos = await client.photos.upload(galleryId, [
  "./a.jpg",
  { data: buffer, filename: "b.jpg" },
], {
  subGalleryId: eventId,
  concurrency: 4,                                     // parallel uploads, default 4
  onProgress: ({ completed, total, filename }) =>
    console.log(`${completed}/${total} — ${filename}`),
});

Uploaded photos come back with status: "processing"; face detection finishes in the background and sets face_count. Presigned URLs are paired to files by index, so duplicate filenames in one batch still map correctly.

Upload step by step

Useful when the bytes never pass through your process — hand the presigned URL to a browser and confirm once it reports success.

const { files } = await client.photos.presign(galleryId, [
  { filename: "photo1.jpg", content_type: "image/jpeg" },
], { subGalleryId: eventId });

// PUT the bytes to files[0].upload_url yourself. The URL is signed for both
// Content-Type and Cache-Control, so you must send exactly:
//   Content-Type: image/jpeg
//   Cache-Control: public, max-age=31536000, immutable

await client.photos.confirm(galleryId, [
  { filename: "photo1.jpg", key: files[0].key, size_bytes: 102_400 },
], { subGalleryId: eventId });

List and delete

await client.photos.list(galleryId, { page: 1, limit: 24, subGalleryId: eventId });
await client.photos.delete(photoId);

Deleting removes the object from storage and its vectors from the face index.

Owner-side face search across your own galleries. client.search:

const { results, query_faces_detected, total } = await client.search.search("./selfie.jpg", {
  galleryId,          // omit to search every gallery you own
  threshold: 0.4,     // similarity cutoff, 0.0–1.0
  topK: 30,           // maximum matches, 1–100
});

for (const match of results) {
  console.log(match.photo_url, match.similarity, match.bbox);
}

The largest detected face in the query image is the one searched for. Each result carries similarity, the bbox of the matched face as [x1, y1, x2, y2], and the owning gallery_id.

Themes

Styling for an event's public page. save() deep-merges, so you can send one section at a time. client.themes:

await client.themes.get(eventId);

await client.themes.save(eventId, {
  template: "elegant",
  colors: { bg_primary: "#1a1a2e", accent: "#e94560" },
  layout: { grid_columns: 3 },
  branding: { custom_title: "Our Wedding", show_powered_by: false },
  text: { welcome_message: "Find your photos" },
  permissions: { allow_download: true },
});

await client.themes.delete(eventId);

Analytics

const { totals, daily, period_days } = await client.analytics.overview({ days: 30 });

console.log(totals.page_views, totals.searches, totals.search_success_rate);

totals covers galleries, photos, page views, searches, verifications, unique visitors and the percentage of searches that returned at least one match. daily breaks views, searches and verifications down per day.

Guests

Everyone who has verified an OTP against one of your events.

await client.guests.list({ page: 1, limit: 20, search: "john" });

Each guest carries identity (the phone or email used), identity_type, search_count, verified_at and last_search_at.

WhatsApp channels

A channel is a set of Meta Cloud API credentials. client.waChannels:

// Check credentials against Meta without saving them
const check = await client.waChannels.verify({
  phone_number_id: "123456",
  waba_id: "789",
  access_token: "EAA...",
});
if (!check.valid) throw new Error(check.error ?? "invalid credentials");

const channel = await client.waChannels.create({
  phone_number_id: "123456",
  waba_id: "789",
  access_token: "EAA...",
});

await client.waChannels.list();
await client.waChannels.get(channel.id);
await client.waChannels.update(channel.id, { access_token: "new-token" });
await client.waChannels.delete(channel.id);

Templates must exist and be approved on Meta's side before a broadcast can use them:

const templates = await client.waChannels.templates();          // definitions + live status
await client.waChannels.syncTemplate(channel.id, "event_photo_link");
await client.waChannels.templateStatus(channel.id, "event_photo_link");

// Send a single message, mostly for testing a template
await client.waChannels.send(channel.id, {
  to: "+919876543210",
  guest_name: "Rahul",
  event_link: "https://photos.example.com/e/my-event",
  template_name: "event_photo_link",
});

WhatsApp broadcasts

Import a contact list against an event, then fire a templated message at all of it. client.waBroadcasts:

const result = await client.waBroadcasts.importContacts(eventId, [
  { phone: "+919876543210", first_name: "Rahul", last_name: "Sharma" },
]);
console.log(result.added, result.existing, result.invalid, result.invalid_rows);

await client.waBroadcasts.listContacts(eventId);

const job = await client.waBroadcasts.create({
  event_id: eventId,
  channel_id: channel.id,
  template_name: "event_photo_link",
});

// Poll for progress
const latest = await client.waBroadcasts.get(job.id);
console.log(latest.status, latest.sent, latest.failed, latest.total);

await client.waBroadcasts.cancel(job.id);

await client.waBroadcasts.list({ eventId });   // omit eventId for all jobs

importContacts reports five outcomes per batch: added, existing, already_in_event, invalid and duplicates_in_file, with invalid_rows naming each rejected row and why.

Campaigns

A campaign saves a gallery / event / channel / template combination you can fire repeatedly. client.campaigns:

const campaign = await client.campaigns.create({
  name: "Wedding Invite",
  gallery_id: galleryId,
  event_id: eventId,
  channel_id: channel.id,
  template_name: "event_photo_link",
});

await client.campaigns.list();
await client.campaigns.get(campaign.id);

const { broadcast_id, total } = await client.campaigns.shoot(campaign.id);
const progress = await client.waBroadcasts.get(broadcast_id);

await client.campaigns.delete(campaign.id);

shoot() throws ConflictError when a broadcast is already running for that event.

Custom domains

Serve the public event pages from your own hostname. Requires the custom-domain subscription addon; without it these calls throw ForbiddenError. client.customDomains:

const domain = await client.customDomains.create("photos.mybrand.com");
console.log(`Point a CNAME at ${domain.cname_target}, then verify.`);

const { verified, message } = await client.customDomains.verify(domain.id);

await client.customDomains.list();
await client.customDomains.active();   // -> { domain: string | null }
await client.customDomains.delete(domain.id);

Once a domain is verified and pointed at the API, requests arriving on it can list that owner's events with no slug and no token:

const branded = new PhotoDetector({ baseUrl: "https://photos.mybrand.com" });
const { events } = await branded.public.domainLanding();

Public and guest endpoints

Everything under client.public works without an owner token — this is the surface your guest-facing frontend talks to.

const guests = new PhotoDetector({ baseUrl: "https://your-api.example.com" });

// Event metadata and its theme, by public slug
const event = await guests.public.getEvent("my-event-slug");

// The full photo list, for public events
await guests.public.listPhotos("my-event-slug", { page: 1, limit: 24 });

The guest identity flow is OTP → token → selfie → search:

await guests.public.sendOtp("my-event-slug", {
  name: "John",
  email: "[email protected]",
  channel: "email",            // or "whatsapp", which uses `phone`
});

const { guest_token } = await guests.public.verifyOtp("my-event-slug", {
  name: "John",
  code: "1234",
  email: "[email protected]",
});

// Store the selfie once...
const { faces_detected } = await guests.public.uploadSelfie("./selfie.jpg", {
  guestToken: guest_token,
});

// ...then search as often as you like without re-uploading
const { results } = await guests.public.searchEvent(
  "my-event-slug",
  { useStored: true, threshold: 0.4, topK: 30 },
  { guestToken: guest_token },
);

// Or search with a fresh image
await guests.public.searchEvent(
  "my-event-slug",
  { file: "./another-selfie.jpg" },
  { guestToken: guest_token },
);

await guests.public.getSelfie({ guestToken: guest_token });

OTP sends are rate limited to 3 per identity per 15 minutes, codes are 4 digits and expire after 5 minutes, and the returned guest token is valid for 30 days. Pass guestToken on the constructor instead to skip it per call.

File inputs

Anywhere the SDK takes a file — photo uploads, cover images, selfies, search queries — you can pass any of:

"./photo.jpg"                                        // path
buffer                                               // Buffer
new Uint8Array(bytes)                                // Uint8Array or ArrayBuffer
new Blob([bytes], { type: "image/jpeg" })            // Blob
{ path: "./photo.jpg", filename: "renamed.jpg" }     // path, with overrides
{ data: buffer, filename: "a.jpg", contentType: "image/jpeg" }

Filename and content type are resolved most-specific first: whatever you state outright wins, then what the input itself carries (a path's basename, a Blob's type, the file extension), then the method's default. The API accepts JPEG, PNG, WebP, BMP and TIFF, exported as ALLOWED_CONTENT_TYPES.

Reading from disk uses a lazy node:fs/promises import, so bundling this SDK for a browser stays possible as long as you only pass in-memory inputs.

Error handling

Every failure throws a subclass of PhotoDetectorError carrying status, detail (the API's message, with FastAPI validation lists flattened into something readable) and the parsed body.

import {
  PhotoDetectorError,
  NotFoundError,
  AuthenticationError,
  RateLimitError,
} from "@ab-ecosystem/ai-photo-detector";

try {
  await client.galleries.get("nonexistent-id");
} catch (err) {
  if (err instanceof NotFoundError) console.error(err.detail);
  else if (err instanceof AuthenticationError) console.error("token expired");
  else if (err instanceof RateLimitError) console.error(`retry in ${err.retryAfter}s`);
  else if (err instanceof PhotoDetectorError) console.error(err.status, err.detail);
  else throw err;
}
ErrorTrigger
BadRequestError400
AuthenticationError401 — missing or expired token
ForbiddenError403 — not your resource, or a missing subscription addon
NotFoundError404
ConflictError409 — e.g. a broadcast already running for the event
ValidationError422 — request body failed validation
RateLimitError429 — exposes retryAfter in seconds
ServerError5xx
ConnectionErrornetwork failure, or an abort you requested
TimeoutErrorrequest exceeded timeout

408, 429 and 5xx responses are retried automatically up to maxRetries, with exponential backoff that honours Retry-After. An abort you request yourself is never retried.

TypeScript

Type declarations ship with the package — there is no @types/ to install. Every request parameter and response body is exported:

import type {
  Gallery,
  Event,
  Photo,
  FaceSearchResult,
  BroadcastJob,
  Paginated,
} from "@ab-ecosystem/ai-photo-detector";

function render(page: Paginated<Photo>) {
  console.log(page.data.length, page.total, page.total_pages);
}

The package targets NodeNext module resolution and works under "strict": true in both ESM and CommonJS consumers.

Escape hatch

Anything the SDK doesn't wrap is still reachable through the typed transport, which applies the same auth, retries and error handling:

const data = await client.http.get<MyType>("/api/v1/some/new/endpoint", {
  query: { page: 1 },
});

await client.http.post("/api/v1/something", { json: { field: "value" } });

client.http exposes request, get, post, put, patch, delete and rawFetch.

Method index

All 56 resource methods. Every one takes an optional trailing options argument.

Owner resources

MethodEndpoint
galleries.create(params)POST /api/v1/galleries
galleries.list({ page, limit })GET /api/v1/galleries
galleries.get(galleryId)GET /api/v1/galleries/{id}
galleries.update(galleryId, params)PUT /api/v1/galleries/{id}
galleries.delete(galleryId)DELETE /api/v1/galleries/{id}
galleries.uploadCover(galleryId, file)POST /api/v1/galleries/{id}/cover
events.create(galleryId, params)POST /api/v1/galleries/{gid}/events
events.list(galleryId)GET /api/v1/galleries/{gid}/events
events.get(galleryId, eventId)GET /api/v1/galleries/{gid}/events/{eid}
events.update(galleryId, eventId, params)PUT /api/v1/galleries/{gid}/events/{eid}
events.delete(galleryId, eventId)DELETE /api/v1/galleries/{gid}/events/{eid}
photos.presign(galleryId, files, params)POST /api/v1/photos/presign/{gid}
photos.confirm(galleryId, files, params)POST /api/v1/photos/confirm/{gid}
photos.upload(galleryId, files, options)composite — presign, PUT, confirm
photos.list(galleryId, params)GET /api/v1/photos/{gid}
photos.delete(photoId)DELETE /api/v1/photos/{pid}
search.search(file, params)POST /api/v1/search/search
themes.get(eventId)GET /api/v1/themes/{eid}
themes.save(eventId, params)PUT /api/v1/themes/{eid}
themes.delete(eventId)DELETE /api/v1/themes/{eid}
analytics.overview({ days })GET /api/v1/analytics/overview
guests.list({ page, limit, search })GET /api/v1/guests

WhatsApp

MethodEndpoint
waChannels.verify(credentials)POST /api/v1/wa-channels/verify
waChannels.create(credentials)POST /api/v1/wa-channels
waChannels.list()GET /api/v1/wa-channels
waChannels.get(channelId)GET /api/v1/wa-channels/{id}
waChannels.update(channelId, params)PUT /api/v1/wa-channels/{id}
waChannels.delete(channelId)DELETE /api/v1/wa-channels/{id}
waChannels.templates()GET /api/v1/wa-channels/templates/available
waChannels.syncTemplate(channelId, name)POST /api/v1/wa-channels/{id}/sync-template/{name}
waChannels.templateStatus(channelId, name)GET /api/v1/wa-channels/{id}/template-status/{name}
waChannels.send(channelId, params)POST /api/v1/wa-channels/{id}/send
waBroadcasts.importContacts(eventId, rows)POST /api/v1/wa-broadcasts/import
waBroadcasts.listContacts(eventId)GET /api/v1/wa-broadcasts/contacts
waBroadcasts.create(params)POST /api/v1/wa-broadcasts
waBroadcasts.list({ eventId })GET /api/v1/wa-broadcasts
waBroadcasts.get(broadcastId)GET /api/v1/wa-broadcasts/{id}
waBroadcasts.cancel(broadcastId)POST /api/v1/wa-broadcasts/{id}/cancel
campaigns.create(params)POST /api/v1/campaigns
campaigns.list()GET /api/v1/campaigns
campaigns.get(campaignId)GET /api/v1/campaigns/{id}
campaigns.delete(campaignId)DELETE /api/v1/campaigns/{id}
campaigns.shoot(campaignId)POST /api/v1/campaigns/{id}/shoot

Custom domains

MethodEndpoint
customDomains.create(domain)POST /api/v1/domains
customDomains.list()GET /api/v1/domains
customDomains.active()GET /api/v1/domains/active
customDomains.verify(domainId)POST /api/v1/domains/{id}/verify
customDomains.delete(domainId)DELETE /api/v1/domains/{id}

Public and guest

MethodEndpointGuest token
public.domainLanding()GET /api/v1/public/domainno
public.getEvent(slug)GET /api/v1/public/e/{slug}no
public.listPhotos(slug, params)GET /api/v1/public/e/{slug}/photosno
public.sendOtp(slug, params)POST /api/v1/public/e/{slug}/otp/sendno
public.verifyOtp(slug, params)POST /api/v1/public/e/{slug}/otp/verifyno
public.getSelfie()GET /api/v1/public/guest/selfieyes
public.uploadSelfie(file)POST /api/v1/public/guest/selfieyes
public.searchEvent(slug, params)POST /api/v1/public/e/{slug}/searchyes

Client

MemberDescription
new PhotoDetector(options)Create a client.
client.setAccessToken(token)Swap the owner bearer token.
client.setGuestToken(token)Swap the guest token.
client.httpLow-level transport — see Escape hatch.

FAQ

Does this work in the browser? Partly. The transport is plain fetch and has no Node-only imports, and disk reads are behind a lazy node:fs/promises import that only fires when you pass a path. Pass Blob or File inputs instead and a bundler will leave that import alone. Note that owner tokens should never ship to a browser — use the client.public surface there.

Do I need to install type definitions separately? No. Declarations are bundled for both the ESM and CommonJS builds.

Why does an uploaded photo have face_count: 0? Face detection runs asynchronously. Photos return status: "processing" immediately; poll photos.list() until the status changes.

How do I search across more than one gallery? Omit galleryId from search.search() to scan everything you own. For guest-facing search across galleries, set allow_global_search: true on each gallery that should participate.

What happens if a presigned upload returns 403? The presigned URL is signed for both Content-Type and Cache-Control. If you are PUTting manually, send exactly the content_type you presigned with plus Cache-Control: public, max-age=31536000, immutable. photos.upload() does this for you.

Can I use a proxy or a custom HTTP agent? Yes — pass your own fetch implementation as the fetch client option.

Is the SDK retry-safe for uploads? Retries apply to the API calls, not to the S3 PUTs, which are idempotent writes to a presigned key. Re-running photos.upload() after a failure presigns fresh keys.

Development

npm install
npm run build        # emits dist/esm and dist/cjs
npm run typecheck

The build produces two trees from the same sources — dist/esm with declarations and dist/cjs — each stamped with its own module-type marker so Node resolves them correctly.

License

MIT

What you can build with ai-photo-detector

  • Run an event photo gallery where guests find their own photos from a selfie
  • Bulk-upload a shoot and track progress without writing the presign dance yourself
  • Broadcast each guest their matches over WhatsApp from a verified channel
  • Build a white-label guest experience on a custom domain with per-event themes
  • Search one gallery, or every gallery you own, from a single face
  • Let guests in through OTP and a guest token, with no owner credential in the browser
  • Report on reach and engagement from the analytics overview
  • Reach an unwrapped endpoint through client.http with the same auth, retries and errors

Other packages in @ab-ecosystem