Webhooks

Subscribe to payment lifecycle events with Amboss webhooks. Per-environment endpoints, signed deliveries, and at-least-once retries.

Webhooks notify your backend of payment events. Register one or more endpoints per environment and we deliver an HTTP POST whenever a relevant transition occurs.

Event types

EventWhen it fires
payment.pendingAn invoice is created and awaiting payment
payment.completedThe transaction settled successfully (terminal)
payment.failedThe transaction failed — routing failure, recipient rejection, etc. (terminal)
payment.expiredThe invoice's expires_at passed without payment (terminal)

All events cover both receive and send transactions — distinguish with data.direction ("receive" or "send").

Register an endpoint

The URL must be HTTPS and resolve to a public IP — the API rejects loopback, private ranges, and .local / .internal hostnames.

mutation CreateWebhookEndpoint {
  payment {
    webhook_endpoint {
      create(input: {
        environment_id: "81b73615-ddf3-46e3-943a-467c3e442e04"
        url: "https://api.example.com/webhooks/amboss"
        event_filters: ["payment.completed", "payment.failed", "payment.expired"]
      }) {
        secret
        endpoint {
          id
          url
          event_filters
          active
        }
      }
    }
  }
}
curl -X POST https://rails.amboss.tech/graphql \
  -H "Content-Type: application/json" \
  -H "x-api-key: $AMBOSS_API_KEY" \
  -d '{
    "query": "mutation($input: CreateWebhookEndpointInput!) { payment { webhook_endpoint { create(input: $input) { secret endpoint { id url event_filters active } } } } }",
    "variables": {
      "input": {
        "environment_id": "81b73615-ddf3-46e3-943a-467c3e442e04",
        "url": "https://api.example.com/webhooks/amboss",
        "event_filters": ["payment.completed", "payment.failed", "payment.expired"]
      }
    }
  }'

Example response:

{
  "data": {
    "payment": {
      "webhook_endpoint": {
        "create": {
          "secret": "whsec_PvDaoBz6TXhuMKxk0VkckiPUTX9BTSDyCT2Pqc_vgOw",
          "endpoint": {
            "id": "wep_01HX9YQK7P8MVZ3FN4G2RWS6CD",
            "url": "https://api.example.com/webhooks/amboss",
            "event_filters": ["payment.completed", "payment.failed", "payment.expired"],
            "active": true
          }
        }
      }
    }
  }
}

secret is returned once. It's the HMAC key for verifying delivery signatures — store it in your secret manager. If lost, rotate it with rotate_secret.

event_filters is optional. Omit it (or pass []) to receive every event type. Otherwise the endpoint only receives the listed events. Discover the live list at runtime:

query AvailableEvents {
  payment {
    webhook_endpoint {
      available_event_types
    }
  }
}

Payload shape

Every delivery is JSON with this envelope:

type WebhookEnvelope = {
  id: string;             // unique per event — dedupe on this
  event_type:
    | "payment.pending"
    | "payment.completed"
    | "payment.failed"
    | "payment.expired";
  environment: "sandbox" | "live";
  environment_id: string;
  wallet_id: string;
  node_id: string | null; // null in sandbox
  data: {
    id: string;
    direction: "receive" | "send";
    status: string;
    payment_details: {
      payment_type: "bolt11";
      payment_request: string | null;
      payment_hash: string | null;
    };
    amount:        { amount: string; asset_id: string; asset_symbol: string; precision: number } | null;
    fee:           { amount: string; asset_id: string; asset_symbol: string; precision: number } | null;
    settle_amount: { amount: string; asset_id: string; asset_symbol: string; precision: number } | null;
    exchange_rate: string | null;
    description:   string | null;
    settled_at:    string | null; // ISO 8601
    expires_at:    string | null; // ISO 8601
    metadata:      object | null; // round-tripped from create_receive / create_send
  };
};

Example payment.completed for a USDT receive:

{
  "id": "payment.completed:tx_01HX9YQK7P8MVZ3FN4G2RWS6CD",
  "event_type": "payment.completed",
  "environment": "sandbox",
  "environment_id": "81b73615-ddf3-46e3-943a-467c3e442e04",
  "wallet_id": "5e4b1e2a-9f3c-4a5b-8c7d-1234567890ab",
  "node_id": null,
  "data": {
    "id": "tx_01HX9YQK7P8MVZ3FN4G2RWS6CD",
    "direction": "receive",
    "status": "completed",
    "payment_details": {
      "payment_type": "bolt11",
      "payment_request": "lnbc1m1p0...",
      "payment_hash": "3b6e7d..."
    },
    "amount":        { "amount": "100000", "asset_id": "...", "asset_symbol": "USDT", "precision": 6 },
    "fee":           { "amount": "5",      "asset_id": "...", "asset_symbol": "USDT", "precision": 6 },
    "settle_amount": { "amount": "99995",  "asset_id": "...", "asset_symbol": "USDT", "precision": 6 },
    "exchange_rate": null,
    "description":   "Order #42",
    "settled_at":    "2026-06-02T12:31:42.000Z",
    "expires_at":    null,
    "metadata":      { "order_id": "42" }
  }
}

Signature verification

Each delivery is signed using the endpoint's secret (whsec_…). Three headers carry the signature, timestamp, and event type:

x-webhook-signature: <hex HMAC-SHA256 of `${timestamp}.${body}`>
x-webhook-timestamp: <unix seconds>
x-webhook-event:     <event type, e.g. payment.completed>

Always verify on your side before applying side effects, and always hash the raw request body rather than a parsed-and-re-stringified version. The full recipe with Node.js, Python, and Go examples — plus replay protection and rotation handling — is on Verify Webhooks.

Idempotency and retries

Webhooks are delivered at least once, with up to 8 attempts on retryable failures (status 408/429/5xx, timeouts, network errors). Backoff is 10s, then 60s, then 10 minutes per attempt — see Verify Webhooks → Retry policy for the full schedule. The envelope-level id (e.g. payment.completed:tx_01HX9YQK7P8MVZ3FN4G2RWS6CD) is stable across retries — store it and dedupe on it before applying side effects.

A minimal Node.js dedupe:

const seen = new Set();

app.post("/webhooks/amboss", express.json(), (req, res) => {
  const { id, event_type, data } = req.body;
  if (seen.has(id)) return res.sendStatus(200); // already processed
  seen.add(id);

  if (event_type === "payment.completed" && data.direction === "receive") {
    fulfillOrder(data.metadata?.order_id, data.settle_amount);
  }

  res.sendStatus(200);
});

In production, dedupe against a durable store (Redis, your DB) — not an in-memory Set.

Rotate the secret

mutation RotateSecret($id: String!) {
  payment {
    webhook_endpoint {
      rotate_secret(id: $id) {
        secret
        endpoint { id }
      }
    }
  }
}

The new secret is returned once. Update your verifier to accept either old or new for a short overlap window, then switch over.

Update event filters or URL

mutation UpdateEndpoint($input: UpdateWebhookEndpointInput!) {
  payment {
    webhook_endpoint {
      update(input: $input) {
        id
        url
        event_filters
        active
      }
    }
  }
}

Set active: false to pause delivery without deleting the endpoint.

Testing in sandbox

The dashboard has a "Generate test URL" button for sandbox webhooks that creates a one-off endpoint on webhook.site — useful for inspecting payload shape without standing up a real handler. Pair it with amb_sandbox_behavior metadata on create_receive / create_send to fire specific events on demand.

Next steps