---
title: Prove your app to other apps (App verification)
description: Show another app that a call really comes from your app. You get one App verification proof for each app you talk to.
kind: instructive
order: 42
related:
  - start/verify-a-proof.md
  - start/user-verification.md
  - learn/proofs.md
  - reference/api/proofs.md
---

# Prove your app to other apps (App verification)

Use App verification when your app calls another app as itself. Say `commit` tells `remind` and `waveform` that a build finished. Each of them needs a way to check that the call really came from `commit`.

`commit` asks us for one proof for `remind` and another for `waveform`, and sends each app the token made for it. The receiving app then [verifies the token](verify-a-proof.md) with us. A proof is always for exactly one receiving app.

```bash
curl -s -u "commit:$COMMIT_APP_SECRET" \
  -X POST https://accounts.teamofsilicons.com/v1/proofs/app-verification \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: app_verification-remind-1" \
  -d '{"receiving_app":"remind","scopes":["notify"],"access_ttl_seconds":300}'
```

`201 Created` (a real response from a local stack, like every response on this page):

```json
{
  "expires_at": "2026-10-07T12:41:09.361Z",
  "issuing_app": "commit",
  "kind": "app_verification",
  "proof_id": "01a1165d-3411-7233-aa94-3be86373c4cc",
  "proof_refresh_token": "sapr_WJYywoB9Cu5Na1of_mPjwTyxR-N0oBeYW5bp_Py7GLU",
  "proof_token": "sap_mpIk5D-xnK9HebJzGmvlkD_ONRKsQUq6ddI9SBUYsa8",
  "receiving_app": "remind",
  "refresh_expires_at": "2029-03-25T12:36:09.361Z",
  "scopes": ["notify"],
  "user": null
}
```

When `remind` verifies the token with its own credentials:

```json
{
  "valid": true,
  "proof_id": "01a1165d-3411-7233-aa94-3be86373c4cc",
  "kind": "app_verification",
  "expires_at": "2026-10-07T12:41:09.361Z",
  "issuing_app": { "app_id": "commit", "name": "Commit" },
  "receiving_app": { "app_id": "remind", "name": "Remind" },
  "user": null,
  "scopes": ["notify"]
}
```

`waveform`, which this proof is not for, gets `{"valid": false, "expires_at": null}` for the same token. It verifies the proof `commit` issued for it instead, `{"receiving_app": "waveform", …}`.

Asking for several apps at once is refused, so a proof can never be replayed from one receiving app to another:

```json
{
  "error": {
    "code": "app_verification_single_app",
    "message": "An App verification is for exactly one app; ask for one proof per app.",
    "hint": "Send {\"receiving_app\": \"remind\"} to POST /v1/proofs/app-verification instead of \"audiences\", and call it once for every app that should verify a proof from you; each app verifies its own proof.",
    "details": { "field": "audiences", "apps": ["remind", "waveform"] }
  }
}
```

## App verification or User verification

- Use **App verification** when the call is about your app itself: notifications, syncing, one service calling another. The receiving app learns *which app* is calling, and nothing about any account.
- Use **[User verification](user-verification.md)** when you act for an account. The receiving app then learns which account, and the proof ends by itself when the account signs out of your app, removes its access or is deleted.

Don't stretch App verification to act for accounts by putting an account id in your own payload. The receiving app couldn't tell whether the account agreed or still uses your app, and the account couldn't see or revoke it. That is exactly what User verification is for.

## 1. Issue the proof

With your app's credentials: `POST /v1/proofs/app-verification`.

| field | required | rules |
|---|---|---|
| `receiving_app` | yes | The one app id that may verify the proof: 3 to 30 characters of `a-z`, `0-9`, `-` and `_` as Silicon Apps creates them, or an older id of 2 to 40 characters of `a-z`, `0-9` and `-` starting with a letter (trimmed and lowercased). Not your own app, and not Silicon Accounts itself (`silicon-accounts`, `developer`). It must exist and be active. A body with `audiences` (any length) is 422 `app_verification_single_app`. |
| `scopes` | no | Up to 20 distinct strings, each 1 to 100 characters of `A-Z a-z 0-9 _ . : / -`. |
| `access_ttl_seconds` | no | How long each proof token lives: 60 to 1800 seconds, default 1800. |

Send an `Idempotency-Key`. A retry with the same key and body within 10 minutes returns the same proof instead of a second one.

The answer has the same fields as a User verification proof, with `user: null`. `refresh_expires_at` is 900 days after issuing. Keep `proof_refresh_token` on your side, and send `proof_token` to the apps.

**As one of the app's authors.** Every app has an App verification page on [developers.teamofsilicons.com](https://developers.teamofsilicons.com) (`/apps/<app_id>/app-verification`) where its owner makes, sees and revokes App verification proofs, one app at a time. Behind it is the owner endpoint. It takes the owner's session instead of the app secret, with the same body and the same answer:

```bash
curl -s -X POST https://accounts.teamofsilicons.com/v1/apps/commit/proofs/app-verification \
  -H "Authorization: Bearer $AUTHOR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: app_verification-page-1" \
  -d '{"receiving_app":"remind","scopes":["notify"]}'
```

`$AUTHOR_ACCESS_TOKEN` belongs to the author's Silicon Accounts session and has the audience `silicon-accounts`. You get it through code login (`POST /v1/cli/login/start`, then `POST /v1/cli/login/verify`) or the device flow.

The CLI does this for you. Sign in as an author with `silicon-accounts login`, then run `silicon-accounts app --app-id commit proof app-verification …` without an app secret ([example below](#with-the-cli)). On the developer portal, the App verification page uses your developer session, whose audience is `developer`. A Carbon who isn't one of the app's authors gets `403 not_app_owner`.

The proof you get belongs to the app, so refreshing it still needs the app's credentials. Pass the refresh token to your app's server, or create the proof from that server in the first place.

## Central history for apps you manage

Open [App verification](https://developers.teamofsilicons.com/app-verification) on the developer portal to see every App verification record we still keep for the apps you manage today. Records made on the portal, with the CLI and through the API show up together, whether they are active, expired or revoked. Filter by issuing app or status, and load more pages to see older records.

Expand a record to see when it was issued, every token refresh and its revocation. The proof family's expiry and each token's expiry are separate: an active family can be refreshed even after its current proof token has expired. The history marks which expiry times were recorded and which were derived for older records, and anything we don't have stays shown as unavailable.

Proof and refresh token values are shown only once, when they are generated. The history keeps the records after the credentials expire or are removed, but it never brings back raw secrets. Every list and history request checks that you still manage the app, and receiving a proof doesn't give the receiving app access to the issuing app's history.

The App verification tab of each app lives at `/apps/<app_id>/app-verification` and links to this central history with that app already selected. API integrations use `GET /v1/me/app-verifications` and `GET /v1/apps/{app_id}/proofs/{proof_id}/history` ([reference](../reference/api/proofs.md)). Proofs are issued with `/v1/proofs/app-verification` and `/v1/proofs/user-verification`. The JSON kinds are `app_verification` and `user_verification`.

## 2. Send the token with each call

Send `proof_token` to the one app it is for, for example as `Authorization: Proof sap_…`, and keep using it until shortly before `expires_at`. The receiving app verifies it with [`POST /v1/proofs/verify`](verify-a-proof.md) and checks `kind: "app_verification"`, `issuing_app.app_id` and `scopes`.

## 3. Refresh, revoke, list

These work exactly as for User verification proofs, with your app's credentials:

- `POST /v1/proofs/refresh` `{"proof_refresh_token": "sapr_…"}` gives you a new proof token and a new refresh token. Never present the used refresh token again, because that revokes the proof. Without `access_ttl_seconds`, the new token gets the proof's own lifetime. In a local test, refreshing a 60-second proof gave another 60-second token, which verified, while the expired first token answered `{"valid": false, "expires_at": null}`. See [Refresh before the proof token expires](user-verification.md#3-refresh-before-the-proof-token-expires).
- `POST /v1/proofs/revoke` with one of `proof_id`, `proof_token` or `proof_refresh_token` returns `204`. An author can use `DELETE /v1/apps/{app_id}/proofs/{proof_id}` from their session.
- `GET /v1/apps/{app_id}/proofs?kind=app_verification` lists them, newest first:

```json
{
  "items": [
    {
      "proof_id": "01a1165d-3411-7233-aa94-3be86373c4cc",
      "kind": "app_verification",
      "receiving_app": "remind",
      "user": null,
      "scopes": ["notify"],
      "status": "active",
      "access_ttl_seconds": 300,
      "created_at": "2026-10-07T12:36:09.361Z",
      "expires_at": "2029-03-25T12:36:09.361Z",
      "token_expires_at": "2026-10-07T12:41:09.361Z",
      "last_refreshed_at": null,
      "revoked_at": null,
      "revoke_reason": null
    }
  ],
  "next_cursor": null
}
```

An App verification proof ends only when it is revoked or reaches `refresh_expires_at`. While your app is disabled, its proofs don't verify and it can't issue new ones (`403 app_disabled`).

## In Rust

```rust
use silicon_accounts_client::{AccountsClient, IssueAppVerification, ProofVerification};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let url = std::env::var("ACCOUNTS_URL").unwrap_or_else(|_| "https://accounts.teamofsilicons.com".into());
    let client = AccountsClient::new(url)?;

    // commit: one proof per receiving app.
    let commit = client.as_app("commit", std::env::var("COMMIT_APP_SECRET")?);
    let mut proofs = Vec::new();
    for app in ["remind", "waveform"] {
        let proof = commit
            .issue_app_verification(
                &IssueAppVerification {
                    receiving_app: app.into(),
                    scopes: vec!["notify".into()],
                    access_ttl_seconds: Some(300),
                },
                Some(&format!("app_verification-notify-batch-0193-{app}")),
            )
            .await?;
        println!("App verification proof {} for {}", proof.proof_id, proof.receiving_app.as_deref().unwrap_or("?"));
        proofs.push(proof);
    }
    let proof = &proofs[0]; // remind's

    // remind: is this call really from commit?
    let remind = client.as_app("remind", std::env::var("REMIND_APP_SECRET")?);
    match remind.verify_proof(proof.proof_token.expose()).await? {
        ProofVerification::Valid(p) if p.issuing_app.app_id == "commit" && p.scopes.iter().any(|s| s == "notify") => {
            println!("remind: accepted, from {} until {}", p.issuing_app.app_id, p.expires_at);
        }
        _ => println!("remind: refused"),
    }
    Ok(())
}
```

```
App verification proof 01a1165d-… for remind
App verification proof 01a1165d-… for waveform
remind: accepted, from commit until 2026-10-07 12:41:09.361 +00:00:00
```

`issue_app_verification` calls `POST /v1/proofs/app-verification` with app credentials, and refuses a `receiving_app` that names several apps before sending anything. When the client acts as the app's owner (`client.with_token(owner_token).app("commit")`), it calls the owner endpoint instead. `refresh_proof` and `revoke_proof` work as shown in [the User verification example](user-verification.md#in-rust).

## With the CLI

```
$ export ACCOUNTS_APP_ID=commit ACCOUNTS_APP_SECRET=…
$ silicon-accounts app proof app-verification --to waveform --scope notify --ttl 300
App verification proof 01a1165d-49c5-72e3-a5e1-c38f003d30d7 from commit for waveform.
proof token    sap_MJMgDB69Mp_WgDKGdoR_OxnDs8Nf0eg-KmDcCGZM48Q
expires        2026-10-07T12:41:14Z (in 4m)
refresh token  sapr_umG_g5wmVYV48Yp2uXeHS36ya-E2u0yMPsLVqE2xFcM
refresh until  2029-03-25T12:36:14Z (in 899d)
scopes         notify
```

`--to` takes exactly one app. A list is refused before anything is sent, and the hint gives you one command per app:

```
$ silicon-accounts app proof app-verification --to remind,waveform
error: An App verification proof is for exactly one app, but --to names 2: remind, waveform.
hint: Issue one proof per app; each app verifies its own: silicon-accounts app proof app-verification --to remind ; silicon-accounts app proof app-verification --to waveform
```

Signed in as one of the app's authors (`silicon-accounts login`), you can run `silicon-accounts app --app-id commit proof app-verification --to remind --scope notify` without the secret, through the owner endpoint. `silicon-accounts app proof list --kind app_verification`, `refresh` and `revoke` work as for User verification. Refreshing and verifying need the app's own credentials.

## Errors

| status | code | when |
|---|---|---|
| 422 | `app_verification_single_app` | The body has `audiences` (any length): ask for one proof per app with `receiving_app`. `details.apps` lists the valid app ids that were sent. |
| 400 | `invalid_receiving_app` | `receiving_app` is your own app (`"An app can't issue a proof to itself: …"`) or Silicon Accounts itself (`silicon-accounts`, `developer`). |
| 400 | `unknown_receiving_app` | The app doesn't exist. |
| 403 | `receiving_app_disabled` | The receiving app is disabled. |
| 403 | `app_disabled` | Your app is disabled, so it can't issue proofs. |
| 422 | `validation_failed` | Every field problem at once in `details.fields`, for example `{"receiving_app": "…"}` or `{"access_ttl_seconds": "must be between 60 and 1800 seconds; got 30", "scopes[1]": "'has space' contains ' '; …", "scopes[2]": "must not be empty; …"}`. |
| 403 | `not_app_owner` / `app_mismatch` | Author endpoint: you aren't one of the app's authors, or your app credentials belong to another app than the one in the URL. |
| 409 | `idempotency_key_reused` | The `Idempotency-Key` was used with a different body. |

Refresh and revoke errors are the same as for User verification: see [the User verification errors](user-verification.md#errors).

## Related

- [Verify a proof](verify-a-proof.md): what each receiving app does.
- [How proofs work](../learn/proofs.md): why proofs are short-lived, rotate and verify live.
- [Act for an account at another app (User verification)](user-verification.md).
- [Proofs API reference](../reference/api/proofs.md): every proof endpoint, field and error.
