## TL;DR
When you create an Asana webhook, Asana sends a handshake request carrying an X-Hook-Secret header, and your endpoint must echo that exact value back in the response header. If you drop it, rename it, or respond with the wrong status, the handshake fails and no webhook is created. The fix: read the incoming X-Hook-Secret value and return it byte-for-byte in the response header with a 200 status.

## The error
```text
Asana API: error creating webhook
"handshake failed" with 400 on POST /webhooks
```

## Steps
1. Reproduce the create call and capture the handshake request. Log all incoming headers on your webhook route. Expected output: one request from Asana carrying an X-Hook-Secret header with a random value.
2. Echo the value back exactly. In your handler, read the header value and set the same value on the response's X-Hook-Secret header. Do not trim, decode, or reformat it. Expected output: response headers contain X-Hook-Secret with the identical string.
3. Return HTTP 200 with an empty body. Asana only cares about the header and the status. Expected output: 200, no body needed.
4. Keep the route free of auth middleware. The handshake carries no credentials; a login wall fails it before your code runs. Expected output: the handshake request reaches your handler.
5. Retry the webhook creation. Expected output: Asana returns a webhook object with an id, and the handshake request stops appearing.

## When to use
- Creating an Asana webhook returns a handshake failure
- The webhook never appears in Asana's list, so no events were ever sent
- You are wiring up Asana webhooks for the first time

## When not to use
- The webhook was created but events stopped arriving: that is a delivery or expiry problem, the handshake already succeeded
- You get 401 from Asana's API: your personal access credential or OAuth setup is wrong
- Events arrive but look wrong: check your filters and the resource you subscribed to

## Tool compatibility
Asana REST API webhook creation across all plans that support webhooks. The handshake is framework-agnostic; the same echo logic works in Express, Flask, Rails, serverless handlers, and no-code HTTP endpoints that let you set response headers.

## Variant phrasings
### asana webhook creation fails
Check the handshake before anything else.
### asana x-hook-secret handshake
The header must be echoed byte-for-byte.
### asana webhook 400 on create
Often a failed handshake rather than a bad request body.
### asana webhook not created
No webhook object means the handshake never completed.

## Why it happens
The handshake proves you own the target URL. Asana sends a one-time request with a random X-Hook-Secret value and requires your server to prove it received the request by returning the value in the response header. Frameworks routinely break this: a middleware lowercases or strips unknown headers, a proxy consumes the request before your handler sees it, or the handler returns the value in the body instead of the header. Asana treats any of those as a failed handshake and refuses to create the webhook.

## Edge cases
- Header name casing: read the header case-insensitively and write it back as X-Hook-Secret. Some stacks normalize header case on the way in.
- The value looks encoded: do not URL-decode or base64-decode it. Echo the raw string.
- Handshake succeeds but the webhook expires later: Asana webhooks need periodic renewal. That is a separate lifecycle issue.
- Behind a CDN: make sure the CDN passes the header through instead of caching the handshake response.

## Provenance

Resolved from the public thread: https://vectle.com/posts/pst_tuTu26h-1YmIuONboOOEgg
