proofsdocscomparepricinglog in

Documentation

Learn how to integrate proof.holdings

Core Primitives
The mental model in one page
API Reference
Complete API documentation
SDKs
Official client libraries
Smart Reuse
Skip re-verification with existing proofs
Multi-Profile System
Multiple public profiles per account
Message Templates
Custom branding and message templates per project
Comparison
vs SMS OTP, TOTP, WebAuthn
Pricing
Plans and pricing tiers
Security
Threat model and guarantees
MCP Server
167 tools for AI agents
Integrations
n8n, Zapier, Make, and custom integrations
Proof of Me
Anti-impersonation reverse verification via trusted Circle members
Proof of Delegation
A proven domain authorizing an artifact, and how to verify or withdraw it
Resources
GitHub Docs
Status

API Reference

Base URL: https://api.proof.holdings

Authentication: Bearer token via Authorization header

text
Authorization: Bearer pk_live_your_api_key_here

API keys are prefixed with pk_live_ for production and pk_test_ for testing.

Machine-readable spec: The full OpenAPI 3.1 specification is available at `/api/openapi.json` for SDK generation, contract testing, and AI coding assistants.


Sub-accounts and API keys

An account holds one or more sub-accounts (projects). Each sub-account has its own branding, message templates, verification requests, verifications, phone sessions, approval setups and the confirmations and consents sent through them, and circles. What you prove or set up once — phones, emails and domains, and the delegations issued from a domain — belongs to the account.

CredentialReaches
API key bound to a sub-accountThat sub-account only
Account-wide API keyEvery sub-account of the account
Dashboard sessionEvery sub-account of the account

A key bound to a sub-account:

  • Creates in its own sub-account. A request body or query naming another sub-account (profile_id, public_profile_id, business_profile_id) returns 403 profile_mismatch.
  • Sees only its own sub-account's records. A record of another sub-account answers exactly like an id that does not exist — the endpoint's own 404 — on every read and every change, and nothing is sent or charged. Lists, /api/v1/me/search, /api/v1/me/assets/search and /api/v1/me/summary contain only its own sub-account's records. A record that carries no sub-account — an older record, made before its kind recorded one — belongs to the primary sub-account, and a record created by one of the sub-account's keys stays the sub-account's, also when that key was bound later.
  • Reads account-level things but does not change them. It can read settings, usage, billing, the list of sub-accounts, and the account's phones, emails, domains, delegations and Lookalike Watch. Every account-level change returns 403 account_key_required: account settings (including where webhooks are sent), billing, creating or deleting a sub-account or choosing the primary, starting, checking or removing a domain verification, issuing or revoking a delegation, revoking, suspending or unsuspending a proof of a shared asset, Lookalike Watch, ending a sign-in session and retrying a webhook delivery. Use an account-wide key or the dashboard for these. A domain proof the sub-account made itself — a customer's domain verified through its own key — is the sub-account's own: its key may revoke, suspend and unsuspend it, unless the domain is also one of the account's own domains or delegations are live under that proof.
  • Changes its own sub-account's profile. PUT /api/v1/profiles/me and PATCH /api/v1/me/profiles/:profileId work on its own sub-account; GET /api/v1/profiles/me returns it. Any other sub-account, or making its own the primary, returns 403 account_key_required — as does the deletion preview of another sub-account.
  • Sees webhook deliveries without their contents. A delivery does not yet record its sub-account, so a bound key gets each delivery's status but no payload and a null response_body.

Two limits apply to the whole account rather than to each sub-account:

  • Approval setup names and verification request reference_ids are unique per account. A reference_id already used in another sub-account returns 409 duplicate_reference_id without naming that request.
  • Anyone can start a sign-in (POST /api/v1/auth/sessions), with or without a bound key, and once a person approves it the result is a full account session. Approve only sign-ins you started yourself.
CodeStatusMeaning
profile_mismatch403A key bound to a sub-account named another sub-account
account_key_required403A key bound to a sub-account attempted an account-level change, a change to another sub-account, or the deletion preview of another sub-account

Endpoints Overview

MethodEndpointPurpose
POST/api/v1/verificationsCreate verification challenge
GET/api/v1/verificationsList verifications
GET/api/v1/verifications/:idGet verification status
POST/api/v1/verifications/:id/submitSubmit challenge code
POST/api/v1/verifications/:id/verifyTrigger DNS/HTTP check
POST/api/v1/verifications/:id/resendResend verification (email only)
POST/api/v1/verifications/:id/test-verifyAuto-verify (test mode only)
GET/api/v1/verifications/:id/eventsStream status updates (SSE)
GET/api/v1/verifications/usersList verified users (B2B)
GET/api/v1/verifications/users/:externalUserIdGet verified user details (B2B)
POST/api/v1/verifications/domainStart domain verification (B2B)
POST/api/v1/verifications/domain/:id/checkCheck domain verification (B2B)
POST/api/v1/proofs/validateValidate proof token
GET/api/v1/proofs/:id/statusCheck proof status
POST/api/v1/proofs/:id/revokeRevoke a proof
GET/api/v1/proofs/revokedGet revocation list
POST/api/v1/delegationsCreate a delegation (mint token)
GET/api/v1/delegationsList delegations
GET/api/v1/delegations/:idGet a delegation incl. its token
POST/api/v1/delegations/:id/revokeRevoke a delegation
POST/api/v1/sessionsCreate phone verification session
GET/api/v1/sessions/:idGet session status
POST/api/v1/verification-requestsCreate multi-asset verification request
GET/api/v1/verification-requestsList verification requests
GET/api/v1/verification-requests/:idGet verification request
GET/api/v1/verification-requests/by-reference/:referenceIdGet by reference ID
DELETE/api/v1/verification-requests/:idCancel verification request
GET/api/v1/webhook-deliveriesList webhook deliveries
GET/api/v1/webhook-deliveries/:idGet webhook delivery details
GET/api/v1/webhook-deliveries/statsGet delivery statistics
POST/api/v1/webhook-deliveries/:id/retryRetry failed delivery
GET/.well-known/jwks.jsonPublic keys (ES256)
GET/healthService status

