Skip to main content

Identities & credentials

Every tenant and member can carry a canonical Permara identity — a did:safebank:<id> that wallets bind to and rotate under (this is the credential subject; the payment identity payers address is the generated Permara ID, did:permara:<id>, whose document lists this subject in alsoKnownAs). Verified facts about an identity become credentials: EIP-712-signed artifacts a wallet can later present with replay protection. Verification is local-first (the API re-hashes the stored document and checks the signature); the credential subject is the DID, never a wallet address, so wallet rotation never breaks credential continuity.

Two issuer classes sign credentials:

IssuerSignsVerified by
PLATFORMProvider-verified facts (PERSON_IDENTITY, BUSINESS_IDENTITY, DESTINATION_OWNERSHIP, memberships)Permara's per-environment issuer EOA (EIP-712 signTypedData)
ORGANIZATIONMoney authority (AUTHORITY_DELEGATION, PAYMENT_POLICY)Your deployed org Safe, on-chain via EIP-1271 — never a custodied hot key

This page walks the three ceremonies. For KYC/KYB session mechanics (QR handoff, sandbox simulate, normalized statuses) see Identity (KYC/KYB); for authority grants see Approvals & authority.

Ceremony A — verification session → signed credential

A VERIFIED session issues a credential in the same transaction. With signing configured, the credential carries a signed, sealed artifact.

1. Start the session (identity:write):

curl -X POST https://api.sandbox.permara.com/v1/verification-sessions \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "kind": "KYC", "memberId": "mem_1a2b3c", "country": "DE" }'
{
"id": "vs_9f8e7d",
"kind": "KYC",
"subjectType": "TENANT_MEMBER",
"subjectId": "mem_1a2b3c",
"legalName": "Ada Lovelace",
"status": "CREATED",
"requirements": [],
"credentialId": null,
"createdAt": "2026-08-25T10:00:00.000Z",
"updatedAt": "2026-08-25T10:00:00.000Z"
}

2. Complete verification. In LIVE the human finishes the provider flow (usually via the QR handoff); status flows back by webhook and reconcile poller. In the sandbox (TEST key + mock provider), advance deterministically:

# Repeat until status = VERIFIED (each call advances one step).
curl -X POST https://api.sandbox.permara.com/v1/verification-sessions/vs_9f8e7d/simulate \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY"

3. Read the credential the VERIFIED transition issued (GET /v1/verification-sessions/vs_9f8e7d now carries credentialId):

curl https://api.sandbox.permara.com/v1/credentials/cred_5d6e7f \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY"
{
"id": "cred_5d6e7f",
"kind": "PERSON_IDENTITY",
"subjectType": "TENANT_MEMBER",
"subjectId": "mem_1a2b3c",
"status": "ACTIVE",
"legalName": "Ada Lovelace",
"issuedAt": "2026-08-25T10:05:00.000Z",
"expiresAt": "2027-08-25T10:05:00.000Z",
"subjectDid": "did:safebank:c1a2b3c4d5e6f7a8b9c0d1e2f",
"credentialHash": "0x4d2a…",
"issuerKind": "PLATFORM"
}

4. Fetch and verify the signed artifact (404 CREDENTIAL_ARTIFACT_NOT_AVAILABLE for legacy unsigned rows):

curl https://api.sandbox.permara.com/v1/credentials/cred_5d6e7f/artifact \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY"
{
"id": "cred_5d6e7f",
"document": {
"schema": "safebank/credential/PERSON_IDENTITY/v1",
"kind": "PERSON_IDENTITY",
"subjectDid": "did:safebank:c1a2b3c4d5e6f7a8b9c0d1e2f",
"claims": { "verificationStatus": "VERIFIED", "legalName": "Ada Lovelace" },
"provenance": { "provider": "mock", "providerSessionId": "mock_sess_1" },
"issuedAt": 1787652300,
"expiresAt": 1819188300
},
"signature": "0x…",
"credentialHash": "0x4d2a…",
"nonce": "241531…",
"issuerRef": "0xIssuerAddress…",
"schemaVersion": "safebank/credential/PERSON_IDENTITY/v1"
}

Anyone holding a PLATFORM-issued artifact (issuerKind: "PLATFORM") can verify it offline with viem — no Permara round-trip. The typed message binds the document through its hash; the domain anchor 0x5afeba…0002 is a synthetic verifyingContract, not a deployed contract:

import { createHash } from 'node:crypto'
import { verifyTypedData } from 'viem'
import { canonicalJson } from '@permara/attestations' // sorted-key JSON

const domain = {
name: 'Permara',
version: '1',
chainId: 84532, // Base Sepolia in sandbox
verifyingContract: '0x5afeba0000000000000000000000000000000002',
} as const

