# Task: integrate Amboss Payments (GraphQL API)

You are integrating Amboss Payments into this codebase. Amboss Payments settles
USDT, USDC, and BTC over the Lightning Network with sub-second finality and no
chargebacks. Everything runs through one GraphQL endpoint.

Endpoint: https://app.amboss.tech/graphql
Docs: https://docs.amboss.tech/payments

Work in the sandbox environment until the whole flow passes end to end. Do not
touch live keys or live wallets unless I explicitly ask you to.

## Rules you must not break

1. **Amounts are decimal strings in the asset's minor units.** `"100000"` sats
   for BTC (precision 8), `"1000000"` for 1 USDT (precision 6). Never use a
   JavaScript number, never use a float. The API rejects them.
2. **Never hard-code an `asset_id` or a symbol list.** Discover assets at
   runtime with the `taproot_assets { list { id symbol precision } }` query.
   BTC is the entry with `type: BASE_ASSET`.
3. **In sandbox, `metadata.amb_sandbox_behavior` decides the outcome, and it
   defaults to `expire`.** Omit it and your test invoice times out instead of
   settling. Use `"complete"`, `"fail"`, or `"expire"` deliberately.
4. **Verify every webhook before acting on it**, over the *raw* request body.
   See the webhook step below.
5. **Dedupe on the webhook envelope `id`** (e.g.
   `payment.completed:tx_01HX9…`). Delivery is at-least-once, up to 8
   attempts, and the id is stable across retries.
6. **Send `idempotency_key` on every `create_send`.** A retry without one can
   pay twice.
7. **Secrets never touch source, logs, or fixtures.** `plaintext_key` and the
   webhook `secret` are each returned exactly once at creation. Read them from
   environment variables and store them in whatever secret manager this repo
   already uses.

## Environment variables to add

Add these to the repo's env schema / `.env.example` using its existing
conventions. Never commit real values.

- `AMBOSS_API_KEY` — service API key (`amb_test_…` in sandbox, `amb_live_…` in live)
- `AMBOSS_ENVIRONMENT_ID` — the payments environment UUID
- `AMBOSS_WALLET_ID` — the wallet the integration receives into
- `AMBOSS_WEBHOOK_SECRET` — the endpoint secret used to verify deliveries
- `AMBOSS_GRAPHQL_URL` — defaults to `https://app.amboss.tech/graphql`

## Authentication

Two modes hit the same endpoint:

| Mode | Header | Use for |
|---|---|---|
| Service API key | `x-api-key: amb_test_…` | All backend traffic: wallets, transactions, webhooks |
| Dashboard JWT | `Authorization: Bearer <token>` | Master-account only: creating environments, minting or revoking API keys |

Creating environments and API keys is a **master-account, dashboard-auth
operation**. Do not build that into the application backend — assume I have
already created the environment and minted the key, and read them from the env
vars above. Key permissions are scoped per resource
(`ENVIRONMENTS`, `WALLETS`, `PAYMENTS`, `WEBHOOKS`) at `READ` or
`WRITE`; `WRITE` implies `READ`.

## Build this

### 1. A thin GraphQL client

One small module that POSTs `{ query, variables }` to the endpoint with the
`x-api-key` header, and throws a typed error when the response carries a
`errors` array. Map `extensions.code`: `UNAUTHENTICATED` means the key is
missing or wrong, `FORBIDDEN` means the key lacks the permission. Reuse this
repo's existing HTTP client and error types if it has them — do not add a
GraphQL library for four operations.

### 2. Wallet lookup

```graphql
query Wallet($id: String!) {
  payment {
    wallet {
      find_one(id: $id) {
        id
        name
        is_ready
        asset { id symbol precision }
        balance { balance received sent }
      }
    }
  }
}
```

Use `precision` to convert between your display amounts and minor units. Do
not assume 8 or 6. `is_ready` is always `true` in sandbox; on a live wallet it
flips to `true` only once liquidity has been provisioned (around 30 minutes),
so guard invoice creation on it rather than letting the payment fail to route.

### 3. Receiving a payment

```graphql
mutation CreateReceive($input: CreateReceiveTransactionInput!) {
  payment {
    transaction {
      create_receive(input: $input) {
        id
        status
        payment_request
        payment_hash
        expires_at
        amount { display_amount full_amount }
      }
    }
  }
}
```

```json
{
  "input": {
    "wallet_id": "<AMBOSS_WALLET_ID>",
    "amount": "100000",
    "description": "Order #42",
    "expires_in_seconds": 3600,
    "idempotency_key": "order-42-attempt-1",
    "metadata": "{\"order_id\":\"42\",\"amb_sandbox_behavior\":\"complete\"}"
  }
}
```

