Home/Blog/Asana Webhooks: Complete Guide with Payload Examples [2025]
Developer Tools

Asana Webhooks: Complete Guide with Payload Examples [2025]

Complete guide to Asana webhooks with setup instructions, payload examples, signature verification, and implementation code. Learn how to integrate Asana task webhooks into your application with step-by-step tutorials including the handshake process.

By Inventive HQ Team
Asana Webhooks: Complete Guide with Payload Examples [2025]

When a team member assigns a task to you in Asana, you need to know immediately—not when your polling script checks again in 5 minutes. Asana webhooks solve this problem by sending real-time HTTP notifications to your server the moment events occur, enabling you to automate task assignments, sync project updates to external systems, trigger workflows when tasks are completed, and keep your team informed through custom notifications.

Asana webhooks are HTTP callbacks that deliver instant notifications when tasks, projects, stories, and other resources change within your Asana workspace. Unlike traditional API polling which repeatedly queries for updates, webhooks "push" event data to your server immediately after changes occur, reducing latency and API quota consumption.

In this comprehensive guide, you'll learn how to set up Asana webhooks from scratch, understand the unique handshake process required to establish connections, implement secure signature verification with HMAC-SHA256, handle various event types including task changes and project updates, build production-ready webhook endpoints with proper error handling, and test your integration effectively using our Webhook Payload Generator tool.

What Are Asana Webhooks?

Asana webhooks are real-time event notifications sent via HTTP POST requests from Asana's servers to your application whenever monitored resources change. Unlike the Events API which requires continuous polling, webhooks provide an event-driven architecture where Asana "pushes" updates to your endpoint automatically.

The webhook architecture follows this flow:

[Asana Event Occurs] → [Asana Webhook Service] → [Your Webhook Endpoint] → [Your Application Logic]

When you create a webhook subscription for an Asana resource (such as a task or project), Asana monitors that resource and all contained resources. For example, a webhook on a project will receive events for all tasks within that project, subtasks of those tasks, comments (stories) added to tasks, and even changes to custom fields. This "bubbling up" behavior means you can monitor an entire project hierarchy with a single webhook subscription.

Key Benefits of Asana Webhooks:

  • Real-time updates: Events delivered within one minute on average, most within 10 minutes
  • Reduced API calls: No need to poll the API repeatedly, saving on rate limits
  • Hierarchical monitoring: Events propagate up from subtasks to tasks to projects
  • Selective filtering: Subscribe only to specific event types and resource changes
  • Efficient infrastructure: Shared with Asana's production event streaming system

Prerequisites for Using Asana Webhooks:

  • Active Asana account with API access
  • Personal Access Token (PAT) or OAuth token for authentication
  • Publicly accessible HTTPS endpoint to receive webhook events
  • Resource GID (globally unique identifier) for the task, project, or workspace to monitor
  • Server capable of handling the webhook handshake process

Asana webhooks differ from standard webhook implementations in two significant ways. First, they require an initial handshake process using the X-Hook-Secret header to verify your endpoint is ready before sending events. Second, webhook payloads contain compact "lightweight" event data rather than full resource details, meaning you'll need to make additional API calls to fetch complete resource information.

Setting Up Asana Webhooks

Setting up Asana webhooks requires both server-side code to handle incoming webhooks and an API call to establish the webhook subscription with Asana. Follow these steps to configure your first webhook:

Step 1: Prepare Your Webhook Endpoint

First, create an HTTPS endpoint on your server that will receive webhook events. This endpoint must be publicly accessible on the internet. For local development, use a tunneling tool like ngrok.

Your webhook endpoint URL format should be:

https://yourdomain.com/webhooks/asana

For local development with ngrok:

# Start your local server
node server.js  # Port 3000

# In another terminal, start ngrok
ngrok http 3000

# Use the ngrok URL in your webhook setup
https://abc123.ngrok.io/webhooks/asana

Step 2: Implement the Handshake Logic

Before Asana sends events, your endpoint must complete a handshake process. Your server needs to handle the initial X-Hook-Secret header and echo it back:

// Node.js handshake example
app.post('/webhooks/asana', (req, res) => {
  const hookSecret = req.headers['x-hook-secret'];

  // If X-Hook-Secret header exists, this is a handshake request
  if (hookSecret) {
    console.log('Handshake received, echoing secret back');
    // Store the secret for future signature verification
    process.env.ASANA_HOOK_SECRET = hookSecret;
    // Echo the secret back in response header
    res.setHeader('X-Hook-Secret', hookSecret);
    return res.status(200).send('OK');
  }

  // Otherwise, handle webhook event (covered later)
  // ... event handling code
});

Step 3: Create the Webhook via API

With your endpoint ready to handle the handshake, make a POST request to Asana's webhook creation endpoint. You'll need:

  • Resource GID: The ID of the task, project, or workspace to monitor
  • Target URL: Your webhook endpoint URL
  • Access Token: Your Asana Personal Access Token

Here's how to create a webhook using cURL:

curl -X POST https://app.asana.com/api/1.0/webhooks \
  -H "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "resource": "1234567890123456",
      "target": "https://yourdomain.com/webhooks/asana"
    }
  }'

Node.js example:

const axios = require('axios');

async function createAsanaWebhook(resourceGid, targetUrl, accessToken) {
  try {
    const response = await axios.post(
      'https://app.asana.com/api/1.0/webhooks',
      {
        data: {
          resource: resourceGid,
          target: targetUrl
        }
      },
      {
        headers: {
          'Authorization': `Bearer ${accessToken}`,
          'Content-Type': 'application/json'
        }
      }
    );

    console.log('Webhook created:', response.data);
    return response.data;
  } catch (error) {
    console.error('Failed to create webhook:', error.response?.data || error.message);
    throw error;
  }
}

// Usage
createAsanaWebhook(
  '1234567890123456',  // Resource GID
  'https://yourdomain.com/webhooks/asana',
  process.env.ASANA_ACCESS_TOKEN
);

Step 4: Add Webhook Filters (Optional)

To reduce noise and only receive specific events, add filters when creating the webhook:

const response = await axios.post(
  'https://app.asana.com/api/1.0/webhooks',
  {
    data: {
      resource: resourceGid,
      target: targetUrl,
      filters: [
        {
          resource_type: 'task',
          action: 'changed',
          fields: ['assignee', 'due_at', 'completed']
        },
        {
          resource_type: 'story',
          action: 'added'
        }
      ]
    }
  },
  // ... headers
);

This filter configuration ensures you only receive events when task assignees change, due dates are modified, tasks are completed, or new comments are added.

Step 5: Verify the Handshake Succeeded

