# Guide to Asana Webhooks: Features and Best Practices

Asana webhooks notify your application when work changes: a task is updated, a comment (story) is added, a task is completed. If you're syncing Asana with another system or triggering automation off project activity, webhooks are how you react without polling the API.

This guide covers how Asana webhooks work, the `X-Hook-Secret` handshake that establishes the shared secret, how to verify the `X-Hook-Signature`, the compact event batches and heartbeats, and the best practices for running them in production.

## What are Asana webhooks?

An Asana webhook watches a resource (a task, project, or higher-level object) and POSTs a batch of events to your target URL when that resource or its children change. The events are compact, telling you what kind of change happened to which resource, so you follow up with an API read for the full details.

Asana establishes a per-webhook shared secret through a handshake at creation, then signs every delivery with an `X-Hook-Signature` derived from that secret.

## Asana webhook features

| Feature | Details |
| --- | --- |
| Configuration | `POST /webhooks` with `resource` + `target` |
| Handshake | Echo the `X-Hook-Secret` header at creation |
| Signature header | `X-Hook-Signature` (HMAC-SHA256 hex of the raw body) |
| Payload | Batch: `{"events": [...]}`, compact event objects |
| Filters | Required for workspace/team/portfolio/goal resources |
| Heartbeats | Empty `{"events": []}` at handshake and every 8 hours |
| Delivery | ~1 min average (up to ~10 min); >10s or non-2xx is a failed delivery |
| Retry / deletion | Exponential backoff for 24h, then the webhook is deleted |
| Explicit delete | Respond `410` to have Asana delete the webhook |
| Limits | 1,000 webhooks per resource, 10,000 per token |

## Common events

Asana events describe an action on a resource. The `action` values are `added`, `changed`, `removed`, `deleted`, and `undeleted`, applied to resource types like tasks and stories:

| Example event | Fires when |
| --- | --- |
| `changed` on a task | A task's field (name, assignee, due date, completion) changes |
| `added` on a story | A comment or system story is added to a task |
| `added` on a task (in a project) | A task is added to a watched project |
| `deleted` on a task | A task is deleted |

Because events are compact, fetch the full object from the API when you need its current state. For workspace, team, portfolio, and goal resources, filters are required at creation.

## Setting up Asana webhooks

### The X-Hook-Secret handshake

When you create a webhook (`POST /webhooks` with a `resource` and `target`), Asana immediately POSTs to your target URL with an `X-Hook-Secret` header and no events. Your server must echo that same `X-Hook-Secret` back as a response header and return 200, while the create request is still in flight. Store the secret per webhook; you'll use it to verify every subsequent delivery.

```javascript
const secrets = new Map(); // webhook target -> X-Hook-Secret (persist this)

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  // Handshake: first delivery carries X-Hook-Secret and no body events
  const handshakeSecret = req.headers["x-hook-secret"];
  if (handshakeSecret) {
    secrets.set("asana", handshakeSecret); // store durably in production
    res.setHeader("X-Hook-Secret", handshakeSecret); // echo it back
    return res.sendStatus(200);
  }

  // ... otherwise verify X-Hook-Signature (below)
});

```

## Securing Asana webhooks

After the handshake, every delivery carries an `X-Hook-Signature` header: a hex-encoded HMAC-SHA256 of the raw request body, keyed with the stored `X-Hook-Secret`. Recompute it over the exact bytes and compare in constant time.

```javascript
const crypto = require("crypto");

function verify(rawBody, signature) {
  const secret = secrets.get("asana");
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const handshakeSecret = req.headers["x-hook-secret"];
  if (handshakeSecret) {
    secrets.set("asana", handshakeSecret);
    res.setHeader("X-Hook-Secret", handshakeSecret);
    return res.sendStatus(200);
  }

  if (!verify(req.body, req.headers["x-hook-signature"])) {
    return res.sendStatus(401);
  }

  res.sendStatus(200); // acknowledge fast
  const { events } = JSON.parse(req.body);
  for (const event of events) processQueue.add(event); // async
});

```

The same verification in Python:

