> **Building with AI coding agents?** Install the authstack plugin with one command. This equips your agent with accurate Scalekit implementation patterns.
>
> **Recommended**:
> ```bash
> npx @scalekit-inc/cli setup
> ```
>
> Global:
> ```bash
> npm install -g @scalekit-inc/cli
> scalekit setup
> ```
>
> Supports Claude Code, Cursor, GitHub Copilot, Codex + skills for other Agent Skills-compatible agents.
> Skills: integrate-agentkit, implement-saaskit, add-mcp-oauth, implement-sso, implement-scim.
> [Full setup guide](https://docs.scalekit.com/dev-kit/build-with-ai/)

---

# Listen for account events

Get a webhook when an AgentKit connected account changes. Verify the signature in Python or Node.js, and ask users to reconnect when an account expires.
Scalekit sends a webhook to your server when a connected account changes: when a user finishes authorizing, when a token can't be refreshed, or when an account expires or is disconnected. Listen for these events to ask users to reconnect before their next tool call fails, instead of polling every account's status.

## Before you start

- A server with a public HTTPS URL that can receive `POST` requests. While you develop, a tunnel to your local server works.
- The Scalekit SDK installed, as in the [Quickstart](/agentkit/quickstart/). It verifies webhook signatures.
- The **Admin** or **Developer** role, to create an endpoint and read its signing secret. See [Team members and roles](/agentkit/team-members/).

## Events

| Event | Sent when |
| --- | --- |
| `connected_account.created` | A connected account is created |
| `connected_account.updated` | A connected account's details are updated |
| `connected_account.deleted` | A connected account is deleted |
| `connected_account.magic_link_generated` | An authorization link is created for the account |
| `connected_account.oauth_tokens_fetched` | The app returned tokens after the user approved access |
| `connected_account.oauth_succeeded` | The user finished authorizing, including user verification when it's on |
| `connected_account.token_refresh_succeeded` | Scalekit refreshed the account's access token |
| `connected_account.token_refresh_failed` | Scalekit couldn't refresh the access token |
| `connected_account.status_updated` | The account's status changed, for example from `ACTIVE` to `EXPIRED` |

To know when a user needs to act, `connected_account.status_updated` is the one to handle. It's sent only when the status actually changes, not when an account is created, and it carries the old and new status.

`connected_account.token_refresh_failed` arrives before the status changes, so its `status` often still reads `ACTIVE`, and it doesn't say why the refresh failed. Use it as an early warning, and wait for `status_updated` before you ask the user to reconnect.

## Payloads

Every event has the same envelope. The account's details are in `data`:

```json title="connected_account.status_updated"
{
  "spec_version": "1",
  "id": "evt_101652975398683158",
  "type": "connected_account.status_updated",
  "occurred_at": "2026-09-26T06:31:34.895815554Z",
  "environment_id": "env_88640229614813449",
  "object": "ConnectedAccount",
  "data": {
    "id": "ca_133400349586228019",
    "identifier": "user_123",
    "connection_id": "conn_133400101014995480",
    "connection_name": "github-connect",
    "provider": "GITHUB",
    "authorization_type": "OAUTH",
    "status": "EXPIRED",
    "old_status": "ACTIVE"
  }
}
```

| Field | Meaning |
| --- | --- |
| `id` | The event's ID. The same event delivered twice has the same ID |
| `type` | The event name from the table above |
| `occurred_at` | When the change happened |
| `data.id` | The connected account's ID |
| `data.identifier` | Your app's ID for the user |
| `data.connection_name` | The connection, as named in **AgentKit** > **Connections**. It can be missing from `status_updated`, so read it as optional and fall back to `data.connection_id` |
| `data.status` | The status after the change: `ACTIVE`, `PENDING_AUTH`, `PENDING_VERIFICATION`, `EXPIRED` or `DISCONNECTED` |
| `data.old_status` | The status before the change. Only in `status_updated` |

Events other than `status_updated` have no `old_status`. They also include `token_expires_at` and `last_used_at`, which are empty strings when Scalekit has no value. In webhooks, `status` is a string, such as `ACTIVE`, in both Python and Node.js.

For every event's full schema, see [Connected account events](/agentkit/reference/events/) in the REST reference.

1. ## Add a webhook endpoint

   In the Scalekit dashboard, select the environment, then open **Developers** > **Webhooks** and select **Add Webhook**. Enter a **Display Name** and your **Endpoint URL**, choose the events under **Choose events to subscribe**, and save.

   Each environment has its own endpoints. Add one in Production too when you go live.

2. ## Copy the signing secret

   Open the endpoint and select **Show** under **Signing secret**. It starts with `whsec_`. Store it with your other server secrets, for example as `SCALEKIT_WEBHOOK_SECRET`, and never expose it to a browser.

3. ## Verify each request and handle the event

   Scalekit signs every request with the secret. Verify the signature against the raw request body before you trust the payload, so a forged request can't prompt the wrong user to reconnect. The SDK checks the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers, and rejects requests older than five minutes.

   **Python**

   ```python title="webhooks.py"
   import json
   import os

   from fastapi import FastAPI, HTTPException, Request
   from scalekit import ScalekitClient

   scalekit_client = ScalekitClient(
       env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"],
       client_id=os.environ["SCALEKIT_CLIENT_ID"],
       client_secret=os.environ["SCALEKIT_CLIENT_SECRET"],
   )
   app = FastAPI()
   seen_event_ids = set()  # use your database in production

   @app.post("/webhooks/scalekit")
   async def scalekit_webhook(request: Request):
       body = await request.body()
       headers = {k.lower(): v for k, v in request.headers.items()}
       try:
           scalekit_client.verify_webhook_payload(
               os.environ["SCALEKIT_WEBHOOK_SECRET"], headers, body
           )
       except Exception:
           raise HTTPException(status_code=400, detail="invalid signature")

       event = json.loads(body)
       if event["id"] in seen_event_ids:
           return {"ok": True}
       seen_event_ids.add(event["id"])

       data = event["data"]
       if event["type"] == "connected_account.status_updated":
           # connection_name can be missing; fall back to connection_id
           connection = data.get("connection_name") or data.get("connection_id")
           if data.get("status") in ("EXPIRED", "DISCONNECTED"):
               # Ask this user to reconnect, for example with a new authorization link
               print(f"{data.get('identifier')}: reconnect {connection}")
           elif data.get("status") == "ACTIVE":
               print(f"{data.get('identifier')}: {connection} is connected")
       return {"ok": True}
   ```

   **Node.js**

   ```ts title="webhooks.ts"
   import express from 'express';
   import { ScalekitClient } from '@scalekit-sdk/node';

   const scalekit = new ScalekitClient(
     process.env.SCALEKIT_ENVIRONMENT_URL!,
     process.env.SCALEKIT_CLIENT_ID!,
     process.env.SCALEKIT_CLIENT_SECRET!,
   );
   const app = express();
   const seenEventIds = new Set<string>(); // use your database in production

   // Read the raw body: the signature covers the exact bytes Scalekit sent
   app.post('/webhooks/scalekit', express.raw({ type: 'application/json' }), (req, res) => {
     const body = req.body.toString('utf8');
     try {
       scalekit.verifyWebhookPayload(
         process.env.SCALEKIT_WEBHOOK_SECRET!,
         req.headers as Record<string, string>,
         body,
       );
     } catch {
       return res.status(400).send('invalid signature');
     }

     const event = JSON.parse(body);
     if (seenEventIds.has(event.id)) return res.json({ ok: true });
     seenEventIds.add(event.id);

     const { data } = event;
     if (event.type === 'connected_account.status_updated') {
       // connection_name can be missing; fall back to connection_id
       const connection = data?.connection_name ?? data?.connection_id;
       if (data?.status === 'EXPIRED' || data?.status === 'DISCONNECTED') {
         // Ask this user to reconnect, for example with a new authorization link
         console.log(`${data?.identifier}: reconnect ${connection}`);
       } else if (data?.status === 'ACTIVE') {
         console.log(`${data?.identifier}: ${connection} is connected`);
       }
     }
     res.json({ ok: true });
   });

   app.listen(3000);
   ```

   To ask the user to reconnect, create an authorization link for `data.identifier` and `data.connection_name`, as shown in [Authorize a user](/agentkit/tools/authorize/).

## Respond quickly and handle repeats

- **Return a `2xx` status fast.** Do slow work, such as sending an email, after you respond or in a background job. Scalekit retries deliveries that fail or time out.
- **Expect the same event more than once.** Retries and resends repeat an event with the same `id`. Store the IDs you've handled, and skip a repeat.
- **Don't rely on order.** Events can arrive out of order. Compare `occurred_at`, or read the account's current status (`get_connected_account_details` in Python, `getConnectedAccount` in Node.js) before you act on an old event.

## Check it worked

In your Development environment, create an authorization link for a test user and complete it. Your endpoint receives events including `connected_account.magic_link_generated`, `connected_account.oauth_succeeded` and a `connected_account.status_updated` whose `status` is `ACTIVE`.

The endpoint's page in **Developers** > **Webhooks** lists every delivery and its result. Resend a delivery from there to test your handler again.

## Common problems

### The SDK raises `Missing required headers`

The `webhook-id`, `webhook-timestamp` and `webhook-signature` headers didn't reach the SDK. In Python, pass the headers with lowercase keys. Check that a proxy in front of your server doesn't strip them.

### The SDK raises `Invalid signature` or `Invalid Signature`

Python raises `Invalid signature`, and Node.js raises `Invalid Signature`. The body you verified isn't byte-for-byte what Scalekit sent, or the secret is from another endpoint or environment. Verify the raw body before any JSON parsing, and copy the secret again from the endpoint's page.

### The SDK raises `Message timestamp too old`

The request is more than five minutes old, or your server's clock is off. Sync the clock, and don't queue requests before you verify them.

### My endpoint receives nothing

Check that the endpoint is in the environment where the change happened, that it subscribes to the event, and that its URL is reachable from the internet. The delivery list on the endpoint's page shows failed attempts and their responses.

## Next

  - [Manage connected accounts](/agentkit/connected-accounts/): Check an account's status and why accounts expire.
  - [Authorize a user](/agentkit/tools/authorize/): Send the user a new authorization link to reconnect.


---

## More Scalekit documentation

| Resource | What it contains | When to use it |
|----------|-----------------|----------------|
| [/llms.txt](/llms.txt) | Structured index with routing hints per product area | Start here — find which documentation set covers your topic before loading full content |
| [/llms-full.txt](/llms-full.txt) | Complete documentation for all Scalekit products in one file | Use when you need exhaustive context across multiple products or when the topic spans several areas |
| [sitemap-0.xml](https://docs.scalekit.com/sitemap-0.xml) | Full URL list of every documentation page | Use to discover specific page URLs you can fetch for targeted, page-level answers |
