---
name: stripe-oauth-app
description: Registers a Stripe App (the current model) or a legacy Connect OAuth application to obtain Stripe OAuth credentials — client ID, client secret, permissions, redirect URIs, app review, and a safe credential handoff. Use when asked to get Stripe OAuth credentials, connect customer Stripe accounts, build a Stripe integration or Stripe App, set up a Connect extension, rotate a Stripe secret key, or fix a Stripe authorization error like `invalid_grant`, `invalid_client`, or a connector that reads an empty account. For any other vendor's developer portal, use that vendor's skill instead.
---

# Stripe OAuth2 App Registration

Get a working Stripe integration credential for a platform that reads and writes data in *other people's* Stripe
accounts — a client ID, a client secret, the right permissions, redirect URIs, and a route to production.

Stripe is not a conventional OAuth vendor and the usual instinct is wrong here. Stripe has no general "OAuth app"
product. What looks like one is **Stripe Connect OAuth**, built for marketplaces that route payments between
sellers — and the variant of it that data integrations used, **Connect extensions**, is closed to new registration.
The current model is **Stripe Apps**: an app manifest, granular permissions, an install through the Stripe App
Marketplace, and (optionally) OAuth as one of three authentication methods *inside* that model.

Read §1 before doing anything else. Choosing the wrong model here is not a setting you change later — the API
authentication method is frozen once the app is uploaded, and a Connect extension cannot be created at all.

## Inputs to collect before you start

Ask in one batch. Never invent.

| Input | Notes |
| --- | --- |
| **App name**, icon (300x300 PNG), company name | Naming rules are enforced at review (§8) |
| **Which Stripe account owns the app** | One published app per account — this is a hard limit (§3) |
| **Redirect URIs** | Every callback host your platform serves (§5) |
| **Which Stripe objects you read and write** | Drives the permission list (§6) |
| **Authentication method**: platform key, OAuth, or restricted API key | Frozen after upload (§4) |
| **Public or private distribution** | Public means app review; private means your own team only (§8) |
| **Privacy policy URL, support channel, pricing page** | Required fields on the listing (§8) |
| **Test Stripe account credentials for reviewers** | Required, must not need 2FA (§8) |
| **Is there an existing Connect extension or OAuth app?** | Changes the whole run — see §2 |

## Quick Start

1. Decide the model: Stripe App, legacy Connect OAuth, or a customer-pasted API key (§1).
2. Confirm whether a **new** app is needed — existing connections are bound to the current credentials (§2).
3. Sign in to, or create, the Stripe account that will own the app (§3).
4. Create the app with the Stripe CLI and set `stripe_api_access_type` before uploading (§4).
5. Add every redirect URI to `allowed_redirect_uris` (§5).
6. Request the narrowest permission set that covers what you actually call (§6).
7. Capture the client ID, the developer secret key, and the install links (§7).
8. Submit for app review and publish, if the app is public (§8).
9. Handle the things that break connectors after launch: API version pinning, rate allocations, livemode (§9).
10. Verify with a **second** Stripe account, not the one that owns the app (§10).
11. Hand the credentials over — never commit them (§11).

## Platform state (verified 2026-09-20 — re-verify before trusting)

Four facts decide this task. Check the linked pages before following any older runbook:

- **Connect extensions — the read-only OAuth pattern that data integrations used — can no longer be created.**
  Stripe's own page for them is titled "[Deprecated] Legacy Connect extensions" and states: "You can no longer
  build new Connect extensions. Stripe Apps replaces Connect extensions for developing on Stripe." Stripe Apps
  replaced them as the preferred integration model **as of 2022**. Existing extensions keep working "for now",
  with no guaranteed ongoing support and no new features.
- **OAuth is explicitly not recommended for new Connect platforms.** The Connect OAuth guide opens with "OAuth
  isn't recommended for new Connect platforms. We recommend using Connect Onboarding for Standard accounts
  instead." Stripe's app-publishing guide is blunter: "Stripe deprecated the Connect Stripe authentication
  method, so use this OAuth method instead" — meaning Stripe Apps OAuth.
- **`read_only` scope is Extensions-only.** "Only Extensions can use `read_only`." Since extensions can no longer
  be created, a *new* integrator cannot obtain read-only Connect OAuth at all. Registering as a Connect
  *platform* instead buys you `read_write`, which since June 2021 **cannot connect to a Standard account already
  controlled by another platform** — it forces the user to create a second, separate Stripe account.
