Hosted REST API · alpha

Build an Apostille integration.

Register agents, submit explicitly authorized statements, and retrieve portable certificates. Original files and private keys stay with your application.

REST API base URLhttps://ifandonlyif.io/api/apostille/v1

Start with a public request

The status endpoint needs no token. Check enabled before issuing, and read workspace limits from limits.

curl --fail-with-body --silent --show-error \
  https://ifandonlyif.io/api/apostille/v1/status

This is the hosted REST API. Public MCP is a separate read-only interface (below).

Authenticate with an administrator key

Use an ML-DSA-65 administrator key when GET /status lists Core 0.3 in protocols. First login creates a private workspace; later logins return it. An Ed25519 key signs in only to an existing Core 0.1 workspace, which is read-only. No password, OAuth or account recovery.

  1. Request a challenge with the administrator public_key (Ed25519 32 bytes; ML-DSA-65 1952 bytes; unpadded base64url). The response has challenge_id, issuer, message and expires_at (five minutes).
  2. Rebuild the message below from your configured issuer, key fingerprint, returned UUID v4 challenge_id and canonical UTC expires_at, without a trailing newline. Accept only an expiry within seven minutes.
  3. Sign the exact UTF-8 message locally (Ed25519 for Core 0.1, ML-DSA-65 for Core 0.3). POST challenge_id, message and unpadded-base64url signature to /auth/verify. Challenges are single-use; never send the private key.
  4. Keep the administrator access_token in memory; send it only to owner routes as Authorization: Bearer <access_token>. It lasts 900 seconds; then sign in with a new challenge. Do not put administrator sessions or signing keys in CI.
iff-apostille/login/0.1
issuer:https://ifandonlyif.io/apostille
key_id:<administrator_key_id>
challenge:<challenge_id>
expires_at:<expires_at>
purpose:register_or_login

key_id is sha256: plus the lowercase SHA-256 hex of the raw public key. This message is not an artifact signature.

CI read-only credentials

API preview, with per-deployment availability. Before use, require enabled: true plus supply_chain_releases and supply_chain_ci_credentials in /status features, and confirm endpoint availability on the target service.

Use an opaque CI bearer for one exact workspace, product and policy label. It permits only release reads, current-policy reads and metadata evaluations in that scope. All other owner routes reject it. A current policy version and independent issuer/producer pins are still required.

Create with a Core 0.3 administrator session and a signed create_release_credential operation. Expiry is explicit, at most 90 days (30 recommended); scopes have no wildcards and cannot change. The token is returned once; retries return metadata only. The owner session can list or revoke credentials. Expiry/revocation is checked on every request; an in-flight request may finish.

Core versions and hosted Core 0.3

The hosted service issues Core 0.3 (ML-DSA-65) only, when GET /status lists it in protocols. Core 0.1 and 0.2 records stay verifiable (verification_protocols). Only Core 0.3 signatures are post-quantum, not the whole service; SHA-256 digests keep collision strength at NIST category 2. Hosted issuer pins are in HOSTED_KEYS.md in the public repository.

  • GET /keys returns the Core 0.1 directory: the legacy Ed25519 key for verifying historical bundles, or an empty keys list when none is configured. GET /keys?protocol=<URL-encoded full identifier> returns that version’s (protocol, issuer, keys[] with key_id, public_key, algorithm, trust). Core 0.3 keys are ML-DSA-65 with 2603-character public keys. Unknown or unsupported versions, including Core 0.2: 400 unsupported_protocol_version.
  • ML-DSA-65 (2603-character) keys use the login message with iff-apostille/login/0.3 first (below); sign with pure ML-DSA-65 and an empty context (4412-character signature). Ed25519 (43-character) keys use the Core 0.1 login message and sign in only to an existing Core 0.1 workspace; a valid signature from a key with no workspace gets 410 core_0_1_retired. Never fall back to a Core 0.1 login for an ML-DSA key.
iff-apostille/login/0.3
issuer:https://ifandonlyif.io/apostille
key_id:<administrator_key_id>
challenge:<challenge_id>
expires_at:<expires_at>
purpose:register_or_login

