@ab-ecosystem/ai-photo-detector
0.1.0PublicAI vision · Event photosUpload 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@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.
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,FormDataandBlob. Nothing to audit, nothing to patch. - Dual ESM and CommonJS builds —
importandrequireboth 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
NotFoundErrororRateLimitErrordirectly instead of inspecting status codes. - Automatic retries — 408, 429 and 5xx responses back off exponentially and honour
Retry-After. - Cancellable — per-call
timeoutandAbortSignalon every method.
Table of contents
- Requirements
- Installation
- Quick start
- Configuration
- Authentication
- API reference
- File inputs
- Error handling
- TypeScript
- Escape hatch
- Method index
- FAQ
- Development
- License
Requirements
- Node.js 18 or newer. The SDK uses the built-in
fetch,FormData,BlobandAbortController, 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,
});
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl | string | $PHOTO_DETECTOR_BASE_URL | Root URL of the backend. Required — the constructor throws without it. |
accessToken | string | () => string | Promise<string> | $PHOTO_DETECTOR_ACCESS_TOKEN | SSO bearer token for owner endpoints. A function is re-invoked per request, which suits rotating tokens. |
guestToken | string | () => string | Promise<string> | — | Guest JWT sent as X-Guest-Token. Individual public.* calls can override it. |
timeout | number | 30000 | Per-request timeout in milliseconds. |
maxRetries | number | 2 | Retries on 408, 429 and 5xx, plus network failures. |
headers | Record<string, string> | — | Extra headers merged into every request. |
fetch | FetchLike | global fetch | Override 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:
| Variable | Maps to |
|---|---|
PHOTO_DETECTOR_BASE_URL | baseUrl |
PHOTO_DETECTOR_ACCESS_TOKEN | accessToken |
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();
| Option | Type | Description |
|---|---|---|
signal | AbortSignal | Cancel the request. An abort you request yourself is never retried. |
timeout | number | Overrides the client timeout for this call. |
maxRetries | number | Overrides the client retry count for this call. |
headers | Record<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:
| Value | Public page | Full photo list | Face search |
|---|---|---|---|
public | yes | yes | yes |
private | yes | no (403) | yes |
draft | no (403) | no | no |
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.
Face search
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;
}
| Error | Trigger |
|---|---|
BadRequestError | 400 |
AuthenticationError | 401 — missing or expired token |
ForbiddenError | 403 — not your resource, or a missing subscription addon |
NotFoundError | 404 |
ConflictError | 409 — e.g. a broadcast already running for the event |
ValidationError | 422 — request body failed validation |
RateLimitError | 429 — exposes retryAfter in seconds |
ServerError | 5xx |
ConnectionError | network failure, or an abort you requested |
TimeoutError | request 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
| Method | Endpoint |
|---|---|
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 |
| Method | Endpoint |
|---|---|
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
| Method | Endpoint |
|---|---|
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
| Method | Endpoint | Guest token |
|---|---|---|
public.domainLanding() | GET /api/v1/public/domain | no |
public.getEvent(slug) | GET /api/v1/public/e/{slug} | no |
public.listPhotos(slug, params) | GET /api/v1/public/e/{slug}/photos | no |
public.sendOtp(slug, params) | POST /api/v1/public/e/{slug}/otp/send | no |
public.verifyOtp(slug, params) | POST /api/v1/public/e/{slug}/otp/verify | no |
public.getSelfie() | GET /api/v1/public/guest/selfie | yes |
public.uploadSelfie(file) | POST /api/v1/public/guest/selfie | yes |
public.searchEvent(slug, params) | POST /api/v1/public/e/{slug}/search | yes |
Client
| Member | Description |
|---|---|
new PhotoDetector(options) | Create a client. |
client.setAccessToken(token) | Swap the owner bearer token. |
client.setGuestToken(token) | Swap the guest token. |
client.http | Low-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
Every resource is a namespace on the client. This is the full surface of @ab-ecosystem/ai-photo-detector at v0.1.0.
Owner resources
SSO bearer token
| Resource | Methods |
|---|---|
galleries | create · list · get · update · delete · uploadCover/api/v1/galleries |
events | create · list · get · update · delete/api/v1/galleries/{gid}/events |
photos | presign · confirm · upload (composite) · list · delete/api/v1/photos |
search | search(file, { galleryId })POST /api/v1/search/search |
themes | get · save · delete/api/v1/themes/{eid} |
analytics | overview({ days })GET /api/v1/analytics/overview |
guests | list({ page, limit, search })GET /api/v1/guests |
channels, broadcasts and campaigns
| Resource | Methods |
|---|---|
waChannels | verify · create · list · get · update · delete · templates · syncTemplate · templateStatus · send/api/v1/wa-channels |
waBroadcasts | importContacts · listContacts · create · list · get · cancel/api/v1/wa-broadcasts |
campaigns | create · list · get · delete · shoot/api/v1/campaigns |
Custom domains
| Resource | Methods |
|---|---|
customDomains | create · list · active · verify · delete/api/v1/domains |
Public and guest
no owner token — the guest surface for your frontend
| Resource | Methods |
|---|---|
public.domainLanding() | the landing payload for a custom domainGET /api/v1/public/domain |
public.getEvent(slug) | one public eventGET /api/v1/public/e/{slug} |
public.listPhotos(slug, params) | the event’s public photosGET /api/v1/public/e/{slug}/photos |
public.sendOtp · verifyOtp | guest sign-in, returns the guest token/api/v1/public/e/{slug}/otp |
public.getSelfie · uploadSelfie | the guest’s reference faceguest token required |
public.searchEvent(slug, params) | guest-facing face searchguest token required |
Client
| Resource | Methods |
|---|---|
new PhotoDetector(options) | baseUrl · accessToken · guestToken · timeout · maxRetries · headers · fetch |
setAccessToken() · setGuestToken() | swap either identity at runtime |
client.http | request · get · post · put · patch · delete · rawFetchthe escape hatch, same auth and retries |
Install
$ npm install @ab-ecosystem/ai-photo-detector$ pnpm add @ab-ecosystem/ai-photo-detector$ yarn add @ab-ecosystem/ai-photo-detectorEntry points
import { PhotoDetector } from '@ab-ecosystem/ai-photo-detector';
const { PhotoDetector } = require('@ab-ecosystem/ai-photo-detector'); // CommonJS
Quickstart
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);
}
Error classes
| Class | When it is thrown |
|---|---|
PhotoDetectorError | The base class. Every failure carries status, detail (the API message, with FastAPI validation lists flattened) and the parsed body. |
BadRequestError · ValidationError | 400 · 422 — the request body failed validation. |
AuthenticationError · ForbiddenError | 401 missing or expired token · 403 not your resource, or a missing subscription addon. |
NotFoundError · ConflictError | 404 · 409, e.g. a broadcast already running for the event. |
RateLimitError | 429 — exposes retryAfter in seconds. |
ServerError · ConnectionError · TimeoutError | 5xx · a network failure or an abort you requested · the call exceeded its timeout. |
Compatibility
- Runtime
- Node.js 18+
- Module formats
- ESM + CommonJS · sideEffects: false
- Runtime dependencies
- none
- TypeScript
- Types bundled — no @types package
Invariants that are easy to get wrong
Read these before generating code against ai-photo-detector. Each one is behaviour the API guarantees, not a style preference.
- 01
Face detection runs asynchronously. A freshly uploaded photo returns status "processing" with face_count: 0 — poll photos.list() until the status changes.
- 02
Owner tokens must never ship to a browser. Use the client.public surface there, authenticated with a guest token.
- 03
A presigned upload that returns 403 almost always means the headers did not match what was signed — send exactly the content_type you presigned with, plus Cache-Control: public, max-age=31536000, immutable. photos.upload() does this for you.
- 04
Retries cover the API calls, not the S3 PUTs. Re-running photos.upload() after a failure presigns fresh keys.
- 05
An abort you request yourself is never retried, and surfaces as ConnectionError.
- 06
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.
- 07
baseUrl and accessToken fall back to PHOTO_DETECTOR_BASE_URL and PHOTO_DETECTOR_ACCESS_TOKEN, so new PhotoDetector() alone works in a configured environment — but the constructor throws without a baseUrl.
- 08
Browser bundling works only if you pass in-memory inputs. Passing a path triggers the lazy node:fs/promises import.
- 09
The API accepts JPEG, PNG, WebP, BMP and TIFF, exported as ALLOWED_CONTENT_TYPES.
- 10
Pass a function as accessToken and it is re-invoked per request, which is what a rotating token needs.
Highlights
- One-call photo upload
- photos.upload() runs the whole presign → S3 → confirm handshake, in parallel, with progress callbacks. The presigned PUT is signed for Content-Type and Cache-Control, and the SDK sends exactly what it presigned with.
- Find yourself with a selfie
- search.search() takes a face and returns every photo it appears in, with a similarity score. Omit the galleryId to scan everything you own.
- Two identities, one client
- An owner SSO bearer token for galleries, photos and WhatsApp; a guest JWT on X-Guest-Token for the public surface. The same client holds both, and guest-facing code never needs the owner token.
- Retries that honour Retry-After
- 408, 429 and 5xx back off exponentially up to maxRetries. An abort you requested yourself is never retried, and S3 PUTs are left alone — re-running upload() presigns fresh keys.
- File inputs of any shape
- A path, Buffer, Uint8Array, ArrayBuffer, Blob, or an object with explicit filename and contentType. Disk reads sit behind a lazy node:fs/promises import, so in-memory inputs still bundle for the browser.
- Delivery over WhatsApp
- Verified channels, template sync and status, contact import, broadcast jobs with cancellation, and campaigns — so the matches reach guests instead of sitting in a gallery.
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
@ab-ecosystem/cpaas
WhatsApp Business API, CRM and campaigns behind one typed client
v1.1.1 · MIT · 858.8 kB
@ab-ecosystem/aistore-admin
The headless ecommerce admin API, fully typed
v0.1.0 · ISC · 465.5 kB
@ab-ecosystem/aistore-storefront
Catalog, cart, checkout and search for any frontend
v0.1.0 · ISC · 262.5 kB
@ab-ecosystem/sso
Drop-in React auth and account UI, wired to the core
v0.3.2 · Proprietary · 1.13 MB