API Documentation

Integration reference for partners migrating legacy credentials into Hovi-issued verifiable credentials.

Migration flow
A user proves they own a phone number, picks a credential from the legacy database, and receives an offer URL that issues the new credential to their wallet.
┌──────────┐    ┌────────────┐    ┌──────────────────┐
│ sendOtp  │ -> │ verifyOtp  │ -> │ claimCredential  │
└──────────┘    └────────────┘    └──────────────────┘
    SMS code      JWT + list of      Hovi offer URL
                  credentials        per credential

Authentication

Public endpoints use a per-session migration JWT. It's returned by verifyOtp, expires after 15 minutes, and must be passed back into claimCredential. The token is bound to the verified phone number and the credential set discovered at verify time — it cannot be reused for a different phone or a credential not in that set.

Admin endpoints require a Supabase session bearer token from a user with the admin role (enforced server-side via has_role(auth.uid(), 'admin')).

Public endpoints

Server functions are typically called via the typed client (useServerFn or direct import). The raw HTTP path is shown for reference; prefer the typed client when working in TypeScript.

POST
sendOtp
Public
Generate and dispatch a 6-digit verification code to the supplied phone number.

Input

{
  "phone": "+15551234567"   // E.164 recommended
}

Response

{
  "ok": true,
  "integrations": { "sms": boolean, "mongo": boolean, "hovi": boolean, "jwt": boolean },
  "devCode"?: string,        // only when SMS provider is in demo mode
  "devNote"?: string
}

Errors

  • Phone number is required
  • Failed to send code (SMS provider error)

Examples

import { sendOtp } from "@/server/migration.functions";

const res = await sendOtp({ data: { phone: "+15551234567" } });
console.log(res.integrations); // which integrations are live
POST
verifyOtp
Public
Verify the code, look up legacy credentials for that phone in MongoDB, and return a short-lived migration JWT plus the credential list.

Input

{
  "phone": "+15551234567",
  "code": "123456"
}

Response

{
  "token": string,           // migration JWT, 15-min expiry
  "credentials": Array<{
    "id": string,
    "legacy_schema_id": string,
    "hovi_template_id": string,
    "tenant_db": string,
    "display_name": string,
    "summary": string,       // rendered from summary_template
    "attributes": Record<string, string | number | boolean | null>
  }>,
  "integrations": { "sms": boolean, "mongo": boolean, "hovi": boolean, "jwt": boolean },
  "devNote"?: string
}

Errors

  • Invalid or expired code
  • Code already used
  • No mapping found for legacy schema (logged to migration_events)

Examples

import { verifyOtp } from "@/server/migration.functions";

const { token, credentials } = await verifyOtp({
  data: { phone: "+15551234567", code: "123456" },
});
// store token client-side for the next call
POST
claimCredential
Public
Exchange a credential for a Hovi offer URL. Requires the migration JWT returned by verifyOtp.

Input

{
  "token": "<migration JWT from verifyOtp>",
  "credentialId": "<id from credentials[]>"
}

Response

{
  "offerUrl": string,        // open in browser / wallet to claim
  "devNote"?: string
}

Errors

  • No session token
  • Migration session expired
  • Credential not found in session
  • Issuance failed: <provider error>

Examples

import { claimCredential } from "@/server/migration.functions";

const { offerUrl } = await claimCredential({
  data: { token, credentialId: credentials[0].id },
});
window.open(offerUrl, "_blank");

Admin endpoints

All admin endpoints require an authenticated session with the admin role. They power the internal admin UI and are not intended for external partners.
  • listMappingsReturn all credential_mappings rows.
  • upsertMappingCreate or update a mapping (legacy_schema_id ↔ hovi_template_id + attribute_map + summary_template).
  • deleteMappingDelete a mapping by id.
  • toggleMappingEnabledEnable or disable a mapping without deleting it.
  • discoverUnmappedSchemasList legacy schema IDs seen in events that have no mapping yet.
  • getMigrationStatsFunnel counts, daily claims, per-template and per-tenant breakdowns.

Mapping conventions

Every legacy schema must have a row in credential_mappings linking it to a Hovi template and describing how to translate fields.

attribute_map

Maps Hovi attribute keys to MongoDB field paths. Dot-notation is supported.

{
  "fullName": "profile.name",
  "dateOfBirth": "profile.dob",
  "membershipId": "_id"
}

summary_template

A short human-readable summary shown in the credential card. Uses {{key}} placeholders that reference the Hovi attribute keys (post-mapping).

{{fullName}} — member since {{joinYear}}

Integration status

Every public response includes an integrations object describing which downstream services are configured. When any flag is false, that integration is running in demo mode and the response includes a devNote (and possibly devCode) so the flow remains testable end-to-end.

  • smssmspoh — sending OTP codes
  • mongoMongoDB Atlas Data API — legacy credential lookup
  • hoviHovi — issuing credential offers
  • jwtMIGRATION_JWT_SECRET — signing session tokens

Error handling

Server functions throw Error with a human-readable message on failure. The typed client surfaces these as a rejected promise; the raw HTTP transport returns HTTP 500 with { "error": { "message": "…" } }.

  • Invalid or expired codeverifyOtp — code wrong, used, or older than 5 minutes.
  • Migration session expiredclaimCredential — JWT older than 15 minutes; restart from sendOtp.
  • Credential not found in sessionclaimCredential — credentialId not in the verifyOtp response.
  • Issuance failed: …claimCredential — Hovi rejected the offer; suffix is the provider message.
  • Forbidden: admin role requiredAdmin endpoints — caller is not in user_roles with role='admin'.