Developers

Build with Tamga

Three kinds of organization connect to Tamga: websites, verifiers and institutions that issue documents. Every piece is open source and follows the EUDI profiles, so what you build is not tied to Tamga.

What do you want to do?

GoalPackage
Add “Sign up / Sign in with TamgaID” to a website · guide@tamga-network/verifier/web + your server
Verify documents on your server (hiring, campus, age)@tamga-network/verifier
Issue your institution’s documents into people’s wallets@tamga-network/issuer/client (hosted) · @tamga-network/issuer (own service)

Packages

All packages are Apache-2.0 and published under the @tamga-network scope. Current release: 0.1.0 (pre-release) — the API may change before 1.0.

PackageWhat it does
@tamga-network/corehashes, identifier derivation, certificate helpers
@tamga-network/trustloads and verifies the signed trust lists; one read interface (also for the future ledger)
@tamga-network/schemasdocument-type catalogue: type metadata, JSON Schema, content hashes
@tamga-network/sd-jwtSD-JWT VC: selective disclosure, device binding, format checks
@tamga-network/mdocISO 18013-5 mdoc: CBOR, COSE, issuing and verifying
@tamga-network/issuercredential factory, OpenID4VCI helpers, revocation-list publisher; /client for the hosted service
@tamga-network/verifierthe verification pipeline (T0 + A–E), three outcomes, OpenID4VP requests; /web for the page kit
@tamga-network/wallet-corewallet core for Node and React Native: keys, receiving, local checks, presenting

Status today — honestly

PartStateNote
Packageson npm (0.1.0)pre-release; the API may change before 1.0
Hosted verifierworkingresults only to the site that opened the presentation (signed assertion), values once; policies fixed for now
Hosted issuing serviceworkingper-institution API keys with scopes, expiry and revocation
Trust list registrationworkingdone by the Tamga operator on request

Code examples

Install the packages, then start from one of four working examples. This is the real code from the repository’s examples folder: every test run executes it against the real packages, so it cannot drift from them. The packages are not on npm yet; until then, use them from the source repository.

npm install @tamga-network/verifier @tamga-network/trust @tamga-network/issuer

1 · “Sign in with TamgaID” on a website

Your server opens the presentation with a short-lived assertion signed by your trust-list key; the page only shows the QR code; the approved values are handed to your server once.

/**
 * Example 01 — "Sign up / Sign in with TamgaID" on your website, using the hosted verifier.
 *
 * Your SERVER opens each presentation, proving who it is with a short-lived assertion signed by the key of
 * your trust-list registration (ADR-0017). The page only shows the QR code and polls a status token; the
 * values the person approved are handed to your server once.
 *
 *   npm install @tamga-network/verifier
 */
import { createHmac } from "node:crypto";
import { createRpAssertion, type RpSigner } from "@tamga-network/verifier";

export interface SignInConfig {
  /** The hosted verifier, e.g. "https://verify.tamga.network". */
  verifier: string;
  /** Your registration: pemRpSigner(KEY_PEM, CERT_PEM, "x509_san_dns:example.com"). */
  rp: RpSigner;
  /** Your own secret; account keys are derived from it (never store the raw document value). */
  siteSecret: string;
  fetch?: typeof fetch;
}

/** What your page needs to draw the QR code and poll the status. */
export interface StartedPresentation {
  presentation_id: string;
  qr_payload: string;
  expires_at: string;
  status_token: string;
}

export type SignInResult =
  | { ok: true; accountKey: string; givenName?: string; familyName?: string }
  | { ok: false; outcome: "PENDING" | "REJECTED" | "INDETERMINATE" | "ALREADY_USED" };

