Platform

Receive webhooks from Tenklo

Reference for the webhooks Tenklo sends to your app — setup, types, payloads, signature verification, and retries.

Tenklo POSTs webhooks to your app to notify it of important events: a new connection was created, a sync run finished, an async action completed, or an external webhook was forwarded. This page covers how to receive and verify webhooks from Tenklo, plus the payloads for auth, sync, and async action webhooks.

To process webhooks coming from external APIs (not from Tenklo), see Process external webhooks. To forward external API webhooks through Tenklo to your app, see Webhook forwarding.

Set up webhooks from Tenklo

Webhook settings in Tenklo are specific to each environment.

To subscribe to Tenklo webhooks:

  1. Set up a POST endpoint in your app to receive the Tenklo webhooks
  2. Input the endpoint's URL in your Environment Settings, under Webhook URLs (direct link for your dev environment)
  3. Implement verify incoming webhooks to ensure sure only authentic Tenklo webhooks are processed
  4. Implement processing logic for each webhook type from Tenklo you want to handle
    • Make sure your processing logic handles the webhooks you have enabled in the environment settings

You can configure up to two webhook URLs per environment. Tenklo webhooks will be sent to both. A single connection can override these with its own URL.

To test webhooks locally, use a webhook forwarding service like ngrok, or webhook.site.

Override webhook URLs per connection

A single connection can override the environment's webhook URLs with its own URL. The connection still receives all the same webhook types, with the same signing, retries, and logs.

When this override is set, the connection's webhooks are sent only to that URL — the environment's primary and secondary webhook URLs are both bypassed for that connection. It does not add a third destination.

Set webhook_url_override when creating a connect session. It applies to the connection created by that session:

const session = await nango.createConnectSession({
    allowed_integrations: ['<INTEGRATION-ID>'],
    webhook_url_override: 'https://<your-tunnel>.ngrok.app/webhooks-from-nango'
});

You can also set or edit webhook_url_override from:

  • The Create connection flow in the Tenklo dashboard (advanced configuration), or later on the connection's Settings tab
  • PATCH /connections/{connectionId} with a webhook_url_override field (see Patch a connection)

webhook_url_override is set by a trusted actor — via connect-session creation, the public API with an API key, or the dashboard. A webhook_url passed to nango.auth() params from the client is ignored.

Local development: set a connection's webhook_url_override to your local webhook endpoint (for example via ngrok or webhook.site) to receive that connection's webhooks on your machine — without changing the shared environment webhook URLs. See Engineering collaboration for the shared-environment workflow.

Verifying webhooks from Tenklo

Validate webhooks from Tenklo by looking at the X-Nango-Hmac-Sha256 header.

It's an HMAC-SHA256 hash of the webhook payload, using the webhook signing key found in Environment Settings > Webhooks > Signing key in the Tenklo UI.

Tenklo webhook requests include an X-Nango-Hmac-Sha256 header for secure verification. A legacy X-Nango-Signature header (using plain SHA-256) is also sent for backwards compatibility but should not be used. If you're currently using X-Nango-Signature, migrate to X-Nango-Hmac-Sha256 for improved security.

The webhook signature can be verified with the following code:

Verify the signature with the webhook signing key from Environment Settings > Webhooks > Signing key — not your Environment API key. These are two distinct keys. With the Node SDK, pass it as webhookSigningKey; for raw HMAC verification, use it as the HMAC secret. Using the Environment API key will cause verification to fail on any environment where the keys differ.

Pass the webhook signing key as webhookSigningKey so a single client can make API calls with the API key and verify webhooks with the signing key. webhookSigningKey falls back to secretKey when omitted.

const nango = new Nango({
    apiKey: process.env.NANGO_API_KEY, // used for API calls
    webhookSigningKey: process.env.NANGO_WEBHOOK_SIGNING_KEY // used to verify webhooks
});

async (req, res) => {
    const isValid = nango.verifyIncomingWebhookRequest(req.body, req.headers);
}

Only accept a webhook if the X-Nango-Hmac-Sha256 header value matches the webhook signature.