When the webhook creation API call succeeds, you'll receive a 201 Created response containing the X-Hook-Secret:

{
  "data": {
    "gid": "9876543210987654",
    "resource_type": "webhook",
    "active": true,
    "resource": {
      "gid": "1234567890123456",
      "resource_type": "task",
      "name": "Complete project documentation"
    },
    "target": "https://yourdomain.com/webhooks/asana",
    "created_at": "2025-01-24T10:30:00.000Z"
  },
  "X-Hook-Secret": "b537207f20cbfa02357cf448134da559e8bd39d61597dcd5631b8012eae53e81"
}

Pro Tips:

  • Store the X-Hook-Secret securely—you'll need it to verify all future webhook events
  • Use environment variables for secrets, never commit them to version control
  • Webhooks on higher-level resources (workspaces, portfolios) require filters to be specified
  • Your handshake handler must respond within 10 seconds or the connection will timeout
  • Test the handshake locally first before creating production webhooks
  • Store the webhook gid to delete or manage the webhook later

Common Mistakes to Avoid:

  • Not implementing the handshake handler before calling the API (webhook creation will fail)
  • Using HTTP instead of HTTPS (Asana requires secure endpoints)
  • Forgetting to echo back the exact X-Hook-Secret value received
  • Blocking the handshake response while waiting for the API call to complete (causing circular dependency)

Asana Webhook Events & Payloads

Asana webhooks can deliver events for numerous resource types and actions. Understanding the event structure and available event types is crucial for building robust integrations.

Overview of Event Types

Event TypeResource TypeDescriptionCommon Use Case
task.addedTaskNew task created or added to projectTrigger onboarding workflows
task.changedTaskTask fields modified (assignee, due date, status)Send notifications on assignments
task.removedTaskTask removed from projectClean up external references
task.deletedTaskTask permanently deletedArchive related data
task.undeletedTaskDeleted task restoredRestore archived data
story.addedStoryComment added to taskReal-time chat notifications
story.changedStoryComment editedUpdate notification history
project.addedProjectNew project createdInitialize external tracking
project.changedProjectProject details updatedSync project metadata
section.addedSectionNew section added to projectUpdate workflow stages
tag.addedTagTag applied to taskCategorize and route tasks

Event Payload Structure

All Asana webhook events follow this structure:

{
  "events": [
    {
      "user": {
        "gid": "12345",
        "resource_type": "user",
        "name": "Sarah Johnson"
      },
      "resource": {
        "gid": "67890",
        "resource_type": "task",
        "name": "Update API documentation"
      },
      "action": "changed",
      "parent": null,
      "created_at": "2025-01-24T14:22:30.147Z",
      "change": {
        "field": "assignee",
        "action": "changed",
        "new_value": {
          "gid": "54321",
          "resource_type": "user"
        }
      }
    }
  ]
}

The events array can contain multiple events delivered in a single request. Empty arrays indicate heartbeat events sent every 8 hours to verify endpoint availability.

Detailed Event Examples

Event: task.changed - Task Assigned

Description: Triggered when a task's assignee field is modified.

Payload Structure:

{
  "events": [
    {
      "user": {
        "gid": "98765432101234",
        "resource_type": "user",
        "name": "Michael Chen"
      },
      "resource": {
        "gid": "11223344556677",
        "resource_type": "task",
        "name": "Review pull request #342"
      },
      "action": "changed",
      "parent": null,
      "created_at": "2025-01-24T15:45:12.234Z",
      "change": {
        "field": "assignee",
        "action": "changed",
        "new_value": {
          "gid": "55667788990011",
          "resource_type": "user"
        }
      }
    }
  ]
}

Key Fields:

  • user.gid - The user who made the change (not necessarily the new assignee)
  • resource.gid - The task that was modified
  • action - Always "changed" for field modifications
  • change.field - The specific field that changed ("assignee")
  • change.action - How the field was modified ("changed", "added", or "removed")
  • change.new_value - The new assignee's compact representation

Use Case: Send Slack notification to the new assignee when tasks are assigned.

Event: task.changed - Custom Field Updated

Description: Triggered when a custom field value is modified on a task.

Payload Structure:

{
  "events": [
    {
      "user": {
        "gid": "12345678901234",
        "resource_type": "user",
        "name": "Alex Rodriguez"
      },
      "resource": {
        "gid": "99887766554433",
        "resource_type": "task",
        "name": "Bug fix: Login timeout"
      },
      "action": "changed",
      "parent": null,
      "created_at": "2025-01-24T16:20:44.891Z",
      "change": {
        "field": "custom_fields",
        "action": "changed",
        "new_value": {
          "gid": "11111111111111",
          "resource_type": "custom_field"
        }
      }
    }
  ]
}

Key Fields:

  • change.field - "custom_fields" indicates a custom field was modified
  • change.new_value.gid - The custom field definition ID (not the value itself)

Important Note: The webhook payload does not include the actual custom field value. You must make a follow-up API call to fetch the complete task data:

// Fetch full task details after receiving event
const task = await axios.get(
  `https://app.asana.com/api/1.0/tasks/${resource.gid}`,
  {
    params: { opt_fields: 'custom_fields,custom_fields.name,custom_fields.display_value' },
    headers: { 'Authorization': `Bearer ${accessToken}` }
  }
);

Use Case: Track priority changes via custom fields and escalate high-priority tasks.

Event: story.added - Comment Added

Description: Triggered when a comment (story) is added to a task.

Payload Structure:

{
  "events": [
    {
      "user": {
        "gid": "33445566778899",
        "resource_type": "user",
        "name": "Jennifer Liu"
      },
      "resource": {
        "gid": "22334455667788",
        "resource_type": "story",
        "name": null
      },
      "action": "added",
      "parent": {
        "gid": "11223344556677",
        "resource_type": "task",
        "name": "Review pull request #342"
      },
      "created_at": "2025-01-24T17:05:22.456Z",
      "change": null
    }
  ]
}

Key Fields:

  • resource.resource_type - "story" indicates a comment event
  • action - "added" for new comments
  • parent.gid - The task that received the comment
  • change - null for added/removed events

Note: The comment text is not included in the webhook payload. Fetch the story resource to retrieve content:

const story = await axios.get(
  `https://app.asana.com/api/1.0/stories/${resource.gid}`,
  {
    headers: { 'Authorization': `Bearer ${accessToken}` }
  }
);
console.log('Comment text:', story.data.data.text);

Use Case: Feed task comments into a team chat system or notification service.

Event: task.added - Task Added to Project

Description: Triggered when a task is added to a project or created within a project.

Payload Structure:

{
  "events": [
    {
      "user": {
        "gid": "77889900112233",
        "resource_type": "user",
        "name": "David Park"
      },
      "resource": {
        "gid": "44556677889900",
        "resource_type": "task",
        "name": "Implement user authentication"
      },
      "action": "added",
      "parent": {
        "gid": "99001122334455",
        "resource_type": "project",
        "name": "Q1 2025 Development Sprint"
      },
      "created_at": "2025-01-24T18:12:55.789Z",
      "change": null
    }
  ]
}

Key Fields:

  • action - "added" indicates the task was newly added
  • parent - The project where the task was added
  • change - Always null for add/remove events

Use Case: Automatically create corresponding tickets in external project management tools when new tasks are added.

Event: task.changed - Task Completed

Description: Triggered when a task's completion status changes.

Payload Structure:

{
  "events": [
    {
      "user": {
        "gid": "11223344556677",
        "resource_type": "user",
        "name": "Emma Watson"
      },
      "resource": {
        "gid": "22334455667788",
        "resource_type": "task",
        "name": "Write unit tests for API endpoints"
      },
      "action": "changed",
      "parent": null,
      "created_at": "2025-01-24T19:30:18.123Z",
      "change": {
        "field": "completed",
        "action": "changed",
        "new_value": true
      }
    }
  ]
}

Key Fields:

  • change.field - "completed" indicates completion status changed
  • change.new_value - Boolean true for completed, false for reopened

Use Case: Trigger automated deployment pipelines or send completion notifications to stakeholders.

Understanding Event Propagation

Events "bubble up" through the resource hierarchy. If you subscribe to a project webhook, you'll receive events for:

  • Tasks directly in the project
  • Subtasks of those tasks
  • Comments (stories) on tasks and subtasks
  • Custom field changes on any contained task
  • Attachments added to tasks

This hierarchical propagation means a single webhook on a project can monitor an entire team's workflow.

Webhook Signature Verification

Signature verification is critical for security. Without it, malicious actors could send fake events to your endpoint, potentially triggering unauthorized actions or corrupting data. Asana uses HMAC-SHA256 signatures to ensure webhook authenticity.

Why Signature Verification Matters

When your webhook endpoint is publicly accessible, anyone who discovers the URL could theoretically send POST requests to it. Signature verification ensures that:

  • Events genuinely come from Asana's servers
  • The payload hasn't been tampered with during transit
  • Replay attacks using old valid payloads are detectable via timestamps
  • Your integration can safely process events without additional verification

Asana's Signature Method

Asana's webhook signature system uses:

  • Algorithm: HMAC-SHA256 (Hash-based Message Authentication Code with SHA-256)
  • Signature Header: X-Hook-Signature (sent with every event)
  • Secret Source: X-Hook-Secret (received during handshake)
  • Signed Data: The complete raw request body
  • Encoding: Hexadecimal string representation

Step-by-Step Verification Process

  1. Extract the signature from the X-Hook-Signature header in the incoming request
  2. Retrieve the webhook secret stored during the handshake (X-Hook-Secret)
  3. Get the raw request body as a string or buffer (do NOT parse JSON first)
  4. Compute the expected signature using HMAC-SHA256 with the secret and body
  5. Compare signatures using constant-time comparison to prevent timing attacks
  6. Validate timestamp (optional but recommended) to prevent replay attacks

Implementation Examples

Node.js / Express

const crypto = require('crypto');
const express = require('express');
const app = express();

// Store the webhook secret (set during handshake)
const WEBHOOK_SECRET = process.env.ASANA_HOOK_SECRET;

// IMPORTANT: Use raw body parser for signature verification
app.use('/webhooks/asana', express.raw({type: 'application/json'}));

app.post('/webhooks/asana', (req, res) => {
  // Handle handshake first
  const hookSecret = req.headers['x-hook-secret'];
  if (hookSecret) {
    // This is the handshake - echo secret back
    res.setHeader('X-Hook-Secret', hookSecret);
    // Store the secret for future verifications
    process.env.ASANA_HOOK_SECRET = hookSecret;
    return res.status(200).send('OK');
  }

  // Verify signature for webhook events
  const signature = req.headers['x-hook-signature'];

  if (!signature) {
    console.error('Missing X-Hook-Signature header');
    return res.status(401).send('Unauthorized');
  }

  // Compute expected signature from raw body
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  // Use timing-safe comparison to prevent timing attacks
  if (!crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  )) {
    console.error('Invalid signature - potential spoofing attempt');
    return res.status(401).send('Unauthorized');
  }

  // Signature verified - safe to parse payload
  const payload = JSON.parse(req.body.toString());

  // Process webhook events
  console.log(`Received ${payload.events.length} events`);
  payload.events.forEach(event => {
    console.log(`Event: ${event.action} on ${event.resource.resource_type} ${event.resource.gid}`);
  });

  // Return 200 immediately
  res.status(200).send('Webhook received');

  // Process async (recommended)
  processWebhookAsync(payload);
});

