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.
Before you start
Section titled “Before you start”- A delivery endpoint and its signing secret, from Set up a trigger. The code below reads the secret from
SCALEKIT_TRIGGER_SECRET. - Your Scalekit API credentials in
SCALEKIT_ENVIRONMENT_URL,SCALEKIT_CLIENT_IDandSCALEKIT_CLIENT_SECRET, as in the Quickstart. The SDK client verifies the signature.
Verify and handle each request
Section titled “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.
Install the dependencies, then run the server with uvicorn triggers:app --port 3000:
pip install scalekit-sdk-python fastapi uvicornimport jsonimport os
from fastapi import FastAPI, Request, Responsefrom scalekit import ScalekitClientfrom 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)Install the dependencies, then save the code as triggers.ts and run it with npx tsx triggers.ts:
npm install @scalekit-sdk/node expressnpm install --save-dev tsx @types/expressimport 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 signedapp.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);Without a Scalekit SDK, verify the request yourself before you parse the body:
- Read the
webhook-id,webhook-timestampandwebhook-signatureheaders. Reject the request if one is missing. - Reject the request if
webhook-timestamp, in Unix seconds, is more than five minutes from your server’s current time. - Remove the
whsec_prefix from the signing secret and base64-decode the rest. That’s the key. - Compute an HMAC-SHA256 with that key over
{webhook-id}.{webhook-timestamp}.{raw body}, and base64-encode the result. webhook-signatureholds one or more space-separated signatures, each writtenv1,<base64>. Accept the request if anyv1signature equals your result. Compare in constant time.
For example, with only Python’s standard library:
import base64import hashlibimport hmacimport 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 FalseAfter the signature checks out, parse the body as JSON and read the fields in 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
Section titled “Respond quickly and handle repeats”- Return a
2xxstatus fast. Start the agent’s work after you respond or in a background job. A response other than2xx, 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
2xxfrom 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 samededupe_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_atwhen order matters.
Retries
Section titled “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
Section titled “Event fields”Every event has the same shape, for webhook and polling triggers alike. This event came from a Salesforce Lead created subscription:
{ "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.
The fields keep these snake_case names in every language. occurred_at is an RFC 3339 string in UTC.
- Treat
dedupe_keyandcorrelation_idas 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_modeandpayload_state. Ignore fields you don’t know, and keep a default branch for unknown values. - Read
payloadwith 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
Section titled “Check it worked”Make a change the trigger watches for, as in Set up a trigger. 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
Section titled “Common problems”Missing required headers
Section titled “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
Section titled “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
Section titled “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
Section titled “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.