- **The legacy Connect token response's `access_token`, `refresh_token` and `stripe_publishable_key` are marked
  Deprecated in the reference.** Stripe directs you to use the `Stripe-Account` header with your platform's own
  secret key instead. The field you actually need from that response is `stripe_user_id`.

The current API version is `2026-08-26.dahlia` (major releases are named — Acacia, … , Dahlia; monthly releases
are backward-compatible and reuse the last major name). If the Dashboard or docs do not look like this, stop and
report what you actually see rather than clicking on.

## 1. The model decision — read this first

**For a new multi-tenant integration that reads and writes many customers' Stripe accounts today, build a Stripe
App.** That is Stripe's stated current model, it is the only one open to new registration, and it is the only one
that gets granular permissions, Marketplace discoverability and continued feature work.

Stripe App is not a synonym for "Dashboard UI widget". An app with no UI is a first-class case: Stripe calls it
`data_integration`, and the publishing guide tells you to leave `ui_extension` empty for it. A headless connector
is exactly that.

Inside the Stripe App model you then pick **one** of three API authentication methods (`stripe_api_access_type`):

| Method | What your backend holds | Fits |
| --- | --- | --- |
| `platform` (default) | **Your own** account's secret key, plus the customer's account ID; you call with the `Stripe-Account` header | Fewest keys to manage; the drop-in replacement for a legacy Connect extension |
| `oauth` | Per-account access token (1 hour) and refresh token (1 year, rotated on every exchange) | You already speak OAuth; the user manages the integration from your product. **This is the option that yields an OAuth client ID and secret.** |
| `restricted_api_key` | A permissioned `rk_` key the customer copies out of Stripe and pastes into your product | Your software cannot do redirect-based onboarding, or runs on-premise |

Stripe says `platform` and `oauth` are the two that offer legacy extensions a drop-in replacement. For a
Unified.to-style connector doing browser-based connect flows, `oauth` is the natural fit and the one this skill
assumes unless the user says otherwise.

**The decision is one-way.** `stripe_api_access_type` cannot be changed after the app is uploaded (Stripe states
this outright for the restricted-key case, and app IDs are globally unique, so "just re-upload" means a new app
and a re-onboarding of every customer). Confirm the choice with the user before the first `stripe apps upload`.

When **not** to build a Stripe App:

- **You already operate a working Connect extension or Connect OAuth integration.** It keeps working. Migrating is
  a customer-visible event: every existing user must re-authorize the new permission set in their Dashboard
  before they can use the migrated app. That is a project, not a config change (§2).
- **You are actually building a marketplace** that creates accounts and routes payments. That is Connect proper —
  use Connect Onboarding for Standard accounts, not OAuth, and not this skill.
- **The customer just wants to paste a key.** A restricted API key created by the customer in their own Dashboard
  needs no app at all. It is the lowest-friction path and a legitimate answer for a handful of accounts; it does
  not scale to self-serve onboarding, and the key's permissions are whatever the customer happened to tick.

## 2. Decide: reuse the existing integration, or register a new one

New credentials orphan existing connections. Every customer connection is bound to the client ID (or to the
platform secret key) that created it. Reuse the existing registration for: adding a redirect URI, adding a
permission, rotating a compromised secret, or diagnosing a failing authorization.

Create a **new** app only when the user explicitly wants one: a deliberate migration off a legacy Connect
extension, a separate product, or a replacement for a compromised app. Two Stripe-specific constraints shape
that migration:

- **Upload the new app to the same Stripe account that owns the extension** if you want existing users to migrate
  in place. They then see a prompt in their extension settings to re-authorize the app's permissions, and
  accepting it **overwrites** the unrestricted read/write grant the extension had. So the manifest must already
  request every permission the integration needs *before* anyone migrates, or working customers break.
- **Upload it to a different Stripe account** if you do not want to prompt existing users — but one account cannot
  hold both an existing Connect extension and an unrelated public Stripe App.

Say which path you are taking, out loud, before touching anything.

## 3. The Stripe account that owns the app

- **A Stripe account you control** owns the app. You need an **activated** account to publish (Stripe requires
  activation, and the business purpose must not be on the Prohibited and Restricted Businesses list).
