App endpoints
Set up your app's sign-in and see its history, look after its user base, import the users you already have, and manage webhooks, event subscriptions and account verification requests.
On this page
Create your app in Silicon Apps first. Then use these endpoints to set up its sign-in, look after its users, bring in the users you already have and hear about changes through webhooks.
Most /v1/apps/{app_id}/… routes take app or author authentication: either the app's own credentials (-u app_id:app_secret) or the session of one of its authors, meaning its owner or a co-author who accepted an invite in Silicon Apps. Three routes work differently: /public, /account-verification-request and verification history at /proofs/{proof_id}/history. Their sections say what access they need.
When the caller is wrong, we say how. Credentials for a different app get 403 app_mismatch, an account that isn't one of the app's authors gets 403 not_app_owner, and an unknown app gets 404 unknown_app. A disabled app's own credentials get 403 app_disabled, but its authors can still manage it.
curl -s "$ACCOUNTS_URL/v1/apps/$APP_ID" -u "$APP_ID:$APP_SECRET"{
"app_id": "briefcase",
"name": "Briefcase",
"description": "File storage for Carbons and Silicons: upload, share and keep every file in one place.",
"logo_url": "data:image/svg+xml;base64,…",
"logo_dark_url": "data:image/svg+xml;base64,…",
"homepage_url": "http://127.0.0.1:8593/briefcase/",
"owner": { "uuid": "zQo", "kind": "carbon", "id": "c:saket", "display_name": "Saket", "pfp_url": "…", "status": "active" },
"status": "active",
"source": "fake",
"created_at": "2026-09-01T09:00:00.000Z",
"updated_at": "2026-10-07T02:31:52.122Z",
"signin_config": {
"methods": { "email": true, "phone": true, "google": true, "apple": true },
"method_order": ["google", "apple", "email", "phone"],
"google": { "mode": "managed", "client_id": null, "prompt": "select_account", "hosted_domain": null, "client_secret_set": false },
"apple": { "mode": "managed", "services_id": null, "team_id": null, "key_id": null, "private_key_set": false },
"redirect_uris": ["http://127.0.0.1:8593/briefcase/callback"],
"allowed_origins": ["http://127.0.0.1:8593"],
"required_fields": ["email"],
"optional_fields": ["timezone"],
"allowed_email_domains": [],
"allow_signup": true,
"remember_browser": true,
"branding": { "theme": "auto", "…": "…" },
"copy": { "title": "Sign in to Briefcase", "subtitle": "Your files, for every Carbon and Silicon.", "terms_url": "https://teamofsilicons.com/legal/terms", "privacy_url": "https://teamofsilicons.com/legal/privacy", "support_email": "support@teamofsilicons.com" }
},
"config_version": 1,
"webhook": { "url": "http://127.0.0.1:8593/briefcase/webhooks", "secret_set": true },
"stats": { "users": 2, "active_last_30d": 2, "imported_unclaimed": 0 }
}source is silicon_apps, fake (a development stand-in) or first_party. stats.users
counts live members (active or imported, and not deleted). imported_unclaimed counts imported
Carbons who haven't finished setting up their account yet.
Manual account verification requests
If you want your app's sign-in to run on your own domain, open the app's Sign-in setup on Silicon Developers and click Request account verification. A small form asks why you need it. Sending it starts a manual review of whether your account can run app authorization on its own domain. A reply can take up to 48 hours.
Sending the form doesn't verify your account, approve a domain or set up hosting on your domain. The Team does the review, and any domain setup, by hand.
GET /v1/apps/{app_id}/account-verification-request
A signed-in account that currently manages the app: an Accounts session, or an access token issued to silicon-accounts or to the developer platform (developer). The app's Basic credentials can't submit or read the account's request. We check current ownership or accepted authorship on every request.
Returns 200 with your latest request, or null if you have none:
{"request": null, "response_time_hours": 48}The request belongs to your account, not to each app separately, so a request you sent from another app you manage can show up here. Other managers of the app don't see your reason or your request. Responses are Cache-Control: no-store.
POST /v1/apps/{app_id}/account-verification-request
The same signed-in manager authentication. The body is {"reason":"Why I need account verification"}. We trim the reason; it must be 1 to 5,000 characters with no NUL, and we treat it as plain text. Unknown fields are refused, so a caller can't choose the email recipients or the approval state. An optional Idempotency-Key replays the same submission, with its original status; reusing the key with a different body is a conflict.
A new request returns 201:
{
"request": {
"request_id": "01928c7e-3b7a-7c4e-9a51-2f3d4c5b6a79",
"account_uuid": "zQo",
"context_app": {"app_id": "briefcase", "name": "Briefcase", "logo_url": null},
"reason": "I need authorization on my own domain for my app.",
"status": "pending",
"submitted_at": "2026-10-08T12:00:00.000Z",
"response_expected_by": "2026-10-10T12:00:00.000Z",
"reviewed_at": null
},
"created": true,
"response_time_hours": 48
}There is at most one pending request per immutable account, across all of its apps. Sending again with a new or missing idempotency key while one is pending returns 200 with created: false and the original request: the reason isn't replaced and no more notifications go out. This holds for concurrent submissions too. response_expected_by is when to expect a reply, not a deadline for automatic approval and not an expiry for the request.
We save the request and two separate email notifications in one transaction. They always go to lords@teamofsilicons.com and saket@teamofsilicons.com. Each email has the request ID, the reason, the requesting account, its verified primary email when it has one, and the app it was sent from. The emails go out through our retrying outbox, so 201 means the request and its notifications were saved, not that either email has reached its recipient. There is no public approve or reject endpoint and no automatic domain change. As the Team handles the request, its status may later become approved or rejected, with reviewed_at set.
Validation failures are 422 validation_failed, missing or unsuitable sign-in is 401, and a caller who doesn't currently manage the app gets 403 not_app_owner. Losing management access also stops you replaying an earlier submission through that app.
GET /v1/apps/{app_id}/public
Everything a sign-in page needs to know about the app. It's public and sends
Access-Control-Allow-Origin: *, on errors too, so an embed can read why it failed. It's
Cache-Control: no-cache, so branding changes show at once.
{
"app_id": "remind",
"name": "Remind",
"logo_url": "data:image/svg+xml;base64,…",
"logo_dark_url": "data:image/svg+xml;base64,…",
"homepage_url": "http://127.0.0.1:8593/remind/",
"methods": ["email"],
"branding": { "theme": "auto", "font_family": "Geist", "radius": 18, "light": { "primary": "#1F5FB8", "…": "…" }, "dark": { "…": "…" }, "…": "…" },
"copy": { "title": "Sign in to Remind", "subtitle": "Reminders on your own clock.", "terms_url": "…", "privacy_url": "…", "support_email": "…" },
"allowed_origins": ["http://127.0.0.1:8593"]
}methods lists the enabled methods in order. Managed Google and Apple are left out when this
deployment has no credentials for them. allowed_origins are the origins that may frame the
sign-in iframe (/embed/v1/buttons, also through the SDK's mountFrame); the SDK's own buttons
work on any origin. Errors: 404 unknown_app, 403 app_disabled.
GET /v1/me/owned-apps
account (Carbon): the apps you own, newest first, paginated.
{
"items": [
{ "app_id": "remind", "name": "Remind", "logo_url": "data:image/svg+xml;base64,…", "status": "active", "source": "fake", "users": 2, "created_at": "2026-09-01T09:20:00.000Z" }
],
"next_cursor": "WzE3ODgyNTQ0MDAwMDAwMDAsInJlbWluZCJd"
}GET /v1/apps/{app_id}
The app with its sign-in setup, webhook and statistics (the example is at the top of this page).
We never return secrets: google.client_secret_set and apple.private_key_set tell you whether
one is stored.
PATCH /v1/apps/{app_id}/signin-config
Changes the sign-in setup. Idempotent. Send only what you want to change: objects merge,
arrays and plain values replace, and null resets a field to its default. Unknown keys are
refused. Add "expected_version": n (from config_version) and we fail with 409
config_version_conflict instead of overwriting a change someone else made in between. The body
can be up to 512 KB, enough for two inline logos of up to 128 KB each. Answers 200 with the
same body as GET /v1/apps/{app_id}.
curl -s -X PATCH "$ACCOUNTS_URL/v1/apps/$APP_ID/signin-config" -u "$APP_ID:$APP_SECRET" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: cfg-2026-10-07-1' \
-d '{"expected_version":1,"optional_fields":["email","dob"],"copy":{"subtitle":"Reminders that respect your timezone."}}'Your own Google or Apple secrets go in the same patch. We store them encrypted, outside the
document, and never return them: {"google": {"client_secret": "…"}}, {"apple": {"private_key": "-----BEGIN PRIVATE KEY-----\n…"}} (a PKCS#8 P-256 key; null removes it). The read-only masks
client_secret_set and private_key_set are accepted and ignored, so you can PATCH back exactly
what you GET. A patch that changes nothing makes no new version, and every change adds a history
entry.
| Field | Default | Rule |
|---|---|---|
methods | {"email": true, "phone": false, "google": false, "apple": false} | at least one enabled |
method_order | ["google","apple","email","phone"] | order of the buttons |
google.mode / apple.mode | managed | managed (one click, our credentials) or byo (yours) |
google.client_id | null | required with byo (with a client_secret); at most 255 characters |
google.prompt | select_account | select_account, consent, none or consent select_account |
google.hosted_domain | null | a domain: only that Google Workspace may sign in |
apple.services_id, apple.team_id, apple.key_id | null | required with byo (with a private_key); team and key ids are 10 letters or digits |
redirect_uris | [] | at most 50; https, or http on localhost / 127.0.0.1 / [::1], or a reverse-domain native scheme |
allowed_origins | [] | at most 50 origins that may frame the iframe (/embed/v1/buttons, the SDK's mountFrame); the SDK's buttons need none |
required_fields | [] | any of email, phone, dob, timezone: always shared; a missing email or phone is added on the details page before the app gets the account |
optional_fields | [] | the same values, a checkbox on the details page (unticked until the Carbon ticks it); never also required |
flow | null | the app's pages: {steps: [{id, fields, title, subtitle, continue_label, layout}], review}; 1 to 8 steps, every requested detail on exactly one step; null is one page with every detail. See Flows. Without flow in a patch, a detail no longer asked leaves its step (an emptied step is dropped) and a new one joins the last step |
allowed_email_domains | [] | at most 100 domains; empty = any |
allow_signup | true | false = only existing (and imported) accounts may sign in |
remember_browser | true | offer "Continue as …" for the browser's signed-in Carbon |
device_flow | false | let your own command-line tool sign Carbons in with a code they approve on the account site (the device authorization grant, with client_id alone): Sign people into your CLI |
public_client | false | treat your desktop and command-line tools as public clients: they redeem authorization codes (PKCE S256 required) and refresh with client_id alone |
branding | the Silicon Accounts look | see Branding: theme, logo_url, logo_dark_url, logo_height (16 to 96), show_app_name, font_family, heading_font_family, corner_style, radius (0 to 40), button_style, layout, background_style, background_image_url, density, light and dark palettes (#RRGGBB; button text and page text need 4.5:1 contrast) |
copy | nulls | title (≤ 80 characters), subtitle (≤ 200), signup_title (≤ 80), signup_subtitle (≤ 200), opening_title (≤ 80, only the {provider} and {app} placeholders), terms_url, privacy_url, support_email |
The page footer always says "Powered by Silicon Accounts", and no setting removes it. Errors:
422 validation_failed with every problem keyed by its path, 409 config_version_conflict
(with details.current_version) and 413 payload_too_large.
{
"error": {
"code": "validation_failed",
"message": "Invalid fields: branding.light.primary: 'blue' must be a #RRGGBB colour; branding.radius: is 99 but must be between 0 and 40 (pixels); redirect_uris[0]: 'http://example.com/cb' uses http; only https is allowed, except http://localhost and http://127.0.0.1 for local development.",
"hint": "Fix the fields listed in details.fields and send the request again.",
"details": {
"fields": {
"branding.light.primary": "'blue' must be a #RRGGBB colour",
"branding.radius": "is 99 but must be between 0 and 40 (pixels)",
"redirect_uris[0]": "'http://example.com/cb' uses http; only https is allowed, except http://localhost and http://127.0.0.1 for local development"
}
}
}
}Every option is explained in the guide, Sign-in configuration.
GET /v1/apps/{app_id}/signin-config/history
Every version of the setup, newest first, paginated. actor is app, system or the uuid of
the author who made the change (then actor_account names them). Secrets show as
"[redacted]" with "secret": true.
{
"items": [
{
"version": 2,
"actor": "app",
"actor_account": null,
"at": "2026-10-07T02:36:18.263Z",
"changes": [
{ "path": "copy.subtitle", "before": "Reminders on your own clock.", "after": "Reminders that respect your timezone." },
{ "path": "optional_fields", "before": ["email"], "after": ["email", "dob"] }
]
}
],
"next_cursor": null
}The user base
GET /v1/apps/{app_id}/users
Every account that signed into the app or was imported. You can filter with:
q: matches the uuid exactly, the id, display name,external_id, the emails and phones you imported, and the primary email or phone only where you were granted that scope;status:active,imported,access_removedordeleted;kind:carbonorsilicon;source:signin,sltorimport;limitandcursor.
{
"items": [
{
"membership_id": "briefcase:8HV",
"uuid": "8HV",
"kind": "carbon",
"id": "c:ada",
"display_name": "Ada King",
"pfp_url": "https://accounts.teamofsilicons.com/v1/photos/01a11437-b512-76e4-ae95-3378b29e547e",
"email": "ada.work@example.test",
"timezone": "Europe/London",
"status": "active",
"account_status": "active",
"source": "signin",
"external_id": null,
"granted_scopes": ["profile", "email", "timezone"],
"first_signed_in_at": "2026-10-07T02:32:55.947Z",
"last_signed_in_at": "2026-10-07T02:33:16.195Z",
"created_at": "2026-10-07T02:32:55.947Z"
}
],
"next_cursor": null
}The contact fields follow what your app may see. For an active member you get the primary
email and phone (Carbons only), and dob and timezone within the granted scopes. For an
imported member you get the values from your import. For access_removed you get nothing. A
deleted account stays in the list as history, with status: "deleted",
display_name: "Deleted account", the default photo, id: null and no contact details. Errors:
400 invalid_query (an unknown status, kind or source).
GET /v1/apps/{app_id}/users/{uuid}
One member, plus its history: the last 20 sign-ins at this app (at, method, outcome, and
no IP addresses).
{
"uuid": "8HV",
"membership_id": "briefcase:8HV",
"status": "active",
"…": "…",
"history": [
{ "at": "2026-10-07T02:33:16.195Z", "method": "session", "outcome": "success" },
{ "at": "2026-10-07T02:32:55.947Z", "method": "email", "outcome": "new_account" }
]
}404 user_not_found. Uuids are case-sensitive, so 8hv is not 8HV.
Imports
Bring in the users your app already has. Follow Import existing users for the steps; How imports work explains the rules behind every row outcome.
POST /v1/apps/{app_id}/imports
Idempotent. Send either CSV (Content-Type: text/csv or application/csv) with the options
as query parameters, or JSON (application/json) as {"rows": [ {…}, … ], "options": {…}}.
- Columns (case-insensitive):
external_id,email,emails(a list, or;-separated),phone,phones,display_name(aliasname),username(the handle they'd like),dob,timezone,pfp_url,email_verified(informational). Nothing else: your app's user base has only the columns Silicon Accounts gives it. - Options:
default_country(the ISO code for local phone numbers),ignore_unknown_columns,dry_run(decide everything, write nothing) andupdate_existing. Unknown options are refused. - Limits: at most 100,000 rows and 50 MB per import, 60 imports per app per hour, and 2,000,000 rows per app per 24 hours.
curl -s -X POST "$ACCOUNTS_URL/v1/apps/$APP_ID/imports?default_country=US" -u "$APP_ID:$APP_SECRET" \
-H 'Content-Type: text/csv' -H 'Idempotency-Key: import-users-2026-10-07' --data-binary @users.csvAnswers 202 {"job": ImportJob}, and the rows are processed in the background:
{
"job": {
"id": "01a11438-429b-73d2-bbc0-cbd7ddb62960",
"app_id": "remind",
"status": "queued",
"format": "csv",
"dry_run": false,
"options": { "default_country": "US", "dry_run": false, "ignore_unknown_columns": false, "update_existing": false },
"total_rows": 4,
"processed_rows": 0,
"counts": { "created": 0, "matched": 0, "updated": 0, "skipped": 0, "error": 0, "warnings": 0 },
"created_by": "app",
"created_at": "2026-10-07T02:36:33.819Z",
"started_at": null,
"finished_at": null,
"error": null
}
}status goes queued → running → completed or failed. These are refused before a job is
made:
- 400
invalid_content_type,invalid_query,invalid_json; - 413
payload_too_large; - 422
unknown_columns(details.unknown_columns,details.allowed_columns),duplicate_columns,no_identifier_columns,empty_import,too_many_rows,invalid_csv,too_many_columns(over 200),value_too_large(a cell over 8 KB),too_many_items(a list over 50 items),validation_failed; - 429
rate_limited; - 503
imports_busy: the server is already parsing two imports and none freed up within 30 seconds (Retry-After: 15).
GET /v1/apps/{app_id}/imports and GET /v1/apps/{app_id}/imports/{job_id}
The jobs, newest first (paginated), or one job as {"job": ImportJob}. 404 import_not_found.
GET /v1/apps/{app_id}/imports/{job_id}/rows
Every row's outcome, in file order. You can filter with outcome (pending, created,
matched, updated, skipped, error), level (rows with a message of that level: error,
warning, info), code (rows with that message code, like missing_identifier), limit and
cursor.
{
"items": [
{
"row_number": 1,
"outcome": "created",
"account_uuid": "ZE6",
"id": "c:grace",
"messages": [],
"input": { "external_id": "u-1", "email": "grace@example.test", "display_name": "Grace Hopper", "username": "grace", "timezone": "America/New_York" }
},
{
"row_number": 4,
"outcome": "skipped",
"account_uuid": null,
"id": null,
"messages": [
{ "level": "info", "code": "duplicate_in_file", "message": "Row 4 repeats the email grace@example.test from row 1, so it was skipped; only row 1 was imported." }
],
"input": { "external_id": "u-4", "email": "grace@example.test", "display_name": "Dup", "username": "", "timezone": "" }
}
],
"next_cursor": null
}Every message code (missing_identifier, ambiguous_match, id_conflict, …) is listed in
Import existing users. A dry run never names a matched account
(account_uuid and id are null), because a dry run must not become a way to map addresses to
accounts.
The app webhook
How events reach your app. The format, signature and event types are in Webhooks, and the guide is Webhooks.
PUT /v1/apps/{app_id}/webhook
Send {"url": "https://app.example/hooks/accounts"} and get 200 {"url", "secret"}.
Idempotent (10 minutes). Every PUT makes a new signing secret, shown once; a retry with the
same key returns the same secret instead of making another. In production the URL must be https
and reach a public address (the SSRF guard).
{ "url": "https://app.example/hooks/accounts", "secret": "whsec_8LgRbzxd9LxcEbtBTPvcSHb4asLvlAskd-SVFU20-oY" }GET /v1/apps/{app_id}/webhook
200 {"url", "secret_set", "events", "subscription_id", "status"}: the endpoint (or null), whether
a signing secret is stored, the updates it gets (events, null for every update), and the webhook
subscription behind it with its status (Event subscriptions).
{ "url": "http://127.0.0.1:8798/briefcase", "secret_set": true, "events": ["id_change"], "subscription_id": "70a1e076-0f93-4b95-ab86-70b681893a19", "status": "active" }DELETE /v1/apps/{app_id}/webhook
204. Pending deliveries become failed, and you can replay them once a URL is set again.
Calling it twice is harmless.
POST /v1/apps/{app_id}/webhook/rotate-secret
200 {"secret": "whsec_…"}. Idempotent (10 minutes). The old secret stops signing at
once, and every delivery, retry and replay is signed with the new one. 409 webhook_not_set.
POST /v1/apps/{app_id}/webhook/test
Queues a ping. Idempotent: a retry doesn't queue a second ping. 202
{"event_id", "delivery_id", "type": "ping"}. 409 webhook_not_set.
GET /v1/apps/{app_id}/webhook/deliveries
Newest first. Filter with status (pending, delivered, failed), limit and cursor.
{
"items": [
{
"id": "01a11438-935f-7491-b075-4a18742f75c7",
"event_id": "01a11438-935f-7491-b075-4a171e84916b",
"type": "ping",
"status": "pending",
"url": "https://app.example/hooks/accounts",
"account_uuid": null,
"attempts": 1,
"last_status": 401,
"last_error": "HTTP 401 Unauthorized: the endpoint must answer with a 2xx status within 10 seconds. Response body: { \"ok\": false, \"error\": { \"code\": \"signature_mismatch\", … } }",
"last_attempt_at": "2026-10-07T02:36:55.202Z",
"next_attempt_at": "2026-10-07T02:37:05.202Z",
"delivered_at": null,
"manual_replays": 0,
"created_at": "2026-10-07T02:36:54.494Z"
}
],
"next_cursor": null
}GET /v1/apps/{app_id}/webhook/deliveries/{delivery_id}
One delivery with its attempts (each {attempted_at, status_code, error, duration_ms}),
attempt_count, and the exact payload that was signed. If the account no longer has a live
membership with your app (it removed your access) or was deleted, events carrying its data show
payload.data cut down to {uuid, membership_id}, with payload_redacted: true and
payload_redacted_reason. 404 delivery_not_found.
POST /v1/apps/{app_id}/webhook/replay
Sends deliveries again. Idempotent. The body is either {"delivery_ids": ["…"]} (1 to 100),
or {"status": "failed", "since": "2026-10-01T00:00:00Z"} (since is optional) for up to 100
failed deliveries, oldest first. A replay goes to the current URL, signed with the
current secret, with the same event_id and payload, and gets a fresh 72 hours of retries.
{
"replayed": ["01a11437-b515-70c4-9a30-c3738c3780a4"],
"skipped": [
{ "delivery_id": "01a11438-935f-7491-b075-4a18742f75c7", "event_id": "01a11438-935f-7491-b075-4a171e84916b", "type": "ping", "reason": "already_pending", "message": "This delivery is still pending; the worker is already retrying it." }
],
"remaining": 0,
"not_replayable": 0,
"url": "https://app.example/hooks/accounts"
}The skip reasons are already_pending, not_found, membership_inactive and
account_deleted. We never replay events carrying an account's data to an app that lost access
to it. With status: "failed", remaining counts the replayable failed deliveries still waiting
(call again until it's 0), and not_replayable counts the failed ones that will never be sent.
Errors: 422 validation_failed (neither field, or more than 100 ids), 409 webhook_not_set.
Event subscriptions
A subscription says where your app's updates go, which updates it wants, and whether it's
active or paused. delivery is webhook (signed POSTs to your URL) or stream (kept for
GET /v1/events/stream). An app has at most one of each. The webhook
subscription is your app's webhook, so PUT /v1/apps/{app_id}/webhook and these endpoints change
the same thing. Auth: app or author.
The updates are the ones you pick in Silicon Apps, and each one brings these event types:
| Update | Event types | Picked for a new subscription |
|---|---|---|
id_change | account.id_changed | yes |
display_name_change | account.updated with display_name in changed | yes |
pfp_change | account.updated with pfp_url in changed | yes |
timezone_change | account.updated with timezone in changed | no |
email_change | account.updated with email in changed | no |
phone_change | account.updated with phone in changed | no |
custodian_change | silicon.custodian_changed, and account.updated with custodian in changed | no |
access_removed | membership.signed_out, membership.access_removed | yes |
account_deleted | account.deleted | yes |
ping always arrives. updates: null means every update, including ones we add later. Webhooks
set up before subscriptions existed have it, so they keep getting what they got before. In
account.updated, changed lists only the fields your subscription picked (and your app may
see), and when none is left the event isn't sent at all. A paused subscription records nothing
until it's active again, but deliveries already queued still go out.
The Subscription object:
{
"id": "01a11e45-c73a-7003-a0b9-38ed30a0fd80",
"app_id": "briefcase",
"delivery": "stream",
"status": "active",
"url": null,
"secret_set": false,
"updates": ["id_change", "display_name_change", "pfp_change", "access_removed", "account_deleted"],
"event_types": ["account.id_changed", "account.updated", "account.deleted", "membership.signed_out", "membership.access_removed", "ping"],
"stream_url": "https://accounts.teamofsilicons.com/v1/events/stream",
"created_at": "2026-10-09T01:27:31.898Z",
"updated_at": "2026-10-09T01:27:31.898Z"
}GET /v1/apps/{app_id}/subscriptions
200 {"items": [Subscription…], "next_cursor": null}, with the webhook first.
POST /v1/apps/{app_id}/subscriptions
{"delivery": "webhook" | "stream", "url"?, "updates"?, "status"?} → 201 Subscription.
Idempotent (10 minutes). url is required for a webhook and refused for a stream. Leave
updates out to get the defaults above, or send null for every update. status defaults to
active. A new webhook subscription returns its signing secret once, in secret; a retry with
the same key returns the same secret. Unknown fields are refused.
curl -s -X POST "$ACCOUNTS_URL/v1/apps/$APP_ID/subscriptions" -u "$APP_ID:$APP_SECRET" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: stream-sub-1' \
-d '{"delivery":"stream"}'Errors: 409 subscription_exists (details.subscription_id: change that one instead), 422
invalid_updates (details.allowed), 422 validation_failed (url missing, refused, or not a
public https URL in production).
GET /v1/apps/{app_id}/subscriptions/{subscription_id}
200 Subscription. 404 subscription_not_found.
PATCH /v1/apps/{app_id}/subscriptions/{subscription_id}
{"updates"?, "status"?, "url"?} (at least one) → 200 Subscription. Idempotent (24 hours).
status: "paused" pauses it and "active" resumes it. url moves a webhook to another endpoint
and keeps its signing secret (rotate it with POST …/webhook/rotate-secret).
curl -s -X PATCH "$ACCOUNTS_URL/v1/apps/$APP_ID/subscriptions/$SUB_ID" -u "$APP_ID:$APP_SECRET" \
-H 'Content-Type: application/json' -d '{"updates":["id_change"]}'Errors: 404 subscription_not_found, 422 invalid_updates, 422 validation_failed (nothing to
change, or a url for a stream).
DELETE /v1/apps/{app_id}/subscriptions/{subscription_id}
204. Deleting the webhook subscription removes the webhook URL and secret, just like
DELETE /v1/apps/{app_id}/webhook (pending deliveries fail, and you can replay them once a URL is
set again). Deleting the stream subscription ends its open streams within 30 seconds
(stream.closed, reason subscription_deleted). 404 subscription_not_found.
POST /v1/apps/{app_id}/subscriptions/{subscription_id}/test
Queues a ping on that subscription, whether it's active or paused. Idempotent: a retry
doesn't queue a second ping. 202 {"subscription_id", "event_id", "delivery_id", "type": "ping"}.
delivery_id is null for a stream, where the ping arrives as a frame with that event_id.
{ "subscription_id": "01a11e45-c73a-7003-a0b9-38ed30a0fd80", "event_id": "01a11e45-eec2-774a-83b0-138146e4f988", "delivery_id": null, "type": "ping" }POST /v1/internal/apps/sync
The Silicon Apps stand-in: Silicon Apps upserts the apps it owns through this endpoint. Auth:
internal (Authorization: Bearer <ACCOUNTS_INTERNAL_TOKEN>), so apps and accounts can't call
it. The body is {"apps": [SiliconAppsApp…]} (or a bare array), at most 5 MB. Each app has
app_id, name, description, logo_url, logo_dark_url, homepage_url, owner_uuid /
owner_id / owner_email, secret, status, created_at and signin_defaults (a partial
sign-in config applied when the app is new).
{
"apps": [
{
"app_id": "docs-demo",
"action": "created",
"owner": "c:ada",
"owner_created": false,
"config": "applied",
"config_version": 1,
"webhook": "none",
"changed": [],
"warnings": []
}
]
}Everything is checked first, then applied in one transaction. An existing app keeps its app_id,
users, sign-in setup and webhook. Errors: 403 internal_api_disabled (no token configured on
the server), 401 internal_token_required / invalid_internal_token, 422 validation_failed
(paths like apps[0].secret), 422 owner_not_found, 409 owner_email_conflict and 409
owner_unavailable.