function processWebhookAsync(payload) {
  // Your business logic here
}

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Webhook server listening on port ${PORT}`);
});

Python / Flask

import hmac
import hashlib
import os
from flask import Flask, request, jsonify

app = Flask(__name__)

# Store the webhook secret (set during handshake)
WEBHOOK_SECRET = None

@app.route('/webhooks/asana', methods=['POST'])
def asana_webhook():
    global WEBHOOK_SECRET

    # Handle handshake first
    hook_secret = request.headers.get('X-Hook-Secret')
    if hook_secret:
        # This is the handshake - echo secret back
        WEBHOOK_SECRET = hook_secret
        os.environ['ASANA_HOOK_SECRET'] = hook_secret
        response = jsonify({'status': 'ok'})
        response.headers['X-Hook-Secret'] = hook_secret
        return response, 200

    # Verify signature for webhook events
    signature = request.headers.get('X-Hook-Signature')

    if not signature:
        print('Missing X-Hook-Signature header')
        return 'Unauthorized', 401

    if not WEBHOOK_SECRET:
        print('Webhook secret not set - handshake may have failed')
        return 'Unauthorized', 401

    # Get raw request body
    payload = request.get_data()

    # Compute expected signature
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        payload,
        hashlib.sha256
    ).hexdigest()

    # Use constant-time comparison to prevent timing attacks
    if not hmac.compare_digest(signature, expected_signature):
        print('Invalid signature - potential spoofing attempt')
        return 'Unauthorized', 401

    # Signature verified - safe to parse payload
    data = request.get_json()

    # Process webhook events
    print(f"Received {len(data['events'])} events")
    for event in data['events']:
        print(f"Event: {event['action']} on {event['resource']['resource_type']} {event['resource']['gid']}")

    # Return 200 immediately
    return 'Webhook received', 200

PHP

<?php
// Store webhook secret in environment or config
$webhookSecret = getenv('ASANA_HOOK_SECRET');

// Get raw POST body
$payload = file_get_contents('php://input');

// Handle handshake
$hookSecret = $_SERVER['HTTP_X_HOOK_SECRET'] ?? null;
if ($hookSecret) {
    // This is the handshake - echo secret back
    header('X-Hook-Secret: ' . $hookSecret);
    // Store secret for future use
    putenv("ASANA_HOOK_SECRET=$hookSecret");
    http_response_code(200);
    echo 'OK';
    exit;
}

// Get signature from header
$signature = $_SERVER['HTTP_X_HOOK_SIGNATURE'] ?? null;

if (!$signature) {
    error_log('Missing X-Hook-Signature header');
    http_response_code(401);
    die('Unauthorized');
}

if (!$webhookSecret) {
    error_log('Webhook secret not set - handshake may have failed');
    http_response_code(401);
    die('Unauthorized');
}

// Compute expected signature
$expectedSignature = hash_hmac('sha256', $payload, $webhookSecret);

// Use constant-time comparison to prevent timing attacks
if (!hash_equals($signature, $expectedSignature)) {
    error_log('Invalid signature - potential spoofing attempt');
    http_response_code(401);
    die('Unauthorized');
}

// Signature verified - safe to parse payload
$data = json_decode($payload, true);

// Process webhook events
error_log(sprintf("Received %d events", count($data['events'])));
foreach ($data['events'] as $event) {
    error_log(sprintf(
        "Event: %s on %s %s",
        $event['action'],
        $event['resource']['resource_type'],
        $event['resource']['gid']
    ));
}

// Return 200 immediately
http_response_code(200);
echo 'Webhook received';
?>

Common Verification Errors

  • ❌ Parsing JSON before verification: The body is modified when parsed, breaking the signature

    • ✅ Solution: Always verify using the raw body, parse JSON only after verification passes
  • ❌ Using wrong secret: Test secret used in production or vice versa

    • ✅ Solution: Store secrets per environment and verify you're using the correct one
  • ❌ Not using constant-time comparison: Opens vulnerability to timing attacks

    • ✅ Solution: Use crypto.timingSafeEqual() (Node.js), hmac.compare_digest() (Python), or hash_equals() (PHP)
  • ❌ Missing the handshake handler: Webhook creation fails because server can't echo secret

    • ✅ Solution: Implement handshake logic before attempting to create webhooks via API
  • ❌ Encoding issues: Using wrong character encoding when computing hash

    • ✅ Solution: Ensure consistent UTF-8 encoding for both secret and payload

Testing Asana Webhooks

Testing webhooks during development presents unique challenges since Asana's servers need to reach your endpoint over the internet. Here are proven strategies for testing effectively.

Local Development Challenges

The primary obstacle when testing Asana webhooks locally:

  • Asana servers cannot reach localhost or 127.0.0.1
  • Your development machine isn't publicly accessible
  • You need HTTPS for security (Asana requires it)
  • The handshake process requires bidirectional communication

Solution 1: Using ngrok

ngrok creates a secure tunnel from a public URL to your local development server:

# Install ngrok (macOS)
brew install ngrok

# Or download from ngrok.com for other platforms

# Start your local webhook server
node server.js  # Listening on port 3000

# In another terminal, start ngrok tunnel
ngrok http 3000

# Output shows your public URL:
# Forwarding  https://abc123def456.ngrok.io -> http://localhost:3000

Use the ngrok HTTPS URL when creating your webhook:

createAsanaWebhook(
  '1234567890123456',  // Resource GID
  'https://abc123def456.ngrok.io/webhooks/asana',  // ngrok URL
  process.env.ASANA_ACCESS_TOKEN
);

Benefits of ngrok:

  • ✅ Full end-to-end testing with real Asana events
  • ✅ Tests handshake process and signature verification
  • ✅ Handles HTTPS automatically
  • ✅ Inspects request/response via web interface (http://localhost:4040)

Considerations:

  • Free tier URLs change with each restart
  • Requires keeping ngrok running during development
  • Network latency for each request

Solution 2: Webhook Payload Generator Tool

For testing without external dependencies, use our Webhook Payload Generator:

Step-by-step testing process:

  1. Visit the tool: Navigate to our Webhook Payload Generator

  2. Select provider: Choose "Asana" from the provider dropdown

  3. Choose event type: Select an event like "task.changed" or "story.added"

  4. Customize payload: Modify field values to match your test scenario:

    • Task GID
    • User information
    • Changed field (assignee, due_at, completed)
    • Custom field values
  5. Generate signature: The tool automatically generates a valid X-Hook-Signature using your webhook secret

  6. Send to local endpoint: Copy the generated payload and send it to your local server:

curl -X POST http://localhost:3000/webhooks/asana \
  -H "Content-Type: application/json" \
  -H "X-Hook-Signature: generated-signature-here" \
  -d @payload.json

Benefits of the Webhook Payload Generator:

  • ✅ No tunneling or public exposure required
  • ✅ Test signature verification logic independently
  • ✅ Customize payload values for edge case testing
  • ✅ Test error handling without triggering real Asana events
  • ✅ Rapid iteration during development

What it can't test:

  • The actual handshake process (use ngrok for this)
  • Event propagation and filtering
  • Asana's retry behavior

Testing the Handshake Process

The handshake is the most common failure point. Test it explicitly:

// Test handshake handler
const request = require('supertest');
const app = require('./server');  // Your Express app

describe('Asana Webhook Handshake', () => {
  it('should echo X-Hook-Secret in response', async () => {
    const testSecret = 'test-secret-12345';

    const response = await request(app)
      .post('/webhooks/asana')
      .set('X-Hook-Secret', testSecret)
      .expect(200);

    expect(response.headers['x-hook-secret']).toBe(testSecret);
  });
});

Testing Checklist

Before deploying to production, verify:

  • Handshake handler echoes X-Hook-Secret correctly
  • Signature verification passes with valid signatures
  • Signature verification rejects invalid signatures
  • Endpoint returns 200 status within 10 seconds
  • Handles empty events array (heartbeat events)
  • Processes multiple events in single payload
  • Implements idempotency (handles duplicate events)
  • Error handling for malformed payloads
  • Async processing doesn't block response
  • Logging captures all events for debugging

Using Asana's Webhook Inspector

After creating a webhook, monitor its health via the API:

// Check webhook status
const webhook = await axios.get(
  `https://app.asana.com/api/1.0/webhooks/${webhookGid}`,
  {
    headers: { 'Authorization': `Bearer ${accessToken}` }
  }
);

