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?
| Goal | Package |
|---|---|
| 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.
| Package | What it does |
|---|---|
@tamga-network/core | hashes, identifier derivation, certificate helpers |
@tamga-network/trust | loads and verifies the signed trust lists; one read interface (also for the future ledger) |
@tamga-network/schemas | document-type catalogue: type metadata, JSON Schema, content hashes |
@tamga-network/sd-jwt | SD-JWT VC: selective disclosure, device binding, format checks |
@tamga-network/mdoc | ISO 18013-5 mdoc: CBOR, COSE, issuing and verifying |
@tamga-network/issuer | credential factory, OpenID4VCI helpers, revocation-list publisher; /client for the hosted service |
@tamga-network/verifier | the verification pipeline (T0 + A–E), three outcomes, OpenID4VP requests; /web for the page kit |
@tamga-network/wallet-core | wallet core for Node and React Native: keys, receiving, local checks, presenting |
Status today — honestly
| Part | State | Note |
|---|---|---|
| Packages | on npm (0.1.0) | pre-release; the API may change before 1.0 |
| Hosted verifier | working | results only to the site that opened the presentation (signed assertion), values once; policies fixed for now |
| Hosted issuing service | working | per-institution API keys with scopes, expiry and revocation |
| Trust list registration | working | done 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.