Test Mode (Sandbox)

Use test mode to build and test your integration without sending real messages, consuming quota, or incurring charges. Test mode is activated automatically when you authenticate with a test API key (pk_test_*).

How It Works

AspectProduction (pk_live_*)Test Mode (pk_test_*)
Channel deliveryReal messages sentSkipped — no real messages
Quota usageCounted toward monthly limitNot counted
BillingCharged per proofFree
WebhooksDelivered normallyDelivered with "test": true flag
Data isolationOnly sees production verificationsOnly sees test verifications
Proof tokensValid, signed JWTValid, signed JWT (identical format)

Auto-Verify Endpoint

POST /api/v1/verifications/:id/test-verify

Instantly complete a verification without user action. Only available with test API keys (pk_test_*). Returns 403 for production keys.

json
{
  "id": "507f1f77bcf86cd799439011",
  "type": "phone",
  "channel": "whatsapp",
  "status": "verified",
  "identifier": "+37069199199",
  "verified_at": "2026-02-13T12:25:00Z",
  "proof_token": "eyJhbGciOiJFUzI1NiIs...",
  "proof_expires_at": "2026-03-15T12:25:00Z",
  "test_mode": true
}

Example: Full Test Flow

bash
# 1. Create verification with test key (no real message sent)
curl -X POST https://api.proof.holdings/api/v1/verifications \
  -H "Authorization: Bearer pk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"type":"phone","channel":"whatsapp","identifier":"+37069199199"}'

# 2. Auto-verify (skip waiting for user action)
curl -X POST https://api.proof.holdings/api/v1/verifications/{id}/test-verify \
  -H "Authorization: Bearer pk_test_..."

# 3. Use the proof token exactly as you would in production

Webhook Behavior

Test mode webhooks are delivered normally but include "test": true in the payload:

json
{
  "event": "verification_request.completed",
  "test": true,
  "request_id": "req_abc123",
  "status": "completed",
  "proofs": [...]
}

Environment Isolation

Test and production data are completely isolated. A pk_test_ key can only see test verifications, and a pk_live_ key can only see production verifications.


Create Verification

POST /api/v1/verifications

Create a new verification challenge.

Request Body

ParameterTypeRequiredDescription
typestringYesphone, email, domain, social, wallet, telegram_bot
channelstringYesChannel for verification (see table below)
identifierstringYesThe asset to verify
client_metadataobjectNoCustom metadata for your reference

Type/Channel Compatibility

TypeValid Channels
phonewhatsapp, telegram, sms
emailemail
domaindns, http, email, auto
socialgithub, google, facebook, x, linkedin, instagram, youtube, tiktok
walletethereum, solana, bitcoin
accountcoinbase, kraken
telegram_bottelegram_bot_token

Example Request

bash
curl -X POST https://api.proof.holdings/api/v1/verifications \
  -H "Authorization: Bearer pk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "phone",
    "channel": "whatsapp",
    "identifier": "+37069199199",
    "client_metadata": {
      "user_id": "usr_123",
      "action": "login"
    }
  }'

Response

json
{
  "id": "507f1f77bcf86cd799439011",
  "type": "phone",
  "channel": "whatsapp",
  "status": "pending",
  "identifier": "+37069199199",
  "challenge": {
    "code": "X7K2M9",
    "expires_at": "2026-02-04T11:00:00Z",
    "instruction": "Send X7K2M9 via WhatsApp",
    "deep_link": "https://wa.me/37069199199?text=X7K2M9"
  },
  "created_at": "2026-02-04T10:50:00Z"
}

Get Verification

GET /api/v1/verifications/:id

Poll for verification status.

Response (Verified)

json
{
  "id": "507f1f77bcf86cd799439011",
  "type": "phone",
  "channel": "whatsapp",
  "status": "verified",
  "identifier": "+37069199199",
  "verified_at": "2026-02-04T10:51:30Z",
  "proof": {
    "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...",
    "expires_at": "2026-03-06T10:51:30Z"
  }
}

Submit Challenge Code

POST /api/v1/verifications/:id/submit

Submit an OTP challenge code for email/phone verification.

Request Body

ParameterTypeRequiredDescription
challenge_codestringYesThe 6-character challenge code
source_identifierstringNoPhone/email that sent the code (for validation)

Response

Returns the updated verification object (same as Get Verification).


Resend Verification

POST /api/v1/verifications/:id/resend

