> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getdialed.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Receive webhooks

> Let an external system trigger a Definition — create a signed inbound webhook, copy its receive URL, and POST HMAC-signed deliveries

Inbound webhooks let external systems (Five9, Stripe, anything that can POST) trigger one of your Definitions. You create a webhook config bound to an active Definition; the sender POSTs signed deliveries to a per-customer receive URL; each verified delivery starts a normal job (`source: "webhook"`).

<Note>
  This surface is **inbound-only** — deliveries coming *into* GetDialed. Outbound webhooks (a flow calling an external URL) are a separate catalog action, not receiver machinery.
</Note>

## How inbound webhooks work

1. An admin creates a webhook config (`POST /webhooks`) bound to an active Definition. GetDialed mints a signing secret and returns it **once**.
2. GetDialed returns a `receive_url` — a relative path `/webhooks/{slug}/{webhook_id}`.
3. Your external sender computes an HMAC-SHA256 signature over each delivery body and POSTs to the public receive URL.
4. The receiver verifies the signature on the raw bytes, then starts a job with the normalized payload as `input_data`.

## Step 1 — Create the webhook

The `X-API-Key` header is admin-equivalent; a Clerk JWT needs the admin role. Only `provider: "standard"` ([Standard Webhooks](https://www.standardwebhooks.com/), HMAC-SHA256) and `mode: "trigger"` are implemented today — other values are rejected with `422`.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.getdialed.ai/flows/webhooks \
    -H "X-API-Key: $API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "definition_id": "def_a1b2c3d4",
      "description": "Five9 call-completed events"
    }'
  ```

  ```javascript JavaScript theme={null}
  const res = await fetch("https://api.getdialed.ai/flows/webhooks", {
    method: "POST",
    headers: {
      "X-API-Key": process.env.API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      definition_id: "def_a1b2c3d4",
      description: "Five9 call-completed events",
    }),
  });
  const webhook = await res.json();
  // Store webhook.signing_secret NOW — it is never returned again.
  ```

  ```python Python theme={null}
  import os
  import httpx

  res = httpx.post(
      "https://api.getdialed.ai/flows/webhooks",
      headers={"X-API-Key": os.environ["API_KEY"]},
      json={
          "definition_id": "def_a1b2c3d4",
          "description": "Five9 call-completed events",
      },
  )
  webhook = res.json()
  # Store webhook["signing_secret"] NOW — it is never returned again.
  ```
</CodeGroup>

The `201` response carries two **show-once** fields, `signing_secret` and `receive_url`:

```json theme={null}
{
  "id": "8a9c86a8b78c48d3a3e5e00ad0ed5b96",
  "definition_id": "def_a1b2c3d4",
  "org_id": "org_12345678",
  "description": "Five9 call-completed events",
  "mode": "trigger",
  "provider": "standard",
  "enabled": true,
  "created_at": "2026-07-07T20:00:00Z",
  "updated_at": "2026-07-07T20:00:00Z",
  "signing_secret": "$WEBHOOK_SIGNING_SECRET",
  "receive_url": "/webhooks/acme/8a9c86a8b78c48d3a3e5e00ad0ed5b96"
}
```

<Warning>
  The `signing_secret` is returned **exactly once** — here, and again only if you explicitly rotate it. It is never retrievable from any `GET`. Copy it into the sending system immediately. If you lose it, use `POST /webhooks/{webhook_id}/rotate-secret` (24h old-secret overlap).
</Warning>

## Step 2 — Build the receive URL

The `receive_url` is a **relative path** with no host and no `/flows` prefix. Prepend the production base URL to get the address your sender calls:

```
https://api.getdialed.ai/flows/webhooks/{slug}/{webhook_id}
```

For the example above that is `https://api.getdialed.ai/flows/webhooks/acme/8a9c86a8b78c48d3a3e5e00ad0ed5b96`. The `webhook_id` is a 128-bit opaque hex value, so the URL is unguessable by construction. The tenant is identified by the `slug`; the webhook by the `webhook_id`.

## Step 3 — Sign and send a delivery

The receiver carries **no** `X-API-Key`/`Authorization` requirement — the trust boundary is the HMAC-SHA256 signature over the **raw request body**, verified per the Standard Webhooks spec before the body is parsed. Every delivery must carry three headers:

| Header              | Description                                                                                                                      |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `webhook-id`        | Sender-chosen unique delivery ID. **This is the idempotency key** — resending the same `webhook-id` is safe (dedups to one job). |
| `webhook-timestamp` | Unix seconds. Deliveries outside a 5-minute replay window are rejected with `401`.                                               |
| `webhook-signature` | `v1,<base64 HMAC-SHA256>` computed over `{webhook-id}.{webhook-timestamp}.{raw body}` with the signing secret.                   |

