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

---

# Receive trigger events

Verify and parse AgentKit trigger events in Python or Node.js, respond fast, skip repeats with dedupe_key, and read each field of the event.
Each time a trigger fires, Scalekit sends your delivery endpoint an HTTP `POST` with the event as JSON. Your server verifies the signature, returns a `2xx` status quickly and hands the event to your agent. This page shows a handler in Python and Node.js, how to handle repeats and retries, and what each field in the event means.

> note: Triggers are in beta
>
> [Contact us](/support/contact-us/) to turn triggers on for your environment.

## Before you start

- A delivery endpoint and its signing secret, from [Set up a trigger](/agentkit/triggers/set-up/#add-a-delivery-endpoint). The code below reads the secret from `SCALEKIT_TRIGGER_SECRET`.
- Your Scalekit API credentials in `SCALEKIT_ENVIRONMENT_URL`, `SCALEKIT_CLIENT_ID` and `SCALEKIT_CLIENT_SECRET`, as in the [Quickstart](/agentkit/quickstart/). The SDK client verifies the signature.

## Verify and handle each request

Scalekit signs every request with the endpoint's secret. Verify the signature against the raw request body before you trust the event, so a forged request can't make your agent act. The SDK checks the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers and rejects requests more than five minutes old. Then parse the body as JSON.

**Python**

Install the dependencies, then run the server with `uvicorn triggers:app --port 3000`:

```bash
pip install scalekit-sdk-python fastapi uvicorn
```

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

from fastapi import FastAPI, Request, Response
from scalekit import ScalekitClient
from scalekit.common.exceptions import WebhookVerificationError

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


@app.post("/scalekit/triggers")
async def scalekit_trigger(request: Request):
    body = await request.body()  # the exact bytes Scalekit signed
    try:
        scalekit_client.verify_webhook_payload(
            os.environ["SCALEKIT_TRIGGER_SECRET"], request.headers, body
        )
    except WebhookVerificationError:
        # A bad or missing signature, or a request older than five minutes
        return Response(status_code=400)
    event = json.loads(body)

    # Retries repeat an event. Key on dedupe_key plus the account you act as.
    key = (event["dedupe_key"], event["connected_account_id"])
    if key in handled:
        return Response(status_code=204)

    if event["delivery_scope"] == "account":
        print(f"{event['trigger_type']} for {event['connected_account_id']}: {event.get('resource_id')}")
    else:
        # A connection-wide event names no account: your app decides who acts on it
        print(f"{event['trigger_type']} on {event['connection_id']}: {event.get('resource_id')}")
    # Start your agent in a background job here, then respond

    handled.add(key)
    return Response(status_code=204)
```

**Node.js**

Install the dependencies, then save the code as `triggers.ts` and run it with `npx tsx triggers.ts`:

```bash
npm install @scalekit-sdk/node express
npm install --save-dev tsx @types/express
```

```ts title="triggers.ts"
import express from 'express';
import { ScalekitClient, WebhookVerificationError } 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 handled = new Set<string>(); // use your database in production

// express.text keeps the body as the exact text Scalekit signed
app.post('/scalekit/triggers', express.text({ type: 'application/json' }), (req, res) => {
  const body: string = req.body;
  try {
    scalekit.verifyWebhookPayload(
      process.env.SCALEKIT_TRIGGER_SECRET!,
      req.headers as Record<string, string>,
      body,
    );
  } catch (err) {
    if (!(err instanceof WebhookVerificationError)) throw err;
    // A bad or missing signature, or a request older than five minutes
    return res.sendStatus(400);
  }
  const event = JSON.parse(body);

  // Retries repeat an event. Key on dedupe_key plus the account you act as.
  const key = `${event.dedupe_key}:${event.connected_account_id}`;
  if (handled.has(key)) return res.sendStatus(204);

  if (event.delivery_scope === 'account') {
    console.log(`${event.trigger_type} for ${event.connected_account_id}: ${event.resource_id}`);
  } else {
    // A connection-wide event names no account: your app decides who acts on it
    console.log(`${event.trigger_type} on ${event.connection_id}: ${event.resource_id}`);
  }
  // Start your agent in a background job here, then respond

  handled.add(key);
  res.sendStatus(204);
});

app.listen(3000);
```

**Any language**

Without a Scalekit SDK, verify the request yourself before you parse the body:

1. Read the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers. Reject the request if one is missing.
2. Reject the request if `webhook-timestamp`, in Unix seconds, is more than five minutes from your server's current time.
3. Remove the `whsec_` prefix from the signing secret and base64-decode the rest. That's the key.
4. Compute an HMAC-SHA256 with that key over `{webhook-id}.{webhook-timestamp}.{raw body}`, and base64-encode the result.
5. `webhook-signature` holds one or more space-separated signatures, each written `v1,<base64>`. Accept the request if any `v1` signature equals your result. Compare in constant time.

For example, with only Python's standard library:

```python title="verify_signature.py"
import base64
import hashlib
import hmac
import time


def verify_signature(body: bytes, headers: dict, secret: str) -> bool:
    """headers: the request headers, with lowercase names."""
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not (msg_id and timestamp and signatures and timestamp.isdigit()):
        return False
    if abs(time.time() - int(timestamp)) > 5 * 60:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for candidate in signatures.split(" "):
        version, _, signature = candidate.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

After the signature checks out, parse the body as JSON and read the fields in [Event fields](#event-fields).

The signature covers the body byte for byte. Verify the raw body exactly as received: parsing the JSON first and serializing it again changes the bytes and fails verification.

## Respond quickly and handle repeats

- **Return a `2xx` status fast.** Start the agent's work after you respond or in a background job. A response other than `2xx`, or no response within 30 seconds, counts as a failure and is retried.
- **Verify before you queue.** Verify the signature in the handler that receives the request, then queue the verified event. A queued request can be older than five minutes by the time a worker reads it, and the SDK rejects it.
- **Expect the same event more than once.** Delivery is at least once. Scalekit sends an event again when it doesn't get a `2xx` from your endpoint within 30 seconds, including when your server handled the event but its response was slow or didn't arrive. Each repeat carries the same `dedupe_key`. Store the keys you've handled and skip a repeat. When one connection-wide event makes several of your users act, store the key together with each user's account, so a retry after a partial failure skips only the ones that finished.
- **Don't rely on order.** Events can arrive out of order. Use `occurred_at` when order matters.

### Retries

Scalekit makes up to 8 attempts to deliver an event, with increasing delays between them. Any status outside `2xx` is a failure, including `4xx`, and each attempt waits up to 30 seconds for a response.

| Attempt | Delay after the previous attempt |
| ------- | -------------------------------- |
| 1       | Immediately                      |
| 2       | 5 seconds                        |
| 3       | 5 minutes                        |
| 4       | 30 minutes                       |
| 5       | 2 hours                          |
| 6       | 5 hours                          |
| 7       | 10 hours                         |
| 8       | 10 hours                         |

If the eighth attempt fails, about 27 hours after the first, the delivery is marked as failed. Each attempt and your server's response code appear under **Recent delivery attempts** on **Developers** > **Trigger Delivery**.

## Event fields

Every event has the same shape, for webhook and polling triggers alike. This event came from a Salesforce **Lead created** subscription:

```json title="salesforce_lead_created"
{
  "version": "1",
  "trigger_type": "salesforce_lead_created",
  "subscription_id": "trgs_141020933185734981",
  "delivery_scope": "account",
  "connection_id": "conn_133400101014995480",
  "connected_account_id": "ca_133400349586228019",
  "resource_type": "salesforce_lead_created",
  "resource_id": "00Q5g00000AbCdEFGH",
  "occurred_at": "2026-10-10T09:14:03Z",
  "detection_mode": "poll",
  "payload_state": "full",
  "payload": {
    "attributes": {
      "type": "Lead",
      "url": "/services/data/v66.0/sobjects/Lead/00Q5g00000AbCdEFGH"
    },
    "Id": "00Q5g00000AbCdEFGH",
    "Name": "Jane Doe",
    "FirstName": "Jane",
    "LastName": "Doe",
    "Email": "jane@example.com",
    "Phone": null,
    "Company": "Example Corp",
    "Title": "VP Sales",
    "Status": "Open - Not Contacted",
    "LeadSource": "Web",
    "OwnerId": "0055g00000XyZabCDE",
    "CreatedDate": "2026-10-10T09:14:03.000+0000"
  },
  "dedupe_key": "ca_5F133400349586228019.trgs_5F141020933185734981.00Q5g00000AbCdEFGH",
  "correlation_id": "ca_5F133400349586228019.trgs_5F141020933185734981.00Q5g00000AbCdEFGH"
}
```

| Field | Meaning |
| --- | --- |
| `version` | The event format's version, `"1"` |
| `trigger_type` | The trigger that fired, such as `salesforce_lead_created` |
| `subscription_id` | The subscription that produced the event. Tells apart events from two subscriptions on the same account |
| `delivery_scope` | `account`: the event is about one connected account. `connection`: the event is about the whole connection |
| `connection_id` | The connection the event came from. Always present |
| `connected_account_id` | The connected account, for `account` events. An empty string for `connection` events: branch on `delivery_scope`, not on this field |
| `resource_type` | The kind of record that changed |
| `resource_id` | The app's ID for the record, such as a Salesforce lead ID. Can be missing |
| `occurred_at` | When the change happened in the app, in UTC (RFC 3339). Can be missing |
| `detection_mode` | `poll` for a polling trigger, `webhook` for a webhook trigger |
| `payload_state` | `full`: `payload` holds the record. `reference`: the record couldn't be included, for example because it's too large, and `payload` is `null`. Fetch the record from the app with `resource_id` |
| `payload` | The app's own record or event, as its API returns it, with the app's field names |
| `dedupe_key` | The same on every delivery of this event. Use it to skip repeats |
| `correlation_id` | Carry it into the work the event starts, to trace that work back to the event |

These fields are the same for every trigger. Only `payload` differs: it holds the app's own data, so its fields depend on the trigger. For a polling trigger it's the record, as above. For a webhook trigger it's the request body the provider sent. Each connector's page describes what its triggers send, as in [Slack triggers](/agentkit/connectors/slack/#triggers).

The fields keep these snake_case names in every language. `occurred_at` is an RFC 3339 string in UTC.

- **Treat `dedupe_key` and `correlation_id` as opaque strings.** Compare them; don't parse them. Their format can change.
- **Expect new fields and values.** Later versions can add fields and new values for `delivery_scope`, `detection_mode` and `payload_state`. Ignore fields you don't know, and keep a default branch for unknown values.
- **Read `payload` with the app's field names.** It's the app's own data, not a Scalekit format, so its shape differs per connector and trigger.

## Check it worked

Make a change the trigger watches for, as in [Set up a trigger](/agentkit/triggers/set-up/#check-it-worked). Your server prints a line such as `salesforce_lead_created for ca_133400349586228019: 00Q5g00000AbCdEFGH`, and **Recent delivery attempts** on **Developers** > **Trigger Delivery** shows the attempt with status `204`.

## Common problems

### `Missing required headers`

The `webhook-id`, `webhook-timestamp` and `webhook-signature` headers didn't reach the SDK. Check that a proxy in front of your server doesn't strip them, and pass the request's headers object unchanged.

### `Invalid signature`

Node.js spells it `Invalid Signature`. The body you verified isn't byte for byte what Scalekit sent, or the secret belongs to another endpoint or environment. Verify the raw body before any JSON parsing, and copy the secret again with **Reveal** on **Developers** > **Trigger Delivery**.

### `Message timestamp too old`

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

### My endpoint returns `2xx` but the event is handled twice

A retry or a second delivery repeated the event. Skip events whose `dedupe_key`, together with the account you act as, you've already handled.

## Next

  - [Manage subscriptions](/agentkit/triggers/manage/): Read subscription status and intervals, and remove a subscription.
  - [Call tools with the SDK](/agentkit/tools/scalekit-optimized-tools/): Let your agent act on the event with the user's connected account.


---

## 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 |
