# Call a workflow’s webhook

Start a published workflow from any service with one HTTP call, and get its answer in the same call.

## You need

- **A webhook workflow**: A workflow that starts with the Webhook trigger: [Start from a webhook](https://docs.gorunner.io/build/start-from-a-webhook).
- **A token, once**: Only to read the address. Calling it needs none.

## 1. Get the address

The address is your host, then `/api/webhooks/` and the workflow’s webhook token. It answers only once the workflow is published.

In the builder, the Webhook trigger shows it under **Webhook URL (production)**. From the API, [publishing](https://docs.gorunner.io/api/publish/publish-workflow) answers `webhookToken`, and so does [Get a workflow](https://docs.gorunner.io/api/workflows/get-workflow) for a token that may run workflows.

> **Warning** Anyone who has the address can start production runs. Keep it secret, or protect it with the trigger’s **Authentication**. To stop an old address, press **Regenerate URL** under it, or [get a new one by API](https://docs.gorunner.io/api/publish/rotate-workflow-webhook-token).

```bash
WEBHOOK_TOKEN=$(curl -s -X POST "$GORUNNER/api/workflows/$ID/publish" \
  -H "Authorization: Bearer $GORUNNER_TOKEN" \
  | jq -r .data.webhookToken)
HOOK="$GORUNNER/api/webhooks/$WEBHOOK_TOKEN"
```

## 2. Call it

Send any method the trigger allows. No token: the address is the key. If the trigger’s **Authentication** asks for Basic Auth, a header or an HMAC signature, send that.

The workflow receives the request as its trigger data:

- `{{trigger.body}}`: JSON as sent. Form fields as an object. Multipart fields too, with each file’s name, size and type. Anything else as text; nothing as `{}`.
- `{{trigger.query}}`: The query string, as an object.
- `{{trigger.headers}}`: Names in lowercase. `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key` and the trigger’s own header are kept as `[redacted]`.
- `{{trigger.method}}`: `POST`, `GET` and so on.

A body may be up to 5 MiB.

```bash
curl -i -X POST "$HOOK?source=crm" \
  -H "Content-Type: application/json" \
  -d '{"orderId": "A-1001"}'
```

```text
HTTP/1.1 201 Created
Content-Type: application/json

{"received":"A-1001"}
```

This workflow ends with a **Return Response** step: status 201, and a JSON body built from `{{trigger.body.orderId}}`.

## 3. Read the answer

GoRunner waits up to 30 seconds for the run, then answers:

- **With **Return Response****: Its status, body and headers. The response type sets `Content-Type`, over any you gave. With **After responding** set to **Keep running**, you get it as soon as that step runs. A header that would act on GoRunner’s own address, such as `Set-Cookie`, fails the step, and you get `500`. Check a definition warns of it.
- **Without one**: `200` and the last step’s output, with the run’s ID in `X-Run-Id` and its state in `X-Run-Status`. `{}` if no step gave output.
- **Still running**: `202` with `runId`, `status` and a message: after 30 seconds, when the run waits on a Delay or a person, or when it waits for a free slot.
- **Not waited for**: `202` with `X-Run-Wait: overloaded` and `Location: /api/runs/<id>`, when this server already holds as many waiting callers as it can, or your organization’s share of them. The body has `runId`, `status` `pending`, `wait` `overloaded` and `poll`. The run is queued and runs; it is never dropped. Read its result by its ID. A self-hosted installation sets the limits with `SYNC_WAITS_MAX` (4,096 per API process unless changed) and `SYNC_WAITS_PER_ORG` (an eighth of it).
- **The run failed**: `500`, or `504` when a time limit stopped it, with `runId` and `status`. The reason isn’t sent: read the run.
- **Someone cancelled it**: `409` with `runId` and `status`, while it waited for a slot or while it ran. `X-Run-Status` says `cancelled`.

```bash
curl -i -X POST "$HOOK" \
  -H "Content-Type: application/json" \
  -d '{"name": "Ada Lovelace"}'
```

```text
HTTP/1.1 200 OK
Content-Type: application/json
X-Run-Id: f91487db-…
X-Run-Status: completed

{"result": "ADA LOVELACE"}
```

## 4. Don’t wait

Add `?async=true`. GoRunner answers `202` at once with the run’s ID, and runs the workflow after.

Follow it with [Get a run](https://docs.gorunner.io/api/runs/get-run), or [follow it as it happens](https://docs.gorunner.io/api/streaming).

```bash
curl -X POST "$HOOK?async=true" \
  -H "Content-Type: application/json" \
  -d '{"orderId": "A-1002"}'
```

```text
{
  "data": {
    "message": "Workflow triggered",
    "runId": "c73bfcdb-…"
  }
}
```

## Retry safely

A sender that retries after a lost answer or a `202` starts a second run. To stop that, send an `Idempotency-Key` header with the call, the same on every retry. Without it, nothing changes.

- **The key**: Up to 255 characters. Kept 24 hours, for this workflow’s live runs only.
- **The same key, the same request**: No new run. You get the first run’s answer again, with `Idempotency-Replayed: true`, or `202` with its ID while it still runs.
- **The same key, another request**: `422`. The method, query and body must match.
- **While the first is recorded**: `409` with `Retry-After: 1`.
- **A longer key**: `400`.

A call is never refused for its key. Past 10,000 live keys for one workflow, or while Redis can’t be reached, it runs without the check. Apps’ own triggers, such as Slack or Stripe, don’t take one.

## When it’s refused

Errors come in the API’s usual shape. Retry the ones that send `Retry-After`.

- 404 `NOT_FOUND`

  “Webhook not found.” The address is wrong, or the workflow isn’t published.

- 405 `METHOD_NOT_ALLOWED`

  The trigger doesn’t allow this method. `Allow` lists the ones it does.

- 401 `UNAUTHORIZED`

  The trigger’s **Authentication** didn’t match.

- 413 `PAYLOAD_TOO_LARGE`

  The body is over 5 MiB.

- 429 `CONCURRENCY_LIMIT`

  The workflow is at [its limit](https://docs.gorunner.io/build/runs-at-once) and set to refuse. `RUNS_WAITING_LIMIT`: too many runs already wait.

- 429 `RATE_LIMITED`

  Too many calls from one address: 3,000 a minute unless a self-hosted installation changes `WEBHOOK_RATE_LIMIT`.

- 503 `BUSY`

  The server is holding as much request data as it can. Nothing was done, so a retry is safe. `Retry-After: 2`.

- 503 `SERVICE_UNAVAILABLE`

  GoRunner couldn’t look up the address just now. Nothing was done. Retry after `Retry-After`.

- 503 `WORKER_UNAVAILABLE`

  Nothing can run the workflow just now, and nothing was recorded. `Retry-After: 10`. With `?async=true`, the run is queued instead.

- 503 `STORAGE_UNAVAILABLE`

  The run finished, but its answer couldn’t be read back. The run is intact: read it with the ID in `X-Run-Id`.

- 503 `ACCOUNT_SUSPENDED`

  The account is suspended. `Retry-After: 3600`.

- 503 `PAUSED`

  A self-hosted installation has no license. `Retry-After: 3600`.

**Why didn’t a HEAD or OPTIONS request start a run?**

They never do. `HEAD` answers `200` and `OPTIONS` answers `204` with the allowed methods, so a service can check the address. Sign-up checks from Microsoft Graph, hub.challenge services and Asana are answered too, without a run.

**What about a Form trigger’s address?**

It is a page for a person, on the same address. `GET` shows the form. `POST` takes form fields, multipart or a JSON object, and answers with a page: `200`, or `400` and `415` with the form again. Any other method is `405`.

**Can I call it before publishing?**

Not as a live run: a draft’s address answers `404`. In the builder, press **Listen for the next real event** in the trigger’s test data. The next call to the address within 120 seconds is captured and runs the draft as a test.

## Carry on a waiting run

A **[Delay / Wait](https://docs.gorunner.io/apps/delay-wait)** › **Wait for Approval / Webhook** step gives `{{steps.<name>.resumeUrl}}`, at `/api/webhooks/resume/…`. Send it to the system that answers.

- `POST`: Carries on the run. The query and the JSON body reach the step as `resumeData`. `decision=approve` or `reject` answers an approval.
- **The answer**: `200` with `runId`, `status` `resuming` and `waitStep`. A caller asking for HTML gets a page.
- **Once**: A link works once. After that it answers `404`: `ALREADY_ANSWERED` if it was answered, `TIMED_OUT` if the step’s wait ran out, `NOT_FOUND` if it never existed.
- **Up to 5 MiB**: A longer body is `413`, and the link still works. The same goes for `503` and `500`: nothing was decided, so send it again. While the account is suspended, it is `409 ACCOUNT_SUSPENDED`.
- **A browser**: Opening it shows a page with a button, so a link scanner can’t answer it. A `GET` that doesn’t ask for HTML answers it, as a `POST` does.

## Related

- [Start from a webhook](https://docs.gorunner.io/build/start-from-a-webhook): The Webhook trigger in the builder · 5 min
- [Ask for approval](https://docs.gorunner.io/build/ask-for-approval): Hold a run until a person answers · 4 min
- [Get a run](https://docs.gorunner.io/api/runs/get-run): Its state and each step’s output

Source: https://docs.gorunner.io/api/webhooks
