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
Section titled “Before you start”- A server with a public HTTPS URL that can receive
POSTrequests. While you develop, a tunnel to your local server works. - The Scalekit SDK installed, as in the Quickstart. It verifies webhook signatures.
- The Admin or Developer role, to create an endpoint and read its signing secret. See Team members and roles.
Events
Section titled “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
Section titled “Payloads”Every event has the same envelope. The account’s details are in data:
{ "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 in the REST reference.
-
Add a webhook endpoint
Section titled “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.
-
Copy the signing secret
Section titled “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 asSCALEKIT_WEBHOOK_SECRET, and never expose it to a browser. -
Verify each request and handle the event
Section titled “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-timestampandwebhook-signatureheaders, and rejects requests older than five minutes.webhooks.py import jsonimport osfrom fastapi import FastAPI, HTTPException, Requestfrom scalekit import ScalekitClientscalekit_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_idconnection = 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 linkprint(f"{data.get('identifier')}: reconnect {connection}")elif data.get("status") == "ACTIVE":print(f"{data.get('identifier')}: {connection} is connected")return {"ok": True}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 sentapp.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_idconst 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 linkconsole.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.identifieranddata.connection_name, as shown in Authorize a user.
Respond quickly and handle repeats
Section titled “Respond quickly and handle repeats”- Return a
2xxstatus 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_detailsin Python,getConnectedAccountin Node.js) before you act on an old event.
Check it worked
Section titled “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
Section titled “Common problems”The SDK raises Missing required headers
Section titled “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
Section titled “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
Section titled “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
Section titled “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.