Resend the verification challenge. Currently only supported for email verifications. The verification must be in pending status.

Response

json
{
  "success": true,
  "message": "Verification email sent",
  "expires_at": "2026-02-13T12:10:00Z"
}

Verification Events (SSE)

GET /api/v1/verifications/:id/events

Subscribe to real-time verification status updates via Server-Sent Events. Use this instead of polling GET /verifications/:id.

Usage

javascript
const source = new EventSource(
  'https://api.proof.holdings/api/v1/verifications/{id}/events',
  { headers: { 'Authorization': 'Bearer pk_live_...' } }
);

source.addEventListener('connected', (e) => {
  console.log('Connected:', JSON.parse(e.data));
});

source.addEventListener('status_changed', (e) => {
  const update = JSON.parse(e.data);
  if (update.status === 'verified') {
    source.close();
  }
});

Events

EventDescription
connectedInitial connection established
status_changedVerification status updated

A 30-second heartbeat keeps the connection alive through Cloudflare. Maximum concurrent SSE connections per tenant is configurable (default: 10).


Verified Users (B2B)

List Verified Users

GET /api/v1/verifications/users

List verified users grouped by external_user_id. Only includes verifications where external_user_id was set during creation.

Query parameters: page, limit (max 100)

Response

json
{
  "data": [
    {
      "external_user_id": "user_12345",
      "verification_count": 3,
      "types_verified": ["phone", "email"],
      "verifications": [
        { "id": "507f...", "type": "phone", "channel": "whatsapp", "identifier": "+3706***9199", "verified_at": "2026-02-13T12:00:00Z" }
      ],
      "first_verified_at": "2026-01-10T08:00:00Z",
      "last_verified_at": "2026-02-13T12:00:00Z"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 42, "pages": 3 }
}

Get Verified User

GET /api/v1/verifications/users/:externalUserId

Get all verifications for a specific external user.

Response

json
{
  "external_user_id": "user_12345",
  "verification_count": 3,
  "types_verified": ["phone", "email"],
  "verifications": [
    {
      "id": "507f...",
      "type": "phone",
      "channel": "whatsapp",
      "identifier": "+3706***9199",
      "status": "verified",
      "verified_at": "2026-02-13T12:00:00Z",
      "has_proof": true,
      "proof_expires_at": "2026-03-15T12:00:00Z",
      "created_at": "2026-02-13T11:50:00Z"
    }
  ]
}

Domain Verification (B2B)

Start Domain Verification

POST /api/v1/verifications/domain

Start a domain verification flow. Returns DNS and HTTP instructions for the domain owner.

Request Body

ParameterTypeRequiredDescription
domainstringYesDomain to verify (e.g., example.com)
customer_idstringNoOptional customer identifier
verification_methodstringNomanual_dns (default) or http_file

Response

json
{
  "id": "507f1f77bcf86cd799439011",
  "domain": "example.com",
  "status": "pending",
  "verification_method": "manual_dns",
  "dns_record": {
    "type": "TXT",
    "name": "_proof.example.com",
    "value": "proof-verify=abc123"
  },
  "http_file": {
    "path": "/.well-known/proof-verify.txt",
    "content": "proof-verify=abc123"
  },
  "provider": {
    "detected": "cloudflare",
    "name": "Cloudflare"
  }
}

Check Domain Verification

POST /api/v1/verifications/domain/:id/check

Check if DNS TXT record or HTTP file has been set up correctly.

Response

json
{
  "id": "507f1f77bcf86cd799439011",
  "domain": "example.com",
  "status": "verified",
  "verified": true,
  "verified_at": "2026-02-13T12:00:00Z",
  "check_count": 1
}

Proof Status

GET /api/v1/proofs/:id/status

Quick status check for a proof without validating the full JWT. Returns whether the proof is valid, suspended, revoked, or expired.

:id accepts the public proof handle (ph_ctl_…, the proof_id claim of a proof token, or ph_dlg_… for a delegation) or a verification id. A live proof reads active — the same word POST /api/v1/proofs/:id/unsuspend and GET /api/v1/delegations/:id use.

Response

json
{
  "proof_id": "ph_ctl_0123456789abcdef0123456789abcdef",
  "status": "active",
  "is_valid": true,
  "is_revoked": false,
  "revoked_at": null,
  "revoked_reason": null,
  "expires_at": "2026-03-15T12:00:00Z",
  "is_expired": false
}

Sender name

Every message our bots send for you names one of your sub-accounts: its business name, else its display name. If that sub-account has no name, the message carries Proof Holdings copy. The name is never taken from the request.

  • A session (POST /api/v1/sessions) names the sub-account it is for: profile_id, else the API key's sub-account, else your primary.
  • A consent request (POST /api/v1/authorizations) names the approval setup's sub-account, the same name the approval page shows. The response's business_name is the name used.

business_name on POST /api/v1/authorizations is deprecated and ignored. It is still accepted so existing integrations keep working, and a request that sends it gets a Deprecation response header. To send for several businesses, create one sub-account per business and use its API key.


Sessions (Phone-First Flow)

Sessions provide a phone-first verification flow where the user doesn't need to provide their phone number upfront. Instead, they message our Telegram or WhatsApp bot, or text a short code by SMS to one of our numbers, and the number is captured from that message.

Create Session

POST /api/v1/sessions

ParameterTypeRequiredDescription
channelstringYestelegram, whatsapp or sms
client_metadataobjectNoCustom metadata for your reference
profile_idstringNoThe sub-account the session is for. Omitted: the API key's sub-account, else your primary
context.actionstringNoPicks the bot's wording: login, 2fa, recovery or verification (default). Any other value answers 400 validation_error
bash
curl -X POST https://api.proof.holdings/api/v1/sessions \
  -H "Authorization: Bearer pk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "telegram"
  }'