const types = {
CredentialAttestation: [
{ name: 'subjectDid', type: 'string' },
{ name: 'orgDid', type: 'string' },
{ name: 'kind', type: 'string' },
{ name: 'schemaVersion', type: 'string' },
{ name: 'docHash', type: 'bytes32' },
{ name: 'issuedAt', type: 'uint64' },
{ name: 'expiresAt', type: 'uint64' },
{ name: 'nonce', type: 'uint256' },
],
} as const

// docHash = sha256 of the CANONICAL JSON document (sorted keys,
// optional fields omitted — use the document exactly as returned).
const docHash = `0x${createHash('sha256')
.update(canonicalJson(artifact.document))
.digest('hex')}` as const

const ok = await verifyTypedData({
address: artifact.issuerRef,
domain,
types,
primaryType: 'CredentialAttestation',
message: {
subjectDid: artifact.document.subjectDid,
orgDid: artifact.document.orgDid ?? '',
kind: artifact.document.kind,
schemaVersion: artifact.document.schema,
docHash,
issuedAt: BigInt(artifact.document.issuedAt),
expiresAt: BigInt(artifact.document.expiresAt ?? 0),
nonce: BigInt(artifact.nonce),
},
signature: artifact.signature,
})

The same types/domain (and canonicalJson) ship in @permara/attestations (verifyCredentialAttestation) — prefer importing them over hand-rolling.

ORGANIZATION-issued credentials (issuerKind: "ORGANIZATION", e.g. AUTHORITY_DELEGATION / PAYMENT_POLICY) are signed by the tenant's Safe, not an EOA — verifyTypedData alone cannot validate them because a contract signature never recovers to an address. Verify them EIP-1271 against the deployed Safe with an RPC-backed client:

import { http, createPublicClient, hashTypedData } from 'viem'

const client = createPublicClient({ transport: http(RPC_URL) })
const hash = hashTypedData({ domain, types, primaryType: 'CredentialAttestation', message })
const ok = await client.verifyHash({
address: artifact.issuerRef, // the org Safe — trust = it IS the tenant Safe
hash,
signature: artifact.signature,
})

(the chainId in domain must be the chain the Safe is deployed on).

Ceremony B — wallet bind → challenge → sign → verify → rotate

Wallets prove control with an EIP-191 personal_sign over a server-issued challenge (deliberately not typed data — every wallet, including hardware and embedded wallets, can produce it). Bindings are single-use-challenge, CAS-activated, and one wallet can be ACTIVE under only one identity at a time.

1. Ensure the identity exists (identity:write):

curl -X POST https://api.sandbox.permara.com/v1/identities \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "subjectType": "TENANT_MEMBER", "memberId": "mem_1a2b3c" }'
{
"id": "c1a2b3c4d5e6f7a8b9c0d1e2f",
"did": "did:safebank:c1a2b3c4d5e6f7a8b9c0d1e2f",
"identityType": "PERSON",
"status": "ACTIVE",
"subjectType": "TENANT_MEMBER",
"subjectId": "mem_1a2b3c",
"createdAt": "2026-08-25T10:05:00.000Z"
}

2. Start the binding — returns a PENDING binding plus the exact challenge text to sign:

curl -X POST https://api.sandbox.permara.com/v1/identities/c1a2b3c…/wallets \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "walletAddress": "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B", "chainId": 84532 }'
{
"id": "wb_11aa22",
"identityId": "c1a2b3c4d5e6f7a8b9c0d1e2f",
"walletAddress": "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B",
"walletType": "EOA",
"status": "PENDING",
"verificationMethod": "SIGNATURE_CHALLENGE",
"challenge": "Permara wallet binding\ndid: did:safebank:c1a2b3c4d5e6f7a8b9c0d1e2f\nwallet: 0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B\nnonce: 3f9c…"
}

3. Sign the challenge with the wallet (EIP-191 — signMessage, NOT signTypedData for this ceremony):

import { privateKeyToAccount } from 'viem/accounts'

const account = privateKeyToAccount(process.env.WALLET_KEY as `0x${string}`)
const signature = await account.signMessage({ message: binding.challenge })

4. Verify — atomic PENDING→ACTIVE; a replayed or raced verify gets WALLET_BINDING_NOT_PENDING:

curl -X POST https://api.sandbox.permara.com/v1/identities/c1a2b3c…/wallets/wb_11aa22/verify \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "signature": "0x…" }'

5. Rotate — bind a NEW wallet in place of an ACTIVE one. The rotate call returns a new PENDING binding + challenge for the new wallet; verifying that binding retires the old one (ROTATED) in the same transaction. The identityId — and therefore every credential — never changes:

curl -X POST https://api.sandbox.permara.com/v1/identities/c1a2b3c…/wallets/wb_11aa22/rotate \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "newWalletAddress": "0xNewWallet…" }'
# → { "id": "wb_33cc44", "status": "PENDING", "challenge": "Permara wallet binding\n…" }

