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

# Receiving Action Run Callbacks

> Get notified when an action run completes instead of polling for the result.

Instead of polling `GET /v2/action-runs/{id}` for every result, you can include a `callback_url` in your request. When the run reaches a terminal state, Zapier POSTs the result directly to your endpoint.

This is the recommended approach at high volumes — polling creates one request per run, while callbacks let Zapier push results to you.

***

## Adding a Callback URL

Include the optional `callback_url` field in your `POST /v2/action-runs` request:

```http theme={null}
POST https://api.zapier.com/v2/action-runs/
Authorization: Bearer YOUR_ACCESS_TOKEN
Content-Type: application/json

{
  "action": "core:89sg4uhs5g85gh53hso59hs399hgs59",
  "authentication": "UHsi8e6K",
  "input": {
    "email": "user@example.com",
    "message": "Hello from Powered by Zapier!"
  },
  "callback_url": "https://your-app.example.com/zapier/callbacks"
}
```

The response is unchanged — you receive a run ID immediately while Zapier executes the action asynchronously:

```json theme={null}
{
  "data": {
    "type": "run",
    "id": "arun_abc123"
  }
}
```

### Validation Rules

The `callback_url` must meet these requirements, or the request returns `400 Bad Request`:

| Rule       | Requirement                                                                              |
| ---------- | ---------------------------------------------------------------------------------------- |
| Protocol   | HTTPS only                                                                               |
| IP ranges  | Public IPs only — private ranges (10.x, 172.16–31.x, 192.168.x, 127.x, ::1) are rejected |
| Max length | 2048 characters                                                                          |

***

## Callback Payload

When the action reaches a terminal state, Zapier sends a `POST` to your `callback_url`. Your endpoint must return a `2xx` response to acknowledge receipt.

### Success

```http theme={null}
POST https://your-app.example.com/zapier/callbacks
Content-Type: application/json
Zapier-Callback-Signature: <signed JWT>

{
  "data": {
    "type": "run",
    "id": "arun_abc123",
    "status": "success",
    "results": [
      { ... }
    ],
    "errors": []
  }
}
```

The `id` in the payload matches the run ID returned by the original `POST /v2/action-runs` response. You can use the same `callback_url` for every action run and dispatch on `id` to correlate each callback with its originating request.

### Error

```http theme={null}
POST https://your-app.example.com/zapier/callbacks
Content-Type: application/json
Zapier-Callback-Signature: <signed JWT>

{
  "data": {
    "type": "run",
    "id": "arun_abc123",
    "status": "error",
    "results": [],
    "errors": [
      {
        "code": "user",
        "title": "Record not found",
        "detail": "The specified record could not be found in the target app."
      }
    ]
  }
}
```

***

## Security

### Verifying Callback Authenticity

Each callback includes a `Zapier-Callback-Signature` header containing a JWT signed with Zapier's private key. Verify it using Zapier's public keys from our JWKS endpoint:

```
https://zapier.com/.well-known/jwks.json
```

<Tabs>
  <Tab title="Python">
    ```python theme={null}
    import jwt                         # pip install PyJWT
    import requests
    from jwt.algorithms import RSAAlgorithm
    from functools import lru_cache

    JWKS_URL = "https://zapier.com/.well-known/jwks.json"

    @lru_cache(maxsize=1)
    def _get_jwks():
        return requests.get(JWKS_URL).json()["keys"]

    def get_public_key(kid):
        for key in _get_jwks():
            if key["kid"] == kid:
                return RSAAlgorithm.from_jwk(key)
        raise ValueError(f"Unknown key ID: {kid}")

    def verify_zapier_callback(headers):
        token = headers.get("Zapier-Callback-Signature")
        if not token:
            raise ValueError("Missing Zapier-Callback-Signature header")

        header = jwt.get_unverified_header(token)
        public_key = get_public_key(header["kid"])
        # PyJWT validates exp automatically — expired tokens raise ExpiredSignatureError
        return jwt.decode(token, public_key, algorithms=["RS256"])
    ```
  </Tab>

  <Tab title="Node.js">
    ```javascript theme={null}
    import { createRemoteJWKSet, jwtVerify } from 'jose'; // npm install jose

    const JWKS = createRemoteJWKSet(new URL('https://zapier.com/.well-known/jwks.json'));

    export async function verifyZapierCallback(headers) {
      const token = headers['zapier-callback-signature'];
      if (!token) throw new Error('Missing Zapier-Callback-Signature header');

      // jwtVerify validates exp automatically — expired tokens throw JWTExpired
      const { payload } = await jwtVerify(token, JWKS, { algorithms: ['RS256'] });
      return payload;
    }
    ```
  </Tab>
</Tabs>

### Replay Protection

The JWT includes `iat` and `exp` claims. Both PyJWT and jose validate `exp` automatically and will throw on an expired token — no manual timestamp check required. Callbacks are valid for **5 minutes** from issuance.

***

## Reliability

### Retry Behavior

Zapier retries on **5xx responses and network errors** using exponential backoff, up to **3 total attempts**. Retries happen quickly (within seconds).

**4xx responses are not retried.** If your endpoint returns a 4xx, the callback is immediately marked failed — fix the endpoint issue and fall back to polling to retrieve the result.

After all retries are exhausted, the callback is marked failed. You can still retrieve the result by polling `GET /v2/action-runs/{id}`.

### Idempotency

Network issues may occasionally cause your endpoint to receive the same callback more than once. **Deduplicate on the run `id` in the payload body.**

***

## Failure Modes

| Scenario                       | What happens                                             | What to do                                                                  |
| ------------------------------ | -------------------------------------------------------- | --------------------------------------------------------------------------- |
| Endpoint returns 5xx           | Retried with exponential backoff (up to 3 attempts)      | Fix the endpoint; check your server logs                                    |
| Endpoint returns 4xx           | Not retried; callback permanently failed                 | Fix the endpoint error, then poll `GET /v2/action-runs/{id}` for the result |
| Endpoint unreachable           | Retries exhaust; callback marked failed                  | Poll `GET /v2/action-runs/{id}` as fallback                                 |
| Duplicate callback received    | Can occur if your endpoint times out after returning 2xx | Deduplicate on the run `id` in the payload body                             |
| Long-running action (>45s)     | Callback fires when the action eventually completes      | No action needed — just wait                                                |
| Invalid `callback_url` on POST | `400 Bad Request` with a validation error                | Use a valid HTTPS URL on a public IP                                        |

***

## Fallback: Polling

Callbacks are best-effort. If your endpoint is unavailable and retries are exhausted, fall back to polling:

```http theme={null}
GET https://api.zapier.com/v2/action-runs/arun_abc123/
Authorization: Bearer YOUR_ACCESS_TOKEN
```

See [Retrieving Action Run Results](/powered-by-zapier/running-actions/retrieve-action-run) for full details.