- **One published app per account.** This is a hard limit, and it interacts with §2: plan which account owns what
  before you upload anything.
- **A separate account for development.** Stripe recommends building and testing the app under a different or new
  Stripe account, with a different app ID (`com.example.myapp.test`), because **app IDs are globally unique**.
- **Sandboxes** are the isolated test environments (`sk_test_`, `rk_test_`, `pk_test_` keys). Set
  `sandbox_install_compatible: true` in the manifest to let the app be installed into one.

Hand control back to the user for anything only they can do: account signup and activation, business details,
identity verification when revealing a secret key, MFA, accepting terms. Do not retry a blocked step in a loop.

**If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click.
The CLI does most of this work — hand the user the exact commands and the literal values to paste (the redirect
URIs from §5, the permission list from §6), then continue once they report back with the client ID.

## 4. Create the app

Stripe Apps are created and uploaded with the Stripe CLI, not through a web form.

```bash
stripe apps create <app-name>
stripe apps set api-access-type oauth        # or platform / restricted_api_key — frozen after upload
stripe apps set distribution public          # private is the default
stripe apps upload
```

The manifest (`stripe-app.json`; v2 apps use `stripe-app.yaml`, and `stripe apps migrate` converts) carries
everything that matters:

```json
{
  "id": "com.example.myapp",
  "version": "0.0.1",
  "name": "Example Sync",
  "icon": "./icon.png",
  "distribution_type": "public",
  "stripe_api_access_type": "oauth",
  "sandbox_install_compatible": true,
  "allowed_redirect_uris": [],
  "permissions": [],
  "ui_extension": []
}
```

Fields that carry more weight than they look:

- **`id`** is globally unique and validated on first submission. Choosing it badly is permanent.
- **`ui_extension: []`** is the correct value for a headless data integration. Stripe's publishing guide says so
  explicitly; shipping an empty app drawer instead is a review finding.
- **`name`** cannot contain "Stripe", "app", "free" or "paid" for public apps (since September 2024), and review
  also rejects "RAK", "Generator", "API Key" and "Authenticator". It must match the listing name exactly.
- **`permissions[].purpose`** is shown to the installing user; **`permissions[].name`** is the explanation shown to
  Stripe's reviewers. Thin justifications are among the most common review failures.

**Legacy path, for reference only.** A pre-existing Connect OAuth integration is configured in the Dashboard at
**Settings → Connect → Onboarding options → OAuth**, which is where its `client_id` (prefix `ca_`) and redirect
URIs live. Its authorize endpoint is `https://connect.stripe.com/oauth/authorize` with
`response_type=code&client_id=…&scope=read_only|read_write`, the exchange is
`POST https://connect.stripe.com/oauth/token` authenticated with your platform secret key as the HTTP Basic
username, and deauthorization is `POST https://connect.stripe.com/oauth/deauthorize` with `client_id` and
`stripe_user_id`. Do not build something new on this.

## 5. Redirect URIs

Register **every** callback host your platform serves in `allowed_redirect_uris`. The first entry is the default
used when the install link omits `redirect_uri`. For Unified.to these are one per data center; confirm the
current list with the platform owner rather than assuming:

```
https://api.unified.to/oauth/code          # us (default)
https://api-eu.unified.to/oauth/code       # eu
https://api-au.unified.to/oauth/code       # au
https://api-dev.unified.to/oauth/code      # dev
```

Notes:

- A `redirect_uri` passed on the install link must **exactly match** one of the registered values. Live mode
  requires HTTPS.
- **`localhost` and placeholder URLs are a documented review rejection.** Stripe names them as a security risk and
  tells you to remove them before uploading. Strip development entries before submitting.
- The redirect URI is your platform's, not Stripe's, and does not vary per customer account.

## 6. Permissions (and what they replaced)

A Stripe App declares granular, per-resource permissions in the manifest, each in the form `<resource>_read` or
`<resource>_write`, added with `stripe apps grant permission "<name>" "<explanation>"`. The installing account
administrator must accept them. Calling an API the app lacks permission for raises an invalid request error.

Names you will need for a payments/billing/commerce connector, as Stripe spells them:

| Data | Permissions |
| --- | --- |
| Customers | `customer_read`, `customer_write` |
| Charges and Refunds | `charge_read`, `charge_write` |
| Payment Intents | `payment_intent_read`, `payment_intent_write` |
| Invoices | `invoice_read`, `invoice_write` |
| Credit Notes | `credit_note_read`, `credit_note_write` |
| Products | `product_read`, `product_write` |
| Prices | `plan_read`, `plan_write` — note the legacy name |
| Subscriptions | `subscription_read`, `subscription_write` |
| Payment Links | `payment_links_read`, `payment_links_write` — note the plural |
| Payouts | `payout_read`, `payout_write` |
| Balance | `balance_read` |
| Tax Rates | `tax_rate_read`, `tax_rate_write` |
| Account | `connected_account_read` |
| Events (webhooks) | `event_read`; endpoints themselves need `webhook_read` / `webhook_write` |

Four traps worth more than the table:

1. **Permission names do not track object names.** Prices are `plan_*`; payment links are plural; the account
   object is `connected_account_read`. Guessing from the API resource name produces permissions that do not exist.
2. **Expansion needs its own permission.** If you `expand` a nested object in a response, you must hold a
   permission for the expanded object too. `balance_transaction_source_read` exists specifically to expand a
   balance transaction's `source`, and it implies a cluster of others. A connector that expands to enrich a
   payment will fail on permissions it never explicitly reads.
3. **Not every resource appears in the reference.** Financial Connections accounts and transactions, for example,
   are not listed among the object permissions as of 2026-09-20. If your integration touches a resource that has
   no permission listed, do not assume it is covered — confirm with Stripe before designing around it.
4. **`webhook_write` is flagged sensitive** because it allows subscribing to events across the entire account.
   Expect it to draw reviewer attention, and justify it properly.

Request the narrowest set that matches what the connector actually calls. Over-broad permissions are a review
finding and a customer security objection, and after migration the permission list is what *replaces* a legacy
extension's unrestricted read/write grant.

**Legacy scopes, for reference.** Connect OAuth has exactly two: `read_only` and `read_write`, space-free, passed
as `scope` on the authorize URL, defaulting to `read_only`. `read_only` is Extensions-only (§Platform state).
There is no middle ground and no per-object control — which is precisely the gap Stripe Apps permissions fill.

## 7. Capture the credentials

For a Stripe App using `oauth`:

- **Client ID** — generated for the app; the install link is built from it.
- **Install links** — the Dashboard generates separate links for live mode, test mode and sandbox. The
  **External test** tab's links are for testing only; the **Settings** tab holds the **public** install links,
  which are what app review requires and what customers eventually use. Submitting the test link is a listed
  common review failure. Public links do not work until the app is published (reviewers can still use them).
- **The install link shape** is
  `https://marketplace.stripe.com/oauth/v2/authorize?client_id=…&redirect_uri=…&state=…`. Always send `state`,
  and verify it on the callback.
- **Your app developer account's secret key** is the `client_secret` for the token exchange — it is your own
  account's key, not a separate OAuth secret. Which key depends on which link the user installed from: live-mode
  link → live key; test-mode link → test key; sandbox link → managed sandbox key. Stripe's own advice is to carry
  the link type through the `state` parameter so your callback knows which key to use.
- **Token endpoint**: `POST https://api.stripe.com/v1/oauth/token` with the secret key as HTTP Basic username,
  `grant_type=authorization_code` and `code`. The authorization code is **single-use and valid for 5 minutes**.
- **The response** carries `access_token`, `refresh_token`, `livemode`, `scope: "stripe_apps"`,
  `stripe_publishable_key`, and `stripe_user_id` — the connected account ID (`acct_…`). **Store the account ID.**
  It is how you identify the customer forever after, and how you scope calls with the `Stripe-Account` header if
  you ever move to platform-key auth.
- **Token lifetimes**: access tokens expire in **1 hour**; refresh tokens expire in **1 year** and are **rotated
  on every exchange**, with the previous one expiring immediately. Persist the new refresh token from every
  refresh. If you refresh at least once a year you never hit the expiry.

Secret keys are shown once in live mode and cannot be re-read; you can only reveal live keys that Stripe created
for you. If you create one yourself, capture it immediately or rotate. Rotating in the Dashboard gives a **7-day
grace period** during which both keys work — use it rather than a hard cutover.

Report the secret once so the user can paste it into their secret store, say plainly that it is now in the
transcript and can be rotated, then move on.

## 8. Review, listing and limits

