# Guide to Argo CD Webhooks: Features and Best Practices

Argo CD webhooks let your GitOps deployments talk to the rest of your stack. When an application syncs, fails to sync or turns unhealthy, the Argo CD Notifications controller can call any HTTP endpoint you choose, so you can update a deployment tracker, post a commit status, open an incident or start a downstream job without polling the Argo CD API.

This guide covers how the webhook notification service is configured, what a request contains, how to authenticate it with a secret header, how retries work, and the practices that keep an Argo CD receiver reliable.

## What are Argo CD webhooks?

Argo CD's webhook support is one of the Notifications controller's services. Argo CD describes it as "a generic HTTP request using the templatized request body and URL". Three pieces of configuration in the `argocd-notifications-cm` ConfigMap work together:

* A **webhook service**, `service.webhook.<name>`, holds the base URL, any headers and the retry settings.
* A **template** decides the request: its `webhook.<name>` block sets the HTTP method, an optional path and the body, written as a Go template over the Application.
* A **trigger** decides when to send: a `when` condition, such as `app.status.operationState.phase in ['Succeeded']`, and the templates to send when it is true.

An Application, or a whole project, then subscribes to a trigger and a service with an annotation.

Because the template writes the body, Argo CD defines no event types and no envelope. A request contains whatever your template renders, and nothing in it names the trigger unless you put it there.

Argo CD doesn't sign webhook requests. Every header listed on the service is set on every request, so you authenticate requests by adding a secret header and checking it on the receiving side.

## Argo CD webhook features

| Feature | Details |
| --- | --- |
| Configuration | `service.webhook.<name>` in the `argocd-notifications-cm` ConfigMap |
| Methods | `GET`, `POST`, `PUT` or `PATCH`, set per template. The default is `GET` |
| Body | The template's `body`, a Go template rendered over the Application |
| Event types | None. You name the trigger in the body, path or a header yourself |
| Authentication | Headers from the service's `headers` list, or `basicAuth`. No signature or timestamp |
| Secrets | Usually referenced as `$key` from `argocd-notifications-secret` |
| Retries | Network errors and 5xx responses, up to `retryMax` (default 3), waiting between `retryWaitMin` (1s) and `retryWaitMax` (5s) |
| TLS | Custom certificates go in `argocd-tls-certs-cm`, or `insecureSkipVerify: true` |
| Source IPs | Not applicable. Requests leave from your own cluster |

## What the request contains

Since there is no Argo CD payload format, the first thing to decide is what your templates send. A small JSON body with the trigger name, the application and its status is enough for most receivers:

```json
{
  "trigger": "on-sync-succeeded",
  "app": "guestbook",
  "sync_status": "Synced",
  "health": "Healthy",
  "revision": "4f2c1a9"
}

```

The `trigger` field in this example is written by the template, not added by Argo CD. Adding it is the simplest way to tell requests apart when several triggers send to the same service. The values come from the Application, for example `{{.app.metadata.name}}`, `{{.app.status.sync.status}}`, `{{.app.status.health.status}}` and `{{.app.status.sync.revision}}`.

Two details catch people out:

* **The method defaults to `GET`.** A template that doesn't set `method` sends a `GET`. The notifications engine attaches the rendered body whatever the method, but many frameworks ignore a body on `GET`, so set `method: POST` on templates that carry data.
* **There is no default `Content-Type`.** Argo CD doesn't document one. If your receiver parses JSON based on the content type, add `Content-Type: application/json` to the service's `headers`.

> **Inspect Argo CD webhooks as they arrive.** Point a webhook service at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL and trigger a sync to see the exact method, headers and body your template produces, with no account or setup required.

## Setting up Argo CD webhooks

Generate a long random value for your secret header:

```bash
openssl rand -hex 32

```

Store it in `argocd-notifications-secret`, for example under the key `webhook-secret`, then reference it from the service as `$webhook-secret`. The full configuration looks like this:

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-notifications-cm
data:
  service.webhook.deploy-events: |
    url: https://example.com/webhooks/argo-cd
    headers:
    - name: X-Argo-Secret
      value: $webhook-secret
    - name: Content-Type
      value: application/json

  template.sync-succeeded-webhook: |
    webhook:
      deploy-events:
        method: POST
        body: |
          {
            "trigger": "on-sync-succeeded",
            "app": "{{.app.metadata.name}}",
            "sync_status": "{{.app.status.sync.status}}",
            "health": "{{.app.status.health.status}}",
            "revision": "{{.app.status.sync.revision}}"
          }

  trigger.on-sync-succeeded: |
    - when: app.status.operationState.phase in ['Succeeded']
      send: [sync-succeeded-webhook]

```

Then subscribe an Application (or set the same annotation on a project):

```yaml
metadata:
  annotations:
    notifications.argoproj.io/subscribe.on-sync-succeeded.deploy-events: ""

```

The service also accepts `basicAuth` with a `username` and `password`, which Argo CD sends as an `Authorization: Basic` header. A custom header is easier to rotate and to check, and it is what Hookdeck's Argo CD source is built around, so this guide uses `headers`.

**With Hookdeck.** Argo CD webhooks are not signed. Add a secret header to the webhook service (`service.webhook.<name>.headers` in `argocd-notifications-cm`), then create a Hookdeck source with the **Argo CD** type and enter the same header name in **API Key Header** and the exact value in **API Key**. Hookdeck finds the header case-insensitively and compares the whole value, so if your header value is `Bearer abc123`, enter `Bearer abc123`. Use the source URL as the service's `url`.

Because Argo CD sends `GET` unless the template sets a method, the source pre-selects both `GET` and `POST` without managing the method. If any of your templates use `PUT` or `PATCH`, allow that method on the source as well.

## Securing Argo CD webhooks

Read your header, compare its whole value with the one you stored in constant time, and only then look at the body. Accept every method your templates use, check the header for each of them, and handle an empty body.

```javascript
const crypto = require('crypto');
const express = require('express');

