# Bind an agent
Source: https://docs.air3.com/api-reference/agents/bind-agent-key
api-reference/agent-openapi.json POST /v2/auth/agent/keys/partner
Registers an agent key for the holder authenticated by the partner access
token. Call this once per agent. `publicKey` is optional; omit it for
API-key-only agents.
`agentApiKey` is returned **once**. Store it on your backend.
# Create an agent session
Source: https://docs.air3.com/api-reference/agents/create-agent-session
api-reference/agent-openapi.json POST /v2/auth/agent/session
Issues a **15-minute** access token (`type=agent`) with no refresh token.
Create one per operation.
Identify the agent with **exactly one** of the `x-agent-api-key` header or
the body `signedMessage`. API-key-only agents must use the header.
`signedMessage` requires a public key stored at bind time.
The requested `scope` must be a subset of the bound key's scopes; omit it
to receive the full bound set. Tokens issued before a key rotation fail.
# Create a checkout session
Source: https://docs.air3.com/api-reference/agents/create-checkout-session
api-reference/agent-openapi.json POST /v2/auth/agent/checkout-session
Issues a short-lived access token (`type=agent`) for a merchant checkout.
No refresh token. The bound key must include `commerce.checkout`.
Identify the agent with **exactly one** of the `x-agent-api-key` header or
the body `signedMessage`.
The request `scope` names the merchant checkout target, not an agent
scope. `pivota.checkout` is currently the only supported value. The JWT's
`scope` equals the requested value, and its `aud` is the merchant mapped to
that target. The JWT includes the holder's abstract account address.
AIR and Credential APIs reject this token because of the audience. The
merchant must verify `aud`.
# Agent APIs
Source: https://docs.air3.com/api-reference/agents/introduction
Bind an agent to an AIR Account, mint short-lived agent JWTs, list and verify the holder's credentials, and mint merchant checkout tokens.
The Agent APIs let your backend act for a holder, the signed-in user, through a bound agent. You bind an agent once, then mint a short-lived agent JWT for each operation and use it to list or verify the holder's credentials. If the agent's key includes checkout, you can also mint a token for a merchant checkout.
For a step-by-step walkthrough with request examples, see the [Using Agentic Identity](/products/agents/partner-agent-api). Get the user's approval before you bind an agent; the bind request has no approval step of its own. See [Ask for consent before binding](/products/agents/delegation-and-consent#ask-for-consent-before-binding).
## Base URLs
The Agent APIs span two hosts. Use the pair for the same environment.
| Environment | AIR API (keys, session, checkout) | Credential API (list, verify) |
| - | - | - |
| Sandbox | `https://air.api.sandbox.air3.com` | `https://credential-testnet.api.sandbox.air3.com` |
| Production | `https://air.api.air3.com` | `https://credential-mainnet.api.air3.com` |
Agent keys must be enabled for your partner account. Otherwise, AIR API calls return `403 AGENT_KEYS_DISABLED`. Contact the AIR team to enable agent keys and confirm which environment is available for your integration.
## Authentication
Each route uses a different combination of headers.
| Header | Value | Used by |
| - | - | - |
| `x-partner-id` | Your Partner ID from the Developer Dashboard | All key routes and session creation |
| `x-partner-access-token` | The holder's AIR Kit partner access token from `airService.getAccessToken()` | All key routes |
| `x-partner-jwt` | A JWT signed with your partner login key | Bind, rotate, and revoke |
| `x-agent-api-key` | The `air_ag_…` key returned at bind or rotate | Session and checkout creation |
| `Authorization: Bearer` | The agent JWT from session creation | List and verify credentials |
Sign `x-partner-jwt` on your backend with the same key you use for AIR Kit login. Its `partnerId` must match `x-partner-id` and the holder's session. See [SDK authentication](/get-started/authentication/sdk-auth) for key setup.
Session and checkout creation accept exactly one agent identity: the `x-agent-api-key` header, or a `signedMessage` in the body for agents bound with a P-256 public key.
Treat `agentApiKey` and `x-partner-jwt` as backend secrets. Never send them to a browser or embed them in page JavaScript.
## Scopes
Bind an agent with a space-delimited `scope`. AIR adds `openid` automatically.
| Scope | Allows |
| - | - |
| `credentials.read` | List the holder's credentials. |
| `credentials.verify` | Verify the holder's credentials against a program. |
| `commerce.checkout` | Mint a merchant checkout token. |
Partner bind cannot include `wallet.sign`. A session can request a subset of the bound scopes; omit `scope` to receive all of them.
## Tokens and key lifecycle
| Item | Lifetime | Notes |
| - | - | - |
| `agentApiKey` | Until rotated or revoked | Returned only at bind and rotate. |
| Agent JWT | 15 minutes | No refresh token. Create a new session for each operation. |
| Checkout JWT | Short-lived | Its `aud` is the merchant. AIR and Credential APIs reject it. |
Rotating a key returns a new `agentApiKey` and invalidates agent JWTs already issued for that key. Revoking a key stops new sessions for it.
## Errors
Errors return a JSON body with a machine-readable `code` and a `message`.
| Status | AIR API | Credential API |
| - | - | - |
| `400` | Invalid request, for example a missing `scope` | Invalid query or body |
| `401` | Missing or invalid partner session, partner JWT, or agent identity | Missing or invalid bearer token |
| `403` | Agent keys disabled, requested scope not a subset of the bound key, or `wallet.sign` on a partner route | User JWT on verify, or agent JWT missing the required scope |
| `404` | Agent key not found | — |
| `409` | Maximum agent keys per user reached | — |
| `502` | — | Credential storage unavailable |
AIR API codes: `INVALID_PARAMETER`, `UNAUTHORIZED`, `INVALID_TOKEN`, `AGENT_KEYS_DISABLED`, `AGENT_SCOPE_DENIED`, `AGENT_SCOPED_SESSION_DISABLED`, `CONFLICT_REQUEST`, `NOT_FOUND`, and `INTERNAL_SERVER_ERROR`.
Credential API codes: `INVALID_PARAMETER`, `UNAUTHORIZED`, `FORBIDDEN`, `CREDENTIAL_STORAGE_UNAVAILABLE`, and `VERIFICATION_PROGRAM_NOT_FOUND`.
A `502 CREDENTIAL_STORAGE_UNAVAILABLE` on verify means storage could not be read. It does not mean the holder lacks the credential; that case returns `200` with `status: "NotFound"`.
## Endpoints
Register an agent for the holder and receive its API key once.
Mint a 15-minute agent JWT.
Page through the holder's credentials with optional filters.
Run a verification program and receive a presentation when compliant.
Mint a token for a supported merchant checkout.
List, rename, rotate, and revoke bound agents.
# List bound agents
Source: https://docs.air3.com/api-reference/agents/list-agent-keys
api-reference/agent-openapi.json GET /v2/auth/agent/keys/partner
Keys this partner bound for the holder identified by the partner access
token. Unbound keys and other partners' keys are omitted. `agentApiKey` is
never returned here.
# List credentials
Source: https://docs.air3.com/api-reference/agents/list-credentials
api-reference/agent-openapi.json GET /v2/credentials
Cursor-paginated list of the authenticated holder's credentials. Agent JWTs
need `credentials.read`. A holder user JWT also works.
Filters are repeatable. `vct` also accepts a comma-separated list (except
for `http(s)` URLs). Filters on different fields are combined as an
intersection.
# Revoke an agent key
Source: https://docs.air3.com/api-reference/agents/revoke-agent-key
api-reference/agent-openapi.json DELETE /v2/auth/agent/keys/partner/{id}
Removes the agent key. After revocation, session requests with that API key
(or its public key) fail. `wallet.sign` keys cannot be revoked on this path.
# Rotate an agent API key
Source: https://docs.air3.com/api-reference/agents/rotate-agent-key
api-reference/agent-openapi.json POST /v2/auth/agent/keys/partner/{id}/rotate
Issues a new `agentApiKey` (returned once), increments the key's token
version, and invalidates agent JWTs already issued for it. `wallet.sign`
keys cannot be rotated on this path.
# Update an agent nickname
Source: https://docs.air3.com/api-reference/agents/update-agent-key-nickname
api-reference/agent-openapi.json PATCH /v2/auth/agent/keys/partner/{id}
Letters, numbers, spaces, hyphens, and colons; maximum 32 characters. An
empty string clears the nickname. Does not require `x-partner-jwt`.
# Verify a credential
Source: https://docs.air3.com/api-reference/agents/verify-credential-by-agent
api-reference/agent-openapi.json POST /v2/credentials/verify-by-agent
Runs a verification program against the holder's credentials. Requires an
agent JWT with `credentials.verify`; user JWTs are rejected. Verification
always runs offchain, without a zero-knowledge proof.
`status` is `Compliant`, `Non-Compliant`, or `NotFound`.
`verifiablePresentation` is present only when the status is `Compliant`.
A storage outage returns `502 CREDENTIAL_STORAGE_UNAVAILABLE`, not
`NotFound`.
# API Reference
Source: https://docs.air3.com/api-reference/introduction
Moca Network REST API reference — server-to-server endpoints for credential issuance, issuance status, and partner operations.
The Moca Network REST API enables server-to-server operations that do not require user presence. Use it for backend automation, credential issuance into encrypted DStorage, and programmatic status checks. Issuers sign credentials with their own keys and encrypt them to the holder; AIR stores and routes opaque ciphertext and metadata only.
## Base URLs
| Environment | Base URL |
| - | - |
| Sandbox | `https://api.sandbox.mocachain.org/v1` |
| Production | `https://mocachain-mainnet.api.air3.com/v1` |
## Authentication
Authentication depends on the credential surface:
* Direct AIR endpoints (`initialize-user` and `dstorage/vcs`) use a **Partner JWT** in `x-partner-auth`.
* Your issuer backend protects `available-vc` and `issue-vc` with `x-api-key`.
* Public credential-status endpoints do not require authentication.
* Self-hosted admin endpoints use `x-admin-api-key`.
For direct AIR endpoints, sign the Partner JWT with your private key (RS256 or ES256) and include:
| Claim | Required | Description |
| - | - | - |
| `partnerId` | Yes | Your Partner ID from the Developer Dashboard |
| `scope` | Yes | Operation scope (e.g. `"issue"` or `"verify"`) |
| `exp` | Yes | Expiration timestamp (recommended: 5 minutes) |
For on-demand issuance, do not put the recipient email in the Partner JWT. Send it in the `POST /auth/initialize-user` request body instead. Other operations, such as custom user authentication, have their own claim requirements.
The JWT header must include `kid` (Key ID) matching a key in your JWKS endpoint and `typ: "JWT"`.
For full setup instructions, key generation, and code examples see [SDK authentication](/get-started/authentication/sdk-auth).
**API Playground:** Requests from the Try it out playground are sent directly from your browser to the API. Your API must allow CORS from your docs origin (e.g. your Mintlify subdomain or custom domain) for the playground to work. If you see 403 from the playground, check that the API allows the request origin and that your Partner JWT is valid.
## Credentials
On-demand credential issuance resolves the recipient and stores an issuer-signed, holder-encrypted credential in DStorage. See the [Issuance API Reference](/api-reference/issuance-api) for the `initialize-user` and `dstorage/vcs` endpoints, request and response details, and code examples.
## Agent APIs
The Agent APIs use their own hosts and authentication. They let your backend bind an agent to a user's AIR Account, mint short-lived agent JWTs, list and verify the user's credentials, and mint merchant checkout tokens.
Base URLs, authentication, scopes, and endpoints for partner-bound agents.
## Code examples
For end-to-end integration examples including issue-and-poll flows, webhook-driven issuance, and retry logic, see [Issuance API Reference](/api-reference/issuance-api).
# Issuance API Reference
Source: https://docs.air3.com/api-reference/issuance-api
AIR credential issuance API reference: the AIR API endpoints for initializing accounts and storing encrypted VCs, and the endpoints your issuer service hosts.
* Read concepts first: [Issuing Credentials](/products/identity/issuing-credentials)
* For JWT/JWKS setup and signing examples, see [SDK authentication](/get-started/authentication/sdk-auth).
This page covers two separate surfaces. Do not mix their base URLs or authentication headers.
| Surface | Base URL | Who calls it | Authentication |
| - | - | - | - |
| [AIR API](#air-api) | `{AIR_API_BASE_URL}` | Your backend | `x-partner-auth: ` |
| [Your issuer service](#your-issuer-service): holder-facing routes | `{ISSUER_ORIGIN}` | AIR, during SDK issuance | `x-api-key: ` |
| [Your issuer service](#your-issuer-service): admin routes | `{ISSUER_ORIGIN}` | Your own servers only | `x-admin-api-key: ` |
| [Your issuer service](#your-issuer-service): public routes | `{ISSUER_ORIGIN}` | Verifiers and AIR | None |
# AIR API
## AIR API base URL
This base URL applies only to the AIR-hosted endpoints on this page — `POST /auth/initialize-user` and `POST /dstorage/vcs` — shown below as `{AIR_API_BASE_URL}`.
| Environment | Base URL |
| - | - |
| Sandbox | `https://api.sandbox.mocachain.org/v1` |
| Production | `https://mocachain-mainnet.api.air3.com/v1` |
`/available-vc`, `/issue-vc`, `/admin/*`, and the status endpoints are hosted on your issuer service under its public `ISSUER_ORIGIN`. You never call the AIR API base URL for them.
Moca Chain mainnet is live. Production SD-JWT issuers register in the [production Developer Dashboard](https://developers.air3.com/dashboard); see [Production mainnet access](/get-started/environments/about#production-mainnet-access).
## Authentication
Direct AIR API requests for direct issuance use a Partner JWT sent in the `x-partner-auth` header. The JWT must include the partner identity, at minimum `partnerId`, and for issuance flows should be scoped for issuance, e.g. `scope: "issue"` where required by the endpoint. The JWT should be signed with the partner’s private key and verifiable through the configured JWKS endpoint; its header should include `kid` matching the JWKS key and `typ: "JWT"`. For the newer `initialize-user` flow, the recipient/user identifier, typically `email`, is passed in the `initialize-user` request body, and the returned DID/public key are then used when storing the encrypted VC through `/dstorage/vcs`.
Your issuer service uses its own keys, described in [Your issuer service](#your-issuer-service). They are never sent to the AIR API.
## Issuance surfaces
There are two issuance patterns. In both cases, the issuer controls the signing keys and the resulting encrypted credential is stored in DStorage.
* **Hosted SDK issuance** — holder present. Your frontend calls `air.issueCredential(...)`. AIR resolves the authenticated holder and calls your registered issuer backend's `POST /available-vc` to retrieve an encrypted credential-subject preview. After the holder confirms, AIR calls `POST /issue-vc`. The issuer backend generates the authoritative claims, signs the VC, encrypts it to the holder's public key, persists the issuance record, and uploads the envelope to DStorage.
* **Direct issuance** — no holder interaction. The recipient is resolved by email with `POST /auth/initialize-user`, the VC is issuer-signed and encrypted to the returned holder public key, and the envelope is stored through `POST /dstorage/vcs`. The SD-JWT issuer service wraps these steps in `POST /admin/issue-vc` (see [Admin endpoints](#4-admin-endpoints-called-by-your-servers)). You can also call the two AIR API endpoints yourself.
## 1) Initialize or resolve an AIR account
Resolve or create the recipient’s AIR Account using their email address, and return the AIR user UUID, holder DID, and public key required for direct issuance.
```text theme={null}
POST {AIR_API_BASE_URL}/auth/initialize-user
```
### Request headers
| Header | Required | Value |
| - | - | - |
| `Content-Type` | Yes | `application/json` |
| `x-partner-auth` | Yes | Signed Partner JWT |
### Request body
```json theme={null}
{
"email": "user@example.com"
}
```
| Field | Type | Required | Description |
| - | - | - | - |
| `email` | string | Yes | The individual recipient's AIR Account email. AIR uses this value to resolve or create the destination account. Do not use a partner, service, admin, or shared email address. |
### Response
```json theme={null}
{
"userId": "7f01c42c-02cf-4325-96ed-ba034700f724",
"did": "did:air:id:test:5P44fsVUhPctDTWH2Nz26pZJFsg6CqyiAELTGeVQDB",
"publicKey": "0x04a1..."
}
```
| Field | Description |
| - | - |
| `userId` | AIR user UUID for the resolved or newly created AIR account. This is not necessarily the same identifier as the partner primary `userId` supplied to issuer-hosted endpoints. |
| `did` | Holder DID used as `holderDid` when storing the credential |
| `publicKey` | Holder public key; encrypt the VC payload to this key |
## 2) Store encrypted VC
Store an encrypted VC envelope in DStorage. Before calling this endpoint, the issuer must build and sign the VC, then encrypt the complete signed credential to the holder’s public key returned by `initialize-user`.
AIR and DStorage receive the encrypted envelope and do not construct or sign the credential.
```text theme={null}
POST {AIR_API_BASE_URL}/dstorage/vcs
```
### Request headers
| Header | Required | Value |
| - | - | - |
| `Content-Type` | Yes | `application/json` |
| `x-partner-auth` | Yes | Signed Partner JWT |
### Request body
```json theme={null}
{
"holderDid": "did:air:id:test:5P44fsVUhPctDTWH2Nz26pZJFsg6CqyiAELTGeVQDB",
"schemaId": "c21s70g0i54sn0023172Cv",
"expiresAt": "2027-07-28T08:00:00.000Z",
"data": "",
"iv": "",
"authTag": "",
"encryptedKey": "",
"externalId": "urn:uuid:7f01c42c-02cf-4325-96ed-ba034700f724"
}
```
| Field | Type | Required | Description |
| - | - | - | - |
| `holderDid` | string | Yes | Holder DID from `initialize-user` |
| `schemaId` | string | Yes | Schema the credential is built on |
| `expiresAt` | ISO 8601 string | Yes | Credential expiration time |
| `data` | base64 string | Yes | Encrypted, issuer-signed VC |
| `iv` | base64 string | Yes | AES-GCM initialization vector |
| `authTag` | base64 string | Yes | AES-GCM authentication tag |
| `encryptedKey` | base64 string | Yes | Ephemeral data-encryption public key |
| `externalId` | string | Yes | Stable issuer-controlled unique identifier for this credential. Reuse the same value when retrying the same storage operation; do not generate a new value for each transient retry. |
### Response 201
```json theme={null}
{
"storagePath": "dstorage://vc/7f01c42c/c28t30c048pe502a3713w0",
"state": "...",
"envelopeVersion": "...",
"createdAt": "2026-08-26T12:37:19.000Z"
}
```
# Your issuer service
These routes run on your own issuer service at its public `ISSUER_ORIGIN`, not on the AIR API. The examples follow the SD-JWT branch of the [AIR issuer service](https://github.com/MocaNetwork/air-issuer-service/tree/main). Iden3 issuers use a separate branch; see [Iden3 credentials](/products/identity/iden3-credentials).
## 3) Holder-facing endpoints (called by AIR)
Your issuer service exposes `POST /available-vc` and `POST /issue-vc` for hosted SDK issuance. AIR—not the browser—calls these registered endpoints with the holder identity resolved through the authenticated AIR session.
Both endpoints require:
```http theme={null}
Content-Type: application/json
x-api-key:
```
Configure the same key as `API_KEY` in the issuer service and register it with AIR during issuer activation. These routes are hosted under your issuer backend’s public `ISSUER_ORIGIN`, not under the AIR API base URL.
Do not authorize issuance or retrieve claims from `holderDID` alone. Use the AIR-resolved partner primary `userId` to look up the holder’s eligibility and authoritative claims in your own systems. Do not assume this value is interchangeable with the AIR user UUID returned by `initialize-user`.
### `POST {ISSUER_ORIGIN}/available-vc`
Return credentials the holder can claim. `schemaId` and `proofType` are optional filters. AIR calls this endpoint during hosted SDK issuance to discover credentials available to the authenticated holder. The issuer backend validates eligibility using `userId`, generates authoritative claims, encrypts the preview to `pubKey`, and returns it to AIR.
`schemaId` is a credential schema ID—not the Dashboard issuance program ID
```json theme={null}
{
"holderDID": "did:air:id:test:5P44fsVUhPctDTWH2Nz26pZJFsg6CqyiAELTGeVQDB",
"pubKey": "0x04a1...",
"userId": "partner-user-123",
"schemaId": "c21s70g0i54sn0023172Cv",
"proofType": "SD_JWT_VC"
}
```
| Field | Type | Required | Description |
| - | - | - | - |
| `holderDID` | string | Yes | Holder DID |
| `pubKey` | string | Yes | Holder public key used to encrypt each credential subject preview |
| `userId` | string | Yes | Partner primary identifier |
| `schemaId` | string | No | Return only this schema |
| `proofType` | string | No | Return only this proof type. The SD-JWT issuer service issues `SD_JWT_VC`; the Iden3 issuer service issues `BJJ_SIG_2021` |
Response:
```json theme={null}
{
"data": [
{
"holderDID": "did:air:id:test:5P44fsVUhPctDTWH2Nz26pZJFsg6CqyiAELTGeVQDB",
"schemaId": "c21s70g0i54sn0023172Cv",
"credentialSubject": {
"encryptedData": "",
"iv": "",
"authTag": "",
"dataEncPublicKey": ""
},
"proofType": "SD_JWT_VC"
}
]
}
```
### `POST {ISSUER_ORIGIN}/issue-vc`
AIR calls this endpoint after the holder confirms hosted issuance. The issuer backend regenerates or retrieves the authoritative claims, signs the complete VC, encrypts it to `pubKey`, persists the issuance record, uploads the encrypted envelope to DStorage, and returns HTTP `201` with an empty body.
```json theme={null}
{
"holderDID": "did:air:id:test:5P44fsVUhPctDTWH2Nz26pZJFsg6CqyiAELTGeVQDB",
"pubKey": "0x04a1...",
"userId": "partner-user-123",
"schemaId": "c21s70g0i54sn0023172Cv",
"signingKey": { "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } },
"proofType": "SD_JWT_VC"
}
```
| Field | Type | Required | Description |
| - | - | - | - |
| `holderDID` | string | Yes | Holder DID |
| `pubKey` | hexadecimal string | Yes, unless `encryptionKey` is supplied | Holder public key used to encrypt the complete issued credential |
| `encryptionKey` | hexadecimal string | No | Alternative to `pubKey`. If both are present, `encryptionKey` is used for encryption |
| `signingKey` | object | No | `{ "jwk": { ... } }` holder public key. The SD-JWT issuer service writes it to the credential's `cnf` claim, binding the credential to the holder |
| `userId` | string | Yes | Partner primary identifier |
| `schemaId` | string | Yes | Schema to issue |
| `proofType` | string | No | `SD_JWT_VC` on the SD-JWT issuer service, `BJJ_SIG_2021` on the Iden3 issuer service. Each service rejects the other value |
On success, the endpoint returns `201` with an empty response body. The issuer backend uploads the encrypted credential and stores the DStorage response internally; callers should not expect a `storagePath` in this response. A missing or mismatched `x-api-key` on either issuer-hosted endpoint returns `403`, not `401`.
## 4) Admin endpoints (called by your servers)
Admin routes require `x-admin-api-key: `. Call them only from your own servers; never from a browser or through AIR.
| Method | Path | Purpose |
| - | - | - |
| `POST` | `/admin/issue-vc` | Issue directly to a user by email, without a user session |
| `POST` | `/admin/revoke` | Revoke a credential by its `revocationNonce`. See [Revoke credentials](/products/identity/revocation) |
| `GET` | `/admin/issuance-history` | Paginated issuance history (`page`, `limit`, `order`, `holderDid`, `schemaId`, `revocationNonce`) |
| `POST` | `/admin/publish-token-status-list` | Rebuild and publish status list partitions, when the [token status list](/products/identity/revocation#token-status-list) is enabled |
### `POST {ISSUER_ORIGIN}/admin/issue-vc`
Resolves the holder through AIR `initialize-user`, signs the SD-JWT VC, encrypts it to the holder, and uploads it to DStorage. No registered schema class is needed.
```json theme={null}
{
"userId": "member@example.com",
"schemaId": "c21s70g0i54sn0023172Cv",
"vct": "c21s70g0i54sn0023172Cv",
"expiration": "2030-01-01T00:00:00Z",
"credentialSubject": { "tier": "Gold" },
"disclosureFrame": { "_sd": ["tier"] }
}
```
| Field | Type | Required | Description |
| - | - | - | - |
| `userId` | string | Yes | The email AIR knows the user by |
| `schemaId` | string | Yes | Schema the credential is built on |
| `vct` | string | Yes | Credential type |
| `expiration` | ISO 8601 string | Yes | Must be in the future |
| `credentialSubject` | object | Yes | Claims. Must not contain `cnf`, `exp`, `iat`, `id`, `iss`, `nonce`, `status`, `sub`, `vct`, or `vct#integrity` |
| `disclosureFrame` | object | No | Selectively disclosable claims. Defaults to every top-level claim |
Credentials issued this way have no `cnf` holder key.
## 5) Public endpoints (called by verifiers)
These routes do not require an API key. `ISSUER_ORIGIN` must be the stable public HTTPS origin of the issuer service, without a trailing slash. For SD-JWT issuers it also defines the issuer DID (`did:web:`), so changing it changes your issuer identity.
| Endpoint | Purpose | Response |
| - | - | - |
| `GET /.well-known/did.json` | Issuer `did:web` document with the keys that verify your credentials | DID document |
| `GET /.well-known/jwt-vc-issuer` | SD-JWT VC issuer metadata | `{ issuer, jwks }` |
| `GET /.well-known/air-partner-info` | Partner info referenced from the DID document | `{ partnerId }` |
| `GET /revocation-status/:nonce` | Check whether a credential has been revoked | `{ "isRevoked": boolean }` |
| `GET /statuslist/:partition` | Signed token status list partition, when enabled | `application/statuslist+jwt` |
The Iden3 issuer service also serves `GET /credential-status/:nonce`, which returns the non-revocation Merkle proof and issuer tree state. See [Iden3 credentials](/products/identity/iden3-credentials).
Example revocation status response:
```json theme={null}
{
"isRevoked": false
}
```
## Error reference
| HTTP status | Likely cause | Suggested fix |
| - | - | - |
| 400 | Missing/invalid request fields | Verify `email`, `holderDid`, `schemaId`, and all encrypted payload fields |
| 401 | Invalid/expired JWT, JWKS mismatch | Validate signature, `kid`, `typ: "JWT"`, token expiry |
| 403 | Feature not enabled or schema not allowed | Enable feature in dashboard, verify schema ownership |
| 404 | Unknown issuer/program/user | Recheck IDs and recipient email in dashboard |
| 409 | Consent rejected / conflict | Check user consent and duplicate handling |
| 500 | Server-side failure | Retry with backoff and inspect logs |
## Troubleshooting
* If `dstorage/vcs` succeeds, record the `storagePath`; the credential is available for the holder to present to any AIR verifier.
* If AIR never calls `/available-vc` or `/issue-vc`, confirm that the Issuer DID, Partner ID, API key, and both public endpoint URLs have been registered and activated by AIR.
* For authentication errors, verify the JWT contains `partnerId` and `scope: "issue"` (not the recipient email), and that the header includes `typ: "JWT"`.
* Ensure your JWKS endpoint is public and `kid` maps to the signing key.
* The SDK’s `credentialId` is the Dashboard issuance program ID. The issuer backend receives `schemaId`. Passing one in place of the other can result in “schema not found” or routing failures.
# Migration & Compatibility
Source: https://docs.air3.com/api-reference/migration-compatibility
Browser, Node.js, and framework compatibility requirements for AIR Kit and AIR Kit Connector packages — including passkey support and migration notes.
## Browser Compatibility
### Minimum Requirements
Due to passkey authentication requirements, AIR Kit requires:
**Minimum Supported Versions:**
* **iOS**: 16+
* **Android**: 10+
* **Safari**: 16+
* **Chromium**: 126+
* **Firefox**: 97+
**Required Features:**
* Passkey support (WebAuthn)
* Iframe support
* PostMessage API
* ES6+ JavaScript support
## Package Compatibility
### AIR Kit (`@mocanetwork/airkit`)
**Distribution Formats:**
* **ESM**: `dist/airkit.esm.js` (ES6 modules)
* **CommonJS**: `dist/airkit.cjs.js` (ES5 format)
* **UMD**: `dist/airkit.umd.min.js` (browser use)
**Requirements:**
* Node.js >=18.x
* npm >=9.x
### AIR Kit Connector (`@mocanetwork/airkit-connector`)
**Peer Dependencies:**
* `@wagmi/core` ^2.x
* `viem` ^2.x
**Requirements:**
* Node.js >=18.x
* npm >=9.x
## Framework Support
**Framework-Agnostic Design:**
Since AIR Kit spawns iFrames to run the wallet and UI, it should work with most JavaScript frameworks. The SDK communicates and provides an EIP-1193 provider interface, making it compatible with:
* React
* Next.js
* Vue
* Angular
* Svelte
* SolidJS
* And other modern JavaScript frameworks
# Release Notes
Source: https://docs.air3.com/api-reference/release-notes
AIR Kit SDK release notes — version history, new features, breaking changes, deprecations, and migration notes for partners upgrading their integration.
## **Version 1.12.1 (Sep 16, 2026)**
### ✨ **New Features**
* **Japanese, Vietnamese, and Turkish UI localization** — the SDK UI now ships `ja`, `vi`, and `tr` catalogs alongside existing locales. They are picked up automatically via `sessionConfig.locale` or browser language detection; no partner action needed unless you restrict `supportedLocales`
* **Scoped agent keys** — `registerAgentKey` now accepts an OAuth `scope`, so you can bind credential-only agent keys without a signing key. `AgentPublicKey` now returns optional `scope` and `nickname` fields, and `getAgentKeys` returns the same enriched shape
* **Optional `nonce` for SD-JWT verification** — `nonce` is no longer required for `SD_JWT_VC` programs. Omit it to skip the key-binding JWT entirely; when supplied, a KB-JWT is attached only if the issuer JWT carries `cnf.jwk` — see [Verifying credentials](/products/identity/verify)
* **Nested selective disclosure for SD-JWT** — `fieldsToDisclose` now accepts dotted claim paths (e.g. `address.city`) to disclose nested claims on `SD_JWT_VC` programs
* **Issuer revocation checks** — verification programs can enable issuer-revocation checking; revoked credentials are excluded from verification and a `Revoked` result is returned when every matching credential is revoked
### ➕ **Improvements**
* More reliable SDK initialization and popup flows — the iframe/popup handshake was unified, eliminating missed-announcement failures during init
* Faster first load — improved build chunking, deferred loading, and preloading of the credential flow
* Wallet actions now validate the session first — fixes users appearing logged in after their tokens were deleted (e.g. clicking "Deploy Smart Account" with an expired session)
* Recovery and PIN signature payloads now include a timestamp for replay protection
* Credential holder signatures now use ERC-7739 typed-data signing — relevant if you verify holder signatures server-side
* New `CHANNEL_CLOSED` error name, thrown when the user closes a popup or the messaging channel closes mid-flow — handle it alongside `USER_CANCELLED` if you switch on error names
### 📋 **Migration Guide**
**SD-JWT nonce is now optional:**
```js theme={null}
// Before (1.11.x) — nonce required for SD_JWT_VC programs
await airService.verifyCredential({ programId, fieldsToDisclose, nonce });
// After (1.12.x) — omit nonce to skip the KB-JWT
await airService.verifyCredential({ programId, fieldsToDisclose });
// Nested claims via dotted paths
await airService.verifyCredential({
programId,
fieldsToDisclose: ["address.city", "age"],
});
```
When nonce is omitted, the compliant presentation's `verifiableCredential[0]` is `~~` with no KB-JWT segment — update verifier-side parsing if you assumed three segments.
## **Version 1.11.1 (Aug 13, 2026)**
`1.11.1` is a patch over `1.11.0` with no SDK behavior changes between them. Everything below landed in the 1.11 line — upgrade directly from `1.10.0` to `1.11.1`.
### ✨ **New Features**
* **W3C Verifiable Presentation on every compliant verification** — `verifyCredential` now returns the disclosed claims and any proof material on `verifiablePresentation` — see [Verifying credentials](/products/identity/verify#response)
* **SD-JWT VC verification** — verification programs can use the `SD_JWT_VC` proof type. These programs require both `fieldsToDisclose` and the new `nonce` parameter
* **`IDEN3` proof type** available for verification programs alongside `BJJ_SIG_2021`
* Verification programs with multiple ZK queries return one proof per query on `verifiablePresentation.proof`
### ➕ **Improvements**
* Google login now works in mobile in-app browsers and Android Custom Tabs via a server-side redirect fallback
* The SDK removes its transient `airkit_handoff` and `airkit_login_error` parameters from your page URL after login, so a page refresh cannot replay a consumed one-time code
* The partner linking confirmation screen is removed from login, reducing steps for new users
* Improved session cleanup when a user requests account deletion
* Package license changed from MIT to Apache 2.0
### ⚠️ **Breaking Changes**
* **`AirUserDetails.partnerUserId` is removed.** AIR no longer stores partner-local user IDs — key off the AIR user UUID (`user.id`) and keep the join in your own system.
* **`verifyCredential` no longer accepts `offchain`.** Off-chain versus on-chain mode is configured on the verification program in the Developer Dashboard.
* **`zkProofs`, `transactionHash`, and `disclosedData` are deprecated and no longer populated** on compliant verification results. Read `verifiablePresentation` instead.
### 📋 **Migration Guide**
**Reading verification results:**
```js theme={null}
// Before (1.10.0 and earlier)
if (result.status === "Compliant") {
console.log(result.transactionHash);
console.log(result.zkProofs);
console.log(result.disclosedData.credentialSubject);
}
// After (1.11.x)
if (result.status === "Compliant") {
const vp = result.verifiablePresentation;
// Disclosed claims live on the embedded credential
const [credential] = vp?.verifiableCredential ?? [];
if (credential && typeof credential !== "string") {
console.log(credential.credentialSubject);
}
// proof is present only when the program requires a ZKP
const proof = Array.isArray(vp?.proof) ? vp.proof[0] : vp?.proof;
console.log(proof?.transactionHash); // on-chain programs only
console.log(proof?.proofValue);
}
```
When a verification program is not configured to require a zero-knowledge proof, the presentation has **no `proof` block** — it is unsigned and carries no cryptographic guarantee. In that case the verification session status is the only trust anchor. Ask the verifier owner to enable the ZKP requirement if you need an attested result.
For `SD_JWT_VC` programs, `verifiableCredential[0]` is a compact string rather than an object, and both `fieldsToDisclose` and `nonce` are required on the call. See [Verifying credentials](/products/identity/verify).
**Replacing `partnerUserId`:**
```js theme={null}
const { user } = await airService.getUserInfo();
// Persist the AIR user UUID against your own user record
await db.users.update(localUserId, { airUserId: user.id });
```
***
## **Version 1.10.0 (June 11, 2026)**
### ✨ **New Features**
* Program-driven verification modes — proof type, ZKP requirement, and off-chain / on-chain mode configured centrally — see [Issuing credentials](/products/identity/issuing-credentials) and [Verifying credentials](/products/identity/verify)
* Selective and full data disclosure on verify (`fieldsToDisclose`) — see [Selective Disclosure](/products/identity/selective-disclosure)
* In-wallet Transfer and Receive UIs: `showTransferUI`, `showReceiveUI`
* Agent key management: `getAgentKeys`, `registerAgentKey`, `removeAgentKey`
* Streamlined PIN setup / confirmation / recovery and account recovery entry points (`startRecovery`)
* Login / `getUserInfo` payloads: optional `user.wallet` and `abstractAccountAddresses`
### ➕ **Improvements**
* More reliable recovery OTP and Forgot PIN flows (including Safari)
### ⚠️ **Breaking Changes**
* Sandbox no longer accepts `credentialNetwork` — it is **Testnet-only**. Devnet opt-in is removed.
* `CredentialNetwork` is now `"testnet" | "mainnet"` (no longer `"devnet" | "testnet"`)
* Private Mainnet requires `buildEnv: BUILD_ENV.PRODUCTION` with `credentialNetwork: "mainnet"`
* Credentials and programs issued on Devnet are not available on Testnet or Mainnet — re-issue on the target network
### 📋 **Migration Guide**
**Sandbox (Testnet only):**
```js theme={null}
import { AirService, BUILD_ENV } from "@mocanetwork/airkit";
const airService = new AirService({
partnerId: YOUR_PARTNER_ID
});
await airService.init({
buildEnv: BUILD_ENV.SANDBOX,
enableLogging: true
});
```
**Private Mainnet (production):**
```js theme={null}
await airService.init({
buildEnv: BUILD_ENV.PRODUCTION,
credentialNetwork: "mainnet",
enableLogging: true
});
```
If you previously passed `credentialNetwork: "devnet"` (or any Sandbox `credentialNetwork`), remove it. Re-issue test credentials on Testnet or Mainnet as needed.
Moca Chain Mainnet is currently private. For production launches, use the production AIR pages and contact Moca for mainnet access and \$MOCA gas tokens. See [Production mainnet access](/get-started/environments/about#production-mainnet-access).
***
## **Flutter SDK 1.6.0**
### ✨ **New Features**
* Credential issuance and verification (`issueCredential`, `verifyCredential`, `preloadCredential`)
* Google and email passwordless login
### ➕ **Improvements**
* Credential stability improvements
Install with `version: ^1.6.0` via OnePub — see [Flutter installation](/get-started/sdks/flutter/installation).
***
## **Version 1.8.0 (Feb 9, 2026)**
### ✨ **New Features**
* Credential payment support
* Issue credentials on behalf of users
* Compliance encryption keys support for credential issuance and verification
* Display currency configuration (USD, EUR, CNY, KRW, TRY)
* New `air_accounts` RPC method to check account status across all supported chains
* `wallet_sendCalls` RPC method for batch transaction execution
* Temporary Devnet opt-in on Sandbox via `credentialNetwork: "devnet"` (removed in 1.10.0)
### ➕ **Improvements**
* Credential issuance and verification UI improvements and bug fixes
* Swap and OnRamp UI improvements and bug fixes
* WalletConnect stability improvements
### ⚠️ **Breaking Changes**
* Sandbox environment now defaults to **Moca Chain Testnet** instead of Devnet
* Credentials issued on Devnet will not be available on Testnet
* Devnet support remained available via `credentialNetwork: "devnet"` until **1.10.0**, when it was removed
### 📋 **Migration Guide**
After upgrading to `1.8.0`, Sandbox connects to Testnet by default. If your integration was not ready, you could temporarily stay on Devnet:
```js theme={null}
import { AirService, BUILD_ENV } from "@mocanetwork/airkit";
const airService = new AirService({
partnerId: YOUR_PARTNER_ID
});
await airService.init({
buildEnv: BUILD_ENV.SANDBOX,
credentialNetwork: "devnet", // Temporary opt-in; removed in 1.10.0
enableLogging: true
});
```
We recommend migrating to Testnet as soon as possible. Re-issue any test credentials on Testnet. **As of 1.10.0, Devnet opt-in is removed** — see the 1.10.0 migration guide above.
***
## **Version 1.7.0 (Nov 18, 2025)**
### ✨ **New Features**
* Cross-chain credential verification support
* Transaction payment token support
* Custom RPC URL configuration
* Automatic in-wallet login flow
### ➕ **Improvements**
* Credential UI updates
* Passkey UI updates
* Significant Air Services performance improvements
* Improved Credential error handling
* Swap UI improvements and bug fixes
## **Version 1.6.0 (Sept 16, 2025)**
### ✨ **New Features**
* Initial Air Credential support
* Flutter SDK released (version 1.5.0)
* Swap beta support
* OnRamp beta support
* Account deletion (via air3.com/recovery)
* Additional chains added:
* Ethereum Testnet & Mainnet
* Moca Testnet
* Gnosis Testnet & Mainnet
* Edu Testnet & Mainnet
### ➕ **Improvements**
* Air theme update
* Improved wallet login and mobile support
* Improved i18n support
* Improved error handling
* Overall Air Services stability
### ⚠️ **Notes**
* Air theme uses light theme only now
## **Version 1.5.0 (July 30, 2025)**
### ✨ **New Features**
* Flutter SDK beta released (version `1.5.0-beta.x`)
* Account email update and recovery (via air3.com/recovery)
* Additional chains added:
* BNB Smart Chain
* Kaia and Kairos
### ➕ **Improvements**
* Improved iOS Safari PWA support
### ⚠️ **Notes**
* `eth_accounts` will only return AA from now on
## **Version 1.4.0 (June 26, 2025)**
### ✨ **New Features**
* Moca Devnet chain added
### ➕ **Improvements**
* New SANDBOX environment for development and testing
## **Version 1.3.0 (June 5, 2025)**
### ➕ **Improvements**
* Captcha required during OTP for enhanced security
* Signing screen shows actual message
* Improved security by moving signing and transaction screens into separate window
* Improved Passkey provider suggestions
* Wagmi connect method now exposes authToken parameter
* Access token contains `sourcePartnerId` in case of cross partner rehydration
* Many small stabilization improvements
### ⚠️ **Notes**
* Signing and transaction screens are now shown in a separate browser window instead of iframe modals
## **Version 1.2.0 (May 7, 2025)**
### ✨ **New Features**
* User login token refresh support
### ➕ **Improvements**
* Improved MFA setup and verification flow
* More user-friendly transaction screen
* Bottom sheet instead of modal on mobile screens
* Animations for smoother transitions
* Support of small mobile screens
* Improved session key support
* Improved provider error handling
## **Version 1.1.0 (March 27, 2025)**
### ✨ **New Features**
* Persistent user sessions across AIR Kit dApps
* Protect user accounts with MFA via Passkey
* Login via EoA wallet
* Soneium chain support (Testnet / Mainnet)
### ➕ **Improvements**
* Simplified transaction screens
* Improved modal UI
* Wallet can be preloaded in the background
* Provider can be retrieved and subscribed to before wallet initialization
* Paymaster policies can be defined per chain
* Various minor bug fixes and optimizations
### ⚠️ **Notes**
* The AA will only be returned after the user has MFA set up
* MFA setup will automatically trigger on any wallet action
## **Version 1.0.0 (Feb 27, 2025)**
### ✨ **New Features**
* Smart accounts (AA) can be checked for deployment
* Smart accounts (AA) can be deployed without minting an Air Id
* Experimental session key support
### ➕ **Improvements**
* Full wallet services support
* Improved token refresh mechanism
* Login rehydration across partners
### ⚠️ **Notes**
* The `getUserInfo()` and `getPartnerUserInfo()` methods have been merged into `getUserInfo()`
## **Version 0.6.0 (Feb 17, 2025)**
### ✨ **New Features**
* Beta version of wallet services
### ➕ **Improvements**
* Small UI updates
* More helpful error messages
### ⚠️ **Notes**
* The wallet initialized event also returns the Smart Account (AA) address from now on
## **Version 0.5.0 (Feb 11, 2025)**
The first official release of AIR Kit, rebranded from Realm SDK, focuses on drastic performance and UX improvements and sets the foundation for upcoming features.
### ✨ **New Features**
* Customizable language support, including localized emails
* Toggleable email input field
* Support for custom authentication (Bring Your Own Auth)
* Optional partner user linking flow
* Introduction of a Global User ID to unify multiple Air IDs
### ➕ **Improvements**
* Simplified design tokens for UI customization
* Significantly faster initialization time and login experience
* Keep login session alive by automatic token refresh
* Backward compatibility down to ES2020
* Various minor bug fixes and optimizations
### ⚠️ **Notes**
* The MPC Signer address is no longer being returned
* External wallet login (e.g. MetaMask) is not yet supported
* Wallet-related services, including EIP1193 provider support, are unavailable in this version
* Smart Account (AA) addresses created in this version are different from previous versions
# Web SDK reference
Source: https://docs.air3.com/api-reference/sdk-reference
AIR Kit Web and Flutter SDK reference — AirService class, type definitions, and methods for AIR Account login, smart accounts, and credentials.
Your app works with AIR Kit through one class, `AirService`. It handles login, smart accounts, and issuing and verifying credentials.
This page lists every method and type in the Web and Flutter SDKs. To issue credentials from your server without the user presence, see the [REST API Reference](/api-reference/introduction).
## Platforms
npm package `@mocanetwork/airkit`. Browser-first; works in any JS framework.
OnePub package `airkit`. Native iOS and Android via platform channels.
## Install
| Platform | Package | Guide |
| - | - | - |
| Web | `@mocanetwork/airkit` | [Installation](/get-started/sdks/web) |
| Flutter | `airkit` (OnePub) | [Flutter installation](/get-started/sdks/flutter/installation) |
See [Initialization](/get-started/sdks/web) for `init` options and environment selection.
## Authentication
* **AIR Account login** — the SDK runs the user flow and returns an access token. See [User authentication](/get-started/authentication/login).
* **Partner authentication (JWT)** — your backend signs a Partner JWT with your private key for server-to-server calls. See [Partner authentication](/get-started/authentication/sdk-auth).
Pick an environment per `BUILD_ENV` (`SANDBOX` or `PRODUCTION`). Production serves Moca Chain mainnet — see [Production mainnet access](/get-started/environments/about#production-mainnet-access).
### AirService
```ts theme={null}
class AirService {
constructor({ partnerId: string; });
get buildEnv(): BUILD_ENV_TYPE;
get isInitialized(): boolean;
get isLoggedIn(): boolean;
get isWalletInitialized(): boolean;
get provider(): EIP1193Provider;
init({
buildEnv: BUILD_ENV_TYPE;
// Only when buildEnv is PRODUCTION:
credentialNetwork?: "testnet" | "mainnet";
enableLogging: boolean;
skipRehydration: boolean;
preloadWallet: boolean;
preloadCredential: boolean;
sessionConfig?: Partial;
}): Promise;
login(options?: { authToken?: string }): Promise;
isSmartAccountDeployed(): Promise;
deploySmartAccount(): Promise<{ txHash: string }>;
getProvider(): EIP1193Provider;
preloadWallet(): Promise;
preloadCredential(): Promise;
setupOrUpdateMfa(): Promise;
getUserInfo(): Promise;
goToPartner(partnerUrl: string): Promise<{ urlWithToken: string }>;
getAccessToken(): Promise<{ token: string }>;
updateSessionConfig(config: Partial): Promise;
showSwapUI(options?: ShowSwapUIOptions): Promise<{
txHash: `0x${string}`;
/** `amount` is in base units — divide by 10 ** decimals to display. */
from: Token & { amount: string };
to: Token & { amount: string };
}>;
showOnRampUI(options: {
displayCurrencyCode: string;
targetCurrencyCode?: string;
}): Promise;
showTransferUI(options?: ShowTransferUIOptions): Promise;
showReceiveUI(): Promise;
getAgentKeys(): Promise;
registerAgentKey(publicKey: string): Promise;
removeAgentKey(id: string): Promise;
claimAirId(options?: ClaimAirIdOptions): Promise;
startRecovery(payload?: StartRecoveryPayload): Promise;
issueCredential({
authToken: string;
issuerDid: string;
credentialId: string;
credentialSubject: Record;
curve?: "secp256r1" | "secp256k1";
waitForOnchainCompletion?: boolean;
}): Promise<{ cakPublicKey?: string }>;
verifyCredential({
authToken: string;
programId: string;
redirectUrl?: string;
fieldsToDisclose?: "*" | string[];
nonce?: string;
}): Promise;
logout(): Promise;
cleanUp(): Promise;
on(listener: AirEventListener): void;
off(listener: AirEventListener): void;
}
```
`claimAirId` and the `show*UI` methods are experimental and may change in future releases.
`fieldsToDisclose` is required for `SD_JWT_VC` verification programs; `"*"` discloses every field, and dotted paths such as `address.city` select nested fields. See [Selective Disclosure](/products/identity/selective-disclosure). `nonce` is optional: pass a fresh value from your backend so that credentials with a holder key return a key-binding JWT. See [Verify SD-JWT on your backend](/products/identity/verify-sd-jwt).
### Types
```ts theme={null}
export type AirIdDetails = {
id: string;
name?: string;
node: string;
status: "minting" | "minted";
chainId: number;
imageUrl?: string;
};
export type AbstractAccountAddressEntry = {
readonly address: string;
readonly chainIds: readonly string[];
};
export type AirUserDetails = {
partnerId?: string;
airId?: AirIdDetails;
user: {
id: string;
abstractAccountAddress?: string;
email?: string;
wallet?: string;
isMFASetup: boolean;
};
};
export type AirInitializationResult = {
rehydrated: boolean;
};
export type AirLoginResult = {
isLoggedIn: boolean;
id: string;
abstractAccountAddress?: string;
abstractAccountAddresses?: readonly AbstractAccountAddressEntry[];
token: string;
isMFASetup: boolean;
};
export type AirWalletInitializedResult = {
abstractAccountAddress: string | null;
isMFASetup: boolean;
};
export type AgentPublicKey = {
id: string;
publicKey: string;
createdAt: string;
};
export type TokenSymbol = {
symbol: string;
chainId: number;
};
export type Token = TokenSymbol & {
decimals: number;
address: `0x${string}`;
};
export type ShowSwapUIOptions = {
/** Preferred "from" token. Used when the user holds a balance and the token is supported. */
initialFromToken?: TokenSymbol;
/** "From" token to show when the user holds no assets. */
fallbackFromToken?: TokenSymbol;
/** Preferred "to" token. */
initialToToken?: TokenSymbol;
/** Slippage tolerance, in percent. Omitted or out-of-range values use automatic slippage. */
defaultSlippage?: number;
};
export type ShowTransferUIOptions = {
tokenSymbol?: string;
chainId?: number;
recipientAddress?: string;
/** Display decimal string, e.g. "1.5" — not base units. */
amount?: string;
};
export type ShowTransferUIResult = {
txHash: `0x${string}`;
symbol: string;
chainId: number;
decimals: number;
address: `0x${string}`;
recipientAddress: string;
/** Display decimal string, as entered by the user. */
amount: string;
};
export type CredentialProof = {
type: string;
coreClaim?: string;
issuerData?: Record;
signature?: string;
/** Compact SD-JWT presentation string (SD-JWT VC proof type). */
jwt?: string;
mtp?: Record;
};
/** @deprecated No longer populated. Read verifiablePresentation instead. */
export type DisclosedCredentialData = {
credentialSubject: Record;
credentialType: string;
issuerDid: string;
issuanceDate: string;
expirationDate: string;
proof?: CredentialProof[];
};
/** A credential embedded in a Verifiable Presentation. */
export type PresentationVerifiableCredential = {
"@context": string[];
type: string[];
issuer: string;
issuanceDate: string;
expirationDate?: string;
/** Disclosed subject fields, respecting selective disclosure. */
credentialSubject: Record;
proof?: CredentialProof[];
};
/**
* Presentation-level proof. Present only when the verification program
* requires a zero-knowledge proof.
*/
export type VerifiablePresentationProof = {
type: string;
proofPurpose: "authentication";
circuitId: string;
/** Present when the program has multiple ZK queries. */
zkQueryId?: string;
proofValue: {
pi_a: string[];
pi_b: string[][];
pi_c: string[];
protocol?: string;
curve?: string;
};
publicSignals: string[];
/** On-chain submission hash, for on-chain programs only. */
transactionHash?: string;
};
export type AirVerifiablePresentation = {
"@context": string[];
type: string[];
/** The holder DID. */
holder: string;
/**
* Embedded credentials. For SD_JWT_VC programs, an
* EnvelopedVerifiableCredential whose `id` is a `data:` URL carrying the
* compact SD-JWT. W3C VC objects for BJJ_SIG_2021 programs.
* Type-check each entry before reading it.
*/
verifiableCredential: Array;
/** Omitted when the program does not require a ZKP. */
proof?: VerifiablePresentationProof | VerifiablePresentationProof[];
};
export type CredentialVerificationResult =
| {
status:
| "Non-Compliant"
| "Pending"
| "Revoking"
| "Revoked"
| "Expired"
| "NotFound";
}
| {
status: "Compliant";
verifiablePresentation?: AirVerifiablePresentation;
cakPrivateKey?: string;
/** @deprecated Not populated. Use verifiablePresentation.proof. */
zkProofs?: Record;
/** @deprecated Not populated. Use verifiablePresentation.proof.transactionHash. */
transactionHash?: string;
/** @deprecated Not populated. Use verifiablePresentation.verifiableCredential. */
disclosedData?: DisclosedCredentialData;
};
export type ClaimAirIdResult = {
airId: AirIdDetails;
};
export type StartRecoveryOptions = {
type?:
/** Delete the current user's account. Performs its own email step-up. */
| "delete"
/** Create or rotate the current user's recovery key. Requires a session. */
| "recovery_key_setup"
/** Recover an account when the user lost email access, then set a new PIN. */
| "email_recovery"
/** Recover an account when the user forgot their PIN but still controls their email. */
| "pin_recovery"
/** Update the current user's email address. Requires a session. */
| "update_email"
/** Update the current user's PIN. Requires a session. */
| "update_pin";
};
export type StartRecoveryPayload = {
options?: StartRecoveryOptions;
};
export type AirEventOnInitialized = {
event: "initialized";
result: AirInitializationResult;
};
export type AirEventOnLoggedIn = {
event: "logged_in";
result: AirLoginResult;
};
export type AirEventOnAirIdMintingStarted = {
event: "air_id_minting_started";
};
export type AirEventOnAirIdMintingFailed = {
event: "air_id_minting_failed";
errorMessage?: string;
};
export type AirEventOnLoggedOut = {
event: "logged_out";
};
export type AirEventOnWalletInitialized = {
event: "wallet_initialized";
result: AirWalletInitializedResult;
};
export type AirEventData =
| AirEventOnInitialized
| AirEventOnLoggedIn
| AirEventOnWalletInitialized
| AirEventOnAirIdMintingStarted
| AirEventOnAirIdMintingFailed
| AirEventOnLoggedOut;
export type AirEventListener = (data: AirEventData) => void;
export type CredentialNetwork = "testnet" | "mainnet";
export type SupportedCurrencyCode = "EUR" | "USD" | "CNY" | "KRW" | "TRY";
export type AirSessionConfig = {
locale: string;
currency: SupportedCurrencyCode;
};
export type ClaimAirIdOptions =
| {
token?: string;
background?: false;
offchain?: boolean;
}
| {
token: string;
background: true;
offchain?: boolean;
};
```
### AirService
```dart theme={null}
class AirService {
Stream get airEvents;
void on(AirEventListener listener);
void off(AirEventListener listener);
void clearEventListeners();
bool get isInitialized;
Future initialize({
required String partnerId,
Environment env = Environment.production,
required GlobalKey navigatorKey,
bool enableLogging = false,
SessionConfig? sessionConfig,
});
Future login({
String? authToken,
});
Future rehydrate();
Future getUserInfo();
Future updateSessionConfig({
String? locale,
String? currency,
});
Future preloadWallet();
Future preloadCredential();
Future issueCredential({
required String authToken,
required String issuerDid,
required String credentialId,
required Map credentialSubject,
String? curve,
});
Future verifyCredential({
required String authToken,
required String programId,
String? redirectUrl,
});
Future getAbstractAccountAddress();
Future> getAccounts();
Future setupOrUpdateMfa();
Future getBalance(String address);
Future call(
String address,
String function,
List params,
String abi,
);
Future signMessage(String message);
Future sendTransaction(Transaction transaction);
Future sendEthereumRpcRequest(
EthereumRpcRequest request
);
Future deploySmartAccount();
Future isSmartAccountDeployed();
Future showSwapUi();
Future showOnRampUi({
required String displayCurrencyCode,
String? targetCurrencyCode,
});
Future logout();
void cleanup();
}
```
### Models
```dart theme={null}
enum Environment { staging, uat, sandbox, production }
class SessionConfig {
final String? locale;
final String? currency;
}
class LoginResult {
final bool isLoggedIn;
final String? id;
final String? abstractAccountAddress;
final String? token;
final bool? isMFASetup;
}
enum AirIdStatus {
minting,
minted,
}
class AirId {
final String id;
final String name;
final String node;
final AirIdStatus status;
final int? chainId;
final String? imageUrl;
}
class UserInfo {
final AirId? airId;
final String? partnerId;
final User? user;
}
class User {
final String id;
final String? abstractAccountAddress;
final String? email;
final bool isMFASetup;
}
class CredentialIssuanceResult {
final String? cakPublicKey;
}
class EthereumRpcRequest {
final String method;
final List params;
final String? requestId;
}
class EthereumRpcSuccessResponse {
final dynamic response;
}
class AirEvent { }
class AirInitializedEvent extends AirEvent {}
class AirLoggedInEvent extends AirEvent {
final LoginResult payload;
}
class AirLoggedOutEvent extends AirEvent { }
class AirWalletInitializedEvent extends AirEvent { }
typedef AirEventListener = void Function(AirEvent event);
enum ExceptionType {
client,
sdk,
server,
unknown,
}
class AirKitException implements Exception {
final String message;
final ExceptionType type;
}
```
# Custom Auth
Source: https://docs.air3.com/get-started/authentication/custom-auth
Let users already signed in to your app use AIR Kit without logging in again, by passing their email in a Partner JWT at login.
With Custom Auth, users who are already signed in to your app skip the AIR Kit login dialog. Your backend signs a [Partner JWT](/get-started/authentication/sdk-auth) that includes the user's email, and your app passes it to `login({ authToken })`. AIR Kit then creates or loads the AIR Account for that email.
The first time an email is used, AIR verifies it with a one-time password sent to that address, because the email identifies the user across AIR. Later logins with the same email skip this step.
Custom Auth needs a registered [JWKS endpoint](/get-started/authentication/jwks-endpoint), like any Partner JWT.
## JWT payload
```json theme={null}
{
"partnerId": "your-partner-id",
"email": "user@example.com",
"iat": 1728970084,
"exp": 1728973684
}
```
| Claim | Required | Description |
| - | - | - |
| `partnerId` | Yes | Your Partner ID |
| `email` | Yes | The user's verified email, used as their AIR Account identifier |
| `exp` | Yes | Expiration time; 5 minutes is recommended |
| `iat` | Recommended | Issued-at time |
Sign the token on your server with a `kid` header that matches your JWKS.
For a full implementation with backend and frontend code, see the [Bring your own auth](/get-started/recipes/bring-your-own-auth) recipe.
# JWKS endpoint
Source: https://docs.air3.com/get-started/authentication/jwks-endpoint
Set up the public HTTPS JWKS endpoint AIR uses to validate your Partner JWTs, register its URL in the Dashboard, match the kid, and test locally.
A JWKS (JSON Web Key Set) endpoint publishes the public key that matches the private key you sign [Partner JWTs](/get-started/authentication/sdk-auth) with. AIR fetches it to check every Partner JWT, so credential calls fail until it is live and registered.
| Operation | JWKS required? |
| - | - |
| `issueCredential` and `verifyCredential` (SDK) | Yes |
| [Direct issuance](/products/identity/issuing-credentials#direct-issuance) (AIR API) | Yes |
| `login` with a Partner JWT (Flutter, [Custom Auth](/get-started/authentication/custom-auth), or optionally on Web) | Yes |
| `login` without a Partner JWT (Web) | No |
## How AIR uses your JWKS
```mermaid theme={null}
sequenceDiagram
participant App as Your backend
participant SDK as AIR Kit SDK
participant AIR as AIR Kit servers
participant JWKS as Your /api/.well-known/jwks
App->>App: Sign Partner JWT with private key
header.kid = partnerId
App->>SDK: authToken (signed JWT)
SDK->>AIR: issueCredential / verifyCredential
with authToken
AIR->>JWKS: GET registered JWKS URL
JWKS-->>AIR: { keys: [ { kid, n, e, ... } ] }
AIR->>AIR: Validate JWT signature + kid match
AIR-->>SDK: Proceed (or 401 if anything fails)
```
If AIR cannot fetch your JWKS, or no key in it matches the JWT's `kid`, the request fails with `401`.
## Step 1: Implement the route
This Next.js route is the one used by every issuer and verifier app in [`air-examples`](https://github.com/MocaNetwork/air-examples). It converts your public key from PEM to JWK with [`jose`](https://github.com/panva/jose) and sets `kid` to your Partner ID.
```ts app/api/.well-known/jwks/route.ts theme={null}
import { NextResponse } from "next/server";
import * as jose from "jose";
export async function GET() {
try {
const publicKeyPEM = `-----BEGIN PUBLIC KEY-----\n${process.env.PARTNER_PUBLIC_KEY!}\n-----END PUBLIC KEY-----`;
const publicKey = await jose.importSPKI(publicKeyPEM, process.env.SIGNING_ALGORITHM!);
const jwk = await jose.exportJWK(publicKey);
return NextResponse.json(
{
keys: [
{
...jwk,
kid: process.env.NEXT_PUBLIC_PARTNER_ID!,
use: "sig",
alg: process.env.SIGNING_ALGORITHM!,
},
],
},
{
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=3600",
},
},
);
} catch {
return NextResponse.json({ error: "Failed to generate JWKS" }, { status: 500 });
}
}
```
Required environment variables (server-side only — never expose `PARTNER_PRIVATE_KEY`):
| Variable | Used for |
| - | - |
| `PARTNER_PUBLIC_KEY` | The base64 body of your RS256 or ES256 public key (between the BEGIN/END markers). |
| `PARTNER_PRIVATE_KEY` | The matching private key used to sign your Partner JWT. **Server-only.** |
| `SIGNING_ALGORITHM` | `RS256` or `ES256`. Must match the key type. |
| `NEXT_PUBLIC_PARTNER_ID` | Your Partner ID. Used as `kid` so the JWKS key matches the JWT header. |
Generate the key pair as described in [SDK authentication](/get-started/authentication/sdk-auth#generating-an-rs256-key-pair).
## Step 2: Register the URL in the Dashboard
1. Open the [Developer Dashboard](https://developers.sandbox.air3.com/dashboard).
2. Go to **Account → General Settings**.
3. Paste the full HTTPS URL into **JWKS URL**, for example `https://app.example.com/api/.well-known/jwks`, and save.
AIR fetches exactly this URL, with no path discovery or fallback. Register the path your app actually serves: `/api/.well-known/jwks` for the route above, or whatever route you defined yourself.
Each Partner ID has one JWKS URL. If your issuer and verifier apps share a Partner ID, register one JWKS and sign all Partner JWTs with a `kid` it contains. If you need separate JWKS per service, request a second Partner ID.
## Step 3: Match the `kid`
The `kid` in each Partner JWT header must appear as a `keys[].kid` in your JWKS. The examples use your Partner ID for both. If you use another convention, such as key-rotation IDs, the rule is the same.
Check the endpoint before you call the SDK:
```bash theme={null}
curl https://app.example.com/api/.well-known/jwks
# → { "keys": [ { "kty": "RSA", "kid": "", "alg": "RS256", ... } ] }
```
## Local development (HTTPS tunnel)
AIR servers cannot reach `localhost`. Expose your dev server over public HTTPS and register the tunnel URL:
```bash theme={null}
ngrok http 3000
# → forwarding https://abc123.ngrok.app -> http://localhost:3000
```
Register `https://abc123.ngrok.app/api/.well-known/jwks` in the dashboard.
```bash theme={null}
cloudflared tunnel --url http://localhost:3000
# → trycloudflare https://random-words.trycloudflare.com
```
Register `https://random-words.trycloudflare.com/api/.well-known/jwks` in the dashboard.
## Troubleshooting
If AIR still rejects your Partner JWT, see [Common issues](/help/common-issues#jwt-and-jwks-errors):
* [JWKS endpoint unreachable](/help/common-issues#jwks-endpoint-unreachable) — AIR cannot fetch your URL.
* [Invalid signature](/help/common-issues#invalid-signature-or-jwt-verification-failed) — JWKS reachable but signature does not validate.
* [kid not found](/help/common-issues#kid-not-found) — JWT header `kid` is not present in `keys[]`.
# User login
Source: https://docs.air3.com/get-started/authentication/login
Log users in with AIR Kit using built-in Google and passwordless email login, and request optional EOA wallet authentication from the AIR team.
## Using AIR Kit as your authentication provider
By default, users can log in with:
* Google
* Passwordless email
Wallet (EOA) login is not enabled by default. The AIR team enables it on request; see [EOA wallet authentication](#eoa-wallet-authentication-optional-add-on). To change which methods appear and in what order, see [Login options](/get-started/customization/login-options).
See the [single sign-on demo](https://air-bd-v2.netlify.app/demo/single-sign-on) for a click-through of the login flow. It is a walkthrough, not a live session.
**Google sign-in redirect on recent Apple OSes.** On **iOS 26.5.2 and above (any browser)** and **macOS 26.5.2 with Safari**, Google sign-in completes through a full-page redirect instead of a popup. Single-page apps — or any app whose state is not fully represented by the URL — must persist and restore their page state across the redirect so the user returns to where they left off after authenticating.
```tsx theme={null}
login(options?: { authToken?: string }): Promise
```
On Flutter, call `login` with a **Partner JWT** after `initialize()`:
```dart theme={null}
Future login({
required String authToken,
OnOtpRequest? onOtpRequest,
});
```
See the [SDK reference](/api-reference/sdk-reference) (Flutter tab).
Calling `login()` opens the AIR Kit login dialog with your enabled login methods. On Web, `authToken` is optional but recommended: pass a [Partner JWT](/get-started/authentication/sdk-auth) to identify your app, or one that includes the user's email for [Custom Auth](/get-started/authentication/custom-auth). On Flutter, `authToken` is required.
After login, see [Sessions & user info](/get-started/authentication/sessions).
## EOA Wallet Authentication (optional add-on)
EOA-based authentication is available as an optional customization on top of the default AIR Account login flow.
This allows your users to authenticate using any EOA wallet (e.g., MetaMask, Trust Wallet) while still benefiting from the AIR Account identity stack. The EOA wallet is used only for authentication and identity verification. It does not replace the underlying MPC-based account abstraction or control mechanisms. In effect, the wallet address serves purely as a login identifier, while account control remains securely managed by AIR.
### Why enable EOA auth?
* Seamlessly supports existing “Connect Wallet” flows
* Zero disruption to current users
* Adds AIR Account–powered authentication without requiring UX changes
* Works alongside Web2 and other login methods
If your application already uses EOA wallet login, you can enable AIR Account authentication on top of it—ensuring a unified identity experience while preserving your existing flow.
### How to request EOA authentication
EOA auth is not enabled by default.
To enable it for your project, please contact our support team with the following details:
### EOA auth enablement request template
**Subject:** Request to Enable EOA Authentication for \
**Email Body:**
```
Hi Team,
I would like to request **EOA authentication** to be enabled for our project.
**Project Name:**
**Partner ID: (Can be obtained from the Dev dashboard: https://developers.sandbox.air3.com/dashboard/general):**
**Environment:** (Production / Sandbox / Both)
**Current Login Method:**
(e.g., Connect Wallet, Web2 login, AIR Account)
**Reason for Enabling EOA Auth:**
(e.g., we support existing EOA wallet login and want to integrate AIR Account without disrupting our users)
Please let us know if you require any additional information or configuration details.
Thank you!
```
# Multi-factor authentication (MFA)
Source: https://docs.air3.com/get-started/authentication/mfa
Set up passkey-based multi-factor authentication for AIR Kit users with setupOrUpdateMfa, and decide when to prompt for it.
## setupOrUpdateMfa()
Sets up or updates multi-factor authentication for the user's account. This method guides the user through the MFA setup process using passkeys.
**Method Signature:**
```ts theme={null}
public async setupOrUpdateMfa(): Promise
```
**What happens during MFA setup:**
1. The method ensures the wallet is initialized
2. Opens the MFA setup flow and guides the user through passkey creation/verification
3. Updates the user's MFA status in the system, including `loginResult` to reflect the new MFA status
**Requirements:**
* User must be logged in
**Important Notes:**
* **MFA is generally required**: If your dApp doesn't trigger `setupOrUpdateMfa()` after login and the user hasn't set up MFA, it will be triggered automatically with any wallet related request, requiring the user to complete setup before the request can be processed
* **Currently only passkey is supported** as the MFA method
* **Only MFA setup is currently implemented, not updates**: It's recommended to check if the user has already set up MFA before calling this method to avoid unnecessary prompts
* **Abstract Account Address availability**: The abstract account address will only be returned in user info and token if MFA is set up
## MFA setup integration
Since MFA is generally required, you can either:
**Option 1: Proactive Setup (Recommended)**
```ts theme={null}
try {
const userInfo = await airService.getUserInfo();
if (!userInfo.user.isMFASetup) {
await airService.setupOrUpdateMfa();
// Abstract account address will now be available
}
} catch (error) {
// Handle error appropriately
}
```
**Option 2: Let AIR Kit Handle Automatically**
Simply proceed with wallet operations - MFA will be prompted automatically if required.
# SDK authentication (Partner JWT)
Source: https://docs.air3.com/get-started/authentication/sdk-auth
Sign a short-lived Partner JWT on your backend to authenticate AIR Kit credential operations and Custom Auth login, with required claims and headers.
A Partner JWT proves that a request comes from your app. Your backend signs it with your private key, your app passes it to AIR Kit, and AIR checks the signature against the public key in your registered [JWKS endpoint](/get-started/authentication/jwks-endpoint).
You need a Partner JWT for:
* `issueCredential` and `verifyCredential` in the SDK
* [Direct issuance](/products/identity/issuing-credentials#direct-issuance) through the AIR API
* `login` on Flutter, and `login` with [Custom Auth](/get-started/authentication/custom-auth) on Web
On Web, standard AIR login works without a Partner JWT, but passing one is recommended. Always sign Partner JWTs on your server, never in the browser.
## JWT requirements
| Part | Requirement |
| - | - |
| Algorithm | `RS256` or `ES256`, matching the key type in your JWKS |
| Header | `kid` set to a key ID in your JWKS, and `typ: "JWT"` |
| `partnerId` claim | Always required: your Partner ID |
| `scope` claim | `"issue"` for `issueCredential` and direct issuance, `"verify"` for `verifyCredential` |
| `email` claim | Only for Custom Auth login. For direct issuance, send the recipient's email to `initialize-user`, not in the JWT. |
| `exp` claim | Required. Keep tokens short-lived; 5 minutes is recommended. |
To learn more about JWTs, see [jwt.io](https://www.jwt.io).
## Next.js Partner JWT endpoint
Install `jose`:
```bash theme={null}
npm i jose
```
Create a server-only endpoint that returns a five-minute token. This example signs an issuance token; use `scope: "verify"` for verification.
```ts app/api/partner-jwt/route.ts theme={null}
import { NextResponse } from "next/server";
import * as jose from "jose";
function wrapPrivateKeyPem(body: string): string {
const trimmed = body.trim();
if (trimmed.includes("BEGIN")) return trimmed;
return `-----BEGIN PRIVATE KEY-----\n${trimmed}\n-----END PRIVATE KEY-----`;
}
export async function POST() {
try {
const privateKeyBody = process.env.PARTNER_PRIVATE_KEY;
const algorithm = process.env.SIGNING_ALGORITHM;
const partnerId = process.env.NEXT_PUBLIC_PARTNER_ID;
if (!privateKeyBody || !algorithm || !partnerId) {
return NextResponse.json(
{ error: "Missing Partner JWT configuration" },
{ status: 500 },
);
}
const privateKey = await jose.importPKCS8(
wrapPrivateKeyPem(privateKeyBody),
algorithm,
);
const now = Math.floor(Date.now() / 1000);
const token = await new jose.SignJWT({
partnerId,
scope: "issue",
})
.setProtectedHeader({
alg: algorithm,
kid: partnerId,
typ: "JWT",
})
.setIssuedAt(now)
.setExpirationTime(now + 5 * 60)
.sign(privateKey);
return NextResponse.json({ token });
} catch {
return NextResponse.json(
{ error: "Failed to sign Partner JWT" },
{ status: 500 },
);
}
}
```
Keep `PARTNER_PRIVATE_KEY` server-only. This example uses the Partner ID as the `kid`, matching the [JWKS route](/get-started/authentication/jwks-endpoint#step-1-implement-the-route).
## Generating an RS256 key pair
Generate a private key and extract its public key with OpenSSL:
```bash theme={null}
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.key
openssl pkey -in private.key -pubout -out public.key
```
Sign Partner JWTs with `private.key` and keep it secret. Publish `public.key` through your [JWKS endpoint](/get-started/authentication/jwks-endpoint).
## Examples
Sign a Partner JWT in other backend languages. Each example signs an issuance token with RS256; change `scope` for verification.
```js theme={null}
const jwt = require("jsonwebtoken");
const fs = require("fs");
const privateKey = fs.readFileSync("path/to/private.key");
const payload = {
partnerId: "your-partner-id",
scope: "issue",
exp: Math.floor(Date.now() / 1000) + 5 * 60 // 5 minutes expiry
};
const token = jwt.sign(payload, privateKey, {
algorithm: "RS256",
header: {
kid: "your-key-id",
typ: "JWT"
}
});
console.log(token);
```
```java theme={null}
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import java.util.HashMap;
import java.util.Map;
Algorithm algorithm = Algorithm.RSA256(null, privateKey); // Use your private key
Map headerClaims = new HashMap<>();
headerClaims.put("kid", "your-key-id");
headerClaims.put("typ", "JWT");
String token = JWT.create()
.withHeader(headerClaims)
.withClaim("partnerId", "your-partner-id")
.withClaim("scope", "issue")
.withExpiresAt(new Date(System.currentTimeMillis() + 5 * 60 * 1000))
.sign(algorithm);
System.out.println(token);
```
```csharp theme={null}
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;
using System.Collections.Generic;
// Load your private key and create signing credentials
var securityKey = new RsaSecurityKey(yourPrivateRsa);
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256);
var claims = new[] {
new Claim("partnerId", "your-partner-id"),
new Claim("scope", "issue"),
};
var header = new JwtHeader(credentials); // sets typ: "JWT"
header["kid"] = "your-key-id";
var token = new JwtSecurityToken(
header,
new JwtPayload(
claims: claims,
expires: DateTime.UtcNow.AddMinutes(5),
notBefore: null,
issuedAt: null,
audience: null,
issuer: null
)
);
var jwt = new JwtSecurityTokenHandler().WriteToken(token);
Console.WriteLine(jwt);
```
```go theme={null}
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
func main() {
privateKey := []byte("your-private-key") // Use PEM for RS256/ES256
claims := jwt.MapClaims{
"partnerId": "your-partner-id",
"scope": "issue",
"exp": time.Now().Add(5 * time.Minute).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = "your-key-id" // golang-jwt sets typ: "JWT" by default
signedToken, err := token.SignedString(privateKey)
if err != nil {
panic(err)
}
fmt.Println(signedToken)
}
```
Replace `your-partner-id`, `your-key-id`, and the key paths with your own values. For ES256, use the matching signing method and an EC key.
# Sessions & user info
Source: https://docs.air3.com/get-started/authentication/sessions
Work with AIR Kit sessions after login — the login result token, automatic session rehydration, logout, and retrieving user information with getUserInfo.
## Login result token
After a successful login, `login()` returns an `AirLoginResult`. Its `token` property is a JWT issued by AIR with these claims:
```json theme={null}
{
sub: string,
abstractAccountAddress: string,
partnerId: string,
sourcePartnerId?: string
}
```
To trust this token on your backend, validate it against AIR's JWKS at [https://static.air3.com/.well-known/jwks.json](https://static.air3.com/.well-known/jwks.json).
## Session management
AIR Kit manages sessions for you. Returning users are logged in automatically for up to 30 days, unless they logged out.
* To require users to log in every time they visit your app, set `skipRehydration` to true when initializing the SDK.
* Call `isLoggedIn` to check the login state of a user.
* To log out, call `logout()`.
* To change locale or currency after initialization, call `updateSessionConfig()`. See [Language support](/get-started/customization/language) and [Other options](/get-started/customization/other-options).
* To keep users logged in after an app restart, call `rehydrate()`. It logs in a returning user who still has a valid session; otherwise, call `login()`.
## User information
### getUserInfo()
Retrieves detailed information about the currently logged-in user.
**Method Signature:**
```ts theme={null}
public async getUserInfo(): Promise
```
**Returns:**
```ts theme={null}
{
partnerId?: string;
airId?: AirIdDetails;
user: {
id: string;
abstractAccountAddress?: string;
email?: string;
wallet?: string;
isMFASetup: boolean;
};
}
```
**Requirements:**
* User must be logged in
**Example:**
```ts theme={null}
try {
const userInfo = await airService.getUserInfo();
} catch (error) {
console.error("Failed to get user info:", error);
// Handle error appropriately
}
```
# Connect your agent
Source: https://docs.air3.com/get-started/build-with-your-agent
Connect your agent to the AIR documentation MCP server, try your first prompt, and choose between Docs MCP, the AIR skill, and static docs while building.
## Connect via MCP
Choose your tool below and add the AIR documentation server:
```text theme={null}
https://docs.air3.com/mcp
```
1. Navigate to the [Connectors](https://claude.ai/settings/connectors) page in the Claude settings.
2. Select **Add custom connector**.
3. Add the Moca Network MCP server:
* Name: `MocaNetwork`
* URL: `https://docs.air3.com/mcp`
4. Select **Add**.
1. When using Claude, select the attachments button (the plus icon).
2. Select the MocaNetwork MCP server.
3. Ask Claude a question about Moca Network or AIR Kit.
See the [Model Context Protocol documentation](https://modelcontextprotocol.io/docs/tutorials/use-remote-mcp-server#connecting-to-a-remote-mcp-server) for more details.
Run the following command:
```bash theme={null}
claude mcp add --transport http MocaNetwork https://docs.air3.com/mcp
```
Verify the connection by running:
```bash theme={null}
claude mcp list
```
See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code/mcp#installing-mcp-servers) for more details.
1. Use Command + Shift + P (Ctrl + Shift + P on Windows) to open the command palette.
2. Search for "Open MCP settings".
3. Select **Add custom MCP**. This opens the `mcp.json` file.
In `mcp.json`, add:
```json theme={null}
{
"mcpServers": {
"MocaNetwork": {
"url": "https://docs.air3.com/mcp"
}
}
}
```
In Cursor's chat, ask "What tools do you have available?" Cursor should show the MocaNetwork MCP server as an available tool.
See the [Cursor documentation](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) for more details.
Create a `.vscode/mcp.json` file in your project root.
In `.vscode/mcp.json`, add:
```json theme={null}
{
"servers": {
"MocaNetwork": {
"type": "http",
"url": "https://docs.air3.com/mcp"
}
}
}
```
See the [VS Code documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more details.
For another MCP-compatible tool, add the URL as a remote HTTP MCP server in its connection settings.
## Try your first prompt
Once connected, ask your agent to look up the docs and help with a specific task:
```text theme={null}
Use the AIR docs MCP server to help me add AIR Kit login to my app.
Check my framework and existing authentication, then explain the setup
and changes before implementing them.
```
You can also ask about credential issuance and verification, wallet transactions, or integration errors. Include your framework and what you have already built.
## Choose your setup
| Option | What it provides | When to use it |
| - | - | - |
| **Docs MCP** | Searchable AIR documentation, SDK guides, and API references. | Start here for current reference material. |
| **AIR skill** | Integration workflows and checks for setup, authentication, and common errors. | Add alongside MCP for guidance while building. |
| **Static docs** | Documentation files you attach or index in your tool. | Use when your tool cannot connect through MCP; refresh the files as docs change. |
Your agent uses these resources to plan, write, and review integration code. Live AIR operations run through your application's configured account, authentication, and programs.
## Add the AIR skill (optional)
For tools that support [Agent Skills](https://agentskills.io/home), run:
```bash theme={null}
npx skills add https://docs.air3.com
```
Select your agent in the CLI to install the skill.
Ask your agent to confirm these prerequisites before generating application code:
* Your partner's public JWKS endpoint is reachable over HTTPS.
* The exact JWKS URL is registered in the Developer Dashboard.
* Your JWT signing key stays server-side, and its `kid` matches a published JWKS key.
Follow [JWKS endpoint setup](/get-started/authentication/jwks-endpoint), then use the [issuance](/products/identity/issuing-credentials) or [verification](/products/identity/verify) guide for your flow.
## Static docs files
If your AI tool does not support MCP yet, you can use one of Moca Network's static documentation files instead.
Static files are snapshots and may not include the latest updates. Use MCP when possible for always-current docs.
## Which file should I use?
* **`llms.txt`** — A curated overview of Moca Network. Use it to route an agent to the right docs, understand product structure, and choose the right integration path.
* **`llms-full.txt`** — A full static snapshot of the documentation. Use it when your tool needs more exhaustive reference material in one file.
## Setup with Cursor
Go to **Settings** → **Features** → **Docs**.
Click **Add new doc** and paste: `https://docs.air3.com/llms.txt`
If your agent needs deeper static reference coverage, add: `https://docs.air3.com/llms-full.txt`
Use **@docs** → **Moca** in your AI chat to reference the documentation.
## Setup with Claude Desktop
Download the curated overview from: [https://docs.air3.com/llms.txt](https://docs.air3.com/llms.txt)
Download the full documentation snapshot from: [https://docs.air3.com/llms-full.txt](https://docs.air3.com/llms-full.txt)
Save one or both files in your project directory or another known location on your system.
Drag and drop the file into your Claude Desktop chat, or use the attachment button to upload it. Claude will then have access to the uploaded Moca Network documentation context for that conversation.
# Language support
Source: https://docs.air3.com/get-started/customization/language
Choose AIR Kit languages, customize copy with hosted JSON, and set or change the language with the Web SDK.
AIR Kit includes English (`en`), Japanese (`ja`), Korean (`ko`), Turkish (`tr`), and Vietnamese (`vi`). English is always the fallback for missing translations. To override copy or add languages, host JSON files and register them through `localeUrls` in partner configuration. The SDK selects the language; it does not accept translation strings.
## How language selection works
AIR Kit selects the language in this order:
1. The SDK's `sessionConfig.locale`, if set.
2. The user's browser language preferences, unless detection is disabled.
3. Your `defaultLocale`, or `en` if unset.
Regional tags resolve to available catalogs, such as `ko-KR` to `ko` and `en-US` to `en`, with region fallback. A language must be bundled or have a registered JSON URL, and must pass your `supportedLocales` allowlist. Adding a code to the allowlist does not provide translations.
Browser language changes are followed when detection is enabled and no SDK locale is explicitly set.
## Partner configuration
Set these fields through partner configuration (backend or dashboard), separate from SDK initialization:
| Field | Purpose |
| - | - |
| `localeUrls` | Recommended: locale code → JSON URL, with one combined file per language for all AIR Kit interfaces. |
| `defaultLocale` | Fallback language when no SDK or browser language is selected. Defaults to `en`. |
| `supportedLocales` | Allowed languages. Empty means no restriction, including bundled languages. A non-empty list rejects other locales; use it to opt out of a bundled language. English remains the fallback. |
| `disableBrowserLocaleDetection` | Ignore browser language preferences and use `defaultLocale`, unless the SDK sets a locale. |
## Customize copy or add a language
1. Use the combined English catalog's key structure, which covers all interfaces.
2. Host a JSON file for each locale, such as `en.json` or `es.json`. Include only the keys you want to change; nested objects are merged and missing keys fall back to English.
3. Register the URLs in `localeUrls` in partner configuration. If you use a non-empty `supportedLocales` list, include the new locale.
For example, register one combined Spanish translation across all four interfaces:
```json theme={null}
{
"defaultLocale": "es",
"localeUrls": { "es": "https://cdn.example.com/airkit/i18n/es.json" }
}
```
English overrides customize the fallback copy. Bundled translations take precedence over that copy, so also provide a locale-specific override to customize wording for Japanese, Korean, Turkish, or Vietnamese users.
### Translate selected interfaces
If you use only a subset of AIR Kit, you can fully translate those interfaces with separate files instead of working from the combined catalog. Each file should follow that interface's composed English catalog.
Use `authLocaleUrls` for login, `walletLocaleUrls` for the wallet, `recoveryLocaleUrls` for recovery, or `credentialLocaleUrls` for credentials. Each maps a locale code to a JSON URL. For example, an integration using only login and credentials can register translations in `authLocaleUrls` and `credentialLocaleUrls`.
If you also use `localeUrls`, interface-specific overrides take precedence for the same locale.
## Set or change the language with the Web SDK
Set `sessionConfig.locale` at [initialization](/get-started/sdks/web#initialize) to override browser detection and the partner default. Omit it to allow automatic selection.
```tsx theme={null}
await airService.init({
buildEnv: BUILD_ENV.SANDBOX,
sessionConfig: {
locale: "ja"
}
});
```
Change the language after initialization across all loaded AIR Kit interfaces:
```tsx theme={null}
await airService.updateSessionConfig({ locale: "ko" });
```
To set the display currency, see [Other options](/get-started/customization/other-options).
# Login options
Source: https://docs.air3.com/get-started/customization/login-options
Customize the AIR Kit login screen via partner config — control available login methods, branding, and how the login interface appears to your users.
## Login Methods (`loginMethods`)
Control which authentication methods are available on the login screen:
```tsx theme={null}
type LoginMethod =
| "passwordless" // Email-based passwordless login
| "passwordlessToggle" // Email input revealed behind a link
| "google" // Google OAuth login
| "wallet" // Wallet connection login
| "passkey"; // Passkey login
```
**Available Options:**
* **`passwordless`**: Email-based passwordless authentication, with the email input shown up front
* **`passwordlessToggle`**: Same as `passwordless`, but the input starts collapsed behind a link and appears once the user taps it. Useful when you want social or wallet login to be the visually dominant option
* **`google`**: Google OAuth login integration
* **`wallet`**: Wallet-based authentication. Requires [EOA wallet authentication](/get-started/authentication/login#eoa-wallet-authentication-optional-add-on), which the AIR team enables on request.
* **`passkey`**: Passkey login
**Example Configuration:**
```tsx theme={null}
// Email and Google
loginMethods: ["passwordless", "google"];
```
**Note:** The order in the array determines the display order in the UI (top to bottom).
**Defaults and automatic adjustments:**
* If `loginMethods` is omitted or empty, AIR Kit falls back to `["passwordless", "google"]`.
* Inside webviews, `google` is dropped automatically, since Google blocks OAuth in embedded browsers. If neither `passwordless` nor `passwordlessToggle` was configured, AIR Kit adds `passwordless` so users always have a way to sign in.
* Unknown and duplicated values are ignored.
### Wallet Login Methods (`walletLoginMethods`)
When `wallet` is included in `loginMethods`, specify which wallet providers are available:
```tsx theme={null}
type LoginWalletId =
| "bybit" // Bybit Wallet
| "coinbase" // Coinbase Wallet
| "crypto.com" // Crypto.com DeFi Wallet
| "metamask" // MetaMask
| "okx" // OKX Wallet
| "phantom" // Phantom Wallet
| "rabby" // Rabby Wallet
| "rainbow" // Rainbow Wallet
| "trust" // Trust Wallet
| "walletConnect"; // WalletConnect
```
**Example Configuration:**
```tsx theme={null}
// Enable wallet login with specific providers
loginMethods: ["wallet"],
walletLoginMethods: ["metamask", "coinbase", "walletConnect"]
// Default wallet providers if not specified
walletLoginMethods: undefined;
// ["metamask", "phantom", "okx", "coinbase", "rabby", "walletConnect"]
```
The order in the array determines the display order in the UI (left to right, top to bottom). Since AIR accounts are shared across partners, consider including email-based login to avoid forcing users to link wallets to existing accounts.
### Style Configuration (`style`)
Customize the visual appearance of the login interface:
```tsx theme={null}
type PartnerConfigStyle = {
passwordlessButtonVariant?: "inline" | "standalone";
airLogoTheme?: "default" | "reverse" | "dark_only" | "light_only";
};
```
**Style Options:**
* **`passwordlessButtonVariant`**: Controls how the passwordless login button appears
* `"inline"`: Button appears inline with other elements
* `"standalone"`: Button appears as a standalone element below the input
* **`airLogoTheme`**: Controls the AIR logo appearance
* `"default"`: Standard logo theme
* `"reverse"`: Reversed color scheme
* `"dark_only"`: Dark theme only
* `"light_only"`: Light theme only
**Example Configuration:**
```tsx theme={null}
style: {
passwordlessButtonVariant: "standalone",
airLogoTheme: "dark_only",
}
```
## OTP delivery customization
OTP delivery branding can be customized for your project:
* **SMS sender name**: Use `{Partner Name} via AIR`, such as `Minds via AIR`.
* **Email logo**: Display your logo in OTP emails.
* **Email sender domain**: Send OTP emails from your domain by providing your own SMTP server. You can use providers such as Mailgun, SendGrid, or AWS SES.
These customizations are enabled by the AIR team and are not configured through the SDK. Contact us to request custom OTP delivery branding for your project.
# Other options
Source: https://docs.air3.com/get-started/customization/other-options
Other AIR Kit session options — set the display currency for wallet balances, transactions, and on-ramp and swap interfaces.
Session options are set in `sessionConfig` when you call `init()`, and can be changed later with `updateSessionConfig()`. For language, see [Language support](/get-started/customization/language).
## Currency
Set the optional `sessionConfig.currency` at initialization, or update it alongside the locale:
```tsx theme={null}
const updatedConfig = await airService.updateSessionConfig({ currency: "EUR" });
```
Supported currencies are `EUR` (Euro), `USD` (US Dollar), `CNY` (Chinese Yuan), `KRW` (South Korean Won), and `TRY` (Turkish Lira). Currency controls monetary display in wallet balances, transaction amounts, and on-ramp/swap interfaces. The update returns the complete `AirSessionConfig`, including locale and currency.
# Theming
Source: https://docs.air3.com/get-started/customization/theming
Customize AIR Kit UI appearance with CSS variables — override text colors, button styles, backgrounds, and fonts to match your brand.
## Customizable Elements
| Element Category | CSS Variables | Description |
| - | - | - |
| **Text Colors** | `--air-text-primary-color` `--air-text-secondary-color` `--air-text-placeholder-color` | Primary text, secondary text, input placeholders |
| **Button Colors** | `--air-button-primary-color` `--air-button-secondary-color` `--air-button-outlined-color` | Primary buttons, secondary buttons, outlined buttons |
| **Background Colors** | `--air-background-color` `--air-surface-color` `--air-container-primary-color` | Main background, surface areas, containers |
| **Border Colors** | `--air-border-color` `--air-border-focus-color` | Default borders, focused input borders |
| **Status Colors** | `--air-status-success-color` `--air-status-error-color` `--air-status-pending-color` | Success, error, pending states |
| **Icon Colors** | `--air-icon-primary-color` `--air-icon-secondary-color` `--air-icon-hover-color` | Primary icons, secondary icons, hover states |
| **Logo** | `--air-logo` | Custom logo URL |
| **Font Family** | `--air-font-family` | Custom font family (requires font import) |
## Example Partner Theme
The Mocaverse theme used on mocaverse.xyz:
```css theme={null}
:root {
--mocaverse-air-logo: url(https://static.air3.com/assets/mocaverse.svg);
--mocaverse-air-text-primary-color: #ffffff;
--mocaverse-air-text-secondary-color: #b0b0b0;
--mocaverse-air-text-placeholder-color: #767676;
--mocaverse-air-text-disabled-color: #ffffff40;
--mocaverse-air-text-inverse-color: #000000;
--mocaverse-air-text-on-color: #ffffff;
--mocaverse-air-button-primary-color: #ff49a0;
--mocaverse-air-button-primary-hover-color: #000000;
--mocaverse-air-button-secondary-color: #2450ee;
--mocaverse-air-button-secondary-hover-color: #ffffff;
--mocaverse-air-button-disabled-color: #3a3a3a;
--mocaverse-air-button-outlined-color: #ffffff;
--mocaverse-air-button-outlined-hover-color: #ffffff14;
--mocaverse-air-button-outlined-secondary-color: #4e4150;
--mocaverse-air-button-outlined-secondary-hover-color: #ffffff14;
--mocaverse-air-border-color: #ffffff40;
--mocaverse-air-border-focus-color: #ffffff;
--mocaverse-air-link-color: #3c8cff;
--mocaverse-air-text-button-color: #ef6aba;
--mocaverse-air-icon-primary-color: #ffffff;
--mocaverse-air-icon-secondary-color: #b0b0b0;
--mocaverse-air-icon-hover-color: #000000;
--mocaverse-air-icon-disabled-color: #00000040;
--mocaverse-air-status-success-color: #74ffcd;
--mocaverse-air-status-error-color: #ff49a0;
--mocaverse-air-status-pending-color: #f99247;
--mocaverse-air-surface-color: #110a17;
--mocaverse-air-surface-dim-color: #343a3b;
--mocaverse-air-overlay-color: #00000080;
--mocaverse-air-container-primary-color: #1d1d1d;
--mocaverse-air-container-secondary-color: #2f2f2f;
--mocaverse-air-decorative-color: #ef6aba;
--mocaverse-air-secondary-5-color: #ffffff;
--mocaverse-air-background-color: linear-gradient(90deg, #202734 0.5%, #3c253c 100.5%);
}
```
## Testing Your Theme
The best way to test your custom theme is to use browser developer tools to manipulate CSS custom properties in real-time:
1. **Open Developer Tools**: Press F12 or right-click → Inspect
2. **Navigate to Elements Tab**: Find the `` element
3. **Access CSS Variables**: In the Styles panel, look for `:root` or `html[theme='your-theme']`
4. **Modify Properties**: Click on any CSS custom property value to edit it
5. **See Changes Instantly**: Changes apply immediately without page refresh
This allows you to:
* Test different color combinations quickly
* Verify contrast ratios
* Ensure your theme works across all screens
* Fine-tune values before finalizing your CSS file
## Best Practices
* **Accessibility**: Ensure sufficient contrast ratios for text and backgrounds. Adjust our AIR logo via config value if necessary
* **Dark/Light Mode**: Consider both light and dark theme variations
* **Font Testing**: Test custom fonts across different devices and browsers
# Setting up your partner account
Source: https://docs.air3.com/get-started/dashboard/account-setup
Set up your AIR Kit partner account on the Developer Dashboard: get your Partner ID, add your domains, register your JWKS URL, and enable credential services.
Every AIR Kit integration starts with a partner account in the Developer Dashboard. Sign in by connecting your EVM wallet, then set up the following.
| Setting | Where | Needed for |
| - | - | - |
| **Partner ID** | **Account → General Settings** (copy it) | Everything. You pass it to the SDK and your backend. |
| **Name, logo, and website** | **Account → General Settings** | Shown to users during login, issuance, and verification. |
| **Domains** | **Account → Domains** | Loading AIR Kit from your app's origin. |
| **JWKS URL** | **Account → General Settings** | Issuing or verifying credentials, and [Custom Auth](/get-started/authentication/custom-auth). See [JWKS endpoint](/get-started/authentication/jwks-endpoint). |
If you only use login, the Partner ID and domains are enough.
## Enable credential services
To issue or verify credentials, register your own DID with AIR. AIR does not create one for you: you publish it from your domain with your own keys. Until a DID is registered, the **Issuer** and **Verifier** menus stay hidden. See [Issuing credentials](/products/identity/issuing-credentials) for the steps.
For a tour of every Dashboard section, see [Features](/get-started/dashboard/features). This page uses the sandbox Dashboard; production has its own, covered in the [Launch checklist](/get-started/launch-checklist).
# Developer Dashboard features
Source: https://docs.air3.com/get-started/dashboard/features
What each section of the AIR Kit Developer Dashboard does — account settings and domains, issuer schemas and programs, and verifier programs and fee wallet.
The Developer Dashboard is where you configure your AIR Kit integration. You sign in by connecting an EVM wallet, which identifies your partner account. It has three sections:
* **Account:** your partner settings and allowed domains.
* **Issuer:** schemas, issuance programs, and issuance records.
* **Verifier:** verification programs, verification records, and the fee wallet.
The Issuer and Verifier sections appear once you register your DID. See [Setting up your account](/get-started/dashboard/account-setup).
## Account
### General Settings
| Field | Description |
| - | - |
| **Partner ID** | Your AIR Kit partner ID. Read-only; copy it into your SDK and backend configuration. |
| **Name** | Your app's name, shown to users during login, wallet transactions, issuance, and verification. |
| **Logo URL** | Your app's logo, shown in the same places as the name. |
| **Website URL** | Your app's website, for users who want to learn more about your app. |
| **JWKS URL** | The public HTTPS URL of your JWKS. Required for issuing and verifying credentials and for Custom Auth. See [JWKS endpoint](/get-started/authentication/jwks-endpoint). |
### Domains
AIR Kit only loads on domains you allow. You can add up to 3 domains or subdomains, and wildcards such as `*.myapp.example.com` are supported. Some domains are blocked for security reasons; contact the AIR team if you need one of them.
In sandbox, `localhost` is allowed on ports `3000`, `5173`, and `8200`, and you cannot add it to the list yourself. Production requires HTTPS domains.
## Issuer
The Issuer section is for partners that issue credentials. It has three areas: the schema builder, issuance programs, and issuance monitoring.
### 1. Schema Builder
This is the starting point for creating any new credential. A Schema acts as a blueprint, defining the structure, data type, and rules for a specific type of credential.
* **Create Schemas**: Issuers can build custom schemas by defining a title, version, and description.
* **Define Attributes**: Within each schema, issuers add specific data fields (attributes). For each attribute, they define a name, data type (e.g., string, number, boolean), a descriptive title, and can mark it as required.
* **Publish**: Publish the schema to use it in issuance programs. See [Schema creation](/products/identity/schema-creation) for field types and design rules.
Here is an example of a simple schema for a "DAO Membership" credential:
```json theme={null}
{
"title": "DAO Membership",
"version": "1.0",
"description": "Verifies that the holder is a member of a specific DAO.",
"attributes": [
{
"name": "daoName",
"type": "string",
"title": "DAO Name",
"required": true
},
{
"name": "memberSince",
"type": "date",
"title": "Member Since",
"required": true
},
{
"name": "votingPower",
"type": "number",
"title": "Voting Power",
"required": false
}
]
}
```
### 2. Issuance Program
An issuance program defines a credential your issuer service can issue, based on a published schema.
* **Select a Schema**: The process begins by selecting the appropriate schema, which loads its predefined attributes.
* **Set Issuance Rules**: Issuers can configure key parameters for the credential, such as:
* **Accessible Until**: An optional date range during which the credential is valid.
* **Maximum Issuance**: An optional cap on the total number of credentials that can be claimed.
* **Expiration Duration**: The lifespan of the credential after it has been issued to a user (e.g., 90 days, 1 year, permanent).
* **Use the program ID**: Pass the program ID to `issueCredential` in your app. Your [issuer service](/products/identity/backend-hosting) signs and issues the credential when the user claims it.
### 3. Issuer Dashboard Monitoring
This section provides a high-level overview and detailed logs of all issuance activity.
* **Key Metrics**: View statistics like the Total Issued Number (the total number of credentials claimed by users) and the Total Credential Number (the number of different credential types created).
* **Claim Records**: See a detailed list of every credential that has been claimed by a user, including the Holder ID, Credential ID, and the Claimed Time. Issuers can also revoke a claimed credential directly from this interface.
## Verifier
The Verifier section is for partners that check users' credentials. It is centered on verification programs.
### 1. Verification Program Management
A verification program defines which credentials you accept and which claims you request.
* **Choose the credential**: Select the schema, the issuers you trust, and the accepted proof type (`SD_JWT_VC` by default).
* **Request claims**: Choose which claims the user is asked to disclose. The user sees them on the consent screen and shares only those. See [Selective disclosure](/products/identity/selective-disclosure).
* **Use the program ID**: Pass the program ID to `verifyCredential` in your app.
Conditions on a value without revealing it, such as "age greater than 18", and on-chain verification need [Iden3 credentials](/products/identity/iden3-credentials).
### 2. Verifier Dashboard Monitoring
This dashboard provides a comprehensive record of all verification activities.
* **Key Metrics**: View the Total Verified Number of credentials and other relevant statistics.
* **Verification Records**: Access a detailed log of all verification attempts, including the Holder ID, the Program Name used, the Verified Time, and the Current Status (e.g., Passed, Verification Failed).
### 3. Settings and Fee Wallet
Issuers and verifiers each have a settings panel. For verifiers, it includes a fee wallet: issuance and verification can incur on-chain gas fees, so you can pre-fund this wallet with \$MOCA. Fees for verification transactions are deducted from it, and the Dashboard shows the history of deposits and charges.
# Environments
Source: https://docs.air3.com/get-started/environments/about
Switch between AIR Kit Sandbox and Production environments — Sandbox uses Moca Chain Testnet; Production supports Testnet or Moca Chain mainnet.
Sandbox always uses **Moca Chain Testnet**.
| Environment | Moca Chain | Purpose | Developer Dashboard |
| - | - | - | - |
| Development + Testing | Testnet (222888) | Partners still on Testnet credentials | [https://developers.sandbox.air3.com/dashboard](https://developers.sandbox.air3.com/dashboard) |
| Production + `credentialNetwork: "mainnet"` | Mainnet (2288) | Production launches | [https://developers.air3.com/dashboard](https://developers.air3.com/dashboard) |
## Production mainnet access
Moca Chain mainnet is live. Production launches use the production AIR pages, including the [production Developer Dashboard](https://developers.air3.com/dashboard), and configure AIR Kit with `BUILD_ENV.PRODUCTION` and `credentialNetwork: "mainnet"`.
Production is self-serve for SD-JWT credentials: register your issuer, schemas, and programs in the production Dashboard the same way you did in sandbox. To use Iden3 credentials in production, contact the AIR team; see [Iden3 credentials](/products/identity/iden3-credentials).
For every step and a go-live checklist, see [Launch checklist](/get-started/launch-checklist). See [Moca Chain network information](/technicals/mocachain/network-information) for RPC and explorer URLs.
\$MOCA is the gas token on Moca Chain mainnet. See how to bridge or buy it.
## Initializing AirService with the correct environment
**Sandbox (Testnet):**
```js theme={null}
import { AirService, BUILD_ENV } from "@mocanetwork/airkit";
const airService = new AirService({
partnerId: YOUR_PARTNER_ID
});
await airService.init({
buildEnv: BUILD_ENV.SANDBOX,
enableLogging: true
});
```
**Mainnet (production):**
```js theme={null}
await airService.init({
buildEnv: BUILD_ENV.PRODUCTION,
credentialNetwork: "mainnet",
enableLogging: true
});
```
`credentialNetwork` is only accepted when `buildEnv` is `BUILD_ENV.PRODUCTION`. Values: `"testnet"` | `"mainnet"`.
```dart theme={null}
await airService.initialize(
partnerId: 'YOUR_PARTNER_ID',
navigatorKey: navigatorKey,
env: Environment.sandbox, // or Environment.production
enableLogging: true,
);
```
## Chains
| Chain | Chain ID | RPC | Explorer |
| - | - | - | - |
| Testnet | 222888 | [https://rpc.testnet.mocachain.dev](https://rpc.testnet.mocachain.dev) | [https://testnet-scan.mocachain.org](https://testnet-scan.mocachain.org) |
| Mainnet | 2288 | [https://rpc.mocachain.org](https://rpc.mocachain.org) | [https://scan.mocachain.org](https://scan.mocachain.org) |
You can add Moca Chain mainnet to a wallet from [ChainList](https://chainlist.org/chain/2288). See [Moca Chain network information](/technicals/mocachain/network-information) for all endpoints.
Before you switch environments, read [Environment best practices](/get-started/environments/best-practices).
# Environment best practices
Source: https://docs.air3.com/get-started/environments/best-practices
Keep AIR Kit sandbox and production separate: match chains to environments, re-issue network-specific credentials, and migrate from older SDK versions.
## Match the chain to the environment
Sandbox always uses Moca Chain Testnet. Production uses Moca Chain mainnet when you set `credentialNetwork: "mainnet"`. If your app's chain does not match its build environment, you can hit unexpected errors. See [Environments](/get-started/environments/about) for the full mapping.
## Treat credentials as network-specific
Credentials, schemas, and programs are network-specific. Credentials issued on Testnet are not available on Mainnet, and Devnet-issued credentials are not available on either. When you move to a new network:
* Re-issue credentials on the target network.
* Recreate issuance schemas and programs if needed.
* Check your Issuer and Verifier DIDs, which may differ per network.
## Keep each environment separate
* Sandbox and production each have their own Developer Dashboard, and nothing you set up in sandbox carries over. Copy the Partner ID from each Dashboard; do not assume they match.
* Register a JWKS URL in each Dashboard, and keep production private keys apart from sandbox keys. See [JWKS endpoint](/get-started/authentication/jwks-endpoint).
* Read `buildEnv`, `credentialNetwork`, Partner ID, program IDs, and API origins from configuration, not hard-coded values, so a production build never ships with sandbox values.
## Test on sandbox, then launch
Build and test the full flow on sandbox first. When it works end to end, follow the [Launch checklist](/get-started/launch-checklist) to set up production.
## Migrating from older SDK versions
Starting with **AirKit 1.10.0**, Sandbox is Testnet-only — the temporary `credentialNetwork: "devnet"` opt-in is removed.
### What changed
| Before (\< 1.8.0) | 1.8.0–1.9.x | 1.10.0+ |
| - | - | - |
| Sandbox → Devnet (Chain ID: 5151) | Sandbox → Testnet by default; optional `credentialNetwork: "devnet"` | Sandbox → Testnet only; no `credentialNetwork` on Sandbox |
| Dashboard: [https://developers.sandbox.air3.com](https://developers.sandbox.air3.com) | Dashboard: [https://developers.sandbox.air3.com](https://developers.sandbox.air3.com) | Dashboard: [https://developers.sandbox.air3.com](https://developers.sandbox.air3.com) |
### Impact
* Credentials issued on Devnet are **not** available on Testnet or Mainnet
* Smart Account addresses remain the same across networks
* Re-issue test credentials on Testnet, or on Mainnet for production launches
# Launch checklist
Source: https://docs.air3.com/get-started/launch-checklist
Move an AIR Kit integration from sandbox to production on Moca Chain mainnet: Dashboard setup, SDK settings, backends, credentials, and go-live checks.
Sandbox and production are separate environments. Production runs on Moca Chain mainnet and has its own Developer Dashboard. Nothing you set up in sandbox carries over: you register your app, keys, issuer, schemas, and programs again, and you switch your code to the production settings.
## What changes in production
| Setting | Sandbox | Production |
| - | - | - |
| Developer Dashboard | [developers.sandbox.air3.com](https://developers.sandbox.air3.com/dashboard) | [developers.air3.com](https://developers.air3.com/dashboard) |
| Web SDK `buildEnv` | `BUILD_ENV.SANDBOX` | `BUILD_ENV.PRODUCTION`, with `credentialNetwork: "mainnet"` |
| Flutter `env` | `Environment.sandbox` | `Environment.production` |
| AIR account domain (passkeys, mobile) | `account.sandbox.air3.com` | `account.air3.com` |
| AIR API base URL (direct issuance) | `https://api.sandbox.mocachain.org/v1` | `https://mocachain-mainnet.api.air3.com/v1` |
| Issuer service `NODE_ENV` | `sandbox` | `production` |
| Moca Chain | Testnet (222888) | Mainnet (2288) |
| Allowed domains | `localhost` ports 3000, 5173, and 8200 are allowed | HTTPS domains only |
Credentials are network-specific. Credentials issued in sandbox do not exist in production. See [Environments](/get-started/environments/about).
## Step 1: Set up your production partner account
1. Sign in to the production Developer Dashboard.
2. In **Account → General Settings**, copy your production **Partner ID**. Do not assume it matches your sandbox Partner ID.
3. Fill in your app's **Name**, **Logo URL**, and **Website URL**. Users see them during login, transactions, issuance, and verification.
4. Register your production **JWKS URL**. It must be public HTTPS and reachable from AIR servers. See [JWKS endpoint setup](/get-started/authentication/jwks-endpoint).
5. Under **Account → Domains**, add the HTTPS domains that load AIR Kit. You can add up to 3, and wildcards such as `*.myapp.example.com` are supported. `localhost` is not available in production.
For what each setting does, see [Developer Dashboard](/get-started/dashboard/features).
## Step 2: Switch the SDK to production
```ts theme={null}
import { AirService, BUILD_ENV } from "@mocanetwork/airkit";
const airService = new AirService({
partnerId: process.env.NEXT_PUBLIC_PARTNER_ID, // production Partner ID
});
await airService.init({
buildEnv: BUILD_ENV.PRODUCTION,
credentialNetwork: "mainnet",
enableLogging: false,
});
```
`credentialNetwork` is only accepted when `buildEnv` is `BUILD_ENV.PRODUCTION`.
```dart theme={null}
await airService.initialize(
partnerId: 'YOUR_PRODUCTION_PARTNER_ID',
navigatorKey: navigatorKey,
env: Environment.production,
enableLogging: false,
);
```
Keep `account.air3.com` in your Android asset statements and iOS associated domains so passkeys work in production. See [Android setup](/get-started/sdks/flutter/android-setup) and [iOS setup](/get-started/sdks/flutter/ios-setup).
Keep sandbox and production values in separate environment configurations, so a production build never ships with a sandbox Partner ID or program ID.
## Step 3: Point your backend at production
Your Partner JWT endpoint signs tokens for the production Partner ID:
* Sign with the private key whose public key is in your production JWKS, and set the `kid` to match.
* Keep tokens short-lived and generate them on the server only. See [SDK authentication](/get-started/authentication/sdk-auth).
* If you call the AIR API directly, for example `initialize-user` and `dstorage/vcs` for direct issuance, use the production base URL.
## Step 4: Set up credentials in production
Skip this step if you use AIR Kit only for login or wallets.
**If you issue credentials:**
1. Deploy your issuer service for production on a domain you control, with a durable database and `NODE_ENV=production`. The reference issuer service uses PostgreSQL. See [Issuer backend hosting](/products/identity/backend-hosting).
2. Register the issuer in **Issuer → Settings** on the production Dashboard: the `did:web` Issuer DID, the issuer API key, the JWKS URL, and the endpoint URLs.
3. Recreate your schemas and issuance programs in the production Dashboard, and update the program IDs in your app.
4. Decide your revocation setup, including whether to enable the [token status list](/products/identity/revocation#token-status-list), before you issue the first production credential.
5. If you issue [Iden3 credentials](/products/identity/iden3-credentials), contact the AIR team to enable them in production.
The issuer host becomes your issuer identity once you issue on mainnet, so do not change it afterwards.
**If you verify credentials:**
1. Recreate your verification programs in the production Dashboard and update the program IDs in your app.
2. Constrain each program to the production Issuer DIDs you trust.
3. Verify every presentation on your backend, with a fresh nonce per verification. See [Verify SD-JWT on your backend](/products/identity/verify-sd-jwt).
## Step 5: Set up accounts and gas
Skip this step if you do not use smart accounts.
* Contact the AIR team to set up gas sponsorship policies and the chains in your partner configuration. See [Paymaster](/products/money/paymaster).
* If your app or users pay gas on Moca Chain, see [How to get \$MOCA](/technicals/mocachain/how-to-get-moca).
* If you use [session keys](/products/money/session-keys), scope them to the smallest set of actions and give them short expiry times.
## Step 6: Test on production before launch
Run your main flows end to end with a real test account on production:
* Log in and log out, including on each mobile platform you ship.
* Issue a credential, verify it, revoke it, and confirm that verification then fails.
* Send a transaction from the smart account, if you use one.
* Check that a request from a domain that is not on your allowed list is refused.
## Go-live checklist
**Dashboard and keys**
* [ ] Signed in to the production Developer Dashboard, and copied the production Partner ID
* [ ] App name, logo URL, and website URL set
* [ ] Production JWKS URL is public HTTPS and registered
* [ ] Partner JWTs are signed server-side with a key whose `kid` is in that JWKS, and expire within 5 minutes
* [ ] Allowed domains list only your HTTPS production domains
* [ ] Private keys and API keys are stored in a secrets manager, not in source control or client code
**SDK and apps**
* [ ] Web apps initialize with `BUILD_ENV.PRODUCTION` and `credentialNetwork: "mainnet"`
* [ ] Flutter apps initialize with `Environment.production`
* [ ] Mobile apps include `account.air3.com` in their associated domains and asset statements
* [ ] No sandbox Partner ID, program ID, or URL remains in the production build
* [ ] Logging is off or reduced for production
**Issuing credentials** (if you issue)
* [ ] Issuer service deployed on a domain you control, with `NODE_ENV=production`
* [ ] The issuer database is durable and its migrations are applied
* [ ] `did:web` Issuer DID, issuer API key, JWKS URL, and endpoint URLs registered in the production Dashboard
* [ ] Schemas and issuance programs recreated in production, and their IDs updated in your app
* [ ] Revocation tested; token status list partition size final and a publish job scheduled, if you use it
* [ ] Iden3 credentials enabled by the AIR team, if you use them
**Verifying credentials** (if you verify)
* [ ] Verification programs recreated in production, and their IDs updated in your app
* [ ] Programs limited to the production Issuer DIDs you trust
* [ ] Backend verifies every presentation, including the key-binding JWT, and uses each nonce only once
**Accounts and gas** (if you use smart accounts)
* [ ] Gas sponsorship policies and chains confirmed with the AIR team
* [ ] Sponsorship spend is monitored
* [ ] Session keys are narrowly scoped with short expiry
**Operations**
* [ ] All main flows tested end to end on production
* [ ] Alerts set up for failed authentication (spikes in 401 and 403 responses) and unusual issuance volume
* [ ] A key rotation plan: add the new key to your JWKS before removing the old one
For the full list of security practices, see the [Security checklist](/technicals/architecture/security-checklist).
# Quickstart: Issuing your first credential
Source: https://docs.air3.com/get-started/quickstarts/issue-credentials
Run the AIR issuer service, register it with AIR, and issue your first SD-JWT credential to a logged-in user with AIR Kit.
This guide implements on-demand issuance with the user present. Your app calls
`issueCredential()` to open the AIR flow. The SDK handles login, credential
discovery, and user confirmation; AIR forwards issuance to your backend, which
authorizes the request, retrieves the data, creates and signs the credential,
encrypts it, and stores it in dStorage.
New issuance programs use the `SD_JWT_VC` proof type. This setup is self-serve
in both sandbox and production. To issue Iden3 (`BJJ_SIG_2021`) credentials
instead, see [Iden3 credentials](/products/identity/iden3-credentials).
This guide covers user-initiated SDK issuance through a self-hosted issuer
backend. To issue from a backend event with no user session, see
[Direct issuance](/products/identity/issuing-credentials#direct-issuance).
## Before you start
You need:
* A supported Node.js version
* An issuer backend implementing `available-vc` and `issue-vc`, reachable by AIR
over HTTPS. You can host it or use an HTTPS tunnel during development.
* An AIR Developer Dashboard account and Partner ID
* A server endpoint that signs Partner JWTs, and a public HTTPS JWKS endpoint (you set both up in Step 6)
* A web app whose origin is registered in the Dashboard
**A database is optional in sandbox.**
For development and testing, credential data can come from an existing API or service, or you can use static sample data.
For production, however, if you need to support features such as **credential revocation, issuance history, and token status lists,**
especially at scale with many users, you will need to persist the relevant data in your own database.
Regardless of whether you use a database, encrypted credentials are stored in dStorage.
The backend and JWKS endpoint can share a host. See [Issuer backend hosting](/products/identity/backend-hosting).
Choose a stable public origin for the issuer service before you issue. The issuer DID is `did:web:`, so changing the host later changes your issuer identity.
## What you will build
By the end of this guide, you will have:
* A public issuer backend with `POST /available-vc`, `POST /issue-vc`, a
revocation-status endpoint, and a `did:web` document
* A Dashboard schema and issuance program
* A schema class that produces the claims your backend signs
* A public JWKS endpoint and a server-only Partner JWT endpoint
* A web flow that logs in with AIR Kit and calls `issueCredential`
Issuer endpoints, schema logic, SD-JWT signing, encryption, revocation, and dStorage integration.
## How SDK issuance works
1. Your app starts `issueCredential()` with the issuer and issuance program.
2. The user logs in to AIR if needed.
3. The SDK requests available credentials through AIR. AIR calls the registered
issuer backend's `POST /available-vc` and returns encrypted previews.
4. The SDK decrypts and displays the previews. The user selects a credential and
confirms issuance.
5. The SDK requests issuance through AIR, which calls the issuer's `POST /issue-vc`
with the resolved holder identity, encryption key, holder signing key, and schema.
6. The issuer authorizes the request, loads authoritative claims, creates and
signs the SD-JWT VC, encrypts it, and stores it in dStorage.
7. The SDK reports completion to your app. If the user declines, issuance stops.
The browser does not need the issuer API key or a separate request to fetch claims.
See [Architecture and data flow](/technicals/architecture) for the sequence diagram.
The issuance program ID is an AIR Kit and Dashboard identifier. Your issuer
backend works with a `schemaId`, not the program ID.
## Step 1: Generate partner secrets
Clone the SD-JWT branch of the [AIR issuer service](https://github.com/MocaNetwork/air-issuer-service/tree/main),
or your fork of it, then install its dependencies:
```bash theme={null}
git clone -b main https://github.com/MocaNetwork/air-issuer-service.git
cd air-issuer-service
npm i
```
Check the branch README and `.env.example` when you upgrade.
Generate separate API keys for AIR-to-issuer calls and admin calls:
```bash theme={null}
echo "API_KEY=$(openssl rand -hex 32)"
echo "ADMIN_API_KEY=$(openssl rand -hex 32)"
```
Generate an RSA key pair for Partner JWT signing:
```bash theme={null}
openssl genpkey \
-algorithm RSA \
-pkeyopt rsa_keygen_bits:2048 \
-out partner-private.pem
openssl pkey \
-in partner-private.pem \
-pubout \
-out partner-public.pem
```
You will use the same key pair in three places:
* The issuer backend signs the SD-JWT credentials and its requests to dStorage
with the private key.
* Your web server signs short-lived AIR Kit Partner JWTs with the private key.
* The public key is published through your JWKS and the issuer's `did:web`
document, so AIR and verifiers can check both.
Never expose the private key through a `NEXT_PUBLIC_*` environment variable.
## Step 2: Configure and start the issuer backend
Create the issuer backend environment file:
```bash theme={null}
NODE_ENV=sandbox
# Optional in sandbox; required for revocation, history, and the status list at scale
# DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/issuer-backend
# Public origin of this issuer service, without a trailing slash.
# The issuer DID is did:web:.
ISSUER_ORIGIN=https://issuer.example.com
# Partner identity and signing
PARTNER_ID=
PARTNER_PRIVATE_KEY_KID=
PARTNER_PRIVATE_KEY_ALG=RS256
PARTNER_PRIVATE_KEY_DER=
PARTNER_JWKS=
# AIR-to-issuer and operator authentication
API_KEY=
ADMIN_API_KEY=
# Optional
# AIR_API_ORIGIN and MOCA_CHAIN_API_ORIGIN default per NODE_ENV
# SD_JWT_TSL_PARTITION_SIZE=80000 # enables the token status list; see Revoke credentials
PORT=3000
```
`PARTNER_PRIVATE_KEY_DER` is the base64 body between the `BEGIN PRIVATE KEY`
and `END PRIVATE KEY` lines in `partner-private.pem`. It must be PKCS#8.
`PARTNER_JWKS` contains public keys only, matching your Partner JWT signing key
and `kid`. Generate and publish the document using [JWKS endpoint setup](/get-started/authentication/jwks-endpoint).
The service serves these keys in its `did:web` document and issuer metadata.
If you set `DATABASE_URL` (this issuer service uses PostgreSQL), apply migrations before the first start:
```bash theme={null}
npx mikro-orm migration:up
```
Start the service:
```bash theme={null}
npm run start:dev
```
Once `ISSUER_ORIGIN` is reachable over HTTPS, confirm the public identity routes:
```bash theme={null}
curl https://issuer.example.com/.well-known/did.json
curl https://issuer.example.com/.well-known/jwt-vc-issuer
```
`did.json` returns the issuer DID (`did:web:issuer.example.com`) with your
public keys as verification methods. Credentials are signed with header
`kid` set to `#`.
The credential `iss` is the `did:web` DID, while `/.well-known/jwt-vc-issuer`
identifies the HTTPS origin. AIR verification resolves the DID document.
Some external verifiers expect these identifiers to match exactly; test with
the verifier you plan to support.
## Step 3: Register the issuer with AIR
Expose the issuer backend over public HTTPS. Register the issuer under
**Dashboard → Issuer → Settings** in the sandbox or production Dashboard, then
configure its endpoint URLs:
| Item | Value |
| - | - |
| Issuer DID | `did:web:` |
| Signature type | `SD_JWT_VC` |
| API key | The backend `API_KEY` |
| Partner ID | Your Dashboard Partner ID |
| Available credentials URL (`availableVcApiUrl`) | `https://issuer.example.com/available-vc` |
| Issuance URL (`issueVcApiUrl`) | `https://issuer.example.com/issue-vc` |
| Revocation status URL (`revocationStatusApiUrl`) | Your issuer's `GET /revocation-status/:nonce` route |
After registration:
1. Confirm the Issuer DID in the Dashboard matches the `id` in your
`/.well-known/did.json`.
2. Confirm AIR can reach both registered issuer endpoints over HTTPS.
3. Keep `API_KEY` identical to the value you registered with AIR.
AIR calls these issuer endpoints with `x-api-key`. Keep the key server-side;
your browser calls AIR Kit with a short-lived Partner JWT. A missing or incorrect
issuer API key causes the backend to return `403`.
If you enable CORS on the issuer or a proxy in front of it, allow `*.air3.com`.
## Step 4: Create schema for credential issuance
After registering your issuer, create the schema and issuance program:
1. Open the Sandbox Developer Dashboard.
2. Go to **Issuer → Schemas**.
3. Create a schema with a string attribute named `historical_amount`.
4. Publish the schema and record its schema ID.
5. Go to **Issuer → Programs** and create an issuance program using the
published schema.
6. Select `SD_JWT_VC` as the signature type.
7. Record the issuance program ID.
For SD-JWT credentials, the schema ID is the credential type (`vct`) unless
your schema class sets its own. For schema design rules and supported field
types, see [Schema creation](/products/identity/schema-creation).
## Step 5: Implement the credential data
Create one schema class for each Dashboard schema. The class maps a Dashboard
schema to the claims your backend will issue and marks which claims the holder
can disclose selectively.
```ts src/issuer/sd-jwt-vc-schemas/schema_.ts theme={null}
import { DisclosureFrame } from "@sd-jwt/core";
import { BaseSchema } from "./base-schema";
type Claim = {
historical_amount: string;
};
class Schema_ extends BaseSchema {
public readonly schemaId = "";
public readonly vct = undefined; // defaults to schemaId
public readonly ["vct#integrity"] = undefined;
public readonly disclosureFrame: DisclosureFrame = {
_sd: ["historical_amount"], // claims the holder can disclose selectively
};
public readonly expirySec = 30 * 24 * 60 * 60;
async generateCredentialData(userId: string) {
// Load attributes from your database or API using userId
return {
credentialSubject: {
historical_amount: "1000",
},
expiration: Math.floor(Date.now() / 1000) + this.expirySec,
};
}
}
export default new Schema_();
```
Register the instance so `/available-vc` and `/issue-vc` can resolve its
`schemaId`:
```ts src/issuer/sd-jwt-vc-schemas/index.ts theme={null}
import { BaseSchema } from "./base-schema";
import HistoricalAmountSchema from "./schema_";
const schemas: BaseSchema[] = [HistoricalAmountSchema];
export default schemas;
```
Follow these rules:
* Claim keys and JavaScript types must match the published Dashboard schema.
* Do not return reserved claims from `generateCredentialData`: `id`, `nonce`,
`vct`, `sub`, `exp`, `cnf`, `iss`, `iat`, or `status`. The service sets them.
* `expirySec` sets the credential's `exp`. The `expiration` you return is only
used for the preview.
* List a claim in `disclosureFrame._sd` only if verifiers may request it on
its own. Claims outside `_sd` are always visible in a presentation.
* Keep the backend authoritative. Do not sign claim values supplied by the
browser without validating them against your own data.
* When you replace the static example with a database or API lookup, use the
partner user ID at the issuer boundary for eligibility and data
retrieval.
### Issuer endpoint contract
AIR calls both issuer endpoints during on-demand issuance: `POST /available-vc`
provides encrypted previews, and `POST /issue-vc` performs issuance after the user
confirms. The issuer validates eligibility using the AIR-resolved partner user
identifier and retrieves claims from its own data source.
`POST /available-vc` accepts the holder identity and optional filters:
```json theme={null}
{
"holderDID": "did:air:...",
"pubKey": "0x...",
"userId": "",
"schemaId": "",
"proofType": "SD_JWT_VC"
}
```
It returns encrypted previews:
```json theme={null}
{
"data": [
{
"holderDID": "did:air:...",
"schemaId": "",
"credentialSubject": {
"encryptedData": "...",
"iv": "...",
"authTag": "...",
"dataEncPublicKey": "..."
},
"proofType": "SD_JWT_VC"
}
]
}
```
`POST /issue-vc` accepts the selected schema and the holder's signing key:
```json theme={null}
{
"holderDID": "did:air:...",
"pubKey": "0x...",
"userId": "",
"schemaId": "",
"signingKey": { "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } },
"proofType": "SD_JWT_VC"
}
```
On success, the issuer backend:
1. Generates the credential data.
2. Sets `sub` to `holderDID` and, when `signingKey` is present, `cnf` to the
holder's public key. This binds the credential to the holder.
3. Signs the SD-JWT VC with your partner key under the `did:web` issuer DID.
4. Encrypts the credential to `pubKey`.
5. Persists the issuance record when database persistence is enabled.
6. Uploads the encrypted credential to dStorage.
7. Returns HTTP `201` with an empty response body.
AIR sends `x-api-key: ` to both endpoints. This issuer HTTP response is
distinct from the SDK result returned to your app.
## Step 6: Install AIR Kit and set up Partner authentication
Install AIR Kit in your web application:
```bash theme={null}
npm i @mocanetwork/airkit
```
Add the browser-safe and server-only values to your web environment:
```bash theme={null}
# Browser-safe
NEXT_PUBLIC_PARTNER_ID=
NEXT_PUBLIC_ISSUER_DID=
NEXT_PUBLIC_ISSUE_PROGRAM_ID=
NEXT_PUBLIC_BUILD_ENV=sandbox
# Server-only
PARTNER_PUBLIC_KEY=
PARTNER_PRIVATE_KEY=
SIGNING_ALGORITHM=RS256
```
### Set up the JWKS endpoint
Issuance fails until AIR can fetch your JWKS. Follow [JWKS endpoint](/get-started/authentication/jwks-endpoint),
then check that the registered URL returns the `kid` your Partner JWT will use.
### Sign the Partner JWT
Implement the [Next.js Partner JWT
endpoint](/get-started/authentication/sdk-auth#nextjs-partner-jwt-endpoint).
The endpoint must generate a short-lived token on the server with
`scope: "issue"` and a `kid` that appears in your registered JWKS.
## Step 7: Initialize AIR Kit and issue the credential
The frontend examples use Next.js. Create a singleton AIR service and a helper
for fetching the Partner JWT. Call these helpers from a browser event handler.
```ts lib/air.ts theme={null}
import { AirService, BUILD_ENV } from "@mocanetwork/airkit";
let servicePromise: Promise | undefined;
export function getInitializedAirService(): Promise {
servicePromise ??= (async () => {
const partnerId = process.env.NEXT_PUBLIC_PARTNER_ID;
if (!partnerId) throw new Error("NEXT_PUBLIC_PARTNER_ID is required");
const service = new AirService({ partnerId });
await service.init({ buildEnv: BUILD_ENV.SANDBOX });
return service;
})().catch((error) => {
servicePromise = undefined;
throw error;
});
return servicePromise;
}
export async function fetchPartnerJwt(): Promise {
const response = await fetch("/api/partner-jwt", { method: "POST" });
const body = (await response.json()) as {
token?: string;
error?: string;
};
if (!response.ok || !body.token) {
throw new Error(
body.error ?? `Partner JWT request failed (${response.status})`,
);
}
return body.token;
}
```
Log in before starting issuance, then call `issueCredential`:
```ts theme={null}
import { fetchPartnerJwt, getInitializedAirService } from "@/lib/air";
export async function issueHistoricalAmountCredential() {
const issuerDid = process.env.NEXT_PUBLIC_ISSUER_DID;
const credentialId = process.env.NEXT_PUBLIC_ISSUE_PROGRAM_ID;
if (!issuerDid || !credentialId) {
throw new Error("Issuer DID and issuance program ID are required");
}
const air = await getInitializedAirService();
if (!air.isLoggedIn) {
await air.login();
}
const authToken = await fetchPartnerJwt();
return air.issueCredential({
authToken,
issuerDid,
credentialId,
credentialSubject: {},
});
}
```
Pass `{}` as `credentialSubject`: the AIR credential UI fetches encrypted
previews through AIR and the issuer backend supplies the authoritative claims.
The SDK promise resolves on success and rejects on failure. It does not return the VC or a dStorage path. Handle the SDK result separately from backend storage records.
## Step 8: Test end to end
Add your web app's origin under **Account → Domains**. Start the issuer backend and web app, then:
1. Open the web app.
2. Log in through AIR Kit.
3. Start credential issuance.
4. Confirm the AIR Credential UI shows the available credential.
5. Approve issuance.
6. Confirm the app receives a successful `issueCredential` result.
When database persistence is enabled, you can also check the backend issuance history:
```bash theme={null}
curl -s \
-H "x-admin-api-key: " \
"https://issuer.example.com/admin/issuance-history?page=1&limit=25" \
| jq .
```
Without database persistence, an empty history is not a failed issuance.
Confirm that the SDK completes successfully and that the holder's credential
can be discovered and used in the verification flow.
## Troubleshooting
### Issuer backend returns 403
The caller is not sending the expected `x-api-key`, or the value differs from the
backend `API_KEY`. Confirm the Partner ID, Issuer DID, and API key match what you
registered with AIR.
### AIR cannot validate the Partner JWT
Confirm the token contains `scope: "issue"` and has not expired. For JWKS and
`kid` checks, see [JWKS endpoint troubleshooting](/get-started/authentication/jwks-endpoint#troubleshooting).
### No credential preview appears
Confirm AIR can call the registered `POST /available-vc` endpoint and that the
backend returns an encrypted preview for the logged-in holder. Check the
program's issuer DID, schema, proof type, and the user's eligibility.
### Schema validation fails
Confirm that:
* `schemaId` matches the published Dashboard schema.
* Claim names and types match the schema.
* Your data does not include reserved claims such as `id`, `sub`, `vct`, or `cnf`.
### Issuer backend is unreachable
Check that the service is running, both issuer endpoint URLs are registered,
and your HTTPS domain or tunnel is reachable by AIR. If a development tunnel URL
changes, update `ISSUER_ORIGIN` and the registered URLs. This also changes the
issuer DID, so only do it before you issue credentials you want to keep.
## Next steps
* [Credential verification quickstart](/get-started/quickstarts/verify-credentials)
* [Issuing credentials](/products/identity/issuing-credentials)
* [Revoke credentials](/products/identity/revocation)
* [Issuance API reference](/api-reference/issuance-api)
* [Identity & Credential troubleshooting](/help/identity-credential)
* [AIR Credential example](https://github.com/MocaNetwork/air-credential-example)
# Quickstart: Integrating AIR Kit for login
Source: https://docs.air3.com/get-started/quickstarts/login
Install the AIR Kit Web SDK, get your Partner ID, and log users in with their AIR Account in a few steps.
Add AIR login and session handling to your web app. You can use login without integrating credential issuance or verification.
To see the login experience first, open the [single sign-on demo](https://air-bd-v2.netlify.app/demo/single-sign-on). It is a click-through, not a live session.
## Before you start
* An AIR Developer Dashboard account and Partner ID
* A web app running in the browser, with its origin added in **Account → Domains**
* A supported Node.js version for your framework and AIR Kit
You don't need a database, issuer backend, credential program, or Partner JWT for the standard AIR login shown here.
## Step 1: Install the SDK
```bash theme={null}
npm i @mocanetwork/airkit
```
## Step 2: Get your Partner ID
1. Go to the Developer Dashboard and sign in by connecting your EVM wallet.
2. Open **Account → General Settings** and copy your **Partner ID**.
## Step 3: Initialize and log in
Create the service once in browser code. Call `loginToAir()` from a user action,
such as a login button; do not run this helper during server rendering.
```ts theme={null}
import { AirService, BUILD_ENV } from "@mocanetwork/airkit";
const service = new AirService({
partnerId: ""
});
export async function loginToAir() {
if (!service.isInitialized) {
await service.init({ buildEnv: BUILD_ENV.SANDBOX });
}
if (!service.isLoggedIn) {
await service.login();
}
return service.isLoggedIn;
}
```
This will:
* Initialize the `AirService` within the [sandbox environment](/get-started/environments/about)
* Present the SSO Login screen of AIR Kit for users to log in
* Handle authentication and session setup
* Establish the user session used by Account Services and subsequent credential flows
Login does not activate your partner's issuer or verifier functionality. Credential
operations also require registered partner authentication, the appropriate
Dashboard setup, and an active issuance or verification program.
## Session lifecycle
Use `service.isLoggedIn` to check the current session. To sign the user out, call:
```ts theme={null}
await service.logout();
```
Handle cancelled login and errors in your UI, and confirm that the user can sign
out and log in again. For session rehydration and user details, see
[Sessions & user info](/get-started/authentication/sessions).
## Next steps
* [Issuing your first credential](/get-started/quickstarts/issue-credentials) and [Verifying your first credential](/get-started/quickstarts/verify-credentials), if your app needs credentials
* [Smart accounts](/products/money/smart-account), to use the user's embedded wallet
* [Customization options](/get-started/customization/theming), to change the theme, login methods, and language
# Quickstart: Verifying your first credential
Source: https://docs.air3.com/get-started/quickstarts/verify-credentials
Configure an AIR verification program, obtain user consent, and verify the SD-JWT presentation on your backend with AIR Kit.
This guide adds credential verification to your web app. Your verification
program defines the accepted credentials and requested claims. AIR Kit handles
credential discovery, the consent screen, and presentation generation. Your
backend issues a nonce and verifies the returned SD-JWT presentation.
## Before you start
You need:
* An AIR Developer Dashboard account and Partner ID, with verifier functionality enabled
* A web app with its origin registered in the Dashboard
* A public HTTPS JWKS endpoint registered for your Partner ID
* A server endpoint that signs short-lived Partner JWTs with `scope: "verify"`
* A test holder with a matching credential from an issuer accepted by your program,
or access to that issuer's claim flow
* A supported Node.js version for your web framework and AIR Kit
You do not need to operate an issuer backend or database to verify another
issuer's credentials. The [issuance quickstart](/get-started/quickstarts/issue-credentials)
is optional. AIR login is required for this SDK flow; the code below includes it.
## Step 1: Configure a verification program
1. Open the [Sandbox Developer Dashboard](https://developers.sandbox.air3.com/dashboard)
and copy your Partner ID from **Account → General Settings**.
2. Under **Verifier → Programs**, create a program and select the accepted schema
and issuer configuration.
3. Select `SD_JWT_VC` as the accepted proof type and choose the claims to request.
4. Publish or apply the program so it is active, and copy its verification program ID.
Predicate checks such as "age greater than 18" without revealing the value, and
on-chain verification, need Iden3 credentials. See
[Iden3 credentials](/products/identity/iden3-credentials).
## Step 2: Configure Partner authentication
The code below uses a Next.js app. Keep the private key on the server, including
during local development.
```bash theme={null}
npm i @mocanetwork/airkit jose
```
Set your app's environment variables:
```bash theme={null}
# Browser-safe
NEXT_PUBLIC_PARTNER_ID=
NEXT_PUBLIC_VERIFY_PROGRAM_ID=
# Server-only
PARTNER_PRIVATE_KEY=
PARTNER_PUBLIC_KEY=
SIGNING_ALGORITHM=RS256
```
Verification fails until AIR can fetch your JWKS. If you haven't set one up for
this Partner ID, follow [JWKS endpoint](/get-started/authentication/jwks-endpoint). If you already
issue credentials with the same Partner ID, reuse that JWKS and key pair.
Create a server endpoint for verification tokens:
```ts app/api/verification-token/route.ts theme={null}
import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { importPKCS8, SignJWT } from "jose";
export async function POST() {
// Apply your app's session and access checks before issuing a token.
const partnerId = process.env.NEXT_PUBLIC_PARTNER_ID;
const privateKeyBody = process.env.PARTNER_PRIVATE_KEY;
if (!partnerId || !privateKeyBody) {
return NextResponse.json(
{ error: "Missing Partner JWT configuration" },
{ status: 500 },
);
}
try {
const pem = privateKeyBody.includes("BEGIN PRIVATE KEY")
? privateKeyBody
: `-----BEGIN PRIVATE KEY-----\n${privateKeyBody.trim()}\n-----END PRIVATE KEY-----`;
const key = await importPKCS8(pem, "RS256");
const token = await new SignJWT({ partnerId, scope: "verify" })
.setProtectedHeader({ alg: "RS256", kid: partnerId, typ: "JWT" })
.setIssuedAt()
.setExpirationTime("5m")
.sign(key);
// One-time nonce for the key-binding JWT. Store it against the user's
// session so /api/verify-presentation can check and delete it.
const nonce = randomBytes(16).toString("base64url");
// await saveNonceForSession(session, nonce);
return NextResponse.json({ token, nonce });
} catch {
return NextResponse.json(
{ error: "Unable to sign verification token" },
{ status: 500 },
);
}
}
```
This example uses your Partner ID as the JWKS key's `kid`. If your registered
key uses a different `kid`, use that value in the token header.
## Step 3: Initialize AIR Kit and start verification
Call this helper from a browser event handler, such as your **Verify** button:
```ts lib/verify-credential.ts theme={null}
import { AirService, BUILD_ENV } from "@mocanetwork/airkit";
let servicePromise: Promise | undefined;
function getAirService(): Promise {
servicePromise ??= (async () => {
const partnerId = process.env.NEXT_PUBLIC_PARTNER_ID;
if (!partnerId) throw new Error("Partner ID is required");
const service = new AirService({ partnerId });
await service.init({ buildEnv: BUILD_ENV.SANDBOX });
return service;
})().catch((error) => {
servicePromise = undefined;
throw error;
});
return servicePromise;
}
export async function verifyCredential() {
const programId = process.env.NEXT_PUBLIC_VERIFY_PROGRAM_ID;
if (!programId) throw new Error("Verification program ID is required");
const service = await getAirService();
if (!service.isLoggedIn) await service.login();
// Your server returns a Partner JWT and a fresh nonce stored in the session
const response = await fetch("/api/verification-token", { method: "POST" });
const body = (await response.json()) as {
token?: string;
nonce?: string;
error?: string;
};
if (!response.ok || !body.token || !body.nonce) {
throw new Error(body.error ?? "Unable to obtain verification token");
}
return service.verifyCredential({
authToken: body.token,
programId,
fieldsToDisclose: ["historical_amount"], // or "*" for every field
nonce: body.nonce,
});
}
```
The nonce needs at least 128 bits of randomness, as in the token route above. If your flow needs to redirect users to an issuer when they lack
a credential, add the optional `redirectUrl` pointing to that issuer's claim page.
## Step 4: Obtain consent and handle the result
During verification, AIR Kit:
1. Finds matching credentials. If none exist, the user needs to complete an
accepted issuer's issuance flow before verification can succeed.
2. Fetches and decrypts the credential on the holder's device.
3. Shows the requested data and asks the user to consent.
4. Records consent and generates the presentation. For a holder-bound
credential, the holder signs a key-binding JWT over your nonce.
5. Returns the outcome to your app.
Handle both returned statuses and errors, including a cancelled flow. Grant
access only after a successful result appropriate to your application's trust
requirements.
```ts theme={null}
import { verifyCredential } from "@/lib/verify-credential";
try {
const result = await verifyCredential();
if (result.status === "Compliant") {
// Verify on your backend before granting access
const check = await fetch("/api/verify-presentation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
verifiablePresentation: result.verifiablePresentation,
}),
});
if (!check.ok) throw new Error("Presentation rejected");
} else {
console.log("Verification did not pass:", result.status);
}
} catch {
console.log("Verification was cancelled or could not complete. Allow a retry.");
}
```
The `Compliant` check runs in the browser, and the presentation wrapper is not
signed. Your `/api/verify-presentation` route must verify the SD-JWT inside it:
issuer signature, disclosures, expiry, credential type, and the key-binding JWT
against your nonce and `programId`. Then it deletes the nonce. Follow
[Verify SD-JWT on your backend](/products/identity/verify-sd-jwt), which
includes a reference implementation.
## Step 5: Test the integration
Test with a matching credential, a credential that fails the requested condition,
and a user who declines consent. Confirm your app handles missing credentials,
expired tokens, and failed requests without granting access.
Also confirm your backend rejects a replayed presentation (same nonce twice), a
presentation made for another `programId`, and a presentation with a disclosure
removed.
## Troubleshooting
| Symptom | Check |
| - | - |
| Partner JWT rejected | Public JWKS URL, matching `kid` and key pair, `scope: "verify"`, and token expiry |
| No matching credential | Program schema and accepted issuers, holder's credentials, and issuer claim flow |
| Backend rejects with `Key Binding JWT not exist` | The credential has a holder key but the KB-JWT is missing. Check you passed `nonce` |
| Backend rejects with `Invalid Nonce` | The nonce does not match this session's stored nonce, or it was already used |
| Program unavailable | Correct environment, active program, and enabled verifier functionality |
## Next steps
* [Verification reference](/products/identity/verify)
* [Verify SD-JWT on your backend](/products/identity/verify-sd-jwt)
* [Architecture and data flow](/technicals/architecture)
* [Credential issuance quickstart](/get-started/quickstarts/issue-credentials), if you also operate an issuer
# Bring your own auth
Source: https://docs.air3.com/get-started/recipes/bring-your-own-auth
Skip the AIR Kit login dialog and pass users from Firebase, Auth0, Supabase, or your custom auth into AIR Kit sessions using a Partner JWT.
If your app already authenticates users (via Firebase, Auth0, Supabase, or a custom system), you can bypass the AIR Kit login dialog and pass the authenticated user straight into an AIR Kit session. This is called [Custom Auth](/get-started/authentication/custom-auth).
## How it works
1. Your app authenticates the user through your own system.
2. Your backend signs a Partner JWT containing the user's `email`.
3. Your frontend passes that JWT to `airService.login({ authToken })`.
4. AIR Kit creates or loads the user's AIR Account, skipping the built-in login UI.
The first time an email is used, AIR verifies it with a one-time password. Later logins with that email skip this step.
## Prerequisites
* AIR Kit SDK installed and initialized. See [Web SDK](/get-started/sdks/web).
* A Partner JWT signing key, with its public key published through a registered [JWKS endpoint](/get-started/authentication/jwks-endpoint).
* The authenticated user's email address available on your backend.
## Step 1: Generate a Partner JWT on your backend
Include `email` and `partnerId`:
```js theme={null}
const jwt = require("jsonwebtoken");
const fs = require("fs");
const privateKey = fs.readFileSync("path/to/private.key");
function getAuthToken(user) {
const now = Math.floor(Date.now() / 1000);
return jwt.sign(
{
partnerId: process.env.PARTNER_ID,
email: user.email,
iat: now,
exp: now + 5 * 60,
},
privateKey,
{ algorithm: "RS256", header: { kid: process.env.KEY_ID, typ: "JWT" } }
);
}
// Express example
app.get("/api/air-token", requireAuth, (req, res) => {
const token = getAuthToken(req.user);
res.json({ token });
});
```
## Step 2: Fetch the token and log in on the frontend
```ts theme={null}
import { AirService, BUILD_ENV } from "@mocanetwork/airkit";
const airService = new AirService({ partnerId: "your-partner-id" });
await airService.init({ buildEnv: BUILD_ENV.SANDBOX });
const res = await fetch("/api/air-token");
const { token } = await res.json();
await airService.login({ authToken: token });
```
The user is now logged in without the AIR Kit login dialog, apart from the one-time email check on first use. Their AIR Account is tied to the email from your system.
## Step 3: Use AIR Kit features normally
After login, all SDK methods work as usual — issue credentials, verify credentials, access smart accounts:
```ts theme={null}
if (airService.isLoggedIn) {
const userInfo = await airService.getUserInfo();
console.log("User:", userInfo);
}
```
## Next steps
* [Custom Auth](/get-started/authentication/custom-auth) for the JWT payload reference
* [SDK authentication](/get-started/authentication/sdk-auth) for signing in other languages
* [Sessions & user info](/get-started/authentication/sessions) and [MFA](/get-started/authentication/mfa)
# Task-oriented AIR Kit recipes
Source: https://docs.air3.com/get-started/recipes/index
Task-oriented AIR Kit recipes — focused, copy-paste guides for KYC issuance, loyalty credentials, custom auth, wagmi, and verification flows.
Recipes are focused, task-oriented guides that show you how to accomplish specific goals with AIR Kit. Unlike the [Solutions guides](/solutions) (organized by industry vertical), recipes answer the question "How do I do X?"
Wire a KYC-complete webhook to server-side issuance so credentials appear in user accounts automatically.
Issue tiered loyalty credentials from your backend when users hit spend or activity thresholds.
Prompt a user to present a credential and gate features based on the ZK verification result.
Use the AIR Kit wagmi connector to access smart accounts and sign transactions with React Hooks.
Skip the AIR Kit login dialog and use your existing authentication system with Partner JWT.
# Issue a KYC credential
Source: https://docs.air3.com/get-started/recipes/issue-kyc-credential
Wire a KYC-complete webhook to AIR credential issuance so signed, encrypted verifiable credentials land in users' AIR Accounts with no active session required.
This recipe shows how to issue a verifiable KYC credential the moment your identity provider confirms a user, using [direct issuance](/products/identity/issuing-credentials#direct-issuance). The user does not need an active session.
## What you'll build
1. A webhook handler that fires when your KYC provider confirms a user.
2. A Partner JWT signed with the `issue` scope; the user's email is sent separately to `initialize-user`.
3. A call to `initialize-user` to resolve the recipient's DID and public key.
4. Issuance: build the VC, sign it with issuer keys, encrypt it to the holder, and store it in DStorage.
## Prerequisites
* A published issuance program with a schema that includes KYC fields (e.g. `kycVerified`, `kycLevel`, `verifiedAt`)
* [Partner JWT](/get-started/authentication/sdk-auth) signing configured (RS256 or ES256)
* Issuer signing keys available to your backend
## Step 1: Handle the KYC webhook
When your KYC provider (Sumsub, Onfido, Jumio, etc.) sends a verification-complete callback, extract the user email and verification result.
```js theme={null}
const express = require("express");
const app = express();
app.use(express.json());
app.post("/webhooks/kyc-complete", async (req, res) => {
const { userEmail, kycLevel, verifiedAt } = req.body;
if (!userEmail) return res.status(400).json({ error: "Missing userEmail" });
try {
const result = await issueKycCredential(userEmail, { kycLevel, verifiedAt });
res.json({ success: true, storagePath: result.storagePath });
} catch (err) {
console.error("Issuance failed:", err.message);
res.status(500).json({ error: err.message });
}
});
```
## Step 2: Sign a Partner JWT
Generate a short-lived JWT with `scope: "issue"`. The JWT does not carry an `email` claim — the recipient is identified in the `initialize-user` call below.
The recipient's email (passed to `initialize-user`) is the routing key that determines which AIR Account the credential lands in. Resolve it from the triggering event, and never reuse a partner, service, admin, or static email across recipients — every credential issued against that email lands in the same account.
```js theme={null}
const jwt = require("jsonwebtoken");
const fs = require("fs");
const privateKey = fs.readFileSync("path/to/private.key");
function getPartnerJwt() {
const now = Math.floor(Date.now() / 1000);
return jwt.sign(
{
partnerId: process.env.PARTNER_ID,
scope: "issue",
// No email claim — the recipient is passed to initialize-user, not the JWT
iat: now,
exp: now + 5 * 60,
},
privateKey,
{
algorithm: "RS256",
header: { kid: process.env.KEY_ID, typ: "JWT" },
}
);
}
```
## Step 3: Resolve the user and issue
Resolve the recipient's AIR Account with `initialize-user`, then build, sign, encrypt, and store the credential. `buildVc`, `signVc`, and `encryptToHolder` are issuer-controlled helpers backed by your signing keys.
```js theme={null}
const { buildVc, signVc, encryptToHolder } = require("./lib/credential");
const BASE_URL =
process.env.NODE_ENV === "production"
? "https://mocachain-mainnet.api.air3.com/v1"
: "https://api.sandbox.mocachain.org/v1";
async function issueKycCredential(recipientEmail, kycData) {
const token = getPartnerJwt();
// 1. Resolve or create the recipient's AIR Account
const initRes = await fetch(`${BASE_URL}/auth/initialize-user`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-partner-auth": token },
body: JSON.stringify({ email: recipientEmail }),
});
if (!initRes.ok) throw new Error(`initialize-user failed: ${initRes.status}`);
const { did, publicKey } = await initRes.json();
// 2. Build, sign, and encrypt the credential (issuer-controlled)
const schemaId = process.env.CREDENTIAL_ID;
const vc = buildVc({
holderDid: did,
schemaId,
credentialSubject: {
kycVerified: true,
kycLevel: kycData.kycLevel,
verifiedAt: kycData.verifiedAt || new Date().toISOString(),
},
});
const signed = signVc(vc); // SD-JWT VC, issuer-controlled key
const encrypted = encryptToHolder(signed, publicKey);
// 3. Store the encrypted envelope in DStorage
const storeRes = await fetch(`${BASE_URL}/dstorage/vcs`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-partner-auth": token },
body: JSON.stringify({
holderDid: did,
schemaId,
expiresAt: vc.expirationDate,
data: encrypted.encryptedData,
iv: encrypted.iv,
authTag: encrypted.authTag,
encryptedKey: encrypted.dataEncPublicKey,
externalId: vc.id,
}),
});
if (!storeRes.ok) throw new Error(`dstorage/vcs failed: ${storeRes.status}`);
return storeRes.json(); // { storagePath }
}
```
Issuance typically completes in \~1–4 seconds. Record the returned `storagePath` as your issuance result; the credential is immediately available for the holder to present to any AIR verifier.
## Next steps
* [Reusable KYC demo](https://air-bd-v2.netlify.app/demo/reusable-kyc) to see the issued credential reused by a partner
* [Issuance API Reference](/api-reference/issuance-api) for full endpoint details
* [Schema Creation](/products/identity/schema-creation) to define your KYC credential schema
* [Credential Verification](/products/identity/verify) to let verifiers check the credential
# Verify a credential
Source: https://docs.air3.com/get-started/recipes/verify-credential
Gate a feature in your app on a loyalty tier credential: request the tier, verify the presentation on your backend, and unlock or restrict access.
This recipe gates a feature on a loyalty tier. When a user opens a members-only page, your app asks for their tier credential, checks the result, and unlocks the page only for qualifying tiers.
It builds on the [verification quickstart](/get-started/quickstarts/verify-credentials), which covers the setup this recipe reuses: the server route that returns a Partner JWT and nonce, and the backend route that verifies the presentation.
## Prerequisites
* The verification quickstart's `/api/verification-token` and `/api/verify-presentation` routes.
* A schema with a `tier` claim, and a test user who holds a credential from an issuer you accept.
## Step 1: Create a program that requests the tier
1. In the [Developer Dashboard](https://developers.sandbox.air3.com/dashboard), go to **Verifier → Programs** and create a program.
2. Select the schema with the `tier` claim and the issuers you accept.
3. Request the `tier` claim, and publish the program.
4. Copy the verification program ID.
## Step 2: Request the tier when the user opens the page
```ts theme={null}
async function verifyLoyaltyTier() {
// Your server returns a Partner JWT and a one-time nonce
const response = await fetch("/api/verification-token", { method: "POST" });
const { token, nonce } = await response.json();
const result = await airService.verifyCredential({
authToken: token,
programId: process.env.NEXT_PUBLIC_VERIFY_PROGRAM_ID,
fieldsToDisclose: ["tier"],
nonce,
});
if (result.status === "Compliant") {
grantAccess(result.verifiablePresentation);
} else {
denyAccess(result.status);
}
}
```
```dart theme={null}
final token = await fetchVerificationToken(); // your server endpoint
final result = await airService.verifyCredential(
authToken: token,
programId: verifyProgramId,
);
if (result.status == "COMPLIANT") {
grantAccess();
} else {
denyAccess(result.status);
}
```
If the user doesn't hold the credential yet, pass an optional `redirectUrl` that points to the issuer's claim page.
## Step 3: Gate the feature on the result
Only `"Compliant"` results carry a `verifiablePresentation`. Other statuses, such as `"Non-Compliant"`, `"NotFound"`, `"Expired"`, or `"Revoked"`, have none. See the [response reference](/products/identity/verify#response) for the full list.
```ts theme={null}
async function grantAccess(presentation) {
// Verify the SD-JWT on your backend before unlocking the feature.
const check = await fetch("/api/verify-presentation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ verifiablePresentation: presentation }),
});
if (check.ok) unlockFeature();
}
function denyAccess(status) {
if (status === "Non-Compliant") {
// Credential exists but fails the program's conditions — show a restricted view.
return;
}
// Missing, pending, expired, or revoked credential — send the user to the
// issuer flow or ask them to renew. Log `status` to distinguish the cases.
}
```
Check the disclosed `tier` value on your backend when it verifies the presentation, and unlock the page only for the tiers you allow. Wrap `verifyCredential` in `try/catch`: a cancelled consent dialog or a rejected token rejects the promise instead of returning a status.
A `"Compliant"` result is checked in the user's browser, and the presentation wrapper is not signed. Before granting anything of value, verify the SD-JWT inside it on your backend, including the key-binding JWT against your nonce. See [Verify SD-JWT on your backend](/products/identity/verify-sd-jwt).
## Next steps
* Try it live: [member discount at a café](https://air-bd-v2.netlify.app/try-it-live/member-discount) or [show a seller you passed an ID check](https://air-bd-v2.netlify.app/try-it-live/meet-verified)
* [Verifying credentials](/products/identity/verify) for the full result shape and selective disclosure
* [Verifying your first credential](/get-started/quickstarts/verify-credentials) for the full setup
* [Schema Design](/products/identity/schema-overview) to understand which fields a program can check
# Android setup
Source: https://docs.air3.com/get-started/sdks/flutter/android-setup
Configure Android for AIR Kit Flutter — Digital Asset Links, AndroidManifest entries, network security config, build.gradle settings, and SHA fingerprints.
## Prerequisites
* Android Gradle Plugin and Kotlin versions supported by your Flutter template
* **`minSdk` 26** and **`compileSdk` 34** (required for AIR Kit)
## Asset statements (Digital Asset Links)
Create or edit `android/app/src/main/res/values/strings.xml`:
```xml theme={null}
[
{"include": "https://account.air3.com/.well-known/assetlinks.json"},
{"include": "https://account.sandbox.air3.com/.well-known/assetlinks.json"}
]
```
Reference the resource from `AndroidManifest.xml` inside ``:
```xml theme={null}
```
## App signing and allowlisting
Provide your app’s **SHA-256 signing certificate fingerprint** and **package name** to Moca for allowlisting (debug and release may differ). Use `keytool`:
```bash theme={null}
keytool -list -v -keystore -alias
```
## `minSdk` and `compileSdk`
In your app-level Gradle file (Kotlin DSL or Groovy), set at least:
```kotlin theme={null}
android {
compileSdk = 34
defaultConfig {
minSdk = 26
}
}
```
## Network security config
Optional: add `android/app/src/main/res/xml/network_security_config.xml` for TLS and (if needed) local dev domains:
```xml theme={null}
10.0.2.2
```
Point `` to it:
```xml theme={null}
android:networkSecurityConfig="@xml/network_security_config"
```
## Permissions
Typical requirements:
```xml theme={null}
```
## ProGuard / R8
If you minify release builds, add keep rules for AIR Kit, WebView, and crypto dependencies. **Verify package names** against your resolved dependencies after `flutter pub get`:
```proguard theme={null}
-keepattributes Signature,*Annotation*,EnclosingMethod,InnerClasses
# Adjust package patterns to match your app’s dependencies
-keep class com.google.android.gms.** { *; }
-dontwarn com.google.android.gms.**
```
## Verify the build
```bash theme={null}
flutter clean
flutter pub get
flutter build apk
```
## Troubleshooting
| Issue | What to check |
| - | - |
| Passkey / domain errors | Asset statements JSON, signing cert vs allowlist, `meta-data` wiring |
| WebView blank / SSL | `networkSecurityConfig`, INTERNET permission |
| Gradle conflicts | `flutter clean`, `./gradlew clean` under `android/` |
More help: [Flutter troubleshooting](/help/sdk-flutter) and [Web SDK issues](/help/sdk-web).
## Next steps
* [iOS setup](/get-started/sdks/flutter/ios-setup)
* [Google Sign-In](/get-started/sdks/flutter/google-signin) (optional)
* [Initialize](/get-started/sdks/flutter/installation#initialize)
## Reference
* [Digital Asset Links](https://developers.google.com/digital-asset-links/v1/getting-started)
* [Flutter Android deploy](https://docs.flutter.dev/deployment/android)
# Google Sign-In
Source: https://docs.air3.com/get-started/sdks/flutter/google-signin
Enable Google Sign-In in AIR Kit Flutter apps — configure OAuth client IDs in Firebase, set up Info.plist on iOS, and google-services on Android.
AIR Kit can expose **Google** as a login method when your partner configuration enables it. Configure native Google Sign-In on each platform so the SDK can complete the OAuth flow.
## Prerequisites
* [Flutter installation](/get-started/sdks/flutter/installation) and platform setup ([Android](/get-started/sdks/flutter/android-setup), [iOS](/get-started/sdks/flutter/ios-setup))
* Google Cloud / Firebase project with OAuth client IDs for your Android package name and iOS bundle ID
* Google OAuth settings aligned with the [Developer Dashboard](https://developers.sandbox.air3.com/dashboard/general)
## How it connects to AIR Kit
Partner configuration supplies the Google OAuth client behavior expected by AIR Kit. Ensure the **same OAuth clients** you configure in Firebase are the ones you share with Moca for manual dashboard setup (see [Dashboard](#dashboard)).
## iOS
1. In [Firebase Console](https://console.firebase.google.com/), add an iOS app with your bundle ID.
2. Download `GoogleService-Info.plist` and add it to `ios/Runner/` in Xcode (copy if needed).
3. In `Info.plist`, add `CFBundleURLTypes` using the **`REVERSED_CLIENT_ID`** from `GoogleService-Info.plist` as the URL scheme (see Google’s Flutter / iOS Sign-In docs).
## Android
1. In Firebase, add an Android app with your **application ID** (package name).
2. Download `google-services.json` to `android/app/`.
3. In the **project** `android/build.gradle`, include the Google services classpath if required by your template.
4. In the **app** `android/app/build.gradle`, apply the Google Services plugin per [FlutterFire / Google Sign-In setup](https://pub.dev/packages/google_sign_in).
## Dashboard
Partners must provide the **Google OAuth client IDs** for iOS and Android (from Firebase / Google Cloud). The Developer Dashboard does **not** yet let partners enter these values themselves. Moca sets them **manually** for your partner, the same way as other allowlisted values (for example Android signing certificate fingerprints and iOS app identifiers — see [Android setup](/get-started/sdks/flutter/android-setup) and [iOS setup](/get-started/sdks/flutter/ios-setup)).
Enable Google as a login method in partner configuration once your client IDs are on file. To add or update client IDs, contact the AIR team.
## Troubleshooting
| Symptom | Checks |
| - | - |
| `redirect_uri_mismatch` | iOS URL scheme matches `REVERSED_CLIENT_ID`; OAuth client matches bundle ID |
| Google button missing | Partner config; correct `partnerId` and environment |
| Android build errors | `google-services.json` path; package name; Play Services on device |
## Security
* Do not commit `GoogleService-Info.plist` or `google-services.json` to public repos if they contain secrets; use CI secrets or private config distribution.
## Next steps
* [Login](/get-started/authentication/login)
* [SDK authentication](/get-started/authentication/sdk-auth)
* [Sessions & user info](/get-started/authentication/sessions)
## Reference
* [Google Sign-In for Flutter](https://pub.dev/packages/google_sign_in)
* [Google Sign-In iOS](https://developers.google.com/identity/sign-in/ios/start)
* [Google Sign-In Android](https://developers.google.com/identity/sign-in/android/start)
# Install the AIR Kit SDK
Source: https://docs.air3.com/get-started/sdks/flutter/installation
Install the AIR Kit Flutter SDK via OnePub, configure minimum platform versions for iOS and Android, and add it to your pubspec.yaml.
## Prerequisites
* Flutter SDK and Dart toolchain on your PATH
* OnePub access (credentials provided by Moca)
* Android: `compileSdk` 34+, `minSdk` 26+ (see [Android setup](/get-started/sdks/flutter/android-setup))
* iOS: platform 14.0+ in `Podfile` (see [iOS setup](/get-started/sdks/flutter/ios-setup))
Production mobile apps should use the production AIR pages and domains, including `account.air3.com` for passkey setup. Moca Chain mainnet is live; see [Production mainnet access](/get-started/environments/about#production-mainnet-access).
## OnePub CLI
```bash theme={null}
dart pub global activate onepub
onepub login
```
If `onepub` is not found, add Pub’s global bin directory to your PATH, for example: `export PATH=$PATH:$HOME/.pub-cache/bin`
## Add the dependency
**Option A — `pubspec.yaml`**
```yaml theme={null}
dependencies:
airkit:
hosted: https://onepub.dev/api/sxhddavuhn/
version: ^1.6.0
```
Use the version your team recommends; check [Release notes](/api-reference/release-notes) for current Flutter SDK versions.
**Option B — CLI**
```bash theme={null}
onepub pub add airkit
```
Then run:
```bash theme={null}
flutter pub get
```
## Import
```dart theme={null}
import 'package:airkit/airkit.dart';
```
## Initialize
Create an `AirService` instance and call `initialize`:
```dart theme={null}
Future initialize({
required String partnerId,
required GlobalKey navigatorKey,
Environment env = Environment.production,
bool enableLogging = false,
})
```
You receive your `partnerId` from us. The `navigatorKey` is used to overlay the dialogs that specific user flows need. During development, use `Environment.sandbox` and enable logging.
```dart theme={null}
final navigatorKey = GlobalKey();
final airService = AirService();
await airService.initialize(
partnerId: 'YOUR_PARTNER_ID',
navigatorKey: navigatorKey,
env: Environment.sandbox,
enableLogging: true,
);
// In MaterialApp: navigatorKey: navigatorKey
```
## Next steps
1. [Android setup](/get-started/sdks/flutter/android-setup) — asset statements, manifest, network security
2. [iOS setup](/get-started/sdks/flutter/ios-setup) — associated domains, `Podfile`
3. [Google Sign-In](/get-started/sdks/flutter/google-signin) if you use Google login
# iOS setup
Source: https://docs.air3.com/get-started/sdks/flutter/ios-setup
Configure iOS for AIR Kit Flutter — Associated Domains, Podfile entries, passkey entitlements, and minimum deployment target for in-app login.
## Prerequisites
* Xcode and CocoaPods
* **iOS deployment target 14.0+** in `Podfile` (required for AIR Kit)
## Associated Domains
1. Open `ios/Runner.xcworkspace` in Xcode.
2. Select the **Runner** target → **Signing & Capabilities**.
3. Add **Associated Domains**.
4. Add the domains you use:
```
webcredentials:account.air3.com
webcredentials:account.sandbox.air3.com
```
Optional for non-production testing:
```
webcredentials:account.staging.air3.com
webcredentials:account.uat.air3.com
```
Include only environments you actually use. Production and sandbox are the usual minimum.
## App ID allowlisting
Provide Moca with your **Apple Team ID** and **bundle identifier** in the form `TEAMID.com.example.app`.
## Podfile
Set the platform version:
```ruby theme={null}
platform :ios, '14.0'
```
Then:
```bash theme={null}
cd ios
pod install
```
## Info.plist (optional)
If you need ATS exceptions for specific domains, configure them narrowly in `Info.plist`. Prefer default secure TLS where possible.
Optional usage strings if you add camera / photo features:
```xml theme={null}
NSCameraUsageDescription
Required for features that use the camera.
```
## Testing
* Passkeys and full WebAuthn behavior require a **physical device** in many cases.
* Build with `flutter build ios` and run from Xcode or `flutter run`.
## Troubleshooting
| Issue | What to check |
| - | - |
| Domain verification failures | Capability enabled, correct team, provisioning profile, domains spelled correctly |
| `pod install` errors | `rm -rf Pods Podfile.lock`, `pod repo update`, `pod install` |
| WebView / network | Associated Domains, device network, ATS settings |
See also [Flutter troubleshooting](/help/sdk-flutter).
## Next steps
* [Google Sign-In](/get-started/sdks/flutter/google-signin) (optional)
* [Initialize](/get-started/sdks/flutter/installation#initialize)
* [Login](/get-started/authentication/login)
## Reference
* [Associated Domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains)
* [Flutter iOS deploy](https://docs.flutter.dev/deployment/ios)
# Overview
Source: https://docs.air3.com/get-started/sdks/flutter/overview
Integrate AIR Kit in Flutter apps for AIR Account login, embedded wallets, credential issuance and verification, and native UI flows on iOS and Android.
The AIR Kit Flutter SDK brings AIR Account login, the embedded wallet, and credential issuance and verification to iOS and Android apps. Set it up with [Installation](/get-started/sdks/flutter/installation), then [Android setup](/get-started/sdks/flutter/android-setup) and [iOS setup](/get-started/sdks/flutter/ios-setup). See [Platform support](/get-started/sdks/platform-support) for supported versions.
## Credentials on Flutter
`issueCredential` and `verifyCredential` are available on Flutter. The [issuance](/get-started/quickstarts/issue-credentials) and [verification](/get-started/quickstarts/verify-credentials) quickstarts use Web code, but the issuer service, Dashboard setup, and Partner JWT steps are the same. [Direct issuance](/api-reference/issuance-api) runs on your backend, so it works with any client.
## Authentication on Flutter
On Flutter, sign-in uses `login` with a **Partner JWT** (`authToken`) after `initialize()`. See [Login](/get-started/authentication/login) (Flutter tab) and [SDK authentication](/get-started/authentication/sdk-auth).
## Environment and dashboard
Use the [Developer Dashboard](https://developers.sandbox.air3.com/dashboard) for partner ID, programs, and configuration. Environment values match [Environments](/get-started/environments/about) (`Environment.sandbox`, `production`, etc.).
## Troubleshooting
See [Flutter troubleshooting](/help/sdk-flutter) for common setup and login issues.
# Provider functions
Source: https://docs.air3.com/get-started/sdks/flutter/provider-functions
Call EIP-1193 Ethereum JSON-RPC methods through AirService on Flutter — eth_accounts, eth_chainId, eth_sendTransaction, and more for smart accounts.
On Flutter, `AirService.sendEthereumRpcRequest` exposes an **EIP-1193-style** surface for common RPC methods. Use it directly or wrap it for libraries like **web3dart**.
**wagmi** is for web React apps. For wagmi, see [Wagmi integration](/products/money/wagmi-integration) and the Web SDK [provider](/products/money/provider-functions) docs.
## Request and response shapes
See [Reference](/api-reference/sdk-reference) (Flutter tab) for `EthereumRpcRequest` and `EthereumRpcSuccessResponse`.
## Examples
### `eth_accounts` and `eth_chainId`
```dart theme={null}
final accountsRes = await airService.sendEthereumRpcRequest(
EthereumRpcRequest(method: 'eth_accounts', params: []),
);
final chainRes = await airService.sendEthereumRpcRequest(
EthereumRpcRequest(method: 'eth_chainId', params: []),
);
```
### `eth_getBalance`
```dart theme={null}
final address = await airService.getAbstractAccountAddress();
if (address != null) {
final res = await airService.sendEthereumRpcRequest(
EthereumRpcRequest(
method: 'eth_getBalance',
params: [address, 'latest'],
),
);
}
```
### Switch or add chain
```dart theme={null}
await airService.sendEthereumRpcRequest(
EthereumRpcRequest(
method: 'wallet_switchEthereumChain',
params: [
{'chainId': '0x1'},
],
),
);
```
## web3dart
You can implement a thin adapter that forwards `JsonRpc` calls to `sendEthereumRpcRequest`. Keep the adapter in your app repo and test against your SDK version.
## Next steps
* [Wallet operations](/get-started/sdks/flutter/wallet-operations)
* [Supported chains](/products/money/supported-chains)
# UI components
Source: https://docs.air3.com/get-started/sdks/flutter/ui-components
Use built-in AIR Kit UI components on Flutter — native login screen plus hosted UI for token swap, fiat on-ramp, and multi-factor authentication.
AIR Kit exposes **native Flutter UI** for login and several **hosted UI flows** through `AirService`. APIs below match the [Reference](/api-reference/sdk-reference) Flutter tab.
## Prerequisites
* [Initialization](/get-started/sdks/flutter/installation#initialize) with a valid `GlobalKey` on `MaterialApp`
* For **Swap**, **On-ramp**, and **MFA** below: an authenticated session after `login`
* Optional: `preloadWallet()` before opening wallet UIs for smoother first open
## Login
`login` presents **native Flutter UI** for the configured login methods. After `initialize()`, call `login` with your **Partner JWT** (`authToken`) as described in [Login](/get-started/authentication/login) (Flutter tab).
## Swap UI
```dart theme={null}
await airService.showSwapUi();
```
## On-ramp UI
```dart theme={null}
await airService.showOnRampUi(
displayCurrencyCode: 'USD',
targetCurrencyCode: 'ETH', // optional
);
```
## MFA setup
```dart theme={null}
await airService.setupOrUpdateMfa();
```
Check status via `getUserInfo()` → `user.isMFASetup` (see Reference models).
## Theming, locale, and currency
Web SDK uses `sessionConfig` / `updateSessionConfig` on `init`. For Flutter, confirm behavior in your SDK version and [Release notes](/api-reference/release-notes). Dashboard and partner configuration also affect login and widget appearance — see [Theming](/get-started/customization/theming) and [Language support](/get-started/customization/language) and [Other options](/get-started/customization/other-options) for product-level options.
## Web-first built-in UI
Some Account Services UIs documented under [Built-in UI](/products/money/builtin-ui) are Web-first; Flutter parity varies by release. Confirm APIs and behavior in the [Reference](/api-reference/sdk-reference) Flutter tab and [Release notes](/api-reference/release-notes).
## Next steps
* [Wallet operations](/get-started/sdks/flutter/wallet-operations)
* [Login](/get-started/authentication/login)
# Wallet operations
Source: https://docs.air3.com/get-started/sdks/flutter/wallet-operations
Use AirService on Flutter to fetch accounts, balances, sign messages, and call smart contract methods from the embedded AIR Kit wallet.
After [initialization](/get-started/sdks/flutter/installation#initialize) and login, use `AirService` for embedded wallet operations. Method names match the [Reference](/api-reference/sdk-reference) Flutter tab.
## Prerequisites
* `await airService.initialize(...)` completed
* User authenticated (see [Login](/get-started/authentication/login))
* Optional: `await airService.preloadWallet()` before first wallet UI or heavy RPC use
## Accounts and address
```dart theme={null}
final accounts = await airService.getAccounts();
final address = await airService.getAbstractAccountAddress();
```
## Balances
```dart theme={null}
final address = await airService.getAbstractAccountAddress();
if (address != null) {
final balanceWei = await airService.getBalance(address);
// Format for display (example: 18 decimals)
}
```
## Sign messages
```dart theme={null}
final signature = await airService.signMessage('Hello from AIR Kit');
```
## Contract reads (`call`)
```dart theme={null}
final result = await airService.call(
'0xContractAddress',
'balanceOf',
[/* parameters */],
erc20AbiJsonString,
);
```
## Transactions (`sendTransaction`)
Use `web3dart` `Transaction` / `Transaction.callContract` types as in your SDK version:
```dart theme={null}
import 'package:web3dart/web3dart.dart';
// Example shape — adjust gas, chain, and contract types to your app
final txHash = await airService.sendTransaction(transaction);
```
## Smart account deployment
```dart theme={null}
final deployed = await airService.isSmartAccountDeployed();
if (!deployed) {
final txHash = await airService.deploySmartAccount();
}
```
## Built-in wallet UI
* Swap: `await airService.showSwapUi();`
* On-ramp: `await airService.showOnRampUi(displayCurrencyCode: 'USD');`
See [UI components](/get-started/sdks/flutter/ui-components).
## Error handling
Catch `AirKitException` and inspect `type` (`client`, `sdk`, `server`, `unknown`) for logging and user messaging.
## Next steps
* [Provider functions](/get-started/sdks/flutter/provider-functions) — raw JSON-RPC
* [Account Services](/products/money/smart-account) — product concepts (web-centric sections may still apply conceptually)
* [Paymaster](/products/money/paymaster) — gas sponsorship where supported
# Platform support
Source: https://docs.air3.com/get-started/sdks/platform-support
Supported platforms, browsers, frameworks, and module formats for the AIR Kit Web and Flutter SDKs, plus server languages for direct issuance.
## SDK Packages
| SDK | Package | Install |
| - | - | - |
| Web (JS / TS) | `@mocanetwork/airkit` | `npm i @mocanetwork/airkit` |
| Flutter | `airkit` (Dart) | OnePub hosted — see [Flutter installation](/get-started/sdks/flutter/installation) |
For the latest version numbers see [Release Notes](/api-reference/release-notes).
## Platform Support
| Platform | Web SDK | Flutter SDK |
| - | :-: | :-: |
| Web (Browser) | ✓ | — |
| iOS (native) | — | ✓ |
| Android (native) | — | ✓ |
| React Native | Partial — WebView only | — |
| Electron | ✓ | — |
## Browser Support (Web SDK)
| Browser | Minimum Version | Notes |
| - | :-: | - |
| Chrome / Chromium | 126+ | Full support (passkey required) |
| Firefox | 97+ | Full support (passkey required) |
| Safari | 16+ | Full support (passkey required) |
| Edge | 126+ | Full support (Chromium-based) |
| Opera | 112+ | Full support (Chromium-based) |
| Samsung Internet | 14+ | Full support |
| IE 11 | ✗ | Not supported |
## Framework Compatibility (Web SDK)
| Framework | Status | Notes |
| - | :-: | - |
| React 17+ | ✓ | Full support |
| React 16 | ✓ | Full support |
| Next.js 13+ (App Router) | ✓ | Add `'use client'` directive to the initializing component |
| Next.js 12 (Pages Router) | ✓ | Full support |
| Vue 3 | ✓ | Full support |
| Vue 2 | ✓ | Full support |
| Nuxt 3 | ✓ | Full support |
| SvelteKit | ✓ | Full support |
| Angular 14+ | ✓ | Full support |
| Vanilla JS | ✓ | UMD / ESM builds available |
## Module Formats (Web SDK)
| Format | Import | Best For |
| - | - | - |
| ESM (`dist/airkit.esm.js`) | `import { AirService } from '@mocanetwork/airkit'` | Vite, Webpack 5, modern bundlers |
| CommonJS (`dist/airkit.cjs.js`) | `const { AirService } = require('@mocanetwork/airkit')` | Node.js, older bundlers |
| UMD (`dist/airkit.umd.min.js`) | `