Private apps (the default) are installable only by **team members of the owning account** and go through **no
review**. Public distribution requires app review and Marketplace publication.

- **Turnaround: 4 business days** for an approval or feedback email after submission. Any change to the app or the
  listing means resubmitting and passing review again.
- **Listing requirements** include: name ≤35 chars matching the manifest, a ≥300×300 square PNG/JPG icon matching
  the manifest, "Built by", category, subtitle (≤80), About (≤1000), one to three key features with 1600px+
  screenshots that show no real customer data, pricing and a pricing page if paid, a support channel with a
  response-time estimate, a privacy policy URL, and English (the only supported language).
- **Test credentials are mandatory**, must reach the highest role in your product, must not require 2FA, and
  **must not be real accounts** — Stripe does not permit real accounts for review. The test path must connect
  through app installation, not through Connect onboarding.
- **Common rejections** Stripe names: localhost or broken `allowed_redirect_uris`; thin permission justifications;
  hard-coded API keys in the app; the External-test OAuth link instead of the public one; testing guidance that
  does not cover the onboarding flow.
- **One published app per account**, and once an account has a public app you cannot make a second one public.

Anything the review form asks that is a business claim — compliance attestations, data-handling commitments,
pricing, volume projections, partnership terms — is for a human. Do not invent an answer (§Stop and ask).

## 9. What breaks connectors after launch

None of this is fixed by re-registering the app.

- **API version is pinned per account, not per app.** By default every request uses **the Stripe account's own
  default API version** (set in Workbench), and webhook events use the account's version at the time the event
  occurred unless the endpoint was created with an explicit version. For a connector reading many customer
  accounts that means **the same code can receive differently-shaped payloads from different customers**. The fix
  is to send an explicit `Stripe-Version` header on every request and to pin the version on every webhook
  endpoint, so the connector — not the customer — decides the schema. Note that a pinned version also freezes you
  out of fields added later, and that Event objects are immutable: raising the account version does not reshape
  events already created.
- **Read allocations, not just rate limits.** Beyond 100 req/s live (25 in a sandbox) and 25 req/s per endpoint,
  Stripe enforces a read *allocation*: an average of **500 GET requests per transaction** over a rolling 30 days,
  minimum 10,000 reads/month. A Connect platform has its own allocation separate from its connected accounts',
  computed on aggregate transaction count. A connector that polls low-volume accounts hard is the exact shape
  that trips this. Writes have no allocation.
- **429s are not all rate limits.** A `429` carries a `Stripe-Rate-Limited-Reason` header (`global-rate`,
  `endpoint-rate`, `global-concurrency`, `endpoint-concurrency`, `resource-specific`). A `429` *without* that
  header with code `lock_timeout` is object lock contention — retry with backoff, and serialize writes to the
  same object. List calls and expansions are the usual cause of concurrency limiting.
- **The webhook signing secret is a separate credential** (`whsec_`, per endpoint, different for test and live)
  and is **not** an API key. Verify the `Stripe-Signature` header against the **raw** body; any middleware that
  reparses the body breaks verification. Events are **not ordered**, can be **delivered more than once**, and
  retry for up to three days — dedupe on event ID, never on `created`. Roll signing secrets periodically; Stripe
  lets both be valid for up to 24 hours during a roll.
- **Idempotency** applies to POSTs via the `Idempotency-Key` header (≤255 chars, use a v4 UUID). Stripe replays
  the saved status and body — including 500s — and errors if you reuse a key with different parameters. Keys are
  pruned after ~24 hours. GET and DELETE ignore it.
- **Livemode and test mode are separate universes.** Separate keys, separate objects, separate install links,
  separate webhook secrets, and the token response's `livemode` flag tells you which one a connection is in. A
  test-mode key against a live-mode code returns `invalid_grant`.

## 10. Verify end-to-end

Installing into the account that owns the app proves almost nothing. Test the path a customer takes:

1. Install from a **second Stripe account** (a sandbox or a separate test account) through the real install link,
   and confirm the permission screen lists exactly what you expect.
2. Confirm the token response carries `stripe_user_id`, and that you stored it.
3. Make a real read call with the access token and confirm the object shape matches the `Stripe-Version` you
   intend to pin (§9), not whatever that account happens to default to.
4. Force a refresh, then force a **second** refresh using the token returned by the first — that is what catches a
   client that ignores refresh-token rotation.