Only the first line differs. key_id is sha256: plus the lowercase SHA-256 hex of the raw ML-DSA-65 public key.

  • A workspace’s Core version is its administrator key’s algorithm. New workspaces are Core 0.3 (ML-DSA-65). An existing Core 0.1 (Ed25519) workspace is read-only: reading, downloads, profile edits, revocation, and release-policy updates, withdrawals and evaluations work; registration, issuance and ERC-8004 binding return 410 core_0_1_retired. Core 0.2 returns 400 unsupported_protocol_version.
  • An ML-DSA-65 administrator key opens its own workspace. A Core 0.1 workspace is not migrated: its agents, certificates and profile stay with the Ed25519 key.
  • Check versions both ways: grant protocol equals statement protocol; returned bundle protocol equals the submitted one. Pin the Core 0.3 issuer key for new certificates and the Core 0.1 key for historical ones.

Authorize each publication separately

Login does not authorize publication. Create signed envelopes with the Core SDK or CLI; do not invent a serialization.

  1. The administrator signs a delegation and the agent an acceptance, bound to the same agent and service audience. POST /agents registers them.
  2. Hash the original locally and create the agent-signed origin-statement. Your application selects and keeps the original.
  3. The administrator signs a separate publication-grant binding the statement digest, audience, nonce and private/public visibility. POST only statement and grant to /submissions. A login token cannot replace the grant.
  4. Visibility is set in the signed grant. A public grant authorizes distributing the whole signed bundle (linkable keys, identifiers, timestamps, digest, size), not the workspace profile.
  5. Download the bundle and verify it locally against an independently chosen issuer/key pin. API-returned keys are not trusted automatically.

Endpoint reference

Paths are relative to the REST base URL; GET also supports HEAD. Owner routes use the administrator session, with scoped CI access only on the three read/evaluation routes described below. Public routes need no token; public releases use public_id.

Field lists describe response shapes, not literal JSON. Version and size fields are decimal strings.

GET /status

Public

Read the issuer, the protocols it issues (protocols), the protocols it verifies (verification_protocols), availability and limits. The legacy protocol field is the Core 0.1 identifier. Planned provider names are not enabled identity checks.

Request fields
No request body
Response fields / type
protocol (legacy: the Core 0.1 identifier), protocols[] (full identifiers the service issues; absent on a service that predates it), verification_protocols[] (full identifiers it verifies), issuer, enabled, limits {max_agents, max_daily_certificates}, features[] (supply_chain_releases advertises release API support; supply_chain_ci_credentials advertises scoped CI credentials; check enabled separately; ml_dsa_65_keys when Core 0.3 is enabled; core_0_1_issuance_retired), planned[]
Success HTTP status
200

GET /keys

Public

Read the issuer public key and fingerprint; ?protocol=<full identifier> selects one Core version’s directory. A directory is a lookup, not a trust root.

Request fields
protocol (query, optional): full identifier, URL-encoded
Response fields / type
protocol, issuer, keys[] {key_id, public_key, algorithm}, trust
Success HTTP status
200

POST /auth/challenges

Public

Create a short-lived login challenge. A sixth unconsumed challenge for a key invalidates the oldest.

Request fields
public_key: string (Ed25519, 43 characters; or ML-DSA-65, 2603 characters)
Response fields / type
challenge_id, message, expires_at, issuer
Success HTTP status
201

POST /auth/verify

Public

Consume a challenge and receive a 15-minute session. Expired, altered or replayed proofs are rejected.

Request fields
challenge_id: string, message: string, signature: string (Ed25519, or ML-DSA-65 with 4412 characters)
Response fields / type
access_token, token_type, expires_in, workspace: Workspace
Success HTTP status
200

GET /me

Owner

Read your workspace, agent records and recent certificates. Not paginated in this alpha.

Request fields
No request body
Response fields / type
workspace: Workspace, agents: Agent[], certificates: Certificate[]
Success HTTP status
200

PUT /workspace

Owner

Update the workspace name and public-profile visibility. Publishes no certificate; the profile is self-declared.

Request fields
name: string, is_public: boolean
Response fields / type
Workspace
Success HTTP status
200

POST /agents

Owner

Register a valid delegation and acceptance. An identical authorized replay returns the original record. Core 0.3 only; a Core 0.1 request or workspace returns 410 core_0_1_retired.

Request fields
name: string, delegation: Envelope, acceptance: Envelope
Response fields / type
Agent
Success HTTP status
201 / 200

POST /agents/{id}/revoke

Owner

Irreversibly revoke an agent for new issuance. Existing signed bytes are unchanged; offline verification cannot see revocation.

Request fields
No request body
Response fields / type
Agent (revoked_at)
Success HTTP status
200

POST /submissions

Owner

