# Webhook triggers

> Start an Agent Program from Zapier, Make, n8n, a form tool or your own code with its webhook URL, optional signing, and the responses to expect.

Last updated: October 16, 2026

Source: https://www.willo.ai/docs/agent-programs/webhook-triggers

A webhook trigger gives a program its own URL. Each POST to it starts one run, with the request's body as the run's input. Use it when another app should start the program: Zapier, Make, n8n, a form tool such as Tally, Cal.com, or code you write.

## Get the URL

1. Ask Willo's chat for a program that runs "when my form posts" or "from Zapier". Willo sets the trigger to webhook.
2. Open **Agents** in the left rail, then the program, then its **Trigger** card.
3. Click **Reveal URL** and copy it. It is shown **once**.

Willo's chat never shows the URL, and can't look it up: the chat thread is visible to everyone on the business. If you lose the URL, **Rotate URL** issues a new one and the old one stops working at once.

The URL has this shape. Its last segment is the secret:

```
https://api.willo.ai/webhooks/programs/<public id>/<secret>
```

## Send a call

```bash
curl -X POST "$WILLO_WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"lead": "alice@example.com", "source": "website form"}'
```

- **Body**: JSON, form-encoded or plain text, up to 64 KB. An empty POST is a plain "go".
- **`Idempotency-Key`**: optional. Calls with the same key run once. Without a key, the same body sent twice on the same UTC day runs once. Vary one of them to run again.
- **Response**: `202` with `{"status": "queued"}` when a run is queued. Runs start within a few minutes, one at a time per program.

The run's trace on the Agents tab shows the call under **Trigger**, and the runs list labels it **Webhook**.

## No-code recipes

Leave **Require signature** off for these: they can't compute a signature.

- **Zapier**: add a **Webhooks by Zapier** action, event **POST**, paste the URL, payload type **json**, and add the fields the program should see.
- **Make**: add an **HTTP → Make a request** module, method **POST**, the URL, body type **Raw**, content type **JSON**, and the JSON body.
- **n8n**: add an **HTTP Request** node, method **POST**, the URL, turn on **Send Body**, content type **JSON**, and the fields.
- **Tally**: in the form's **Integrations**, add a **Webhook** and paste the URL. Each submission arrives as JSON with the form's fields.

## Signing calls from your own code

For code you write, switch on **Require signature** on the program's Trigger card. Each call must then carry:

```
X-Willo-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
```

The HMAC key is the URL's last segment. The signed message is the timestamp, a dot, and the exact raw body: `<t>.<body>`. Willo accepts a timestamp up to five minutes either side of its clock. Sign the same bytes you send.

**Node**

```js
import crypto from "node:crypto";

const url = process.env.WILLO_WEBHOOK_URL;
const secret = new URL(url).pathname.split("/").pop();
const body = JSON.stringify({ lead: "alice@example.com", source: "website form" });
const t = Math.floor(Date.now() / 1000);
const v1 = crypto.createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");

const res = await fetch(url, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
    "X-Willo-Signature": `t=${t},v1=${v1}`,
  },
  body,
});
console.log(res.status, await res.json());
```

**Python**

```python
import hashlib, hmac, json, os, time, uuid
import urllib.request

url = os.environ["WILLO_WEBHOOK_URL"]
secret = url.rstrip("/").rsplit("/", 1)[-1]
body = json.dumps({"lead": "alice@example.com", "source": "website form"}).encode()
t = int(time.time())
v1 = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()

req = urllib.request.Request(url, data=body, method="POST", headers={
    "Content-Type": "application/json",
    "Idempotency-Key": str(uuid.uuid4()),
    "X-Willo-Signature": f"t={t},v1={v1}",
})
with urllib.request.urlopen(req) as resp:
    print(resp.status, resp.read().decode())
```

Signing stops a captured call being replayed later. It does not protect a leaked URL; rotating does.

## Responses

| Status | Meaning |
| --- | --- |
| `202` | Queued. One run will start. |
| `200` | A duplicate of a call already accepted. No second run. |
| `401` | Wrong secret, or a missing, malformed, expired or invalid signature. |
| `404` | Unknown or rotated URL. |
| `409` | The program is paused or still a draft. |
| `413` | Body over 64 KB. |
| `415` | Not JSON, form-encoded or plain text. |
| `429` | Over 60 calls a minute, 20 calls waiting, or the daily limit. Honour `Retry-After`. |

Every code, with the exact error names, is on [Webhook request format](https://www.willo.ai/docs/reference/webhook).

## What asks first

A webhook call is outside content. In every mode, a step that reaches other people, such as sending a message or deploying, asks you first. Research, content work and messages to you run unattended.

## Start from Gmail, Slack or Stripe

To run a program when something happens in Gmail, Slack or Stripe, point a no-code tool that watches that app at this webhook. Programs don't use [connectors](https://www.willo.ai/docs/connectors/what-a-connector-can-do#where-it-works), so the webhook is how the event reaches the program.

Related: [Agent Program limits](https://www.willo.ai/docs/agent-programs/limits), [Email triggers](https://www.willo.ai/docs/agent-programs/email-triggers).

## Questions

### Can a program start when a Stripe payment or a Slack message happens?

Yes, through its webhook. Point a no-code tool that watches Stripe or Slack, or your own system, at the program's webhook URL, and each event starts a run.

### What if the webhook URL leaks?

Rotate it on the Agents tab. The old URL stops working at once. Signing limits replayed calls but does not protect a leaked URL.