console.log('Last success:', webhook.data.data.last_success_at);
console.log('Last failure:', webhook.data.data.last_failure_at);
console.log('Failure content:', webhook.data.data.last_failure_content);
console.log('Retry count:', webhook.data.data.delivery_retry_count);

These fields help diagnose delivery issues and verify your endpoint is responding correctly.

Implementation Example

Building a production-ready Asana webhook endpoint requires more than just signature verification. Here's a complete implementation with queue-based processing, idempotency, error handling, and proper logging.

Requirements for Production Webhooks

Your webhook endpoint must:

  • Respond within 10 seconds (Asana's timeout)
  • Return 200 or 204 status code for successful receipt
  • Process events asynchronously to avoid blocking
  • Handle retries gracefully (Asana retries for 24 hours)
  • Implement idempotency to prevent duplicate processing
  • Log all events for debugging and audit trails
  • Respond to heartbeat events every 8 hours

Complete Node.js Implementation

const express = require('express');
const crypto = require('crypto');
const Queue = require('bull');  // npm install bull redis
const axios = require('axios');
const { PrismaClient } = require('@prisma/client');

const app = express();
const prisma = new PrismaClient();
const webhookQueue = new Queue('asana-webhooks', process.env.REDIS_URL);

// Store webhook secret
let WEBHOOK_SECRET = process.env.ASANA_HOOK_SECRET;
const ASANA_ACCESS_TOKEN = process.env.ASANA_ACCESS_TOKEN;

// Raw body parser for signature verification
app.use('/webhooks/asana', express.raw({type: 'application/json'}));

// Asana webhook endpoint
app.post('/webhooks/asana', async (req, res) => {
  try {
    // Step 1: Handle handshake
    const hookSecret = req.headers['x-hook-secret'];
    if (hookSecret) {
      console.log('Webhook handshake initiated');
      WEBHOOK_SECRET = hookSecret;
      process.env.ASANA_HOOK_SECRET = hookSecret;

      // Store secret in database for persistence
      await prisma.config.upsert({
        where: { key: 'asana_webhook_secret' },
        update: { value: hookSecret },
        create: { key: 'asana_webhook_secret', value: hookSecret }
      });

      res.setHeader('X-Hook-Secret', hookSecret);
      return res.status(200).send('OK');
    }

    // Step 2: Verify signature
    const signature = req.headers['x-hook-signature'];

    if (!signature) {
      console.error('Missing X-Hook-Signature header');
      return res.status(401).json({ error: 'Missing signature' });
    }

    if (!WEBHOOK_SECRET) {
      console.error('Webhook secret not configured');
      return res.status(500).json({ error: 'Server configuration error' });
    }

    // Compute expected signature
    const expectedSignature = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(req.body)
      .digest('hex');

    // Timing-safe comparison
    if (!crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    )) {
      console.error('Invalid signature - possible spoofing attempt');
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Step 3: Parse payload after verification
    const payload = JSON.parse(req.body.toString());

    // Handle heartbeat (empty events array)
    if (payload.events.length === 0) {
      console.log('Heartbeat received - webhook is healthy');
      return res.status(200).json({ received: true, heartbeat: true });
    }

    // Step 4: Queue events for async processing
    const queuedEvents = [];

    for (const event of payload.events) {
      const eventId = generateEventId(event);

      // Check for duplicate (idempotency)
      const exists = await prisma.webhookEvent.findUnique({
        where: { eventId }
      });

      if (exists) {
        console.log(`Event ${eventId} already processed, skipping`);
        continue;
      }

      // Queue event for processing
      await webhookQueue.add({
        eventId,
        event,
        receivedAt: new Date()
      });

      queuedEvents.push(eventId);
    }

    // Step 5: Return 200 immediately
    res.status(200).json({
      received: true,
      queued: queuedEvents.length
    });

    console.log(`Queued ${queuedEvents.length} events for processing`);

  } catch (error) {
    console.error('Webhook processing error:', error);
    // Still return 200 to prevent retries for our errors
    res.status(200).json({ received: true, error: true });
  }
});

// Process webhooks from queue
webhookQueue.process(async (job) => {
  const { eventId, event, receivedAt } = job.data;

  try {
    console.log(`Processing event ${eventId}: ${event.action} on ${event.resource.resource_type}`);

    // Mark as processing
    await prisma.webhookEvent.create({
      data: {
        eventId,
        status: 'processing',
        resourceType: event.resource.resource_type,
        resourceGid: event.resource.gid,
        action: event.action,
        payload: event,
        receivedAt
      }
    });

    // Route to appropriate handler based on resource type and action
    await routeEvent(event);

    // Mark as completed
    await prisma.webhookEvent.update({
      where: { eventId },
      data: {
        status: 'completed',
        completedAt: new Date()
      }
    });

    console.log(`Successfully processed event ${eventId}`);

  } catch (error) {
    console.error(`Failed to process event ${eventId}:`, error);

    // Mark as failed
    await prisma.webhookEvent.update({
      where: { eventId },
      data: {
        status: 'failed',
        error: error.message,
        failedAt: new Date()
      }
    });

    throw error; // Will trigger queue retry
  }
});

// Route events to specific handlers
async function routeEvent(event) {
  const { resource, action } = event;

  switch (resource.resource_type) {
    case 'task':
      await handleTaskEvent(event);
      break;

    case 'story':
      await handleStoryEvent(event);
      break;

    case 'project':
      await handleProjectEvent(event);
      break;

    default:
      console.warn(`Unhandled resource type: ${resource.resource_type}`);
  }
}

// Handle task events
async function handleTaskEvent(event) {
  const { resource, action, change } = event;

  if (action === 'changed' && change?.field === 'assignee') {
    // Task was assigned - fetch full details
    const task = await fetchAsanaResource('tasks', resource.gid);

    // Send notification to new assignee
    if (task.assignee) {
      await sendAssignmentNotification(task);
    }
  }

  if (action === 'changed' && change?.field === 'completed') {
    // Task was completed
    const task = await fetchAsanaResource('tasks', resource.gid);

    if (change.new_value === true) {
      await handleTaskCompletion(task);
    }
  }

  if (action === 'added') {
    // New task added to project
    const task = await fetchAsanaResource('tasks', resource.gid);
    await syncTaskToExternalSystem(task);
  }
}

// Handle story (comment) events
async function handleStoryEvent(event) {
  const { resource, action, parent } = event;

  if (action === 'added' && parent?.resource_type === 'task') {
    // New comment added to task
    const story = await fetchAsanaResource('stories', resource.gid);

    if (story.type === 'comment') {
      await notifyTeamOfComment(story, parent.gid);
    }
  }
}

// Handle project events
async function handleProjectEvent(event) {
  const { resource, action } = event;

  if (action === 'changed') {
    // Project details updated
    const project = await fetchAsanaResource('projects', resource.gid);
    await syncProjectMetadata(project);
  }
}

// Fetch full resource details from Asana API
async function fetchAsanaResource(resourceType, gid) {
  const response = await axios.get(
    `https://app.asana.com/api/1.0/${resourceType}/${gid}`,
    {
      headers: {
        'Authorization': `Bearer ${ASANA_ACCESS_TOKEN}`
      }
    }
  );

  return response.data.data;
}

// Business logic: Send assignment notification
async function sendAssignmentNotification(task) {
  console.log(`Sending notification: Task "${task.name}" assigned to ${task.assignee.name}`);

  // Example: Send Slack message
  await axios.post(process.env.SLACK_WEBHOOK_URL, {
    text: `New task assigned: *${task.name}*`,
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `*${task.assignee.name}* was assigned to:\n*${task.name}*`
        }
      },
      {
        type: 'context',
        elements: [
          {
            type: 'mrkdwn',
            text: `Due: ${task.due_on || 'No due date'} | <https://app.asana.com/0/${task.gid}|View in Asana>`
          }
        ]
      }
    ]
  });
}