Issue an origin certificate after validating the agent, statement and separate publication grant. Identical retries still need valid authorization. Core 0.3 only; a Core 0.1 request or workspace returns 410 core_0_1_retired.

Request fields
statement: Envelope, grant: Envelope
Response fields / type
Certificate (id, workspace_id, agent_id, public_id, idempotency_key, body_hash, bundle, is_public, created_at)
Success HTTP status
201 / 200

GET /certificates/{id}/bundle

Owner

Download the signed Bundle itself. Other workspaces cannot fetch it.

Request fields
No request body
Response fields / type
Bundle
Success HTTP status
200

POST /certificates/{id}/hide

Owner

Stop public serving of a certificate and any release depending on it. Cannot restore or erase downloaded copies; separate from release withdrawal.

Request fields
No request body
Response fields / type
id, is_public: false
Success HTTP status
200

GET /public/certificates/{public_id}

Public

Read an explicitly public certificate by public_id (404 if private or hidden). Never serves the original file.

Request fields
No request body
Response fields / type
public_id, bundle: Bundle, created_at
Success HTTP status
200

GET /public/organizations/{public_id}

Public

Read an explicitly public workspace profile by public_id. Marked self_declared; no keys or private fields.

Request fields
No request body
Response fields / type
profile {public_id, name, created_at}, organization_binding: self_declared
Success HTTP status
200

GET /erc8004/config

Public

Read whether ERC-8004 binding is enabled, which registries are supported and which binding profiles are issued (profiles; profile is the legacy 0.1 identifier). Public.

Request fields
No request body
Response fields / type
profile (legacy: the 0.1 identifier), profiles[] (binding profiles the service issues), enabled, networks[] {chain_id, registry_address}, max_binding_age_seconds, wallet_support
Success HTTP status
200

POST /agents/{id}/erc8004

Owner

Submit an administrator-signed binding request plus the EOA owner’s EIP-191 signature. Returns a private, immutable issuer_checked snapshot; no chain transaction is sent. Binding 0.3 for Core 0.3 workspaces; binding 0.1 returns 410 core_0_1_retired.

Request fields
request: Envelope, owner_signature: string (0x + 130 lowercase hex characters)
Response fields / type
id, agent_id, document, created_at, expires_at
Success HTTP status
201 / 200

GET /agents/{id}/erc8004

Owner

Retrieve your latest private binding snapshot (404 for other workspaces). It records issuer-observed ownership at one finalized block, not current ownership or payment authority.

Request fields
No request body
Response fields / type
id, agent_id, document, created_at, expires_at
Success HTTP status
200

POST /releases

Owner

API preview: needs enabled: true and supply_chain_releases in /status features. Register the canonical release manifest covered by a certificate, with a separate administrator-signed authorization. No binaries or source are uploaded. A Core 0.1 workspace or certificate returns 410 core_0_1_retired.

Request fields
certificate_id: string, manifest_base64: string, visibility: private | public, authorization: {payload_base64, statement: Envelope}
Response fields / type
Release
Success HTTP status
201 / 200

GET /releases/{id}

Owner

Retrieve your release manifest, certificate bundle and current hosted status. Accepts the owner session or a CI credential for the same workspace and exact product. A valid signature does not grant current deployment permission.

Request fields
No request body
Response fields / type
release: Release, bundle: Bundle, current_status: {state, withdrawn_at, reason, agent_state, as_of, basis, offline_proof: false, issuer_key_revocation: unknown}
Success HTTP status
200

GET /public/releases/{id}

Public

Retrieve public release evidence by public_id (404 if private or hidden). The status is a hosted observation, not an offline revocation proof.

Request fields
id: release public_id
Response fields / type
public_id, product, version, manifest_base64, manifest_sha256, created_at, bundle: Bundle, current_status
Success HTTP status
200

POST /releases/{id}/status-events

Owner

Irreversibly withdraw a release with a fresh administrator-signed withdraw_release operation; release_id must match the path. A login signature cannot authorize it.

Request fields
release_id: string (matches path), action: withdraw, reason: security_incident | superseded | maintainer_request, authorization: {payload_base64, statement: Envelope}
Response fields / type
event: ReleaseStatusEvent, state: withdrawn
Success HTTP status
201 / 200

POST /release-policies

Owner

Store an immutable policy version, starting at "1" and increasing by one. Pin the expected issuer, issuer and producer keys, product and Core protocols independently; an API key directory is not trust.

