# Send your first webhook delivery

> Configure an active webhook workflow, send one idempotent JSON delivery, and verify the accepted run through JSON:API.

Create one controlled delivery and follow it from `202 Accepted` to the
JSON:API run record. The selected service account needs `webhooks` for delivery
and `api_read` for the verification request.

## What you will do

1. Save and activate a workflow with a webhook trigger.
2. Exchange the selected service account's credentials.
3. Send one JSON body with a stable `Idempotency-Key`.
4. Capture the `workflow_run_id` from the `202` response.
5. Read that run through JSON:API.

## Prerequisites

- In the intended workspace, create a service account with `webhooks` and
  `api_read`, and select an active run-as member.
- Create a workflow, choose a **Webhook** trigger, and select that exact service
  account. Add at least one valid step, save, and activate the workflow.
- Copy the token-only URL displayed on the active workflow. Do not use the
  builder's `/test` endpoint: it only previews a payload and creates no run.
- Collect the selected account's client ID and client secret. Set
  `HODOFLOW_BASE_URL` as described in
  [Base URLs, workspaces, and versions](/get-started/base-urls-workspaces-versions).
- Use Bash, cURL, and `jq` on a trusted machine.

The trigger token is secret material. Do not put it or the bearer token in
source control, logs, screenshots, browser storage, or support messages.

## Request

Read the trigger token and service-account credentials interactively:

```bash
read -r -s -p "Webhook trigger token: " TRIGGER_TOKEN
printf '\n'
read -r -p "Client ID: " CLIENT_ID
read -r -s -p "Client secret: " CLIENT_SECRET
printf '\n'
export TRIGGER_TOKEN CLIENT_ID CLIENT_SECRET
```

Exchange the client credentials. There is no refresh token:

```bash
TOKEN_RESPONSE="$(
  jq -n '{
    grant_type: "client_credentials",
    client_id: env.CLIENT_ID,
    client_secret: env.CLIENT_SECRET
  }' |
    curl --silent --show-error \
      --request POST \
      --header "Content-Type: application/json" \
      --data-binary @- \
      "$HODOFLOW_BASE_URL/api/v1/oauth/token"
)"

TOKEN="$(printf '%s\n' "$TOKEN_RESPONSE" | jq -er '.access_token')"
```

Create a payload file and a sender-stable delivery ID. Reuse this ID only for
retries of this exact delivery:

```bash
DELIVERY_FILE="$(mktemp)"
DELIVERY_ID="first-delivery-$(date -u +%Y%m%dT%H%M%SZ)"
printf '%s\n' '{"event":"order.created","data":{"id":"order-123","total":125.5}}' > "$DELIVERY_FILE"

DELIVERY_RESPONSE="$(
  curl --silent --show-error \
    --request POST \
    --header "Authorization: Bearer $TOKEN" \
    --header "Content-Type: application/json" \
    --header "Idempotency-Key: $DELIVERY_ID" \
    --data-binary "@$DELIVERY_FILE" \
    "$HODOFLOW_BASE_URL/api/v1/webhooks/$TRIGGER_TOKEN"
)"

printf '%s\n' "$DELIVERY_RESPONSE" | jq .
RUN_ID="$(printf '%s\n' "$DELIVERY_RESPONSE" | jq -er '.workflow_run_id')"
```

The request is intentionally one delivery, so there is no collection limit to
set. `202` means the run was durably accepted and enqueued, not completed.

Read the returned run ID:

```bash
curl --silent --show-error --include \
  --header "Accept: application/vnd.api+json" \
  --header "Authorization: Bearer $TOKEN" \
  "$HODOFLOW_BASE_URL/api/json/v1/workflow_runs/$RUN_ID"
```

## Expected response

The delivery response is:

```json
{
  "status": "accepted",
  "workflow_run_id": "550e8400-e29b-41d4-a716-446655440000"
}
```

The lookup returns `200`, `x-api-version: v1`, an `x-request-id`, and one
JSON:API workflow-run resource. Its state may still be `pending` or `running`:

```json
{
  "data": {
    "type": "workflow_run",
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "attributes": {
      "state": "pending"
    }
  }
}
```

Poll deliberately rather than in a tight loop. Stop when the state becomes
`completed`, `failed`, or `suspended`; see
[Follow a workflow run](/guides/follow-workflow-run) for the full sequence.

## Troubleshooting

- `401` with `{"error":"missing_credentials"}`: the bearer token is absent,
  invalid, or expired. Exchange once and retry once.
- `401` with `{"error":"Unauthorized"}`: the token was not issued for the
  exact selected service account, the organization differs, or connector auth
  failed. Correct configuration before retrying.
- `403` with `{"error":"Insufficient scope"}`: grant `webhooks` to the selected
  account; retrying the same token cannot add a scope.
- `404` with `{"error":"Trigger not found"}`: verify the token-only URL and
  that the workflow is saved and active.
- `413`: reduce the body before retrying.
- `429` or `503`: wait `Retry-After`, then retry with the same delivery ID.
- A retry returns `{"status":"duplicate"}`: the first attempt was accepted;
  no second run was created, so continue with the run ID captured previously.

Clean up local secrets and the temporary body:

```bash
rm "$DELIVERY_FILE"
unset CLIENT_ID CLIENT_SECRET TOKEN TOKEN_RESPONSE TRIGGER_TOKEN DELIVERY_ID DELIVERY_FILE DELIVERY_RESPONSE RUN_ID
```

## Next steps

- Add sender verification with [API keys or HMAC signatures](/webhook-ingestion/authentication-signatures).
- Implement [deduplication-aware retries](/webhook-ingestion/retries-troubleshooting).
- Review the exact [webhook endpoint contract](/reference/webhooks/ingest).
