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:
| Issuer | Signs | Verified by |
|---|---|---|
PLATFORM | Provider-verified facts (PERSON_IDENTITY, BUSINESS_IDENTITY, DESTINATION_OWNERSHIP, memberships) | Permara's per-environment issuer EOA (EIP-712 signTypedData) |
ORGANIZATION | Money 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
| Method | Path | Scope |
|---|---|---|
GET | /v1/identities | identity:read |
POST | /v1/identities | identity:write |
GET | /v1/identities/{id} / /{id}/wallets | identity:read |
POST | /v1/identities/{id}/wallets | identity:write |
POST | /v1/identities/{id}/wallets/{bindingId}/verify | identity:write |
POST | /v1/identities/{id}/wallets/{bindingId}/rotate | identity:write |
POST | /v1/credential-presentations/challenge | identity:read |
POST | /v1/credential-presentations/verify | identity:read |
GET | /v1/credentials / /{id} / /{id}/status / /{id}/artifact | identity:read |
POST | /v1/credentials/{id}/refresh | identity:write |
Error codes for all of the above are listed in the error-code reference.