Request fields
policy: Policy, authorization: {payload_base64, statement: Envelope}
Response fields / type
id, version: decimal string, policy: Policy, policy_sha256, authorization, created_at
Success HTTP status
201 / 200

GET /release-policies/{id}

Owner

Retrieve the current policy version. Accepts the owner session or a CI credential for this exact policy label and product in the same workspace. Earlier versions cannot be selected to bypass it.

Request fields
No request body
Response fields / type
Current id, version: decimal string, policy: Policy, policy_sha256, authorization, created_at
Success HTTP status
200

POST /release-policy/evaluations

Owner

Evaluate metadata against the current policy, release, withdrawal and agent state. Accepts the owner session or a CI credential scoped to the same product and policy. allowed_by_policy does not inspect bytes (artifact_bytes_checked is false); valid_until is advisory, at most 30 seconds and no later than credential expiry. Verify and use the same actual file locally.

Request fields
release_id: string, policy_id: string, policy_version: decimal string, artifact_name: string, artifact_sha256: string, artifact_size: decimal string
Response fields / type
decision: allowed_by_policy | blocked | unknown, reasons[], release_id, policy_id, policy_version, policy_sha256 (when found), evaluated_at, valid_until, current_status, artifact_name, artifact_sha256, artifact_size, artifact_bytes_checked: false
Success HTTP status
200

POST /release-credentials

Owner

Owner session plus Core 0.3 administrator-signed create_release_credential authorization over {name,product,policy_id,expires_at}. Policy must already exist for that product; expiry must be in the future, at most 90 days. HTTP 201 returns the token once; an identical authorized retry returns 200 metadata only. After a lost response, list/revoke and replace with a fresh nonce. Limits: 100 active and 1,000 retained credentials per workspace.

Request fields
name: string, product: string, policy_id: string, expires_at: canonical UTC timestamp, authorization: {payload_base64, statement: Envelope}
Response fields / type
credential: {id, name, product, policy_id, created_at, expires_at, revoked_at (optional)}, token: string (201 only; absent on 200 retry)
Success HTTP status
201 / 200

GET /release-credentials

Owner

Owner session only; CI tokens are rejected. List credential metadata, including expired/revoked history. No raw token or token hash is returned, and lost tokens cannot be recovered.

Request fields
No request body
Response fields / type
credentials[] {id, name, product, policy_id, created_at, expires_at, revoked_at (optional)}
Success HTTP status
200

POST /release-credentials/{id}/revoke

Owner

Owner session only; idempotently revoke the credential. No extra signed operation is needed because this only removes access. New requests fail; an in-flight request may finish. Offline evidence is unchanged.

Request fields
No request body
Response fields / type
credential: {id, name, product, policy_id, created_at, expires_at, revoked_at}
Success HTTP status
200

Retries, limits and expiry

Agent registration: the same workspace, agent ID and delegation return 200 with the original record; a conflicting delegation returns 409 agent_already_registered. Valid authorization is still required.

Submission: the grant nonce and body identify a retry; the same nonce with different content returns 409 idempotency_conflict. After a timeout, check /me before retrying.

ERC-8004: the same nonce and exact request return the original snapshot (200); different content returns 409 binding_nonce_conflict. A new observation needs a new signed request.

  • POST/PUT bodies: strict UTF-8 JSON, Content-Type: application/json, at most 256 KiB. Duplicate keys, unknown fields and JSON numbers are rejected; signed quantities are decimal strings. No multipart.
  • max_agents and max_daily_certificates come from /status (alpha default 100). Certificates last at most 24 hours, delegations one year. Quotas reset at the UTC day boundary.
  • Login challenges: 10 per client IP per minute, five unconsumed per key. ERC-8004 creation shares the IP bucket plus 100 new bindings per workspace per UTC day. On 429 honor Retry-After.
  • The reference SDK and console use a 10-second deadline and an 8 MiB response cap, and do not auto-retry writes.

Handle failures by code

Handler errors use the shape below; gateway errors may differ, so check the HTTP status and Retry-After. A failed response does not prove nothing committed.

{"error":true,"code":"authentication_required","message":"authentication required"}

HTTP 400

invalid_request, invalid_public_key, invalid_profile, invalid_agent, invalid_submission, submission_too_large, invalid_owner_signature, invalid_source_statement, delegation_exceeds_one_year, invalid_binding_request, binding_request_expired, erc8004_registry_unsupported, unsupported_protocol_version, unsupported_key_algorithm, invalid_release, invalid_release_manifest, release_certificate_mismatch, invalid_release_status_event, invalid_release_policy, invalid_release_evaluation, invalid_release_credential