Response

json
{
  "id": "sess_abc123",
  "channel": "telegram",
  "status": "pending",
  "deep_link": "https://t.me/bot?start=sess_abc123",
  "qr_code": "https://t.me/bot?start=sess_abc123",
  "expires_at": "2026-02-04T11:00:00Z",
  "instructions": "Open Telegram and tap the link to verify"
}

With "channel": "sms" the person texts a short code to one of our numbers, the same way SMS sign-in works. deep_link opens their messaging app on our default number with the message pre-filled, and sms_dids lists every number the code can be sent to, one per region, each with its own QR code. The person gets no reply SMS: read the result with GET /api/v1/sessions/:id. An SMS proof carries confidence: "standard".

json
{
  "id": "sess_8f14e45fceea167a5a36dedd4bea2543",
  "channel": "sms",
  "status": "pending",
  "deep_link": "sms:+19294568714?body=Verify%20ABC234",
  "qr_code": "data:image/png;base64,...",
  "qr_text": "...",
  "expires_at": "2026-02-04T11:00:00Z",
  "instructions": "Send the verification message by SMS to one of the numbers shown",
  "sms_code": "ABC234",
  "sms_message": "Verify ABC234",
  "sms_dids": {
    "americas": { "did": "+19294568714", "label": "Americas", "flag": "🇺🇸", "qr_code": "data:image/png;base64,...", "qr_text": "..." }
  }
}

Get Session

GET /api/v1/sessions/:id

Poll for session status. When verified, includes the phone number and proof token (for SMS, with the confidence it was issued with). About a minute after an SMS session completes, a delayed spoof check can still act on its proof if the sender was not a real mobile line. That is the proof's own state, not the session's. A revocation is reported by POST /api/v1/proofs/validate and the proof.revoked webhook; a weaker signal instead lowers the confidence to degraded, which shows on GET /api/v1/verifications/{verification_id} (the session returns verification_id).

Response (Verified)

json
{
  "id": "sess_abc123",
  "channel": "telegram",
  "status": "verified",
  "phone_number": "+37069199199",
  "verified_at": "2026-02-04T10:51:30Z",
  "verification_id": "507f1f77bcf86cd799439011",
  "proof": {
    "token": "eyJhbGciOiJFUzI1NiIs...",
    "expires_at": "2026-03-06T10:51:30Z"
  }
}

Verification Requests (Multi-Asset)

Verification requests let you bundle multiple assets into a single verification flow. Useful for P2P trust, vendor onboarding, and KYB workflows.

Create Verification Request

POST /api/v1/verification-requests

ParameterTypeRequiredDescription
assetsarrayYesAssets to verify (see below)
reference_idstringNoYour external tracking ID
action_typestringNoverification, 2fa, login, custom
action_contextobjectNo{ title, description } for display
redirect_urlstringNoWhere to redirect after completion
callback_urlstringNoWebhook URL for completion notification
expires_innumberNoSeconds until expiry (default 604800 = 7 days, max 2592000 = 30 days)
partial_okbooleanNoAccept partial completion

Each asset in the assets array:

ParameterTypeRequiredDescription
typestringYesphone, email, domain, social, wallet, telegram_bot
identifierstringNoAsset to verify (null = user provides)
channelstringNoPreferred channel
allowed_channelsarrayNoRestrict to specific channels
requiredbooleanNoWhether this asset is required (default true)
bash
curl -X POST https://api.proof.holdings/api/v1/verification-requests \
  -H "Authorization: Bearer pk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "reference_id": "deal_12345",
    "assets": [
      { "type": "domain", "identifier": "example.com" },
      { "type": "email", "identifier": "[email protected]" }
    ],
    "action_type": "verification",
    "redirect_url": "https://your-app.com/verified"
  }'

Response

json
{
  "id": "507f1f77bcf86cd799439011",
  "status": "pending",
  "reference_id": "deal_12345",
  "assets": [
    { "type": "domain", "identifier": "example.com", "status": "pending" },
    { "type": "email", "identifier": "[email protected]", "status": "pending" }
  ],
  "verification_url": "https://proof.holdings/verify/507f1f77bcf86cd799439011",
  "created_at": "2026-02-04T10:50:00Z",
  "expires_at": "2026-02-05T10:50:00Z"
}

Get Verification Request

GET /api/v1/verification-requests/:id

List Verification Requests

GET /api/v1/verification-requests

Query parameters: page, limit (max 100), status, reference_id

Get by Reference ID

GET /api/v1/verification-requests/by-reference/:referenceId

Cancel Verification Request

DELETE /api/v1/verification-requests/:id

Verification Request Statuses