5. Exercise one permission you almost use (an `expand`, a nested object) and confirm it does not 400.
6. Deliver one webhook and verify the signature against the raw body.

| Symptom | Cause |
| --- | --- |
| `invalid_grant` on code exchange | Code already used, older than 5 minutes, or a key/mode mismatch (test key vs live-mode link) — §7, §9 |
| `invalid_client` on deauthorize | `client_id` not yours, account not connected, or key mode mismatch |
| Second refresh fails | Client is not storing the rotated refresh token — §7 |
| Connection dies after a long idle period | Refresh token passed its 1-year expiry — §7 |
| "Missing permissions" when refreshing | Using an access token or restricted key instead of the account secret key — §7 |
| Invalid request error on a call that "should work" | Missing permission, often for an **expanded** object — §6 |
| Same code, different field shapes per customer | API version is the customer account's default; pin `Stripe-Version` — §9 |
| 429 with `Stripe-Rate-Limited-Reason` | Rate or concurrency limit — back off, reduce expansions — §9 |
| 429, no reason header, `lock_timeout` | Object lock contention — serialize writes to that object — §9 |
| Webhook signature verification fails | Body was reparsed before verification, or wrong endpoint's secret — §9 |
| Duplicate or out-of-order events | Expected behaviour — dedupe on event ID — §9 |
| Valid token, empty or wrong data | Test mode vs live mode; check `livemode` on the connection — §9 |
| `read_write` connect flow forces a new customer account | Standard account is controlled by another platform (June 2021 rule) — §Platform state |
| Review rejected on redirect URIs | `localhost` or placeholder left in `allowed_redirect_uris` — §5 |

## 11. Hand off — never commit the secret

- **Do not** write the secret key, a restricted key, an access or refresh token, or a webhook signing secret into
  source control, a test, a fixture, a committed `.env`, a ticket, a PR body, or a chat channel. Values go to the
  user, for the secret store or console. Stripe explicitly warns that hard-coded keys in an app fail review.
- **Stripe keys are self-identifying.** A key beginning `sk_live_` is an unrestricted live secret key and is the
  most dangerous string in this workflow; `rk_live_` is a live restricted key; `sk_test_` / `rk_test_` /
  `pk_test_` are sandbox keys and `pk_` publishable keys are safe to expose. If you ever see an `sk_live_` value
  in a diff, a log, or a paste, treat it as compromised, say so, and tell the user to rotate it — Stripe's
  Dashboard rotation gives a 7-day grace window so rotating is cheap.
- Consider recommending **access policies** on live keys (IP/CIDR, or ASN + country) so a leaked key is unusable
  from outside your infrastructure, and Stripe notifies you on blocked attempts.
- If a code change is needed (a redirect host, a permission, `Stripe-Version` pinning), keep it secret-free and
  say what the human must set out of band.
- Close with: app name and app ID; the owning Stripe account; the model and authentication method chosen
  (and that it is frozen); client ID; where the secret was delivered; the install links (public vs test); the
  exact permission strings; the redirect URIs registered; the API version you pinned; review status and what is
  still outstanding; and anything left for the user to do.

## Stop and ask

Hand back to a human rather than guessing when: the choice between Stripe App, Connect OAuth and a
customer-supplied restricted key has not been made by someone who owns the integration (§1 — it is irreversible);
someone proposes migrating a live Connect extension, because every existing customer must re-authorize and an
incomplete permission list breaks them (§2); the owning-account question collides with the one-published-app-per-
account limit (§3); a required resource has no permission listed in Stripe's reference (§6); the review form asks
for compliance, legal, pricing or volume claims (§8); rotating a live secret key is on the table without a
cutover plan (§11); or the Dashboard and docs do not match the **Platform state** section above.

Never run browser automation against a Stripe Dashboard session, and never attempt to create or reveal keys on
the user's behalf through a logged-in browser. Reveal flows require identity verification that only the user can
complete. Give the exact CLI commands and click paths, and let them report back.

## References