// Business logic: Handle task completion
async function handleTaskCompletion(task) {
  console.log(`Task completed: "${task.name}"`);

  // Example: Update external database
  await prisma.externalTask.update({
    where: { asanaGid: task.gid },
    data: {
      status: 'completed',
      completedAt: new Date()
    }
  });

  // Example: Trigger downstream workflow
  if (task.custom_fields.some(cf => cf.name === 'Triggers Deployment' && cf.display_value === 'Yes')) {
    await triggerDeploymentPipeline(task);
  }
}

// Business logic: Sync task to external system
async function syncTaskToExternalSystem(task) {
  console.log(`Syncing new task to external system: "${task.name}"`);

  await prisma.externalTask.create({
    data: {
      asanaGid: task.gid,
      name: task.name,
      assignee: task.assignee?.name,
      dueDate: task.due_on,
      status: task.completed ? 'completed' : 'in_progress'
    }
  });
}

// Business logic: Notify team of comment
async function notifyTeamOfComment(story, taskGid) {
  console.log(`New comment on task ${taskGid}: "${story.text}"`);

  // Example: Send email notification
  // await sendEmail({
  //   to: '[email protected]',
  //   subject: `New comment on task`,
  //   body: story.text
  // });
}

// Business logic: Sync project metadata
async function syncProjectMetadata(project) {
  console.log(`Syncing project metadata: "${project.name}"`);

  await prisma.externalProject.upsert({
    where: { asanaGid: project.gid },
    update: {
      name: project.name,
      dueDate: project.due_on,
      status: project.archived ? 'archived' : 'active'
    },
    create: {
      asanaGid: project.gid,
      name: project.name,
      dueDate: project.due_on,
      status: project.archived ? 'archived' : 'active'
    }
  });
}

// Generate consistent event ID for idempotency
function generateEventId(event) {
  // Combine resource GID, action, and timestamp for unique ID
  const idString = `${event.resource.gid}-${event.action}-${event.created_at}`;
  return crypto.createHash('sha256').update(idString).digest('hex');
}

// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Asana webhook server listening on port ${PORT}`);
});

Key Implementation Details

  1. Raw body parsing: The express.raw() middleware preserves the exact body for signature verification
  2. Timing-safe comparison: crypto.timingSafeEqual() prevents timing attack vulnerabilities
  3. Idempotency check: Database lookup ensures events are processed exactly once
  4. Queue-based processing: Bull queue with Redis ensures fast response times
  5. Error handling: Always returns 200 to Asana, logs errors for internal debugging
  6. Heartbeat handling: Responds to empty events array to maintain webhook health
  7. Resource fetching: Webhook payloads are compact, full details fetched via API

Best Practices

Following these best practices ensures your Asana webhook integration is secure, reliable, and maintainable.

Security

  • Always verify signatures: Never process events without signature verification
  • Use HTTPS endpoints only: Asana requires HTTPS; use SSL certificates from Let's Encrypt or similar
  • Store secrets in environment variables: Never commit X-Hook-Secret to version control
  • Validate timestamp fields: Check created_at to reject old events and prevent replay attacks
  • Rate limit webhook endpoints: Protect against abuse even with signature verification
  • Implement IP whitelisting: Asana doesn't publish IP ranges, but consider reverse proxy filtering
  • Rotate secrets periodically: Delete and recreate webhooks with new secrets quarterly
  • Log security events: Track all signature verification failures for security monitoring

Performance

  • Respond within 10 seconds: Asana's timeout is strict; acknowledge receipt immediately
  • Return 200 immediately, process async: Use queues (Redis, RabbitMQ, AWS SQS) for background processing
  • Batch API calls: When fetching multiple resources, use Asana's batch endpoint
  • Implement exponential backoff: When calling external services, retry with increasing delays
  • Monitor webhook processing times: Track queue depth and processing latency
  • Cache frequently accessed data: Store user mappings, custom field definitions locally
  • Limit concurrent processing: Prevent overwhelming downstream systems with parallel processing limits

Reliability

  • Implement idempotency: Always track event IDs to prevent duplicate processing
  • Handle duplicate webhooks gracefully: Asana may deliver the same event multiple times
  • Implement retry logic for failures: Use exponential backoff when business logic fails
  • Don't rely solely on webhooks: Run periodic reconciliation jobs to catch missed events
  • Log all webhook events: Maintain audit trail for debugging and compliance
  • Handle heartbeat events: Respond to empty events arrays to keep webhook alive
  • Monitor webhook health: Check last_success_at and last_failure_at via API
  • Set up alerting: Get notified when delivery_retry_count increases or failures occur

Monitoring

  • Track webhook delivery success rate: Monitor via Asana API's webhook inspection
  • Alert on signature verification failures: Immediate notification for security issues
  • Monitor processing queue depth: Alert when queue backs up beyond threshold
  • Log event IDs for traceability: Enable debugging by tracking events end-to-end
  • Set up health checks: Expose endpoint returning webhook status and last event time
  • Dashboard key metrics: Track events/hour, processing time, error rate
  • Monitor rate limit usage: Track API calls made while processing webhooks