The `standardwebhooks` library computes the signature for you — never hand-roll the HMAC.

<CodeGroup>
  ```bash curl theme={null}
  BODY='{"call_id": "C123", "disposition": "completed"}'
  MSG_ID="msg_$(uuidgen)"
  TS=$(date +%s)
  SIG=$(python3 -c "
  from standardwebhooks import Webhook
  import datetime
  print(Webhook('$WEBHOOK_SIGNING_SECRET').sign('$MSG_ID', datetime.datetime.fromtimestamp($TS), '$BODY'))
  ")

  curl -i -X POST "https://api.getdialed.ai/flows/webhooks/acme/8a9c86a8b78c48d3a3e5e00ad0ed5b96" \
    -H "Content-Type: application/json" \
    -H "webhook-id: $MSG_ID" \
    -H "webhook-timestamp: $TS" \
    -H "webhook-signature: $SIG" \
    -d "$BODY"
  ```

  ```javascript JavaScript theme={null}
  import { Webhook } from "standardwebhooks";

  const url =
    "https://api.getdialed.ai/flows/webhooks/acme/8a9c86a8b78c48d3a3e5e00ad0ed5b96";
  const body = JSON.stringify({ call_id: "C123", disposition: "completed" });
  const msgId = `msg_${crypto.randomUUID()}`;
  const timestamp = new Date();

  const wh = new Webhook(process.env.WEBHOOK_SIGNING_SECRET);
  const signature = wh.sign(msgId, timestamp, body);

  await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "webhook-id": msgId,
      "webhook-timestamp": Math.floor(timestamp.getTime() / 1000).toString(),
      "webhook-signature": signature,
    },
    body,
  });
  ```

  ```python Python theme={null}
  import os
  import uuid
  import datetime
  import httpx
  from standardwebhooks import Webhook

  url = "https://api.getdialed.ai/flows/webhooks/acme/8a9c86a8b78c48d3a3e5e00ad0ed5b96"
  body = '{"call_id": "C123", "disposition": "completed"}'
  msg_id = f"msg_{uuid.uuid4()}"
  timestamp = datetime.datetime.now()

  wh = Webhook(os.environ["WEBHOOK_SIGNING_SECRET"])
  signature = wh.sign(msg_id, timestamp, body)

  httpx.post(
      url,
      headers={
          "Content-Type": "application/json",
          "webhook-id": msg_id,
          "webhook-timestamp": str(int(timestamp.timestamp())),
          "webhook-signature": signature,
      },
      content=body,
  )
  ```
</CodeGroup>

The receiver acks within \~50ms with `202` — job creation happens asynchronously after the ack.

| Status | Meaning                                                                                                                                  |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `202`  | Accepted. A replayed `webhook-id` also returns `202` but does not dispatch again.                                                        |
| `401`  | Signature verification failed (missing header, expired timestamp, tampered body, or wrong secret — all return the same generic message). |
| `404`  | Unknown slug, unknown `webhook_id`, or a disabled webhook — never `401` for existence failures.                                          |
| `429`  | Per-webhook rate limit exceeded (default 1000 deliveries/minute).                                                                        |

## Step 4 — Confirm the delivery dispatched

The receiver's `202` only means "accepted." Check the delivery log to see whether it started a job:

<CodeGroup>
  ```bash curl theme={null}
  curl "https://api.getdialed.ai/flows/webhooks/8a9c86a8b78c48d3a3e5e00ad0ed5b96/deliveries" \
    -H "X-API-Key: $API_KEY"
  ```

  ```javascript JavaScript theme={null}
  const res = await fetch(
    "https://api.getdialed.ai/flows/webhooks/8a9c86a8b78c48d3a3e5e00ad0ed5b96/deliveries",
    { headers: { "X-API-Key": process.env.API_KEY } },
  );
  const { items } = await res.json();
  ```

  ```python Python theme={null}
  import os
  import httpx

  res = httpx.get(
      "https://api.getdialed.ai/flows/webhooks/8a9c86a8b78c48d3a3e5e00ad0ed5b96/deliveries",
      headers={"X-API-Key": os.environ["API_KEY"]},
  )
  deliveries = res.json()["items"]
  ```
</CodeGroup>

Each delivery record moves `received` → `dispatched` (with the created `job_id`) or `failed` (with an error).

## Next steps

<CardGroup cols={2}>
  <Card title="Webhooks" icon="webhook" href="/concepts/webhooks">
    The full object reference, signing spec, and receiver semantics.
  </Card>

  <Card title="Jobs and executions" icon="diagram-project" href="/concepts/jobs-and-executions">
    Every verified delivery starts a job — track it end to end.
  </Card>
</CardGroup>
