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.

Using TypeScript? The SDK verifies each delivery and hands you a typed event, so you can skip the manual HMAC checks.

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://app.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. payment.pending is a valid, subscribable value like any other event type — the examples above omit it because they focus on terminal states, not because it's unfilterable. 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.

Identifying our requests

Every delivery carries this User-Agent:

User-Agent: Amboss-Webhooks/1.0 (+https://docs.amboss.tech/payments/webhooks)

This header helps identify Amboss webhook deliveries in logs. It does not authenticate requests; always verify the webhook signature before processing.

If your firewall blocks deliveries, inspect its security logs and configure an exception scoped to your webhook endpoint, the POST method, and the specific blocking rule.

HTTP 403 responses are not retried. HTTP 408, 429, 5xx, timeouts, and network errors are retried up to eight total attempts.

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