# Sign the new challenge with the NEW wallet, then:
curl -X POST https://api.sandbox.permara.com/v1/identities/c1a2b3c…/wallets/wb_33cc44/verify \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "signature": "0x…" }'
# → old binding ROTATED, new binding ACTIVE, same identity, same DID.

If the old binding stopped being ACTIVE while the rotation was open you get WALLET_BINDING_ROTATION_STALE (409) — start a fresh rotation.

Ceremony C — presentation challenge → sign → verify

A presentation proves, for one audience and one purpose, that a bound wallet controls the identity a set of credentials was issued to. Challenges are single-use either way: a failed verification burns the challenge, and a replay gets PRESENTATION_REPLAYED (409) even with a valid signature.

1. Mint the challenge (identity:read):

curl -X POST https://api.sandbox.permara.com/v1/credential-presentations/challenge \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "purpose": "payment.approve", "identityId": "c1a2b3c4d5e6f7a8b9c0d1e2f" }'
{
"nonce": "chal_9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
"audience": "https://api.sandbox.permara.com",
"domainTag": "0x5afeba0000000000000000000000000000000003",
"purpose": "payment.approve",
"expiresAt": "2026-08-25T10:15:00.000Z"
}

2. Sign the presentation with a bound wallet — EIP-712 signTypedData this time, under the …0003 presentation domain anchor. credentialHashes are the credentialHash values of the credentials you present, in the order you will send their ids:

import { privateKeyToAccount } from 'viem/accounts'

const account = privateKeyToAccount(process.env.WALLET_KEY as `0x${string}`)
const expiresAt = new Date(challenge.expiresAt)

const signature = await account.signTypedData({
domain: {
name: 'Permara',
version: '1',
chainId: 84532, // Base Sepolia in sandbox
verifyingContract: challenge.domainTag, // 0x5afeba…0003
},
types: {
CredentialPresentation: [
{ name: 'holderDid', type: 'string' },
{ name: 'audience', type: 'string' },
{ name: 'purpose', type: 'string' },
{ name: 'nonce', type: 'string' },
{ name: 'credentialHashes', type: 'bytes32[]' },
{ name: 'expiresAt', type: 'uint64' },
],
},
primaryType: 'CredentialPresentation',
message: {
holderDid: 'did:safebank:c1a2b3c4d5e6f7a8b9c0d1e2f',
audience: challenge.audience, // exactly as returned
purpose: challenge.purpose,
nonce: challenge.nonce, // verbatim — the single-use binding
credentialHashes: ['0x4d2a…'], // credential.credentialHash values
expiresAt: BigInt(Math.floor(expiresAt.getTime() / 1000)),
},
})

(@permara/attestations exports signCredentialPresentation / CREDENTIAL_PRESENTATION_TYPES if you'd rather not inline the types.)

3. Verify — consumes the challenge first, then checks holder identity, signer binding, each credential from first principles (signature over the re-hashed stored document, trusted issuer, validity window), and the presentation signature:

curl -X POST https://api.sandbox.permara.com/v1/credential-presentations/verify \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"nonce": "chal_9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
"holderDid": "did:safebank:c1a2b3c4d5e6f7a8b9c0d1e2f",
"signature": "0x…",
"signerAddress": "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B",
"credentialIds": ["cred_5d6e7f"],
"expiresAt": "2026-08-25T10:15:00.000Z"
}'
{
"verified": true,
"holderDid": "did:safebank:c1a2b3c4d5e6f7a8b9c0d1e2f",
"credentialResults": [
{ "credentialId": "cred_5d6e7f", "credentialHash": "0x4d2a…", "verdict": "VALID" }
],
"reasonCodes": [],
"presentationId": "pres_aa11bb"
}

One bad credential fails the whole bundle: per-credential verdicts are VALID | EXPIRED | REVOKED | UNTRUSTED_ISSUER | SIGNATURE_INVALID | NOT_FOUND, and bundle-level reasonCodes include CHALLENGE_EXPIRED, HOLDER_UNKNOWN, HOLDER_MISMATCH, SIGNER_NOT_BOUND, PRESENTATION_SIGNATURE_INVALID, and CREDENTIAL_INVALID. Legacy unsigned credentials cannot be presented (SIGNATURE_INVALID) — they remain valid for tenant-level gating until re-issued.

Endpoint summary

MethodPathScope
GET/v1/identitiesidentity:read
POST/v1/identitiesidentity:write
GET/v1/identities/{id} / /{id}/walletsidentity:read
POST/v1/identities/{id}/walletsidentity:write
POST/v1/identities/{id}/wallets/{bindingId}/verifyidentity:write
POST/v1/identities/{id}/wallets/{bindingId}/rotateidentity:write
POST/v1/credential-presentations/challengeidentity:read
POST/v1/credential-presentations/verifyidentity:read
GET/v1/credentials / /{id} / /{id}/status / /{id}/artifactidentity:read
POST/v1/credentials/{id}/refreshidentity:write

Error codes for all of the above are listed in the error-code reference.