```python
import hashlib
import hmac

def verify(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")

```

Heartbeats (empty `{"events": []}` deliveries) also carry a signature, so verify them the same way; just don't treat the empty batch as work.

## Asana webhook limitations and pain points

### The handshake must complete in-flight

The Problem: The `X-Hook-Secret` echo has to happen synchronously while the `POST /webhooks` create request is still open. If your endpoint is slow to respond, or handles the handshake asynchronously, webhook creation fails.

Why It Happens: Asana confirms endpoint ownership and the shared secret before it finishes creating the webhook.

Workarounds:

* Detect the `X-Hook-Secret` header, echo it, and return 200 immediately, before any other logic.
* Persist the secret durably (not just in memory) so restarts don't lose it.

How Hookdeck Can Help: Hookdeck can complete the Asana handshake and verify signatures at the edge, so your application receives pre-verified events and never has to race the in-flight create request.

### Missed heartbeats delete the webhook

The Problem: Asana sends a heartbeat at handshake and every 8 hours. If no delivery succeeds for 24 hours, the webhook is deleted, and a slow endpoint (over 10 seconds) or non-2xx responses count as failures that lead there.

Why It Happens: Asana prunes webhooks whose endpoints look dead.

Workarounds:

* Acknowledge every delivery, including heartbeats, with a fast 2xx.
* Monitor for missing heartbeats and be ready to recreate a deleted webhook.
* Don't respond `410` unless you actually want the webhook deleted.

How Hookdeck Can Help: Hookdeck always accepts deliveries from Asana and absorbs downstream failures itself, keeping the webhook alive while it retries to your service, so a brief outage doesn't cost you the whole subscription.

### Compact events require follow-up reads and dedup

The Problem: Events are minimal and batched, and the same change can appear more than once. Handlers that expect full objects, or that assume one event per request, break.

Why It Happens: Asana optimizes the event stream for size and delivers batches at-least-once.

Workarounds:

* Iterate the `events` array and fetch full objects from the API when needed.
* Dedupe on the event's resource plus action plus timestamp.

How Hookdeck Can Help: Hookdeck deduplicates deliveries and can split batches, so your app processes one clean event at a time. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Filters are mandatory for some resources

The Problem: For workspace, team, portfolio, and goal resources, webhook creation requires filters. Omit them and creation fails.

Why It Happens: Broad resources would otherwise generate overwhelming event volume.

Workarounds:

* Provide `filters` at creation for these resource types.
* Scope filters to the resource and action types you actually handle.

How Hookdeck Can Help: Hookdeck's filters let you narrow the stream further downstream, so your handler only sees the events it cares about regardless of what Asana sends.

## Best practices

### Handle the handshake first and persist the secret

Echo `X-Hook-Secret` and return 200 before anything else, and store the secret durably per webhook.

### Verify every delivery, including heartbeats

Recompute the hex HMAC-SHA256 over the raw body with the stored secret and compare in constant time. Heartbeats are signed too.

### Acknowledge within ten seconds, process asynchronously

Return 2xx fast and defer work to a queue so you never approach the 10-second failure threshold or the 24-hour deletion. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Process the batch and dedupe

Iterate `events`, fetch full objects when needed, and dedupe so at-least-once delivery doesn't double-process.

### Provide filters and mind the limits

Supply required filters for workspace/team/portfolio/goal resources, and stay within 1,000 webhooks per resource and 10,000 per token.

## Conclusion

Asana webhooks establish a shared secret through an in-flight `X-Hook-Secret` handshake, then sign every delivery (heartbeats included) with an `X-Hook-Signature` you verify against the raw body. The delivery model has real edges: compact batched events, 8-hour heartbeats, and deletion after 24 hours of failed delivery, so a slow or flaky endpoint can lose the subscription entirely.

That puts the handshake, signature verification, fast acknowledgement, batch handling, and deduplication on you. [Hookdeck](https://hookdeck.com) completes the handshake, verifies signatures, deduplicates, and keeps your webhook alive by absorbing downstream failures, so your app only ever processes verified, unique events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Asana webhooks reliably in minutes.