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

# Subscribe to Webhooks

> Register a webhook URL, receive push.completed and push.failed events, verify the signature, and dedupe by the svix-id header.

## Overview

Skip the poll loop. Subscribe to webhooks and Amps delivers action lifecycle events to your endpoint as they happen. Two events fire: `push.completed` and `push.failed`. Both arrive only when an action reaches a terminal state, typically within 10 seconds in live (3 minutes in sandbox).

Every delivery is signed with an HMAC-SHA256 signature, retried with exponential backoff on non-2xx, and stamped with an event ID delivered in the `svix-id` header for idempotency.

## Step 1: Register the endpoint

Add the webhook URL in the [Amps AI Dashboard](https://app.amps.ai) under Webhooks settings. Pick the events to subscribe to (`push.completed`, `push.failed`) and copy the webhook secret. Store it in an environment variable; never commit it.

## Step 2: Receive a push.completed payload

When a battery action completes, you receive a POST with this body. The event type and event ID are delivered in the `svix-id`, `svix-timestamp`, and `svix-signature` headers, not the body:

```json theme={null}
{
  "actionId": "act_inflight_002",
  "deviceId": "device_abc123",
  "deviceType": "battery",
  "command": "charge",
  "parameters": {
    "target": { "value": 90, "unit": "percent" },
    "power": { "value": 3, "unit": "kw" }
  },
  "result": {
    "success": true,
    "message": "Mode applied"
  },
  "completedAt": "2026-05-08T22:00:05.000Z"
}
```

The payload mirrors the dispatch: the canonical `command`, the constraints-only `parameters`, and the clean `deviceType`. A `push.failed` payload mirrors the same shape and adds `errorCode` and `errorMessage`; `result` carries the canonical error envelope, exactly as `GET /actions/{actionId}` returns it for a failed action:

```json theme={null}
{
  "actionId": "act_inflight_002",
  "deviceId": "device_abc123",
  "deviceType": "battery",
  "command": "charge",
  "parameters": {
    "target": { "value": 95, "unit": "percent" }
  },
  "result": {
    "success": false,
    "error": {
      "code": "SCHEDULER_ACTIVE",
      "message": "A schedule is currently active on the device and must be cleared first."
    }
  },
  "errorCode": "SCHEDULER_ACTIVE",
  "errorMessage": "A schedule is currently active on the device and must be cleared first.",
  "failedAt": "2026-05-08T22:00:32.000Z"
}
```

## Step 3: Verify the signature

Every delivery includes three headers (`svix-id`, `svix-timestamp`, `svix-signature`). Verify the signature before trusting the body; the SDK reads all three. The signature is HMAC-SHA256 of `{id}.{timestamp}.{rawBody}` keyed by your webhook secret.

<Tabs>
  <Tab title="Node">
    ```javascript theme={null}
    import express from "express";
    import { Webhook } from "svix";

    const app = express();
    const wh = new Webhook(process.env.WEBHOOK_SECRET);
    const seen = new Set();

    app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
      let payload;
      try {
        payload = wh.verify(req.body, req.headers);
      } catch (err) {
        return res.status(400).send("Invalid signature");
      }

      const eventId = req.headers["svix-id"];
      if (seen.has(eventId)) {
        return res.status(200).send("duplicate");
      }
      seen.add(eventId);

      // Route by inspecting the flat payload: a failed event carries a top-level
      // `errorCode` and a `result.success` of false. Both events carry `result`,
      // so branch on failure first.
      if (payload.errorCode || payload.result?.success === false) {
        handleFailed(payload);
      } else {
        handleCompleted(payload);
      }

      res.status(200).send("OK");
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import os
    from flask import Flask, request, jsonify
    from svix.webhooks import Webhook, WebhookVerificationError

    app = Flask(__name__)
    wh = Webhook(os.environ["WEBHOOK_SECRET"])
    seen = set()

    @app.route("/webhooks", methods=["POST"])
    def webhooks():
        raw = request.get_data()
        try:
            payload = wh.verify(raw, request.headers)
        except WebhookVerificationError:
            return jsonify({"error": "Invalid signature"}), 400

        event_id = request.headers.get("svix-id")
        if event_id in seen:
            return jsonify({"status": "duplicate"}), 200
        seen.add(event_id)

        # A failed event carries a top-level errorCode and a result.success of
        # false. Both events carry result, so branch on failure first.
        if payload.get("errorCode") or payload.get("result", {}).get("success") is False:
            handle_failed(payload)
        else:
            handle_completed(payload)

        return jsonify({"status": "ok"}), 200
    ```
  </Tab>

  <Tab title="curl (replay test)">
    ```bash theme={null}
    curl -X POST https://your-app.example.com/webhooks \
      -H "Content-Type: application/json" \
      -H "svix-id: msg_2x3y4z" \
      -H "svix-timestamp: 1746748805" \
      -H "svix-signature: v1,abc123def456..." \
      -d '{
        "actionId": "act_inflight_002",
        "deviceId": "device_abc123",
        "deviceType": "battery",
        "command": "charge",
        "parameters": { "target": { "value": 95, "unit": "percent" } },
        "result": {
          "success": false,
          "error": {
            "code": "SCHEDULER_ACTIVE",
            "message": "A schedule is currently active on the device and must be cleared first."
          }
        },
        "errorCode": "SCHEDULER_ACTIVE",
        "errorMessage": "A schedule is currently active on the device and must be cleared first.",
        "failedAt": "2026-05-08T22:00:32.000Z"
      }'
    ```
  </Tab>
</Tabs>

For deeper signature internals (timestamps, replay protection, manual verification), see [Webhook Security](/guides/webhooks/verify-signatures).

## Step 4: Make the endpoint idempotent

Webhooks are at-least-once. Retries fire on any non-2xx response, a timeout (>30s), or a transient network failure. Use the `svix-id` header as the idempotency key and persist seen IDs alongside the work the webhook triggers.

```javascript theme={null}
async function handleCompleted(payload, eventId) {
  const seen = await db.events.findOne({ eventId });
  if (seen) return;

  await db.events.insertOne({
    eventId,
    actionId: payload.actionId,
    deviceId: payload.deviceId,
    completedAt: payload.completedAt,
  });

  await notifyUserActionCompleted(payload);
}
```

In production, store seen IDs in an external store. In-process memory will not survive across instances.

## Retry behaviour

| Condition                              | Effect                                                 |
| -------------------------------------- | ------------------------------------------------------ |
| Endpoint returns 2xx within 30 seconds | Delivery succeeds, no retry.                           |
| Endpoint returns non-2xx or times out  | Retried with exponential backoff.                      |
| Repeated failures                      | Backed off and surfaced in the dashboard delivery log. |

Always respond `200 OK` as soon as the signature verifies and the event is recorded. Push business logic (email, dashboard updates) to async workers so the response stays inside the 30-second window.

## What next

<CardGroup cols={2}>
  <Card title="Webhook security details" icon="shield" href="/guides/webhooks/verify-signatures">
    Manual signature verification, timestamp checks, secret rotation.
  </Card>

  <Card title="Webhook event types" icon="list" href="/reference/webhook-types">
    Full payload reference for every webhook event.
  </Card>

  <Card title="Schedule a charge for later" icon="moon" href="/guides/cookbook/schedule-charge-later">
    A scheduled action that fires push.completed when the window closes.
  </Card>

  <Card title="Handle conflicts" icon="triangle-alert" href="/guides/cookbook/handle-conflict">
    push.failed events surface SCHEDULER\_ACTIVE and other conflict codes.
  </Card>
</CardGroup>

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "HowTo",
"name": "Subscribe to Webhooks",
"description": "Register a webhook URL, receive push.completed and push.failed events, verify the Svix signature, and dedupe by the svix-id header.",
"step": [
{
  "@type": "HowToStep",
  "name": "Step 1: Register the endpoint",
  "position": 1
},
{
  "@type": "HowToStep",
  "name": "Step 2: Receive a push.completed payload",
  "position": 2
},
{
  "@type": "HowToStep",
  "name": "Step 3: Verify the signature",
  "position": 3
},
{
  "@type": "HowToStep",
  "name": "Step 4: Make the endpoint idempotent",
  "position": 4
}
]
})}}
/>