Asana-Specific Best Practices

  • Handle story consolidation: Be aware that rapid changes may result in consolidated stories in Asana's UI, but you'll receive individual webhook events
  • Fetch full resource details: Webhook payloads are compact; always make API calls for complete data
  • Use webhook filters: Reduce noise by filtering at webhook creation time
  • Understand event propagation: Events bubble up from subtasks to tasks to projects
  • Respect the 10,000 webhook limit: Per access token, plan your webhook architecture accordingly
  • Handle deleted resources gracefully: Webhooks are auto-deleted 72 hours after resource deletion
  • Test handshake extensively: Most webhook setup failures occur during handshake
  • Return 410 Gone to delete: If you no longer need a webhook, return 410 status to auto-delete it

Common Issues & Troubleshooting

Issue 1: Webhook Creation Fails During Handshake

Symptoms:

  • API call to create webhook times out or returns error
  • Error message: "Failed to establish webhook"
  • Webhook never appears in GET /webhooks list

Causes & Solutions:

  • Handshake handler not implemented: Server doesn't respond to X-Hook-Secret
    • Solution: Implement handshake logic before calling webhook creation API
// Must handle X-Hook-Secret BEFORE creating webhook
app.post('/webhooks/asana', (req, res) => {
  const hookSecret = req.headers['x-hook-secret'];
  if (hookSecret) {
    res.setHeader('X-Hook-Secret', hookSecret);
    return res.status(200).send('OK');
  }
  // ... rest of handler
});
  • Server not responding fast enough: Handshake times out after 10 seconds

    • Solution: Ensure handshake handler isn't blocked by slow operations
  • Server not publicly accessible: Using localhost or private IP

    • Solution: Use ngrok for development or deploy to public server
  • SSL certificate issues: Invalid or self-signed certificate

    • Solution: Use valid SSL certificate from trusted CA
  • Circular dependency: Server waits for webhook creation while Asana waits for handshake

    • Solution: Handle handshake synchronously in web server, create webhook in separate process

Issue 2: Signature Verification Failing

Symptoms:

  • 401 errors being returned to Asana
  • "Invalid signature" errors in application logs
  • Webhook shows failures in last_failure_content

Causes & Solutions:

  • Using wrong secret: Test secret used in production environment
    • Solution: Verify you're using the correct X-Hook-Secret for the environment
// Check which secret is being used
console.log('Using webhook secret:', WEBHOOK_SECRET?.substring(0, 8) + '...');
  • Parsing JSON before verification: Body is modified, breaking signature
    • Solution: Always use raw body parser for webhook routes
// Correct: raw body
app.use('/webhooks/asana', express.raw({type: 'application/json'}));

// Wrong: JSON parser modifies body
app.use(express.json());  // Don't use this for webhook routes
  • Not using constant-time comparison: Vulnerable to timing attacks
    • Solution: Use timing-safe comparison functions
// Correct
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))

// Wrong - vulnerable to timing attacks
signature === expectedSignature
  • Character encoding issues: Using wrong encoding for signature computation
    • Solution: Ensure UTF-8 encoding consistently
# Python - ensure utf-8 encoding
hmac.new(secret.encode('utf-8'), payload, hashlib.sha256)

Issue 3: Webhook Timeouts

Symptoms:

  • Asana webhook delivery logs show timeout errors
  • delivery_retry_count increasing
  • last_failure_content shows timeout message

Causes & Solutions:

  • Slow database queries blocking response: Synchronous DB operations
    • Solution: Move all processing to async queue
// Wrong - blocks response
app.post('/webhooks/asana', async (req, res) => {
  await processEvent(event);  // Blocks for seconds
  res.status(200).send('OK');
});

// Correct - immediate response
app.post('/webhooks/asana', async (req, res) => {
  await queue.add(event);  // Fast queue operation
  res.status(200).send('OK');  // Returns immediately
  // Processing happens in background
});
  • External API calls blocking: Waiting for third-party services

    • Solution: Return 200 first, call external APIs in background worker
  • Complex business logic: Synchronous processing takes too long

    • Solution: Use background jobs with timeout monitoring
  • Large payload processing: Parsing or validating large events synchronously

    • Solution: Stream processing or chunk processing in background

Issue 4: Duplicate Event Processing

Symptoms:

  • Same action executed multiple times
  • Database constraints violated
  • Duplicate notifications sent

Causes & Solutions:

  • No idempotency check: Events processed every time received
    • Solution: Track processed event IDs in database
const eventId = generateEventId(event);
const exists = await db.webhookEvents.findUnique({ where: { eventId } });

if (exists) {
  console.log('Event already processed, skipping');
  return res.status(200).json({ received: true, duplicate: true });
}
  • Network retries from Asana: Endpoint times out, Asana retries

    • Solution: Implement idempotent operations that can safely run multiple times
  • Using non-unique identifiers: Resource GID alone isn't unique for change events

    • Solution: Combine resource GID, action, and timestamp for unique ID

Issue 5: Webhook Automatically Deleted

Symptoms:

  • GET /webhooks/{gid} returns 404
  • Events stop arriving unexpectedly
  • No notification of deletion

Causes & Solutions:

  • Not responding to heartbeat events: Empty events array ignored
    • Solution: Always respond with 200 to heartbeat events
if (payload.events.length === 0) {
  console.log('Heartbeat received');
  return res.status(200).send('OK');  // Critical!
}
  • 24-hour delivery failure: Endpoint down for extended period

    • Solution: Monitor uptime, set up automatic webhook recreation
  • Resource deleted: Parent task/project was deleted

    • Solution: Webhooks auto-delete 72 hours after resource deletion; recreate if needed
  • Access token revoked: PAT used to create webhook was deleted

    • Solution: Use service account tokens with longer lifetime

Issue 6: Missing Webhook Events

Symptoms:

  • Expected events not arriving
  • Gaps in event sequence
  • Changes visible in Asana UI but no webhook received

Causes & Solutions:

  • Event filters too restrictive: Filter configuration blocks events
    • Solution: Review filter configuration, test with broader filters
// Too restrictive - only assignee changes on milestones
filters: [{
  resource_type: 'task',
  resource_subtype: 'milestone',
  action: 'changed',
  fields: ['assignee']
}]