Rotating the webhook signing key

Rotate the key from Environment Settings > Webhooks > Signing key with Rotate key, or with the rotate webhook signing key API.

For up to 5 minutes after a rotation, webhooks are signed with either the old or the new key. Accept both during that window, then drop the old key.

Types of Tenklo webhooks

New Tenklo webhook types are added regularly, without considering this a breaking change. Your webhook handling logic should gracefully support receiving new types of webhooks by simply ignoring them.

All webhooks from Tenklo are POST requests.

The exact webhook type definitions can be found here.

Auth webhooks

New connection webhooks have "type": "auth" and "operation": "creation". They are sent after a connection has been successfully created.

Payload received following a connection creation:

{
    "type": "auth",
    "operation": "creation",
    "connectionId": "<your-connection-id>",
    "authMode": "OAUTH2 | BASIC | API_KEY | ...",
    "providerConfigKey": "<your-integration-id>",
    "provider": "<your-provider-key>",
    "environment": "DEV | PROD | ...",
    "success": true,
    "tags": {
        "end_user_id": "<your-end-user-id>",
        "end_user_email": "<your-end-user-email>",
        "organization_id": "<your-organization-id>"
    }
}

Processing webhooks with "type": "auth" and "operation": "creation" is necessary. After a connection is created, these webhooks give you the generated connection ID which lets you access the connection later on.

Use tags to reconcile and save the connection ID with the user/org/workspace that initiated the connection.

All authMode values can be found here. The authMode value depends on the provider value.

All operation values are:

  • creation: a new connection has been created
  • override: a connection has been re-authorized
  • refresh: an OAuth connection's access token has failed to refresh, or has recovered after a previous failure
  • deletion: a connection has been deleted

Payload received following a refresh token error:

{
    "type": "auth",
    "operation": "refresh",
    "connectionId": "<your-connection-id>",
    "authMode": "OAUTH2 | BASIC | API_KEY | ...",
    "providerConfigKey": "<your-integration-id>",
    "provider": "<your-provider-key>",
    "environment": "DEV | PROD | ...",
    "success": false,
    "tags": {
        "end_user_id": "<your-end-user-id>",
        "end_user_email": "<your-end-user-email>",
        "organization_id": "<your-organization-id>"
    },
    "error": {
        "type": "<string>",
        "description": "<string>"
    }
}

Webhooks are only sent for certain connection creation errors. For example, during the OAuth flow, some errors are reported locally in the OAuth modal by the external API. Since Tenklo does not receive these errors, it cannot trigger a webhook for them.

Tenklo retries refreshing on its periodic cycle, so a single refresh failure doesn't necessarily mean the connection needs re-authorization. When a connection recovers — i.e. a refresh succeeds after a previous refresh failure — Tenklo sends a follow-up webhook with "success": true:

{
    "type": "auth",
    "operation": "refresh",
    "connectionId": "<your-connection-id>",
    "authMode": "OAUTH2 | BASIC | API_KEY | ...",
    "providerConfigKey": "<your-integration-id>",
    "provider": "<your-provider-key>",
    "environment": "DEV | PROD | ...",
    "success": true,
    "tags": {
        "end_user_id": "<your-end-user-id>",
        "end_user_email": "<your-end-user-email>",
        "organization_id": "<your-organization-id>"
    }
}

This recovery webhook is only sent when a refresh succeeds after a previous refresh failure — not on every routine successful refresh. It's gated by the same webhook setting as refresh failures.

Payload received following a connection deletion:

{
    "type": "auth",
    "operation": "deletion",
    "connectionId": "<your-connection-id>",
    "authMode": "OAUTH2 | BASIC | API_KEY | ...",
    "providerConfigKey": "<your-integration-id>",
    "provider": "<your-provider-key>",
    "environment": "DEV | PROD | ...",
    "success": true,
    "tags": {
        "end_user_id": "<your-end-user-id>",
        "end_user_email": "<your-end-user-email>",
        "organization_id": "<your-organization-id>"
    }
}

This webhook is only sent when a connection is deleted directly through the dashboard or the API. It is not sent for bulk deletions caused by deleting an integration or environment.

