diff --git a/app/en/build/eventing/page.mdx b/app/en/build/eventing/page.mdx index ac9554937..2aaf9dba5 100644 --- a/app/en/build/eventing/page.mdx +++ b/app/en/build/eventing/page.mdx @@ -260,6 +260,70 @@ Use a persistent store in production. Keep each recorded `webhook-id` for at lea The sample records `received_at` for that cleanup policy but does not schedule the cleanup job for you. SQLite serializes these write transactions, so keep the handler's transactional work short and local; use a concurrent durable inbox for production receivers with parallel or network-bound work. +## Recover expired connected accounts + +Subscribe to connection lifecycle events when your app should tell a user that an OAuth connection needs attention: + +- `connected_account.created`—the connection became active for the first time. +- `connected_account.expired`—Arcade can no longer use the connection. +- `connected_account.reconnected`—reauthorization restored an expired connection. + +Create the project-scoped subscription: + +```bash +curl --fail-with-body --silent --show-error \ + --request POST "$SCOPE/webhooks" \ + --header "Authorization: Bearer $ARCADE_API_KEY" \ + --header "Content-Type: application/json" \ + --data "{\"url\":\"$RECEIVER_URL\",\"event_types\":[\"connected_account.expired\"]}" +``` + +Arcade sends the Standard Webhooks envelope. Verify the Standard Webhooks signature against the raw body before reading it: + +```json +{ + "type": "connected_account.expired", + "timestamp": "2026-08-28T21:00:00Z", + "data": { + "organization_id": "org_123", + "project_id": "proj_123", + "user_id": "user@example.com", + "provider_id": "google", + "connection_id": "ac_123", + "status": "expired", + "reason": "refresh_failed" + } +} +``` + +All three events identify the `organization_id`, `user_id`, `provider_id`, `connection_id`, and `status`. Project-bound events also include `project_id`. Only `connected_account.expired` includes `reason`: + +- `no_refresh_token` means the access token expired and no refresh token was available. +- `refresh_failed` means the provider permanently rejected the refresh grant. + +Each lifecycle event can originate from a project-bound or organization-bound connection. The public subscription API is project-scoped, so it delivers the project-bound events in the preceding example. Organization-bound lifecycle events omit `project_id` and are not delivered to a project subscription. + +In the Dashboard, open the expired connected user, and choose **Reconnect**. The same action is available through `POST /v1/orgs/{org_id}/projects/{project_id}/auth/authorize`: + +```bash +curl --fail-with-body --silent --show-error \ + --request POST "$SCOPE/auth/authorize" \ + --header "Authorization: Bearer $ARCADE_API_KEY" \ + --header "Content-Type: application/json" \ + --data '{ + "user_id":"user@example.com", + "auth_requirement":{ + "provider_id":"google", + "provider_type":"oauth2", + "oauth2":{"scopes":["https://www.googleapis.com/auth/gmail.readonly"]} + } + }' +``` + +Open the returned authorization URL. A successful flow restores the same connection and emits `connected_account.reconnected`. Triggers pinned to it become healthy again. Use the scopes on the connected-account record; Dashboard does this for you. Arcade also applies scopes configured on the provider. A reconnected event reports the OAuth state change, but normal scope checks still apply if the provider returns a reduced grant. + +Engine does not observe MCP-managed OAuth refresh outcomes, so MCP-managed OAuth connections do not emit this lifecycle or use the standard OAuth Reconnect action. + ## Try a scheduled event A schedule is a configurable producer; every fire creates a separate retained Arcade event with its own delivery history. Use an interval for this check. diff --git a/tests/eventing-guide.test.ts b/tests/eventing-guide.test.ts index 104c51270..3c7c141d0 100644 --- a/tests/eventing-guide.test.ts +++ b/tests/eventing-guide.test.ts @@ -8,6 +8,9 @@ const TITLE_RE = /title:\s*"Build event-driven integrations"/; const MODEL_SECTION_RE = /## The eventing model([\s\S]*?)## Choose your deployment origin/; const TABLE_DATA_ROW_RE = /^\| (?!Term \|)(?!-)[^|]+\|/gm; +const ACCOUNT_LIFECYCLE_SECTION_RE = + /## Recover expired connected accounts([\s\S]*?)## Try a scheduled event/; +const JSON_BLOCK_RE = /```json\n([\s\S]*?)\n```/; const TIMESTAMP_TOLERANCE_RE = /through (\d+) seconds/; const TIMESTAMP_REJECTION_RE = /timestamps (\d+) seconds away/; const TOLERANCE_CONSTANT_RE = /TOLERANCE_SECONDS = (\d+)/; @@ -107,6 +110,42 @@ describe("unified eventing guide", () => { expect(page).toContain("[Arcade API reference](/references/api)"); }); + test("documents the complete connected-account recovery contract", () => { + const section = page.match(ACCOUNT_LIFECYCLE_SECTION_RE)?.[1] ?? ""; + for (const value of [ + "connected_account.created", + "connected_account.expired", + "connected_account.reconnected", + '--request POST "$SCOPE/webhooks"', + "event_types", + "url", + "no_refresh_token", + "refresh_failed", + "POST /v1/orgs/{org_id}/projects/{project_id}/auth/authorize", + "MCP-managed OAuth", + ]) { + expect(section).toContain(value); + } + expect(section).toContain("**Reconnect**"); + expect(section).toContain("public subscription API is project-scoped"); + expect(section).toContain("Organization-bound lifecycle events omit"); + expect(section).toContain("Verify the Standard Webhooks signature"); + expect(section).toContain("reduced grant"); + + const envelope = JSON.parse(section.match(JSON_BLOCK_RE)?.[1] ?? "{}"); + expect(Object.keys(envelope).sort()).toEqual(["data", "timestamp", "type"]); + expect(envelope.type).toBe("connected_account.expired"); + expect(Object.keys(envelope.data).sort()).toEqual([ + "connection_id", + "organization_id", + "project_id", + "provider_id", + "reason", + "status", + "user_id", + ]); + }); + test("documents the supported lifecycle without hiding retained events", () => { for (const route of [ "GET /triggers/{trigger_id}",