Malformed JSON, invalid fields or signatures, or unacceptable binding request. unsupported_protocol_version: unknown/unsupported Core version, a version different from the workspace’s, or Core 0.2. unsupported_key_algorithm: ML-DSA-65 key while Core 0.3 is disabled.

HTTP 401

authentication_required, invalid_session, invalid_authentication_proof, invalid_ci_credential

Missing or expired session, invalid login proof, replayed challenge, or invalid/expired/revoked CI bearer. Disallowed owner routes reject CI tokens with invalid_session; the scoped routes use invalid_ci_credential.

HTTP 403

invalid_agent_authorization, invalid_publication_grant, source_authorization_inactive, invalid_owner_signature, agent_inactive, invalid_release_authorization, release_publication_denied

The administrator, agent or EOA owner proof does not authorize this action, or the release is not public.

HTTP 404

agent_not_found, certificate_not_found, profile_not_found, binding_not_found, release_not_found, release_policy_not_found, release_credential_not_found

No accessible agent, certificate, binding, release, policy, credential or public profile. Out-of-scope CI release/policy requests also return not found.

HTTP 409

agent_already_registered, agent_limit_reached, idempotency_conflict, binding_nonce_conflict, erc8004_owner_mismatch, agent_inactive, release_nonce_conflict, policy_version_conflict, release_already_withdrawn, release_conflict, release_credential_nonce_conflict, release_credential_conflict

Registration or nonce conflict, agent quota, inactive binding agent, owner mismatch, release collision, policy-version conflict or withdrawn release. Conflicts never authorize overwrite.

HTTP 410

core_0_1_retired

Core 0.1 is retired for issuance. core_0_1_retired: agent registration, certificate issuance, ERC-8004 binding 0.1, register_release for a Core 0.1 certificate, or a new Core 0.1 workspace (refused only after the login signature verifies). Existing Core 0.1 workspaces still sign in, read, edit profiles, revoke and manage release policies. CI credential creation also requires Core 0.3; a Core 0.1 workspace receives core_0_1_retired.

HTTP 422

erc8004_eoa_only

The ERC-8004 owner is a contract wallet; this alpha supports EOA ownership consent only.

HTTP 429

challenge_rate_limit, daily_submission_limit, daily_binding_limit, release_write_limit, release_rate_limit, release_credential_limit

IP, daily or credential quota reached. Honor Retry-After when supplied; reconcile the 100-active/1,000-history credential limits rather than retrying unchanged.

HTTP 500

session_unavailable, stored_agent_invalid, binding_unavailable

Operation failed. Check authenticated history before retrying a write.

HTTP 503

apostille_service_unavailable, challenge_unavailable, workspace_unavailable, agent_unavailable, agent_registration_unavailable, revocation_unavailable, issuance_unavailable, binding_unavailable, erc8004_unavailable, challenge_rate_limit_unavailable, core_0_3_unavailable, release_service_unavailable, release_rate_limit_unavailable, release_credential_unavailable

Hosted issuance, release or credential storage, the ERC-8004 reader or the rate-limit backend is unavailable. core_0_3_unavailable: Core 0.3 issuance is unavailable. Retry with bounded backoff.

Privacy and verification boundaries

Originals, private keys and ZK witnesses stay in your environment. Release registration uploads only the canonical manifest and authorization; evaluation gets a digest and size. Hashes and keys remain linkable.

A certificate proves signature and byte-binding checks within its scope, not content truth, legal identity, current authorization or revocation.

ZK budget is experimental and runs only in the local Go SDK/CLI; there is no hosted ZK endpoint. The single-party development setup could forge proofs.

This page sends no requests and accepts no secrets.

Public MCP is a separate interface

Anonymous Streamable HTTP; read-only, no keys. Seven tools for bundle/binding verification and public issuer data. Verification runs on the hosted server, not independently in your client.

https://iff-mcp-api-production.up.railway.app/mcp/apostille

Send bundle or detached-binding JSON and the exact issuer/key pins you chose for this request; no trust policy is kept. Never upload private keys or originals. Verify locally when independence matters.

Limits: one JSON-RPC object per request; 1 MiB request, 10 MiB response; 60 requests per IP and 300 per instance per minute; four concurrent operations; 20-second deadline. Stateless JSON, no SSE.

MCP capabilities

Implementation references