StatusDescription
pendingWaiting for verifications to complete
partialSome assets verified, others pending
completedAll required assets verified
expiredRequest expired before completion
cancelledRequest was cancelled

Validate Proof

POST /api/v1/proofs/validate

Validate a proof token via API.

Request Body

ParameterTypeRequiredDescription
proof_tokenstringYesThe JWT proof token
identifierstringNoOptional identifier to verify against

Response (Valid)

json
{
  "valid": true,
  "verification": {
    "id": "507f1f77bcf86cd799439011",
    "type": "phone",
    "channel": "whatsapp",
    "verified_at": "2026-02-04T10:51:30Z",
    "expires_at": "2026-03-06T10:51:30Z"
  }
}

Response (Invalid)

json
{
  "valid": false,
  "reason": "expired",
  "message": "jwt expired"
}

Offline Verification

Proofs are signed with ES256 and can be verified without calling our API.

1. Fetch Public Key (cache 24h)

bash
curl https://api.proof.holdings/.well-known/jwks.json
json
{
  "keys": [{
    "kty": "EC",
    "crv": "P-256",
    "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
    "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
    "alg": "ES256",
    "use": "sig",
    "kid": "proof-holdings-2026-01"
  }]
}

2. Fetch Revocation List

bash
curl https://api.proof.holdings/api/v1/proofs/revoked

The list carries two lifetimes that answer different questions. Reuse a fetched list for up to 5 minutes — its cache lifetime (Cache-Control: max-age=300) — then fetch it again to see new revocations. If you cannot reach the API, the list's signature stays verifiable for 1 hour, so the copy you hold keeps working offline for that long; after that, fetch a fresh one.

3. Verify Locally

javascript
import jwt from 'jsonwebtoken';
import { createPublicKey } from 'node:crypto';

// Pick the key the token names. During a rotation the set carries two — the current EC key
// and the retiring RSA one — so taking keys[0] would verify against the wrong key for every
// token minted under the other.
const { kid } = JSON.parse(
  Buffer.from(proofToken.split('.')[0], 'base64url').toString()
);
const jwk = jwks.keys.find(k => k.kid === kid);
const publicKey = createPublicKey({ key: jwk, format: 'jwk' });

// New tokens are ES256; the retiring key's algorithm stays accepted until its last token expires.
const payload = jwt.verify(proofToken, publicKey, { algorithms: ['ES256', 'RS256'] });

// Check not revoked
const isRevoked = revokedList.some(r => r.proof_id === payload.sub);

// Check not expired
const isValid = !isRevoked && payload.exp > Date.now() / 1000;

Revoke Proof

POST /api/v1/proofs/:id/revoke

Revoke a proof (GDPR deletion, fraud, etc).

:id accepts the public proof handle (ph_ctl_… / ph_dlg_…) or a verification id — the same addresses /status, /suspend and /unsuspend take. The revocation reaches the registry, GET /api/v1/proofs/:id/status, the public POST /api/v1/proofs/validate, the revocation list and the Token Status List.

This revokes the proof, not the thing the proof is about: the verified asset, the consent or the delegated artifact keeps its own state and its own surface. Revoking a proof whose subject is not a verification (a domain asset, a request asset, a consent) works only through the handle — there is no other id for it.

Request Body

ParameterTypeRequiredDescription
reasonstringYesuser_request, fraud, key_compromise, asset_transferred, other

Response

json
{
  "success": true,
  "proof_id": "ph_ctl_0123456789abcdef0123456789abcdef",
  "proof_type": "verification",
  "verification_id": "507f1f77bcf86cd799439011",
  "revoked_at": "2026-02-04T12:00:00Z",
  "reason": "user_request"
}

proof_id and proof_type are present when the proof was addressed by handle; verification_id only when the proof belongs to a verification. A proof whose revocation cannot be written answers 503 proof_revocation_failed rather than reporting a success it did not perform.

Who can call it. An API key with proofs:write, or a signed-in dashboard session — the same dual-auth the delegation endpoints use. The scopes bind API keys only; a session owns the account outright. This applies to /status, /revoke, /suspend and /unsuspend alike.

A second revoke means different things on the two resources, deliberately.

ResourceRepeat revokeWhy
Proof (ph_ctl_…, verification id)400 already_revoked with the FIRST revocation's revoked_atThe code is published and read by integrators; changing it would break callers that branch on it
Delegation (ph_dlg_…)200 with the stored revocation, on this endpoint AND on POST /api/v1/delegations/:id/revokeIdempotency is what the delegation contract has always advertised, in these docs and in all four SDKs

Either way the answer never depends on WHICH address you used — one delegation used to answer 200 through its own route and 400 here, and that is fixed. A repeat call reports the revocation that actually happened: the original timestamp and the original reason, never this call's.

Scopes on a delegation handle. Any lifecycle act on a ph_dlg_… — revoke, suspend or unsuspend — additionally requires delegations:write on top of the route's own proofs:write, so the wider group is not a way around the narrower one. Suspending used to need only proofs:write, on the argument that a pause is lifted by the same scope that applied it; what a third party sees while the pause holds is what it sees after a revocation (the token stops verifying, the status list says so), and being reversible bounds the cost of putting it back rather than the reach or the duration of the act. Acting on a domain control proof (ph_ctl_…) is unchanged and still needs proofs:write alone.