`metadata` is a **JSON-encoded string**, not an object. It round-trips onto
the webhook payload, so put your order id in it — that is how you correlate a
settlement back to your own records.

Return `payment_request` (a BOLT11 invoice) to the client and render it as a
QR code or a `lightning:` link. Store `id`, `payment_hash`, and
`expires_at` against your order.

### 4. Sending a payment

```graphql
mutation CreateSend($input: CreateSendTransactionInput!) {
  payment {
    transaction {
      create_send(input: $input) { id status payment_hash }
    }
  }
}
```

Destination is either `request: { bolt11 }` or
`address: { lightning_address, amount }`. Always pass `idempotency_key`.

Important retry semantics: a `FAILED` send is **permanent under its
idempotency key**. Replaying the same `(wallet_id, idempotency_key)` returns
that same `FAILED` record forever — it never re-attempts. But do not
reflexively mint a fresh key after a client-side error either; the original
send may actually be `PENDING` or `COMPLETED`, and a new key would pay twice.
Read the transaction status first, then decide.

### 5. The webhook handler

Register the endpoint once (this is setup, not application code):

```graphql
mutation CreateWebhookEndpoint($input: CreateWebhookEndpointInput!) {
  payment {
    webhook_endpoint {
      create(input: $input) {
        secret
        endpoint { id url event_filters active }
      }
    }
  }
}
```

Event types: `payment.pending`, `payment.completed`, `payment.failed`,
`payment.expired`. An empty or omitted `event_filters` means all of them.

Then build the handler:

- Capture the **raw body bytes before any JSON parser runs**. In Express that
  is `express.raw({ type: "application/json" })`; in NestJS it is
  `NestFactory.create(AppModule, { rawBody: true })`; with the Fetch API it is
  `await request.text()`. A re-serialized body will not match the HMAC.
- Compute the expected signature as
  `HMAC_SHA256(secret, timestamp + "." + rawBody)`, hex-encode it, and compare
  it against the `x-webhook-signature` header with a constant-time comparison
  (`crypto.timingSafeEqual`). Never `===`.
- Reject when `x-webhook-timestamp` is more than 300 seconds from now.
- Dedupe on the envelope `id` before applying any side effect.
- Respond `200` fast. Do the real work on a queue. Non-2xx and timeouts are
  retried at 10s, then 60s, then 10 minutes.

Payload shape:

```json
{
  "id": "payment.completed:tx_01HX9YQK7P8MVZ3FN4G2RWS6CD",
  "event_type": "payment.completed",
  "environment": "sandbox",
  "wallet_id": "5e4b1e2a-…",
  "data": {
    "id": "tx_01HX9YQK7P8MVZ3FN4G2RWS6CD",
    "direction": "receive",
    "status": "completed",
    "amount": { "amount": "100000", "asset_symbol": "USDT", "precision": 6 },
    "fee": { "amount": "5", "asset_symbol": "USDT", "precision": 6 },
    "settle_amount": { "amount": "99995", "asset_symbol": "USDT", "precision": 6 },
    "settled_at": "2026-06-02T12:31:42.000Z",
    "metadata": { "order_id": "42" }
  }
}
```

Credit the order against `settle_amount`, not `amount` — the fee comes out
in between. Branch on `data.direction` so a send and a receive are not
confused.

## Before you tell me you are done

- [ ] No secret, key, or macaroon appears in source, logs, tests, or fixtures.
- [ ] Every amount in the diff is a string in minor units, derived from the
      asset's `precision`.
- [ ] The webhook handler verifies over raw bytes, compares in constant time,
      checks the timestamp window, and dedupes on the envelope `id`.
- [ ] Every `create_send` passes an `idempotency_key`.
- [ ] A sandbox receive with `amb_sandbox_behavior: "complete"` settles and
      the handler credits the order exactly once, including on a replayed
      delivery.
- [ ] The repo's existing lint, typecheck, and test commands pass. Show me the
      output.

Follow this repo's existing conventions for structure, naming, error handling,
and tests. Do not add dependencies that the codebase does not already have.

## Reference

- Payments overview — https://docs.amboss.tech/payments
- Full API walkthrough — https://docs.amboss.tech/payments/integrate
- Receive payments — https://docs.amboss.tech/payments/receive-payments
- Send payments — https://docs.amboss.tech/payments/send-payments
- Webhooks — https://docs.amboss.tech/payments/webhooks
- Verify webhooks — https://docs.amboss.tech/payments/verify-webhooks
- Environments — https://docs.amboss.tech/payments/environments
- API keys — https://docs.amboss.tech/payments/api-keys
- Machine-readable index of all docs — https://docs.amboss.tech/llms.txt