- Stripe Apps overview — https://docs.stripe.com/stripe-apps
- API authentication methods (platform / oauth / restricted_api_key) — https://docs.stripe.com/stripe-apps/api-authentication
- Stripe Apps OAuth 2.0 — https://docs.stripe.com/stripe-apps/api-authentication/oauth
- Restricted API key authentication — https://docs.stripe.com/stripe-apps/api-authentication/rak
- Permissions reference — https://docs.stripe.com/stripe-apps/reference/permissions
- App manifest reference — https://docs.stripe.com/stripe-apps/reference/app-manifest
- Stripe Apps CLI reference — https://docs.stripe.com/stripe-apps/reference/cli
- Create an app — https://docs.stripe.com/stripe-apps/create-app
- Upload and install an app — https://docs.stripe.com/stripe-apps/upload-install-app
- Distribution options — https://docs.stripe.com/stripe-apps/distribution-options
- Publish an app (review, listing, 4 business days) — https://docs.stripe.com/stripe-apps/publish-app
- App review requirements — https://docs.stripe.com/stripe-apps/review-requirements
- Test your app / external testing — https://docs.stripe.com/stripe-apps/test-app
- Enable sandbox support — https://docs.stripe.com/stripe-apps/enable-sandbox-support
- Secret Store API — https://docs.stripe.com/stripe-apps/store-secrets
- Migrate a Connect extension to Stripe Apps — https://docs.stripe.com/stripe-apps/migrate-connect-extension
- [Deprecated] Legacy Connect extensions — https://docs.stripe.com/building-extensions
- Connect OAuth reference — https://docs.stripe.com/connect/oauth-reference
- Using OAuth with Standard accounts — https://docs.stripe.com/connect/oauth-standard-accounts
- OAuth changes for platform-controlled Standard accounts — https://docs.stripe.com/connect/oauth-changes-for-standard-platforms
- Make API calls for connected accounts (`Stripe-Account`) — https://docs.stripe.com/connect/authentication
- Connect webhooks — https://docs.stripe.com/connect/webhooks
- API versioning — https://docs.stripe.com/api/versioning
- Upgrades and backward compatibility — https://docs.stripe.com/upgrades
- API changelog — https://docs.stripe.com/changelog
- Rate limits and read allocations — https://docs.stripe.com/rate-limits
- Idempotent requests — https://docs.stripe.com/api/idempotent_requests
- Webhooks (signing secret, signature verification, retries) — https://docs.stripe.com/webhooks
- API keys and access policies — https://docs.stripe.com/keys
- Restricted API keys — https://docs.stripe.com/keys/restricted-api-keys
- Best practices for managing secret API keys — https://docs.stripe.com/keys-best-practices
- Sandboxes — https://docs.stripe.com/sandboxes
- Stripe CLI install — https://docs.stripe.com/cli/install
- Stripe App Marketplace — https://marketplace.stripe.com/
- Dashboard: Apps — https://dashboard.stripe.com/apps
- Dashboard: Connect OAuth onboarding options (legacy) — https://dashboard.stripe.com/settings/connect/onboarding-options/oauth

## Product fact: the Unified.to Stripe connector

As of 2026-09-20, Unified.to's Stripe connector uses the **legacy Connect/extension-style OAuth** model, not
Stripe Apps: it authorizes against `https://connect.stripe.com/oauth/authorize` and exchanges the code against
Stripe's `/v1/oauth/token` endpoint, sending the client secret as the HTTP Basic username. It also supports a
second, non-OAuth mode where the customer supplies a Stripe secret or restricted API key directly, presented to
the API as the Basic-auth username.

Its OAuth scope set is the legacy pair only: `read_only` for every object it reads, and `read_write` requested for
the objects where it supports create or update (contacts, invoices, tax rates, items, payment links, payments,
subscriptions, credit memos). Collections, payouts, refunds, accounts, transactions and the bank-feed objects are
requested read-only. Because `read_only` is Extensions-only and extensions can no longer be created, this scope
set is only obtainable under the connector's existing registration — it could not be reproduced by registering a
new Connect application today.

The connector identifies the connected account by the Stripe account ID returned in the token exchange, and uses
it to look up that account's details after authorization. It **pins the API version explicitly**, sending a
`Stripe-Version` header of `2024-04-10` on every read, write and list call rather than inheriting each customer
account's default — which is the correct shape for a multi-account connector (§9), but is several major releases
behind the current `2026-08-26.dahlia`. Writes are sent form-encoded, updates use POST, and lists page by cursor.

Treat all of the above as a snapshot, not as authority: **confirm the current model, scope set, pinned API
version and migration plans with the connector's owner before acting on them.**