Delegations

Proof of Delegation: a control-proven domain authorizes a typed artifact — a package (purl) or an https endpoint (url) — for a set of capability scopes. Each create mints an independently revocable ES256 delegation token, signed with the same key and published through the same JWKS as proof tokens. Requires delegations:* API-key scopes; each mint is one billable proof.

Create Delegation

POST /api/v1/delegations

ParameterTypeRequiredDescription
control_proofstringYesPublic handle of YOUR verified domain proof (ph_ctl_…). The delegation's principal is the domain that proof covers.
delegateobjectYesThe authorized artifact: `{ "type": "purl" \"url", "value": "pkg:npm/your-mcp-server" \"https://your-agent.example/api" }`. Versions, qualifiers, query strings and fragments are rejected — a delegation names the artifact, not a release.
scopestring[]Yes1–32 lowercase kebab-case capability tokens, ≤64 characters each (e.g. send-email). These are not API scopes — an API-style resource:action such as payments:read is rejected with 400 invalid_scope. Verifiers compare by exact string match — there is no wildcard.
expires_innumberNoToken lifetime in seconds. Capped by the control proof's own expiry — a delegation never outlives the domain proof under it.

This bound holds after issuance too: if you later renew your domain proof for a shorter term than it had before, the renewal call still succeeds, but whenever the requested duration would drop the domain proof's expiry below an already-issued delegation's own expires_at, that duration is not honoured — the domain proof is kept at (at least) the longest-lived delegation's expiry instead, with no error returned. A delegate's token never silently outlives the domain proof it depends on.

Response

json
{
  "id": "60d5ecb74f421b2d8c9e1234",
  "delegation_id": "ph_dlg_0123456789abcdef0123456789abcdef",
  "principal": "yourdomain.com",
  "delegate": { "type": "purl", "value": "pkg:npm/your-mcp-server" },
  "scope": ["send-email"],
  "control_proof": "ph_ctl_0123456789abcdef0123456789abcdef",
  "status": "active",
  "environment": "production",
  "issued_at": "2026-02-04T12:00:00Z",
  "expires_at": "2027-02-04T12:00:00Z",
  "created_at": "2026-02-04T12:00:00Z",
  "token": "eyJhbGciOiJFUzI1NiIs..."
}

Manage Delegations

  • GET /api/v1/delegations — paginated list. Tokens are not included in lists. Every item carries effective_status and is_valid beside its stored status — read `effective_status`: status records only whether you revoked the delegation, so one whose domain control proof was revoked, suspended or expired — or whose own lifetime simply ran out — still reads active there while every verifier already rejects it. effective_status covers both clocks: a delegation is never valid for longer than the earlier of its own expires_at and the control proof's. The ?status= filter matches the stored record only (the cascade spans two collections and cannot be filtered server-side without breaking pagination), so ?status=active can legitimately return a delegation whose effective_status is revoked. If that cascade cannot be resolved the endpoint answers 503 delegation_status_unavailable rather than serving stored statuses as if they were derived ones.
  • GET /api/v1/delegations/:id — one delegation including its token: re-surfacing the publishable artifact costs nothing, re-minting is billed. Carries the same effective_status/is_valid pair, and the same 503 on an unreadable cascade.
  • POST /api/v1/delegations/:id/revoke — revoke (optional body {"reason": "user_request" | "fraud" | "key_compromise" | "other"}). Idempotent: a repeat answers 200 with the stored revocation, here and through POST /api/v1/proofs/:id/revoke with the ph_dlg_… handle.

Create and revoke return the same delegation shape the reads return, including effective_status and is_valid — the shape of a delegation does not depend on which verb produced it, so there is no second GET to make.

:id accepts the internal id or the ph_dlg_… handle. A revoked delegation appears on the revocation list, the Token Status List and the owner lookup GET /api/v1/proofs/:id/status (authenticated, proofs:read; accepts ph_dlg_… too). Anyone without an account checks a delegation through the public POST /api/v1/proofs/validate — see Verifying a delegation. Revoking one delegation touches nothing else — the domain proof and every other delegation stay valid.

Publishing Your Delegation

A delegation token is a bearer artifact designed to be published where agents already look. The namespaced key is holdings.proof/delegation; its value is { "token": "<delegation JWT>" }.

MCP server card (`server.json`) — vendor-specific data belongs in the namespaced _meta field:

json
{
  "name": "io.github.you/your-mcp-server",
  "_meta": {
    "holdings.proof/delegation": { "token": "eyJhbGciOiJFUzI1NiIs..." }
  }
}

When publishing through the official MCP registry, nest the same entry under _meta["io.modelcontextprotocol.registry/publisher-provided"] — readers should check both locations.

A2A agent card (`agent-card.json`) — an entry in capabilities.extensions:

json
{
  "url": "https://your-agent.example/api",
  "capabilities": {
    "extensions": [{
      "uri": "https://proof.holdings/delegation",
      "required": false,
      "params": { "token": "eyJhbGciOiJFUzI1NiIs..." }
    }]
  }
}

What a verifier checks:

  1. The token's ES256 signature against the issuer's JWKS (cached — no call at request time).
  2. principal against the domain it expects, and delegate against the artifact identity it resolved itself — never against what the card says about itself: a copied token names the genuine artifact, not the impostor's.
  3. Freshness via the token's status.status_list claim and the public validation endpoint below.