export function tamgaSignIn(cfg: SignInConfig) {
  const f = cfg.fetch ?? fetch;
  const auth = async () => ({ authorization: `Bearer ${await createRpAssertion(cfg.rp, cfg.verifier)}` });

  /** POST /tamga/start → return this JSON to your page (TamgaVerifier.mount({ start })). */
  async function start(policy: "site-uyelik" | "site-giris"): Promise<StartedPresentation> {
    const r = await f(`${cfg.verifier}/presentations`, {
      method: "POST",
      headers: { ...(await auth()), "content-type": "application/json", accept: "application/json" },
      body: JSON.stringify({ policy_id: policy }),
    });
    if (!r.ok) throw new Error(`verifier refused the request (${r.status})`);
    return (await r.json()) as StartedPresentation;
  }

  /** POST /tamga/session → your page sends the presentation id after ACCEPTED; decide here, on the server. */
  async function finish(presentationId: string): Promise<SignInResult> {
    const id = encodeURIComponent(presentationId);
    const result = (await (await f(`${cfg.verifier}/presentations/${id}`, { headers: await auth() })).json()) as {
      outcome?: "ACCEPTED" | "REJECTED" | "INDETERMINATE";
    };
    if (result.outcome !== "ACCEPTED") return { ok: false, outcome: result.outcome ?? "PENDING" };

    const r = await f(`${cfg.verifier}/presentations/${id}/claims`, { headers: await auth() });
    if (r.status === 410) return { ok: false, outcome: "ALREADY_USED" }; // values are handed out once
    const { claims } = (await r.json()) as { claims: Record<string, unknown> };

    // Keep your own keyed hash as the account key; the raw value is the same for every site.
    const accountKey = createHmac("sha256", cfg.siteSecret)
      .update(String(claims.document_number_hash))
      .digest("base64url");
    return {
      ok: true,
      accountKey,
      givenName: claims.given_name as string | undefined,
      familyName: claims.family_name as string | undefined,
    };
  }

  return { start, finish };
}

2 · Verify documents on your own server

Without the hosted verifier: verifies the trust lists, pre-fetches revocation lists, signs the request, decrypts the answer and runs the pipeline (T0 + A–E). INDETERMINATE means “could not check right now”, never “invalid”.

/**
 * Example 02 — verify documents on your own server (hiring, campus, age checks), without the hosted verifier.
 *
 * 1. Load the signed trust lists (who may issue what) and pre-fetch the revocation lists.
 * 2. Create a signed OpenID4VP request → show it as a QR code.
 * 3. The wallet posts an encrypted answer → decrypt it → run the canonical checks (T0 + A–E).
 * Three outcomes: ACCEPTED, REJECTED (with the failing step) or INDETERMINATE ("could not check right now").
 *
 *   npm install @tamga-network/verifier @tamga-network/trust @tamga-network/core
 */
import { pemToDer } from "@tamga-network/core";
import { fetchListTrustSource, verifyJws } from "@tamga-network/trust";
import {
  createPresentationRequest,
  dcqlFromPolicy,
  decryptResponse,
  PrefetchStatusCache,
  verifyPresentation,
  type Policy,
  type PresentationRequest,
  type RpSigner,
} from "@tamga-network/verifier";

/** A hiring policy: a bachelor's diploma, four fields, nothing else. */
export const DIPLOMA_POLICY: Policy = {
  policy_id: "hiring-bachelor",
  purpose: { "en-GB": "Confirm graduation for a job application" },
  credentials: [
    {
      id: "diploma",
      vct_values: ["urn:tamga:edu:DiplomaCredential:1"],
      required_claims: ["is_graduate", "qualification_title", "eqf_level", "awarding_body_name"],
      constraints: { is_graduate: true, eqf_level: { min: 6 } },
    },
  ],
  trust: { min_issuer_assurance: "I2", allowed_categories: ["EDUCATION"], require_recognition: true, state_code: "TR" },
  freshness: { max_status_token_age_sec: 7200, max_trust_age_sec: 86400 },
};

