@evidgo/mini-sdk
v0.5.1
Published
SDK for building Evid-Go mini apps. Wraps the WebView bridge (postMessage RPC) into a promise API.
Maintainers
Readme
@evidgo/mini-sdk
The official SDK for building Evid-Go mini apps — web bundles that run inside the Evid-Go mobile app (in a native WebView) or the web app (in an iframe).
Your mini app calls native capabilities (camera, location, storage) and the
org-scoped Evid-Go backend without ever handling a JWT. Every call is proxied
to the host over postMessage RPC; the host injects auth + the active
X-Org-Id, and enforces a per-app capability allowlist you were granted when
your mini app was registered.
import { evid } from "@evidgo/mini-sdk";
await evid.ready(); // wait for the host handshake
const ctx = await evid.getContext(); // { user, orgId, locale, apiUrl }
const { data: projects } = await evid.projects.list();
const loc = await evid.getLocation(); // needs "location"
const shots = await evid.capture({ mode: "photo" }); // needs "capture"Install
npm install @evidgo/mini-sdk
# or
pnpm add @evidgo/mini-sdk
# or
yarn add @evidgo/mini-sdkESM only. Requires a bundler (Vite, webpack, etc.) — it is meant to ship inside a web app you build and register as an Evid-Go mini app.
Concepts
- Host — the Evid-Go native app (React Native WebView) or web app (iframe)
that loads your mini app. Outside a host (a plain browser tab)
evid.isHost()staysfalseand every proxied call rejects withNO_HOST, so you can guard host-only paths and still run your app standalone during dev. - The handshake — the native host injects its globals before your page runs,
so
isHost()/capabilities()answer immediately there. A cross-origin iframe cannot be script-injected, so on web the host introduces itself overpostMessageand those two answerfalse/[]until it lands. Awaitevid.ready()before branching on them.evid.apiand the typed helpers wait on their own — only synchronous checks need this. - Capabilities — each mini app is granted a subset of
api | write | location | capture | upload | storage. A call for a capability you weren't granted rejects with aBridgeError(CAPABILITY_DENIED). Read yours withevid.capabilities(). - Org scope — backend reads/writes are automatically scoped to the user's active organization. You never pass or see a token or org id header.
- Envelopes — backend helpers return a Strapi
{ data }envelope. List helpers on core routes also carrymeta.pagination. - State — Evid-Go stores projects, media and their tags; anything else your
app needs to persist is yours to host.
evid.storageis device-local (native keychain on mobile,localStorageon web), so back real state with your own backend.
Getting started
import { evid, BridgeError } from "@evidgo/mini-sdk";
async function main() {
// Resolves true inside either host, false standalone. Do NOT test
// `evid.isHost()` before this — on web it is not yet known.
if (!(await evid.ready())) {
console.warn("Not running inside Evid-Go — host calls will fail.");
return;
}
console.log("granted:", evid.capabilities());
const { user, orgId, locale, apiUrl } = await evid.getContext();
try {
const { data: feed } = await evid.projects.feed({ limit: 20 });
render(feed);
} catch (err) {
if (err instanceof BridgeError && err.code === "CAPABILITY_DENIED") {
// your app wasn't granted "api"
} else {
throw err;
}
}
}Core API
| Member | Capability | Returns |
| --- | --- | --- |
| evid.isHost() | — | boolean — host known (see the handshake note) |
| evid.ready() | — | Promise<boolean> — true once a host introduced itself |
| evid.capabilities() | — | Capability[] granted to this app |
| evid.getContext() | — | MiniAppContext — { user, orgId, locale, apiUrl } |
| evid.resolveUrl(path) | — | absolute url for a relative API path (media, avatars) |
| evid.api(path, opts?) | api | proxied org-scoped REST call (parsed JSON) |
| evid.getLocation() | location | GeoLocation |
| evid.capturePhoto(opts?) | capture | MediaResult (single system-picker shot) |
| evid.captureVideo(opts?) | capture | MediaResult |
| evid.scanDocument(opts?) | capture | MediaResult (PDF) |
| evid.capture(opts?) | capture | MediaResult[] (full-screen camera UI) |
| evid.storage.get(key) | storage | string \| null (device-local) |
| evid.storage.set(key, value) | storage | void (device-local) |
evid.api(path, { method?, body?, query? }) is the escape hatch for endpoints
the typed helpers don't cover. The host validates path against its allowlist,
so an unlisted endpoint rejects with PATH_DENIED rather than reaching the
backend.
Media urls
Urls in API responses are relative Strapi paths (/uploads/…). The backend
origin is whatever the host is talking to and is handed to you as
context.apiUrl; never bake one into your bundle with a build-time env var, or
the same build breaks between dev, staging and prod.
<img src={evid.resolveUrl(media.file?.url) ?? ""} />resolveUrl passes absolute and data: urls through untouched, and returns the
path unchanged outside a host (nothing to prepend).
Capture
const items = await evid.capture({
projectId, // attach to a project
mode: "photo", // photo | video | audio | scan
tags: [{ key: "task", value: "seal_check" }], // stamped on each media
});capturePhoto/captureVideo/scanDocument take { projectId?, tags? } and return
one MediaResult; capture opens the host's full-screen camera and resolves
with everything captured (empty array if the user backs out).
tags are applied as the media is created, which is how captured evidence stays
linked to whatever your app captured it for — no follow-up evid.tags.apply
round-trip, and the tag is on the record from the very first version of it.
evid.projects.photos(id) returns each media's applied tags flattened to
[{ key, value }], so you can read them back in one query.
Each MediaResult carries { documentId, kind, url } plus, when the host knows
them, projectId, capturedAt and the capture geotag
(latitude/longitude/accuracy).
Capture is a device flow. In the web host all four methods reject with
UNSUPPORTED— design a fallback (or hide the entry point) whencapabilities()includescapturebut you are running framed in the web app.
Typed backend helpers
The api capability unlocks org-scoped, typed helpers over the backend. Prefer
these over raw evid.api(path); drop to evid.api for anything not covered.
const { data: feed } = await evid.projects.feed();
const { data: project } = await evid.projects.get(id); // detail + cover
const { data: photos } = await evid.projects.photos(id); // media + tags
const { data: media } = await evid.media.photos({ page: 1, pageSize: 20 });
const { data: labels } = await evid.labels.list();
const { data: trail } = await evid.custodyLogs.list({ projectId: id });
const { data: members } = await evid.members.list();
const { data: sub } = await evid.subscription.get(); // plan + usage
const { data: tags } = await evid.tags.list(mediaId);
const { data: comments } = await evid.comments.list(mediaId);Reads (api capability)
| Helper | Endpoint |
| --- | --- |
| evid.projects.list(query?) | GET /api/projects |
| evid.projects.feed(query?) | GET /api/projects/feed |
| evid.projects.get(id, query?) | GET /api/projects/:id |
| evid.projects.photos(id, query?) | GET /api/projects/:id/photos |
| evid.media.list(query?) | GET /api/medias |
| evid.media.photos(query?) | GET /api/medias/photos |
| evid.media.get(id, query?) | GET /api/medias/:id |
| evid.labels.list() | GET /api/labels |
| evid.custodyLogs.list({ projectId?, mediaId? }) | GET /api/custody-logs |
| evid.miniApps.list() | GET /api/mini-apps |
| evid.members.list(query?) | GET /api/memberships |
| evid.members.mine() | GET /api/memberships/mine |
| evid.userGroups.list(query?) | GET /api/user-groups |
| evid.userGroups.get(id) | GET /api/user-groups/:id |
| evid.projectGroups.list({ projectId? }) | GET /api/project-groups |
| evid.projectGroups.get(id) | GET /api/project-groups/:id |
| evid.tags.list(mediaId) | GET /api/media-tags?mediaId= |
| evid.tagKeys.list() | GET /api/tag-keys |
| evid.comments.list(mediaId, query?) | GET /api/media-comments?mediaId= |
| evid.comments.replies(id, query?) | GET /api/media-comments/:id/replies |
| evid.projectComments.list(projectId, query?) | GET /api/project-comments?projectId= |
| evid.projectComments.replies(id, query?) | GET /api/project-comments/:id/replies |
| evid.members.get(id) | GET /api/memberships/:id |
| evid.stars.list({ targetType? }) | GET /api/stars |
| evid.stars.items(query?) | GET /api/stars/items |
| evid.shareLinks.list({ projectId? }) | GET /api/share-links |
| evid.shareLinks.view(token) | GET /api/public/share/:token |
| evid.exports.list(query?) | GET /api/export-packages |
| evid.exports.get(id) | GET /api/export-packages/:id |
| evid.exports.estimate(query) | GET /api/export-packages/estimate |
| evid.members.inviteInfo(token) | GET /api/memberships/invites/:token |
| evid.projects.trash(query?) | GET /api/projects/trash |
| evid.media.trash(query?) | GET /api/medias/trash |
| evid.map.mediaPoints(query?) | GET /api/medias/map |
| evid.map.projectPins() | GET /api/projects/map |
| evid.checklist.list(projectId, query?) | GET /api/checklist-items?projectId= |
| evid.checklist.all(query?) | GET /api/checklist-items/all |
| evid.notifications.list(query?) | GET /api/notifications |
| evid.notificationPreferences.get() | GET /api/notification-preferences/mine |
| evid.organization.mine() | GET /api/organizations/mine |
| evid.support.list() | GET /api/support-requests |
| evid.health.get() | GET /api/health |
| evid.home.stats() | GET /api/home/stats |
| evid.subscription.get() | GET /api/subscription |
| evid.subscription.plans() | GET /api/subscription/plans |
| evid.subscription.usageHistory(months?) | GET /api/subscription/usage-history |
| evid.subscription.billingDetails() | GET /api/subscription/billing-details |
evid.members is the org-scoped user surface. The raw users-permissions
/api/users is not reachable — it isn't org-scoped and would leak
cross-org. For the same reason only /api/organizations/mine is reachable, not
the organizations collection: evid.organization.mine() resolves the active org
from X-Org-Id rather than letting a mini app name one.
evid.stars.items() hydrates each star into the same row payload the target's
own list renders, so a "starred" screen is one paged read instead of a call per
type. Rows are grouped by targetType first, then newest star first.
Downloading an export zip is not in the SDK. GET /api/export-packages/:id/
download is an authenticated binary stream and the mini app holds no token —
the host owns that step. Poll evid.exports.get(id) until status === "ready"
and hand off from there.
Writes (write capability)
Writes need the separate write capability. Only the exact method+path
pairs below are allowed; the backend still enforces the caller's org role, so
write never grants privilege the user doesn't already have.
const { data: project } = await evid.projects.create({ name: "Site A" });
const { data: group } = await evid.projectGroups.create({ name: "Region 1" });
const { data: invited } = await evid.members.invite({ email: "[email protected]" });
const { data: link } = await evid.shareLinks.create({ project: projectId });
await evid.tags.apply({ media: mediaId, key: "room", value: "kitchen" });
await evid.comments.add({ media: mediaId, body: "Looks good" });
await evid.projectComments.add({ project: projectId, body: "Site walk done" });
await evid.projects.update(projectId, { archived: true }); // archive
await evid.stars.star("project", projectId);| Helper | Endpoint |
| --- | --- |
| evid.projects.create(input) | POST /api/projects |
| evid.projects.update(id, input) | PUT /api/projects/:id |
| evid.projects.remove(id) | DELETE /api/projects/:id |
| evid.media.update(id, input) | PUT /api/medias/:id |
| evid.media.remove(id) | DELETE /api/medias/:id |
| evid.labels.create(input) | POST /api/labels |
| evid.labels.update(id, input) | PUT /api/labels/:id |
| evid.labels.remove(id) | DELETE /api/labels/:id |
| evid.tags.apply(input) | POST /api/media-tags |
| evid.tags.update(id, value) | PUT /api/media-tags/:id |
| evid.tags.remove(id) | DELETE /api/media-tags/:id |
| evid.tagKeys.create(key) | POST /api/tag-keys |
| evid.tagKeys.update(id, key) | PUT /api/tag-keys/:id |
| evid.tagKeys.remove(id) | DELETE /api/tag-keys/:id |
| evid.comments.add(input) | POST /api/media-comments |
| evid.comments.edit(id, body) | PUT /api/media-comments/:id |
| evid.comments.remove(id) | DELETE /api/media-comments/:id |
| evid.projectComments.add(input) | POST /api/project-comments |
| evid.projectComments.edit(id, body) | PUT /api/project-comments/:id |
| evid.projectComments.remove(id) | DELETE /api/project-comments/:id |
| evid.userGroups.create(input) | POST /api/user-groups |
| evid.userGroups.update(id, input) | PUT /api/user-groups/:id |
| evid.userGroups.remove(id) | DELETE /api/user-groups/:id |
| evid.projectGroups.create(input) | POST /api/project-groups |
| evid.projectGroups.update(id, input) | PUT /api/project-groups/:id |
| evid.projectGroups.remove(id) | DELETE /api/project-groups/:id |
| evid.projectGroups.addProject(id, projectId) | POST /api/project-groups/:id/projects/:projectId |
| evid.projectGroups.removeProject(id, projectId) | DELETE /api/project-groups/:id/projects/:projectId |
| evid.members.invite(input) | POST /api/memberships/invites |
| evid.members.update(id, input) | PUT /api/memberships/:id |
| evid.members.revoke(id) | DELETE /api/memberships/:id |
| evid.members.resendInvite(id) | POST /api/memberships/:id/resend-invite |
| evid.members.reactivate(id) | POST /api/memberships/:id/reactivate |
| evid.members.adminAccept(id) | POST /api/memberships/:id/accept |
| evid.stars.star(type, id) | POST /api/stars/:targetType/:targetId |
| evid.stars.unstar(type, id) | DELETE /api/stars/:targetType/:targetId |
| evid.shareLinks.create(input) | POST /api/share-links |
| evid.shareLinks.revoke(id) | DELETE /api/share-links/:id |
| evid.exports.create(input) | POST /api/export-packages |
| evid.members.acceptInvite(token) | POST /api/memberships/invites/accept |
| evid.projects.restore(id) | POST /api/projects/:id/restore |
| evid.projects.removePermanently(id) | DELETE /api/projects/:id/permanent |
| evid.media.restore(id) | POST /api/medias/:id/restore |
| evid.media.removePermanently(id) | DELETE /api/medias/:id/permanent |
| evid.checklist.add(input) | POST /api/checklist-items |
| evid.checklist.update(id, patch) | PUT /api/checklist-items/:id |
| evid.checklist.remove(id) | DELETE /api/checklist-items/:id |
| evid.checklist.reorder(projectId, ids) | PUT /api/checklist-items/reorder |
| evid.notifications.markRead(id) | PATCH /api/notifications/:id/read |
| evid.notifications.markAllRead() | PATCH /api/notifications/read-all |
| evid.notificationPreferences.update(input) | PUT /api/notification-preferences/mine |
| evid.organization.update(id, input) | PUT /api/organizations/:id |
| evid.support.submit(input) | POST /api/support-requests |
| evid.miniApps.create(input) | POST /api/mini-apps |
| evid.miniApps.update(id, input) | PUT /api/mini-apps/:id |
| evid.miniApps.remove(id) | DELETE /api/mini-apps/:id |
| evid.subscription.checkout(plan) | POST /api/subscription/checkout |
| evid.subscription.portal() | POST /api/subscription/portal |
| evid.subscription.changePlan(plan) | POST /api/subscription/change-plan |
projects.remove and media.remove are soft deletes — the row moves to the
trash and stays restorable until the retention window lapses. restore and
removePermanently are the two ways back out; the permanent one is owner/admin
and refuses anything not trashed first.
The billing writes hand back a Stripe URL for the user to visit; they never move
money by themselves, and the backend answers owner-only. changePlan is a dev
escape hatch and 404s unless the server runs with ALLOW_MANUAL_PLAN_CHANGE=true.
miniApps.create/update/remove grant host capabilities to another app — an admin
surface (owner/admin server-side), not a convenience.
A few of these replace rather than patch: userGroups.update takes the whole
members set and projectGroups.update the whole projects set. To toggle a
single project in a group use addProject/removeProject — they are idempotent
and can't race a concurrent edit into dropping the rest.
Evidence itself is never created through evid.api. New media comes from the
capture flow (evid.capture, evid.capturePhoto, …), which is what hashes the
file, chains the custody anchor and meters the plan quota. evid.media.update
edits metadata (caption, description) on a record that already exists.
Error handling
Every call rejects with a BridgeError on failure. Inspect .code:
| .code | Meaning |
| --- | --- |
| NO_HOST | not running inside an Evid-Go host |
| TIMEOUT | the host didn't answer within 30s |
| CAPABILITY_DENIED | your mini app wasn't granted that capability |
| PATH_DENIED | evid.api path/method is not on the host allowlist |
| PERMISSION_DENIED | the user declined a device permission (camera, location) |
| CANCELED | the user backed out of a capture or scan |
| UNSUPPORTED | device-only method called in the web host |
| UNAVAILABLE | the underlying capability is missing on this device |
| BAD_PARAMS | a required argument was missing |
Backend failures surface with the API's own status/message.
import { BridgeError } from "@evidgo/mini-sdk";
try {
await evid.getLocation();
} catch (err) {
if (err instanceof BridgeError && err.code === "PERMISSION_DENIED") {
// ask the user to enable location for the Evid-Go app
}
}TypeScript
The package ships full types. Import model types alongside evid:
import type {
Capability,
MiniAppContext,
MediaResult,
CaptureTagInput,
Project,
ProjectMedia,
Subscription,
} from "@evidgo/mini-sdk";Example
A runnable React + Vite mini app lives in example/. It exercises
every bridge method and is the fastest way to see the SDK end to end.
It links the SDK with file:.., which hard-copies dist/. After editing SDK
sources: rebuild (pnpm build here), re-run pnpm install in example/, delete
example/node_modules/.vite, then restart the dev server — otherwise Vite serves
a stale pre-bundle.
License
UNLICENSED — for use with the Evid-Go platform.