// Better - all task changes
filters: [{
  resource_type: 'task',
  action: 'changed'
}]
  • Wrong resource subscribed: Webhook on wrong task/project

    • Solution: Verify resource GID is correct
  • At-most-once delivery: Rare event loss (Asana's documented behavior)

    • Solution: Implement reconciliation polling as fallback
  • Event outside filter scope: Higher-level webhooks don't receive all events

    • Solution: Workspace webhooks don't receive task-level events; use project webhooks

Debugging Checklist

When troubleshooting webhook issues:

  • Check Asana webhook delivery logs via API (GET /webhooks/{gid})
  • Verify webhook endpoint is publicly accessible (test with curl)
  • Test signature verification with known-good payload from our Webhook Payload Generator
  • Check application logs for errors and signature verification results
  • Verify SSL certificate is valid and not expired
  • Confirm webhook secret matches what was received during handshake
  • Test with empty events array to verify heartbeat handling
  • Check last_failure_content field for Asana's error messages
  • Monitor delivery_retry_count to see if retries are occurring
  • Verify resource hasn't been deleted (webhooks auto-delete 72 hours later)

Frequently Asked Questions

Q: How often does Asana send webhooks?

A: Asana sends webhooks immediately when events occur, typically within one minute on average. Most events arrive within 10 minutes. In exceptional circumstances, events may be delayed beyond 10 minutes. If delivery fails, Asana retries with exponential backoff for up to 24 hours before deleting the webhook. Additionally, Asana sends heartbeat events (empty payloads) every 8 hours to verify endpoint availability.

Q: Can I receive webhooks for past events?

A: No, Asana webhooks only deliver events that occur after the webhook is created. You cannot receive webhooks for historical events. To retrieve past data, use the Asana API's GET endpoints to fetch resources and their history. The Events API (/events endpoint) provides events for the last 24 hours with sync tokens, but webhooks themselves don't support historical replay.

Q: What happens if my endpoint is down?

A: Asana will retry failed webhook deliveries with exponential backoff for up to 24 hours. After 24 hours of continuous failures, the webhook will be automatically deleted. You can monitor retry attempts via the delivery_retry_count field when fetching webhook details. Asana also sends heartbeat events every 8 hours; if your endpoint doesn't respond to heartbeats for 24 hours, the webhook is deleted even if there are no active events.

Q: Do I need different endpoints for test and production?

A: Yes, it's strongly recommended to use separate webhook URLs for development/test and production environments with different webhook secrets. This allows you to test changes safely without affecting production. Use different Asana workspaces or projects for testing, and ensure your test endpoint handles the same event types as production. You can use the same codebase with environment-specific configuration.

Q: How do I handle webhook event ordering?

A: Asana does not guarantee webhook events will arrive in the order they occurred. Events are delivered as quickly as possible, but network conditions and processing delays can cause reordering. Best practice: always use the created_at timestamp field on events to determine true chronological order. Design your event handlers to be idempotent and handle events regardless of arrival order. If strict ordering is critical, implement a time-based queue that processes events in timestamp order.

Q: Can I filter which events I receive?

A: Yes, when creating a webhook you can specify filters to only receive specific event types. Filters support resource_type (e.g., "task", "story"), resource_subtype (e.g., "milestone"), action (e.g., "changed", "added"), and fields (specific field changes when action is "changed"). Webhooks on higher-level resources like workspaces and portfolios must include filters. Use our Webhook Payload Generator to test how different filter configurations affect event delivery.

Q: Why am I receiving events for resources I'm not subscribed to?

A: This is due to Asana's event propagation behavior. Events "bubble up" from child resources to parent resources. For example, a webhook on a project receives events for all tasks in that project, subtasks of those tasks, comments on tasks and subtasks, and custom field changes. Similarly, a webhook on a workspace receives events for contained projects (if filtered appropriately). This is by design and allows you to monitor entire hierarchies with a single webhook.

Q: What's the difference between webhooks and the Events API?

A: Both use the same infrastructure but differ in delivery method. Webhooks "push" events to your server automatically, requiring a publicly accessible endpoint but providing real-time notifications. The Events API requires you to "poll" for events by making repeated GET requests with sync tokens, is easier to implement (no server needed), and works from localhost. Events are available via the Events API for 24 hours after occurring. For production integrations requiring real-time response, webhooks are preferred.

Q: Can I replay or retrieve old webhook events?

A: No, webhook events are delivered once and cannot be replayed. Once delivered, the event is not stored by Asana for retrieval. However, the same events are available through the Events API endpoint for 24 hours after they occur. After 24 hours, sync tokens expire. If you need an audit trail or event replay capability, store all incoming webhook events in your own database with the complete payload.

Q: How many webhooks can I create?

A: Asana has two webhook limits: (1) 10,000 webhooks per access token, and (2) 1,000 webhooks per resource. Note that Events API streams also count toward the per-resource limit. For large-scale integrations, consider using workspace or project-level webhooks with filters instead of creating webhooks on individual tasks. If you reach limits, audit your webhooks and delete unused ones via DELETE /webhooks/{webhook_gid}.

Next Steps & Resources

Now that you understand Asana webhooks, here's how to put your knowledge into practice:

Try It Yourself:

  1. Set up an Asana webhook following the step-by-step guide above
  2. Test your implementation with our Webhook Payload Generator to validate signature verification
  3. Deploy your webhook endpoint to production with proper monitoring and alerting
  4. Implement idempotency and error handling based on the production example

Additional Resources:

Related Guides:

Asana Developer Resources:

Need Help Testing?

  • Use our Webhook Payload Generator to create test events with valid signatures
  • Test handshake process locally before deploying to production
  • Verify signature verification logic with known-good payloads
  • Validate error handling for malformed or invalid webhook events

Conclusion

Asana webhooks provide a powerful way to build real-time integrations that respond instantly to task changes, project updates, and team collaboration. By following this guide, you now know how to:

  • ✅ Set up Asana webhooks with proper handshake handling
  • ✅ Verify webhook signatures securely using HMAC-SHA256
  • ✅ Implement a production-ready webhook endpoint with queuing and error handling
  • ✅ Handle common issues including duplicate events and webhook timeouts
  • ✅ Test webhooks effectively with ngrok and our payload generator tool

Remember the key principles for production Asana webhook integrations:

  1. Always verify signatures for security using the X-Hook-Secret from the handshake
  2. Respond within 10 seconds to prevent Asana from timing out and retrying
  3. Process asynchronously using queues for reliability and fast response times
  4. Implement idempotency to handle duplicate events gracefully
  5. Handle heartbeat events to keep your webhook active and monitored by Asana

The unique handshake process with X-Hook-Secret ensures your endpoint is ready before events arrive, while compact event payloads keep delivery fast but require follow-up API calls for complete data. Leverage event propagation to monitor entire project hierarchies with minimal webhooks, and use filters to reduce noise and focus on events that matter to your integration.

Start building with Asana webhooks today, and use our Webhook Payload Generator to test your integration thoroughly before deploying to production.

Have questions or run into issues? Drop a comment below or contact us for personalized assistance with your Asana webhook integration.

Need Expert IT & Security Guidance?

Our team is ready to help protect and optimize your business technology infrastructure.