export interface OwnVerifierConfig {
  /** Where the trust lists are published, e.g. "https://trust.tamga.network". */
  trustBase: string;
  /** Root fingerprints, fixed in your configuration (published at tamga.network/trust-anchor). */
  rootFingerprints: string[];
  /** Your registration: pemRpSigner(KEY_PEM, CERT_PEM, "x509_san_dns:example.com"). */
  signer: RpSigner;
  /** Your public base URL; the wallet fetches /vp/req/:id and posts to /vp/response here. */
  publicBase: string;
  fetch?: typeof fetch;
  /** Revocation-list fetcher (defaults to fetch with a timeout). */
  fetchStatus?: (url: string) => Promise<string | null>;
  anchorMaxAgeMs?: number;
}

export async function createOwnVerifier(cfg: OwnVerifierConfig) {
  const f = cfg.fetch ?? fetch;
  const http = async (url: string) => {
    const r = await f(url);
    return { status: r.status, text: () => r.text() };
  };
  // 1) Trust lists, verified against your fixed root fingerprints; anchors included (revocation checks need them).
  const { source: trust, store } = await fetchListTrustSource(cfg.trustBase, http, {
    rootFingerprints: cfg.rootFingerprints,
    verifyJws,
    anchors: true,
    anchorMaxAgeMs: cfg.anchorMaxAgeMs,
  });
  const rootCertsDer = [...store.root_cas.values()].flatMap((ca) => (ca.cert_pem ? [pemToDer(ca.cert_pem)] : []));
  // Pre-fetch every anchored revocation list: no network call at verification time.
  const statusCache = new PrefetchStatusCache(cfg.fetchStatus);
  await statusCache.refresh([...store.status_anchors.values()].map((a) => a.list_uri));

  const pending = new Map<string, { req: PresentationRequest; policy: Policy }>();

  /** 2) Start: returns the QR payload. Serve `requestObject(id)` at GET /vp/req/:id. */
  async function start(policy: Policy = DIPLOMA_POLICY) {
    const req = await createPresentationRequest({
      signer: cfg.signer,
      dcql: dcqlFromPolicy(policy),
      responseUri: `${cfg.publicBase}/vp/response`,
      requestUriBase: `${cfg.publicBase}/vp/req`,
      purpose: Object.values(policy.purpose)[0],
    });
    pending.set(req.presentationId, { req, policy });
    return { presentationId: req.presentationId, qrPayload: req.qrPayload };
  }

  /** GET /vp/req/:id → body with content-type application/oauth-authz-req+jwt. */
  const requestObject = (id: string) => pending.get(id)?.req.requestJwt ?? null;

  /** 3) POST /vp/response (form field `response`) → verify. Each request answers once. */
  async function handleResponse(jwe: string) {
    const kid = JSON.parse(Buffer.from(jwe.split(".")[0], "base64url").toString("utf8")).kid as string;
    const entry = pending.get(String(kid).replace(/^enc-/, ""));
    if (!entry) throw new Error("unknown or already answered request");
    pending.delete(entry.req.presentationId);
    const answer = await decryptResponse(jwe, entry.req.encPrivateKey);
    if (answer.state !== entry.req.state) throw new Error("state mismatch");
    const pc = entry.policy.credentials[0];
    return verifyPresentation({
      presentation: answer.vp_token[pc.id][0],
      aud: cfg.signer.clientId,
      nonce: entry.req.nonce,
      policy: entry.policy,
      policyCredentialId: pc.id,
      trust,
      statusCache,
      rootCertsDer,
      rp: trust.relyingParty(cfg.signer.clientId),
    }); // → { result: { outcome, failed_step, … }, claims } — store names, never values, in your logs
  }

  /** Refresh revocation lists on a timer (e.g. every few minutes). */
  const refreshStatus = () => statusCache.refresh([...store.status_anchors.values()].map((a) => a.list_uri));

  return { start, requestObject, handleResponse, refreshStatus, trust, statusCache };
}

3 · Issue documents as an institution

With your institution’s scoped API key on the hosted issuing service. The offer link becomes a QR code; the PIN goes through a different channel, never inside the link.

