Prove your app to other apps (App verification)
Show another app that a call really comes from your app. You get one App verification proof for each app you talk to.
On this page
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 with us. A proof is always for exactly one receiving app.
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):
{
"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:
{
"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:
{
"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 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 (/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:
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). 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 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). 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 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. Withoutaccess_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.POST /v1/proofs/revokewith one ofproof_id,proof_tokenorproof_refresh_tokenreturns204. An author can useDELETE /v1/apps/{app_id}/proofs/{proof_id}from their session.GET /v1/apps/{app_id}/proofs?kind=app_verificationlists them, newest first:
{
"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
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:00issue_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.
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 waveformSigned 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.
Related
- Verify a proof: what each receiving app does.
- How proofs work: why proofs are short-lived, rotate and verify live.
- Act for an account at another app (User verification).
- Proofs API reference: every proof endpoint, field and error.