A published delegation attests exactly one thing: the controller of the `principal` domain authorized this artifact for these scopes. It is not a statement that the server is safe, audited, or endorsed by proof.holdings. Our own cards use this same publication format — mcp/server.json for pkg:npm/@proof-holdings/mcp-server and `/.well-known/agent-card.json` for our A2A endpoint.

Assemble the JSON above by hand. @proof-holdings/delegation-publisher, a CLI that builds this exact shape from a token you already minted and validates the claim binding, is not on npm, so any command naming it fails. Before you publish, read the card back with verifyPublishedDelegation from @proof-holdings/delegation-verifier the way a third party will — a malformed or mismatched card is otherwise a silent verification failure for every reader.

Verifying a Delegation

Verification needs no account and no API key. Two public surfaces carry it:

  • `POST /api/v1/proofs/validate` — submit the delegation token as proof_token. This is the

only surface that resolves the cascade: a delegation is worth no more than the domain control proof beneath it, and a revoked domain proof does not flip the delegation's own bit in the Token Status List.

  • `GET /api/v1/proofs/status-list` — the signed IETF Token Status List the token's

status.status_list.uri claim points at, for cache-friendly, herd-private revocation checks. The list is served with a Cache-Control: max-age just under a minute and carries the same ttl — the promise minus the issuer's own rebuild interval — so a revocation of the delegation itself reaches every verifier reading this channel within a minute; the validation endpoint above answers immediately. Every slot the issuer hands out is present in the list from the next fetch onward — a token is never issued pointing past the published array.

json
{
  "valid": true,
  "token_type": "delegation",
  "proof_id": "ph_dlg_0123456789abcdef0123456789abcdef",
  "delegation": {
    "id": "ph_dlg_0123456789abcdef0123456789abcdef",
    "principal": "postmarkapp.com",
    "delegate": "pkg:npm/postmark-mcp",
    "scope": ["send-email"],
    "control_proof": "ph_ctl_fedcba9876543210fedcba9876543210",
    "issued_at": "2026-07-31T00:00:00.000Z",
    "expires_at": "2027-07-31T00:00:00.000Z"
  }
}

An invalid answer carries valid: false with reason — revoked, suspended, expired, unknown_delegation (no registry record: delegations are minted fail-closed, so this means invalid, not merely unknown) or invalid. Passing identifier alongside a delegation token returns identifier_unverifiable: a delegation is about an artifact, not an identifier — compare delegate instead.

Reference verifier. `@proof-holdings/delegation-verifier` walks the whole chain — signature → principal → delegate → status — with zero runtime dependencies and no default trust list: you pass the issuers you accept. It reads the delegation out of an MCP or A2A card for you, and it requires the artifact identity you resolved independently, because that comparison is what defeats a copied token.

ts
import { verifyPublishedDelegation } from '@proof-holdings/delegation-verifier';

const result = await verifyPublishedDelegation(serverCard, {
  trustedIssuers: [{
    issuer: 'proof.holdings',
    jwksUri: 'https://api.proof.holdings/.well-known/jwks.json',
    statusEndpoint: 'https://api.proof.holdings/api/v1/proofs/validate',
  }],
  delegate: { type: 'purl', value: 'pkg:npm/postmark-mcp' }, // what YOU are installing
  expectedPrincipal: 'postmarkapp.com',                      // who you expect behind it
});

Both pins matter and they close different attacks. delegate stops a genuine token being copied into another package's card. expectedPrincipal stops one being minted by the wrong party: issuing binds the artifact to nothing, so any domain owner can mint a valid delegation naming someone else's package. A result without it carries principalChecked: false and means "this domain claims the artifact", not "verified".

Our own MCP server ships it as the verify_delegation tool, so an agent can check another server's card before installing it.

Looking up an artifact