const HEADER = (process.env.ARGO_CD_WEBHOOK_HEADER || 'X-Argo-Secret').toLowerCase();
const SECRET = process.env.ARGO_CD_WEBHOOK_SECRET; // the exact, whole header value

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function handleArgoCd(req, res) {
  const received = req.get(HEADER);
  if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
    return res.sendStatus(401);
  }
  if (!req.body || req.body.length === 0) return res.sendStatus(200);

  let notification;
  try {
    notification = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Route on the trigger name your template wrote, queue the work, then respond.
  res.sendStatus(200);
}

const app = express();
const route = '/webhooks/argo-cd';
const raw = express.raw({ type: '*/*' });
// Register the methods your templates use.
app.get(route, raw, handleArgoCd);
app.post(route, raw, handleArgoCd);
app.put(route, raw, handleArgoCd);
app.patch(route, raw, handleArgoCd);

```

The same check in Python:

```python
import hmac
import os

HEADER = os.environ.get("ARGO_CD_WEBHOOK_HEADER", "X-Argo-Secret")
SECRET = os.environ.get("ARGO_CD_WEBHOOK_SECRET", "")

def verify_argo_cd_request(headers) -> bool:
    if not SECRET:
        return False  # fail closed when unconfigured
    received = headers.get(HEADER)  # Starlette and Flask headers are case-insensitive
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))

```

A static header authenticates the sender, not the body. It can't detect a modified payload, and with no signed timestamp a captured request can be replayed and still pass. Use HTTPS for the service URL, a long random value, and keep the value in `argocd-notifications-secret` rather than in the ConfigMap itself.

> **Make Argo CD webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your secret header on every Argo CD notification, stores each one durably, and delivers it to your services with retries you control.

## Argo CD webhook limitations and pain points

### A short retry window

**The Problem:** A receiver that is down for more than a few seconds misses the notification for good.

**Why It Happens:** The webhook service retries network errors and 5xx responses up to `retryMax` times, three by default, waiting between one and five seconds each time. If every attempt fails, the send returns an error, and Argo CD documents no later redelivery.

**Workarounds:**

* Raise `retryMax` and `retryWaitMax` on the service if your receiver has longer maintenance windows.
* Treat notifications as hints and reconcile against the Argo CD API for anything that must not be missed.

**How Hookdeck Can Help:** Hookdeck accepts each notification as soon as it arrives and stores it, then delivers it to your service with [automatic retries](/docs/retries) on a schedule you set. You can replay any stored notification after an outage.

### No event type to route on

**The Problem:** One endpoint receives requests from several triggers and can't tell them apart.

**Why It Happens:** Argo CD sends the body each template writes and adds no event field or header of its own.

**Workarounds:**

* Put the trigger name in every template's body, as in the example above, or use a different `path` per template.
* Keep one template per trigger so the mapping stays obvious.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on any field in the body or any header, so one Argo CD source can feed several destinations, for example sync failures to your incident tool and successful syncs to your deployment tracker.

### `GET` by default

**The Problem:** Requests arrive as `GET`, and your handler sees no data, or your receiver answers `404` or `405`.

**Why It Happens:** A template that doesn't set `method` sends `GET`. Many web frameworks don't route a `GET` to a `POST` handler and don't parse a body on `GET`.

**Workarounds:**

* Set `method: POST` on every template that carries data.
* Register a route for each method your templates use.

**How Hookdeck Can Help:** Hookdeck's Argo CD source accepts `GET` and `POST` out of the box, and you can allow `PUT` or `PATCH` on it. The stored request shows the method each template actually sent.

### A header that is easy to lose

**The Problem:** After a ConfigMap edit, every notification fails authentication.

**Why It Happens:** The secret header lives in the service definition, and its value is usually a `$key` reference into `argocd-notifications-secret`. Renaming the key, removing the header from the list, or adding a second service without it all send requests your receiver will reject.

**Workarounds:**

* Keep the ConfigMap and Secret in Git with the rest of your Argo CD configuration, and review header changes like code.
* Rotate by accepting both the old and new value in your receiver until the new Secret is applied.

**How Hookdeck Can Help:** Hookdeck's Argo CD source compares the header you name with the exact value you enter. Hookdeck answers Argo CD with a `200` either way, then records a request without the right header as failed verification, so it never reaches your destination, and you can see exactly which headers arrived.

## Best practices

### Write a self-describing body

Include the trigger name, the application name and the revision in every template. Your handler can then route, deduplicate and log without calling back into Argo CD.

### Set the method and content type explicitly

Use `method: POST` for anything with a body, and add `Content-Type: application/json` to the service's headers so receivers parse it correctly.

### Keep secrets in the Secret

Reference the header value as `$key` from `argocd-notifications-secret` instead of writing it into `argocd-notifications-cm`, which more people can usually read.

### Make handlers idempotent

Retries and repeated syncs can send the same notification more than once. Key on the application, the revision and the trigger, and skip pairs you have already processed. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast

With only a few seconds of retries, a handler that is slow or briefly down comes close to losing the notification. Check the header, store the body, return `200` and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

The Argo CD Notifications webhook service sends whatever your template renders, with the method the template sets and `GET` by default, and it signs nothing. Add a secret header from `argocd-notifications-secret`, compare its whole value, put the trigger name in the body, set `method: POST`, and respond quickly, because Argo CD retries only a handful of times over a few seconds.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the header, stores every notification and retries delivery to your services, so a short outage doesn't mean a lost deployment event.

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