/**
 * Example 03 — issue documents from your own system, using Tamga's hosted issuing service.
 *
 * Your institution gets a scoped API key from the Tamga operator (ADR-0016). Your server calls the service;
 * the person scans the returned link as a QR code and types the PIN you show them separately.
 *
 *   npm install @tamga-network/issuer
 */
import { createIssuerClient, IssuerClientError } from "@tamga-network/issuer/client";

export function institution(cfg: { apiKey: string; slug: string; baseUrl?: string; fetch?: typeof fetch }) {
  const tamga = createIssuerClient({
    baseUrl: cfg.baseUrl ?? "https://issuer.tamga.network",
    slug: cfg.slug,
    apiKey: cfg.apiKey, // tmg_<slug>_… — server side only
    fetch: cfg.fetch,
  });

  return {
    /** A university: offer a diploma to a graduate in your records (your own student number). */
    async offerDiploma(studentNo: string) {
      const offer = await tamga.createOffer({ subjectId: studentNo, vct: "urn:tamga:edu:DiplomaCredential:1" });
      // offer.deepLink → show as a QR code or an "Add to wallet" link.
      // offer.txCode   → show on a DIFFERENT channel than the link (screen, SMS, e-mail): never inside the QR.
      return offer;
    },

    /** A ticket seller: sell a ticket straight into the buyer's wallet (the ticket carries no personal data). */
    async sellTicket(eventId: string, ticketClass: string) {
      const sale = await tamga.sellTicket({ eventId, ticketClass });
      return { ticketId: sale.ticketId, qr: sale.offer.deepLink, pin: sale.offer.txCode };
    },

    /** Revoke (final) or suspend (reversible); verifiers see it at the next fixed publication. */
    async cancel(credentialId: string, reason: string) {
      try {
        return await tamga.revoke(credentialId, reason);
      } catch (e) {
        if (e instanceof IssuerClientError && e.status === 404) return null; // unknown credential
        throw e;
      }
    },
  };
}

4 · Check an institution

Is it registered, active, and authorised for this document type? Reads the signed trust lists only — no personal data.

/**
 * Example 04 — ask the trust lists: is this institution registered, active, and authorized for this document?
 * Useful for a directory page, an onboarding screen or a back-office check. No personal data involved.
 *
 *   npm install @tamga-network/trust
 */
import { fetchListTrustSource, verifyJws } from "@tamga-network/trust";

export async function institutionStatus(
  trustBase: string,
  rootFingerprints: string[],
  slug: string,
  vct: string,
  opts: { fetch?: typeof fetch; anchorMaxAgeMs?: number } = {},
) {
  const f = opts.fetch ?? fetch;
  const { source } = await fetchListTrustSource(
    trustBase,
    async (url) => {
      const r = await f(url);
      return { status: r.status, text: () => r.text() };
    },
    { rootFingerprints, verifyJws },
  );
  const issuer = source.issuers().find((i) => i.slug === slug);
  if (!issuer) return { registered: false as const };
  const now = Date.now();
  const authorized = issuer.schema_authorizations.some(
    (a) =>
      a.vct === vct &&
      a.allowed &&
      new Date(a.valid_from).getTime() <= now &&
      (!a.valid_until || now < new Date(a.valid_until).getTime()),
  );
  return {
    registered: true as const,
    name: issuer.legal_name,
    category: issuer.category,
    status: issuer.status, // ACTIVE | SUSPENDED | REVOKED | RETIRED
    authorized,
  };
}

Rules for every integration

  • Ask only for the fields you need — a request beyond your registered scope is refused by the wallet.
  • Treat INDETERMINATE as “try again”, never as “invalid”.
  • Never log personal data — no names, no identifiers, no passkey IDs.
  • Decide on the server. Browser code can be changed by the user.

Full developer documentation — guides, specifications, decisions: docs.tamga.network. Roles and participant rules: Tamga ARF. Background: architecture · trust lists · Tamga and EUDI.