When you hold a package name rather than a card, ask where its delegation would be. GET /api/v1/artifacts/claim?type=purl&delegate=pkg:npm/postmark-mcp — public, no API key — reads the places a delegation can be published (the npm manifest, the MCP registry entry the package names, an endpoint's own and host cards, and the _mcp DNS pointer and .well-known cards of up to 5 domains that hold a delegation for the artifact here). The npm manifest is read, but the npm CLI removes every top-level field that begins with _ — _meta included — when it publishes, so an npm package's delegation is found in its MCP registry entry or on its domain's own cards. The answer lists each place and what it carried:

json
{
  "delegate": "pkg:npm/postmark-mcp",
  "delegate_type": "purl",
  "artifact_path": "/npm/postmark-mcp",
  "claim": "no_claim_found",
  "checked_at": "2026-10-08T12:00:00.000Z",
  "locations": [
    {
      "id": "npm_metadata",
      "location": "The npm registry's manifest for postmark-mcp (latest published version)",
      "url": "https://registry.npmjs.org/postmark-mcp/latest",
      "result": "nothing_published",
      "reason": null,
      "detail": "Read; it carries no delegation in any of the sanctioned slots.",
      "checked_at": "2026-10-08T12:00:00.000Z"
    }
  ],
  "delegations": [],
  "listing": null
}

claim is claimed only when a token read from one of those places is signed by this issuer, names exactly this artifact and is in force; each such token is returned in delegations[], with the domain that signed it, so you can verify it yourself as above. A delegation that was issued but never published is not a claim, and the answer does not reveal it. A place that could not be read is unchecked with a reason — never reported as empty. Answers are reused for up to an hour (five minutes when a place could not be read); 503 artifact_claim_busy carries Retry-After.

The same answer is published three more ways:

  • A page per artifact — https://proof.holdings/npm/<name>, /pypi/<name> and /endpoint/<host><path> — with its markdown at /md/npm/<name>.md and its JSON at /api/content/artifact?type=purl&delegate=pkg:npm/<name> on the same site.
  • A README badge, live, so it changes when the delegation does: [![Proof of Delegation](https://proof.holdings/badge/npm/<name>)](https://proof.holdings/npm/<name>).
  • A terminal check, npx @proof-holdings/proof-check pkg:npm/<name>, which asks this endpoint where to look and verifies every token it is given locally, against the identifier you typed. Exit codes: 0 delegated, 1 no delegation found, 2 could not check. The package is not on npm until its first release.

Webhook Deliveries

Monitor and manage webhook deliveries for your account. Webhooks are sent when verification requests complete, partially complete, or expire.

List Webhook Deliveries

GET /api/v1/webhook-deliveries

Query parameters: page, limit (max 100), status (pending, delivered, failed), event_type, verification_request_id

Response

json
{
  "data": [
    {
      "id": "507f1f77bcf86cd799439011",
      "verification_request_id": "507f1f77bcf86cd799439012",
      "event_type": "verification_request.completed",
      "url": "https://yourapp.com/webhook",
      "status": "delivered",
      "attempts": 1,
      "max_attempts": 3,
      "response_status": 200,
      "created_at": "2026-02-13T12:00:00Z",
      "updated_at": "2026-02-13T12:00:01Z"
    }
  ],
  "pagination": { "hasMore": false }
}

Get Webhook Delivery

GET /api/v1/webhook-deliveries/:id

Returns full delivery details including request payload and response body.

Get Delivery Statistics

GET /api/v1/webhook-deliveries/stats

json
{
  "total": 150,
  "by_status": { "pending": 3, "delivered": 142, "failed": 5 },
  "delivery_rate": "94.7%",
  "recent_failures_24h": 1
}

Retry Failed Delivery

POST /api/v1/webhook-deliveries/:id/retry

Queue a fresh ladder of attempts for a delivery that has not been delivered: one with status failed, or a pending one no worker is holding. Attempt numbering continues after the attempts already spent, so a retried attempt never reuses the X-Webhook-Id of an attempt its attempts count already includes. Answers 400 already_delivered for a delivered one and 503 webhook_enqueue_failed when the retry could not be queued.

Webhook Event Types

EventDescription
verification_request.completedAll required assets verified
verification_request.partialSome assets verified (when partial_ok is true)
verification_request.expiredRequest expired before completion
verification.completedIndividual verification completed

Webhook Signature

Webhooks include an HMAC-SHA256 signature in the X-Proof-Signature header. Verify this against your webhook secret to ensure authenticity.

javascript
const crypto = require('crypto');

function verifyWebhook(payload, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

Verification Statuses

StatusDescription
pendingChallenge created, waiting for user
verifiedSuccessfully verified, proof available
failedMax attempts exceeded
expiredChallenge expired
revokedProof was revoked

These describe a verification. A proof has its own, shorter vocabulary — active, suspended, revoked, expired — which is what GET /api/v1/proofs/:id/status, the suspend/unsuspend pair and the delegation surfaces report. A verification that never produced a live proof (pending, failed, cancelled) surfaces its own status there.


Error Responses

All errors return:

json
{
  "error": {
    "code": "error_code",
    "message": "Human-readable message"
  }
}
CodeHTTPDescription
validation_error400Invalid request parameters
invalid_identifier400Identifier format is invalid
invalid_channel_for_type400Channel doesn't match type
unauthorized401Invalid or missing API key
not_found404Resource not found
challenge_expired400Verification challenge expired
rate_limit_exceeded429Too many requests (see Rate Limits)

Rate Limits

Each account has a per-second request allowance set by its plan. All of the account's API keys share it, and it applies to every request, reads included:

PlanRequests per second
Pay as you go10
€920
€2950
€79100
Custom500
  • Short bursts of up to 3× the allowance are absorbed; beyond that the API answers

429 rate_limit_exceeded with a Retry-After header. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.

  • Webhook deliveries: automatic retry with exponential backoff

See Also

FeatureEndpointsDocumentation
Smart ReuseGET /api/v1/verify/request/:id/proofs, POST .../auto-verifySmart Reuse
Multi-ProfileGET/POST /api/v1/me/profiles, PATCH/DELETE .../profiles/:idMulti-Profile System
Message TemplatesGET /api/v1/templates, PUT /api/v1/templates/:channel/:messageTypeMessage Templates

Related

SDKsMCP ServerPricing
Last updated October 15, 2026
Proof Holdings

The verification API for humans and AI agents — every verification a signed proof you hold.

A service of LT Telecom (UAB "LT telekomunikacijos")

PrivacyTermsDPASubprocessorsStatusGitHubLinkedIn