Sync webhooks

Sync webhooks are sent when a sync function execution finishes, whether successful or not.

Payload received following a successful sync execution:

{
    "type": "sync",
    "connectionId": "<your-connection-id>",
    "providerConfigKey": "<your-integration-id>",
    "syncName": "<your-sync-script-name>",
    "model": "<your-model-name>",
    "syncType": "INCREMENTAL | INITIAL | WEBHOOK", // DEPRECATED — use checkpoints instead
    "success": true,
    "modifiedAfter": "<timestamp>",
    "responseResults": {
        "added": number,
        "updated": number,
        "deleted": number
    },
    // checkpoints itself may be missing or null if the sync doesn't use checkpoints —
    // always guard against that before reading `from`/`to`.
    "checkpoints": {
        "from": { "<key>": "<string | number | boolean>" }, // or null if no prior checkpoint
        "to": { "<key>": "<string | number | boolean>" } // or null if the sync didn't save one
    }
}

modifiedAfter is an ISO-8601 timestamp marking the start of the last run. To read the changed records, use cursor-based fetching as described in Records cache → Cursors and sync progress.

Distinguishing full vs incremental syncs: use checkpoints instead of the deprecated syncType field. checkpoints.from/checkpoints.to are checkpoint objects (Record<string, string | number | boolean>) as saved by the sync function via saveCheckpoint() — not strings — and either can be null. The checkpoints field itself can also be missing or null, so guard against that before reading nested fields:

const { checkpoints } = payload;
const isFullSync = !checkpoints || checkpoints.from === null;
  • checkpoints is missing/null, or checkpoints.from === null — the run started with no prior state. This happens on the first run, after a manual reset, or when the sync explicitly cleared its checkpoint. Treat this as a full sync and overwrite your data.
  • checkpoints.from is a non-null checkpoint object — the run continued from a previously saved checkpoint. This is an incremental sync; merge the new records into your existing data.
  • Webhook-triggered runs (deprecated syncType: "WEBHOOK"): always treat these as incremental merges, regardless of checkpoints.from. A webhook-triggered run processes a single incoming external event on top of already-synced data — it never represents a full resync.

Fetch records promptly: Due to Tenklo's data retention policies, you should fetch and store synced records in your own system promptly after receiving webhook notifications. Records not updated for 30 days will have their payload pruned, and sync functions not executed for 60 days will have all records deleted.

By default, Tenklo sends a webhook even if no modified data was detected in the last sync execution (referred as an "empty" sync), but this is configurable in your Environment Settings. In case of an empty sync, the responseResults would be:

{
    "added": 0,
    "updated": 0,
    "deleted": 0
}

Payload received following a failed sync execution:

{
    "type": "sync",
    "connectionId": "<your-connection-id>",
    "providerConfigKey": "<your-integration-id>",
    "syncName": "<your-sync-script-name>",
    "model": "<your-model-name>",
    "syncType": "INCREMENTAL | INITIAL | WEBHOOK", // DEPRECATED — use checkpoints instead
    "success": false,
    "error": {
        "type": "<string>",
        "description": "<string>"
    },
    "startedAt": "<timestamp>",
    "failedAt": "<timestamp>",
    // checkpoints itself may be missing or null — see the note above.
    "checkpoints": {
        "from": { "<key>": "<string | number | boolean>" }, // or null
        "to": { "<key>": "<string | number | boolean>" } // or null
    }
}

External webhook forwarding

Forwarded external webhooks use the same webhook URLs, signing, retries, and logs as other Tenklo webhooks. See Webhook forwarding for setup, routing behavior, and payload shapes.

Webhook retries & debugging

Tenklo retries each webhook with non-2xx responses 2 times with exponential backoff (starting delay: 100ms, time multiple: 2, view details in the code).

Webhooks time out after 20 seconds.

Each webhook attempt is logged in Tenklo's logs.

You can also use the OpenTelemetry exporter to monitor Tenklo webhooks in your own observability stack.

Questions, problems, feedback? Please reach out in the Slack community.

On this page