# DePix App — API Reference

> Generated from docs/en/index.html. The canonical human docs live at https://depixapp.com/docs/en/ (English) and https://depixapp.com/docs/ (Portuguese).

<a id="intro"></a>
## Introduction
The DePix App API lets you create and manage Pix checkouts programmatically. It is the same API that powers the BTCPay Server plugin and the Merchant Dashboard.

### Base URL

```
https://api.depixapp.com
```

### Format

All requests and responses use JSON (`Content-Type: application/json`). Monetary values are always in **centavos** (integers). Example: R$ 10.00 = `1000`.

> ℹ To get started, [create an account](https://depixapp.com/#register), activate your merchant account in the Merchant Dashboard, and generate an API key.

<a id="auth"></a>
## Authentication
Use an API key in the `Authorization` header on all authenticated requests.

**Header**

```
Authorization: Bearer sk_live_<your-key>
```

### Key types

| Prefix | Type | Behavior |
| --- | --- | --- |
| sk_live_ | Live | Real checkouts. Real money. |
| sk_test_ | Test | Test checkouts. No real money is moved. |

To manage your keys (create, list, revoke), go to the **Merchant Dashboard** at [depixapp.com/#merchant](https://depixapp.com/#merchant). Maximum of 5 live keys and 5 test keys active per account.

Every key is born with explicit **scopes** (`merchant_read`, `merchant_write`, `wallet_read`, `wallet_write`) and, for keys with the `wallet_write` scope, with mandatory spending limits. Keys are immutable after creation — see [Key scopes and limits](#scopes).

### Production API access

`sk_test_` keys are issued automatically once you create your merchant account — start integrating against the sandbox without waiting. `sk_live_` keys require manual approval: in the API Keys area click **Solicitar acesso** (Request access), answer 5 short questions about your integration, and our team reviews. Approvals typically land within a few hours on business days.

> ⚠ **Keep your key safe.** It is only displayed once at creation time. If you lose it, revoke it and generate a new one.

<a id="scopes"></a>
## Key scopes and limits
Every API key carries an explicit set of **scopes** that defines what it can do. Scopes are chosen at key creation and **cannot be changed afterwards** — keys are immutable; to change scopes or limits, revoke and create a new one.

Scopes split along two axes: `merchant_*` is the merchant side (the gateway — checkouts and products) and `wallet_*` is the wallet side (the Pix on/off-ramp — deposits and withdrawals).

| Scope | Grants |
| --- | --- |
| merchant_read | All GETs of the merchant surface: list/get checkouts, products, and `GET /api/me`. |
| merchant_write | The "receiving" side: create/simulate checkouts, the product CRUD, and editing the store's light profile fields (`PATCH /api/merchants/me`). |
| wallet_read | Reading the wallet-side status: `GET /api/deposits/:id` and `GET /api/withdrawals/:id`. **Never granted by default.** |
| wallet_write | The "paying" side (moving money): `POST /api/deposit`, `POST /api/withdraw`. **Never granted by default.** |

- **No implicit hierarchy** — `merchant_write` does not include `merchant_read`, and the `wallet_*` scopes include none of them. A key can combine all four: `["merchant_read", "merchant_write", "wallet_read", "wallet_write"]`.

- A call without the required scope → `403` with `error.code = "insufficient_scope"` and `details.required_scope` (see [Errors](#errors)). The response never echoes the key's own scope list.

### wallet_write keys are born with mandatory limits

Every key with the `wallet_write` scope has its own spending limits, enforced **on top of** the account limits (account limits always prevail). If you omit the values at creation, the defaults apply: **R$ 100.00 per transaction** (`per_tx_limit_cents = 10000`) and **R$ 500.00 per day** (`daily_limit_cents = 50000`, a 24-hour rolling window summing the deposits and withdrawals attributed to the key). You may consciously raise the values at creation — but a `wallet_write` key without limits does not exist.

An operation exceeding a key limit → `400` with `error.code = "key_limit_exceeded"` and `details: { limit: "per_tx" | "daily", limit_cents, used_cents }`.

### Creating a key with scopes and limits

Credential management is owner-only: `POST /api/api-keys` accepts only the dashboard JWT (never another API key). Besides the existing fields (`type`, `label`, `expires_in_days`), creation accepts:

| Field | Type |  | Description |
| --- | --- | --- | --- |
| scopes | array | optional | Subset of `["merchant_read", "merchant_write", "wallet_read", "wallet_write"]`, no duplicates. Default: `["merchant_read", "merchant_write"]` (the behavior keys always had). |
| per_tx_limit_cents | integer | optional | Per-transaction limit in centavos (minimum 100). With the `wallet_write` scope, defaults to `10000` (R$ 100.00) when omitted. |
| daily_limit_cents | integer | optional | Daily limit in centavos, 24-hour rolling window (minimum 100). With the `wallet_write` scope, defaults to `50000` (R$ 500.00) when omitted. |
| rate_limit_per_min | integer | optional | Additional per-key rate limit (1–600 req/min). Omitted = no key-level limit; only the merchant's aggregate budget applies. |

**Response — 201 Created**

```
{
  "id":                  "a1b2c3d4e5f6...",
  "key":                 "sk_test_...",       // displayed only once
  "prefix":              "sk_test_",
  "label":               "agent-payments",
  "is_live":             false,
  "expires_at":          null,
  "scopes":              ["merchant_read", "merchant_write", "wallet_read", "wallet_write"],
  "per_tx_limit_cents":  10000,
  "daily_limit_cents":   50000,
  "rate_limit_per_min":  30
}
```

> ⚠ **Immutable after creation.** There is no endpoint to edit scopes or limits. To change anything on a live key, revoke it and create a new one — mutable permissions on a live credential are attack surface.

<a id="idempotency"></a>
## Idempotency
The money-moving POSTs accept the optional `Idempotency-Key` header (1–255 visible ASCII characters). Strongly recommended for agents and for any integration with automatic retries: a retry with the same key returns the original response instead of creating a second QR or a second charge.

### Covered endpoints

- `POST /api/deposit`

- `POST /api/withdraw`

- `POST /api/checkouts`

### Exact semantics

| Scenario | Result |
| --- | --- |
| Same key + same body | Replay of the original response (same status, same body) + `Idempotency-Replayed: true` header. No new side effects. |
| Same key + different body | `422 idempotency_key_reuse` — the handler never runs. |
| Same key on a different endpoint | Independent — the uniqueness scope includes the endpoint. |
| Concurrent request with the same key | `409 idempotency_in_flight` with `retry_after: 5` — retry in a few seconds and receive the replay. |

- **Uniqueness scope**: `(identity, endpoint, key)` — keys of different merchants never collide.

- **Body comparison**: hash of the parsed JSON. Whitespace/formatting never matter; **field order does** — a semantically equal body with reordered fields → `422`. A byte-identical retry always matches.

- `5xx` and `429` responses are never stored — those retries re-execute. Deterministic `4xx` (validation, limits) are stored and replayed.

- **24-hour TTL** — after that, the same key counts as new.

- Sandbox operations (`sk_test_`) participate normally — agents can train the full flow.

> ⚠ **Effectively-once, never exactly-once.** The guarantee is at most ONE concurrent execution. If the owning invocation dies between the provider call and persisting the response, a takeover after 60s re-executes the operation. Design your ledger treating a retry as potentially duplicating.

### Example

**curl**

```
curl -X POST https://api.depixapp.com/api/deposit \
  -H "Authorization: Bearer sk_live_<your-key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: agent-run-42-deposit-1" \
  -d '{ "amountInCents": 1500, "depixAddress": "lq1qq...", "payer_tax_number": "52998224725" }'
```

First call: `200` with the QR. Identical retry: `200` with the same body + `Idempotency-Replayed: true`. Retry with a changed `amountInCents`:

**Response — 422 Unprocessable Entity**

```
{
  "response": { "errorMessage": "Idempotency-Key já utilizada com um corpo diferente." },
  "error": {
    "code": "idempotency_key_reuse",
    "message": "This Idempotency-Key was already used with a different request body.",
    "request_id": "gru1::abcd-1234",
    "docs_url": "https://depixapp.com/docs/en/#errors"
  }
}
```

<a id="errors"></a>
## Errors
Errors carry a **dual envelope**: `response.errorMessage` (legacy Portuguese message, always present and always human-readable) and the `error` object (structured machine contract, English messages). Branch on `error.code` — never on message text.

**Error response**

```
{
  "response": { "errorMessage": "Muitas requisições. Tente novamente em 1 minuto." },
  "error": {
    "code": "rate_limited",
    "message": "Too many requests for this scope.",
    "request_id": "gru1::iad1::v9x4k-1751476800000-abc123",
    "retry_after": 37,
    "docs_url": "https://depixapp.com/docs/en/#errors",
    "details": { "scope": "deposit" }
  }
}
```

- `request_id` — correlation id, returned on **every** response (success included) in the `X-Request-Id` header. Quote it in support requests.

- `retry_after` — seconds to wait before retrying; present on every `429`, `503` and on `409 idempotency_in_flight`. Mirrored in the `Retry-After` HTTP header.

- `details` — optional extras, normative per code: offending field, required scope, limits in centavos (see the table below).

- Preserved legacy siblings: create-checkout validation errors keep `response.errors[]` (per-field list) and the blocked-account 403 keeps `blocked: true`.

- Legacy handlers outside the agent surface may still respond with only `response.errorMessage`, without the `error` object.

### Code catalog

Complete, closed catalog. Every row has a stable anchor in the form `#error-` (e.g. [#error-rate_limited](#error-rate_limited)).

<a id="error-unauthorized"></a>

<a id="error-invalid_api_key"></a>

<a id="error-invalid_token"></a>

<a id="error-insufficient_scope"></a>

<a id="error-account_blocked"></a>

<a id="error-merchant_required"></a>

<a id="error-live_access_required"></a>

<a id="error-whatsapp_verification_required"></a>

<a id="error-withdraw_disabled"></a>

<a id="error-external_wallet_disabled"></a>

<a id="error-sandbox_only"></a>

<a id="error-validation_error"></a>

<a id="error-tax_number_required"></a>

<a id="error-amount_out_of_range"></a>

<a id="error-account_limit_exceeded"></a>

<a id="error-key_limit_exceeded"></a>

<a id="error-not_found"></a>

<a id="error-conflict"></a>

<a id="error-idempotency_in_flight"></a>

<a id="error-idempotency_key_reuse"></a>

<a id="error-rate_limited"></a>

<a id="error-merchant_rate_limited"></a>

<a id="error-payer_velocity_limit"></a>

<a id="error-platform_shutdown"></a>

<a id="error-service_unavailable"></a>

<a id="error-upstream_error"></a>

<a id="error-internal_error"></a>

| Code | HTTP | When |
| --- | --- | --- |
| unauthorized | 401 | Login-only (JWT) route without a valid token. |
| invalid_api_key | 401 | Token with the `sk_` prefix not found, revoked, or expired. |
| invalid_token | 401 | Invalid/expired JWT or missing/malformed `Authorization` header. |
| insufficient_scope | 403 | The API key lacks the scope required by the operation — `details.required_scope`. |
| account_blocked | 403 | Blocked account (legacy sibling `blocked: true` preserved). |
| merchant_required | 403 | The authenticated account has no active merchant profile. |
| live_access_required | 403 | Creating a `sk_live_` key without production access approval. |
| whatsapp_verification_required | 403 | Owner's WhatsApp not verified while the operator requires verification. |
| withdraw_disabled | 403 | Withdrawals temporarily disabled (global kill switch). |
| external_wallet_disabled | 403 | Withdrawals to external wallets temporarily disabled. |
| sandbox_only | 403 | `simulate-payment` called on a live checkout. |
| validation_error | 400 | Invalid input — `details.field` when applicable; create checkout preserves `response.errors[]`. |
| tax_number_required | 400 | Missing mandatory CPF/CNPJ (`payer_tax_number` / `taxNumber`). |
| amount_out_of_range | 400 | Amount outside the endpoint's bounds — `details: { min_cents, max_cents }` with the bounds of THIS endpoint/mode. |
| account_limit_exceeded | 400 | **Unverified**-account caps (first deposit, per-transaction amount, cumulative) — the values depend on the verification level and can change; see [Limits](#pay-overview). `details: { limit, limit_cents, used_cents? }`. |
| key_limit_exceeded | 400 | API-key spending limit — `details: { limit: "per_tx" \| "daily", limit_cents, used_cents }`. |
| not_found | 404 | Route or resource does not exist — includes resources owned by another account (ownership is never disclosed). |
| conflict | 409 | State conflict: invalid checkout transition, duplicate txid, duplicate slug. |
| idempotency_in_flight | 409 | A request with the same `Idempotency-Key` is still executing — `retry_after: 5`. |
| idempotency_key_reuse | 422 | `Idempotency-Key` reused with a different body. |
| rate_limited | 429 | Per-IP, per-user, or per-key rate limit — `retry_after` up to 60. |
| merchant_rate_limited | 429 | Merchant aggregate budget exceeded — `retry_after` up to 60. |
| payer_velocity_limit | 429 | Too many transactions for the same payer CPF/CNPJ in a short period (max 2 per sliding 30-min window; deposits + checkouts combined) — `details: { window_minutes, max_per_window }`, `retry_after` up to the remainder of the window. |
| platform_shutdown | 503 | Platform under maintenance (global kill switch) — `retry_after: 300`. |
| service_unavailable | 503 | Infra dependency unavailable — includes the fail-closed of `wallet_*` routes via API key when the rate limit cannot be checked — `retry_after: 30`. |
| upstream_error | 502 | Malformed response or error from the Pix provider. |
| internal_error | 500 | Unexpected internal error. Try again in a few moments. |

<a id="checkout-create"></a>
## Create checkout
Creates a new Pix checkout. Returns the QR code and the payment URL to display to your customer.

**POST** `/api/checkouts`

### Parameters

| Field | Type |  | Description |
| --- | --- | --- | --- |
| amount | integer | required | Amount in centavos. Minimum: 500 (R$ 5.00). Maximum: 300000 (R$ 3,000.00). |
| payer_tax_number | string | required | Payer's CPF or CNPJ. Accepts a CPF (11 digits) or CNPJ (14 chars, including the new alphanumeric format), with or without mask. |
| description | string | optional | Order description. Maximum 500 characters. Displayed on the payment page. |
| expires_in | integer | optional | Expiration time in seconds. Default: 1200 (20min). Minimum: 300 (5min). Maximum: 1200 (20min). |
| image_url | string | optional | HTTPS URL of the product image. Displayed on the payment page. |
| callback_url | string | optional | HTTPS URL that receives the checkout webhooks. |
| redirect_url | string | optional | URL to redirect the customer after payment. |
| metadata | object | optional | Additional data from your system (order_id, user_id, etc.). Maximum 4KB. Returned in webhooks. |

### Example

```
curl -X POST https://api.depixapp.com/api/checkouts \
  -H "Authorization: Bearer sk_live_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2990,
    "payer_tax_number": "529.982.247-25",
    "description": "T-shirt size M",
    "expires_in": 900,
    "callback_url": "https://my-store.com/webhook/depix",
    "metadata": { "order_id": "ORD-123" }
  }'
```

**Response — 201 Created**

```
{
  "id":          "chk_01jxxxxxxxxxxxxxxxxxxxxxx",
  "status":      "pending",
  "amount":      2990,
  "description": "T-shirt size M",
  "image_url":   null,
  "expires_at":  "2025-06-01T15:30:00.000Z",
  "is_live":     true,
  "payment_url": "https://pay.depixapp.com/chk_01jxxxxxxxxxxxxxxxxxxxxxx",
  "pix": {
    "qr_code": "00020126580014br.gov.bcb.pix..."    // EMV payload for QR code
  }
}
```

> 💡 Display the `payment_url` to your customer or generate a QR code from `pix.qr_code`. The QR code is compatible with any banking app.

<a id="checkout-get"></a>
## Get checkout
Returns the details of a specific checkout.

**GET** `/api/checkouts/:id`

**curl**

```
curl https://api.depixapp.com/api/checkouts/chk_01jxxxxxxxxxxxxxxxxxxxxxx \
  -H "Authorization: Bearer sk_live_<your-key>"
```

**Response — 200 OK**

```
{
  "checkout": {
    "id":             "chk_01jxxxxxxxxxxxxxxxxxxxxxx",
    "status":         "completed",     // pending | processing | approved | completed | cancelled | expired
    "amount":         2990,
    "description":    "T-shirt size M",
    "image_url":      null,
    "callback_url":   "https://my-store.com/webhook/depix",
    "redirect_url":   null,
    "metadata":       { "order_id": "ORD-123" },
    "expires_at":     "2025-06-01T15:30:00.000Z",
    "is_live":        true,
    "created_at":     "2025-06-01T15:00:00.000Z",
    "processing_at":  "2025-06-01T15:02:00.000Z",
    "approved_at":    "2025-06-01T15:03:00.000Z",
    "completed_at":   "2025-06-01T15:22:00.000Z",
    "cancelled_at":   null,
    "blockchain_tx_id": "abc123...def456",  // Liquid txid (present when completed)
    "rejection_reasons": []         // array of reasons when the payment was refunded/held
  }
}
```

While the checkout is `pending`, the response also includes `pix_payload` (the Pix QR EMV payload); the field is dropped once the status leaves `pending`. `approved_at` is always present (`null` until approval).

### Possible statuses

| Status | Meaning |
| --- | --- |
| pending | Awaiting payment. |
| processing | Pix received, processing conversion to DePix. |
| approved | Payment approved by the bank, awaiting settlement in DePix. |
| completed | Payment confirmed. DePix in the merchant's wallet. |
| cancelled | Cancelled/refunded by the Pix provider. |
| expired | Payment deadline expired. |

### Refund reasons (`rejection_reasons`)

When a checkout's payment is refunded or held by the provider, the `rejection_reasons` field carries an array with the reasons (`[]` when the payment was not refused). New codes may appear — handle unknown values generically.

| Code | Meaning |
| --- | --- |
| `PAYER_MISMATCH` | Payment made with a CPF/CNPJ different from the one provided on the checkout. |
| `PAST_DAILY_LIMIT` | Payer's daily limit exceeded. |
| `BLOCKED_USER` | User blocked by the provider. |
| `HIGH_VELOCITY` | Too many transactions from the payer in a short period. |

<a id="checkout-list"></a>
## List checkouts
Lists the merchant's checkouts with filters and pagination.

**GET** `/api/checkouts`

### Query params (all optional)

| Parameter | Description |
| --- | --- |
| status | Filter by status: `pending`, `processing`, `approved`, `completed`, `cancelled`, `expired`. |
| product_id | Filter by product. E.g.: `prd_xxx`. |
| from | Start date (ISO 8601). E.g.: `2025-06-01T00:00:00Z`. |
| to | End date (ISO 8601). |
| q | Search by ID or description. |
| limit | Number of results per page. Default: 50. Maximum: 100. |
| offset | Pagination. Default: 0. |

**curl**

```
curl "https://api.depixapp.com/api/checkouts?status=completed&limit=20" \
  -H "Authorization: Bearer sk_live_<your-key>"
```

**Response — 200 OK**

```
{
  "checkouts": [
    {
      "id":            "chk_01jxxxxxxxxxxxxxxxxxxxxxx",
      "status":        "completed",
      "amount":        2990,
      "description":   "T-shirt size M",
      "product_name":  "Black T-shirt",    // null if checkout is not linked to a product
      "metadata":      "{\"order_id\":\"42\"}",  // JSON string, null if absent
      "created_at":    "2025-06-01T15:00:00.000Z",
      "processing_at": "2025-06-01T15:02:14.000Z",
      "approved_at":   "2025-06-01T15:03:00.000Z",
      "expires_at":    "2025-06-01T15:30:00.000Z",
      "is_live":       true,
      "rejection_reasons": []     // array of reasons when the payment was refunded/held
    }
  ],
  "stats": {
    "total":            47,
    "pending":          2,
    "completed":        40,
    "completed_amount":  189500    // centavos — R$ 1,895.00
  },
  "limit":  20,
  "offset": 0
}
```

<a id="product-create"></a>
## Create product
Creates a new product with a fixed price. Each product generates a permanent payment link that can be shared with your customers.

**POST** `/api/products`

### Parameters

| Field | Type |  | Description |
| --- | --- | --- | --- |
| name | string | required | Product name shown in the UI. 2-80 characters. |
| slug | string | optional | URL identifier. If omitted, auto-generated from `name`. Lowercase letters, numbers, and hyphens. 2-60 characters. Cannot start/end with a hyphen. |
| amount | integer | required | Amount in centavos. Minimum: 500. Maximum: 300000. |
| description | string | optional | Product description. Maximum 500 characters. |
| image_url | string | optional | HTTPS URL of the product image. |
| callback_url | string | optional | HTTPS URL for webhooks. Overrides the merchant default. |
| redirect_url | string | optional | Redirect URL. Overrides the merchant default. |
| metadata | object | optional | Additional data. Maximum 4KB. Included in webhooks for generated checkouts. |
| expires_in | integer | optional | Checkout expiration time in seconds. Default: 1200 (20min). Minimum: 300 (5min). Maximum: 1200 (20min). |

### Example

```
curl -X POST https://api.depixapp.com/api/products \
  -H "Authorization: Bearer sk_live_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "T-shirt M",
    "slug": "tshirt-m",
    "amount": 2990,
    "description": "T-shirt size M"
  }'
```

**Response — 201 Created**

```
{
  "product": {
    "id":           "prd_xxx",
    "name":         "T-shirt M",
    "slug":         "tshirt-m",
    "amount":       2990,
    "description":  "T-shirt size M",
    "image_url":    null,
    "callback_url": null,
    "redirect_url": null,
    "metadata":     null,
    "expires_in":   1200,
    "active":       true,
    "is_live":      true,
    "payment_url":  "https://pay.depixapp.com/joao/tshirt-m"
  }
}
```

<a id="product-list"></a>
## List products
Lists the merchant's products with filters and pagination.

**GET** `/api/products`

### Query params (all optional)

| Parameter | Description |
| --- | --- |
| active | Filter by status: `1` (active) or `0` (inactive). |
| q | Search by name, slug, or description. |
| limit | Number of results. Default: 50. Maximum: 100. |
| offset | Pagination. Default: 0. |

**curl**

```
curl "https://api.depixapp.com/api/products?active=1" \
  -H "Authorization: Bearer sk_live_<your-key>"
```

**Response — 200 OK**

```
{
  "products": [
    {
      "id":          "prd_xxx",
      "name":        "T-shirt M",
      "slug":        "tshirt-m",
      "amount":      2990,
      "description": "T-shirt size M",
      "active":      true,
      "is_live":     true,
      "position":    0,
      "payment_url": "https://pay.depixapp.com/joao/tshirt-m"
    }
  ],
  "stats": {
    "total":  5,
    "active": 4
  },
  "limit":  50,
  "offset": 0
}
```

`position` — integer or `null`. Display order on the public storefront. `null` = not pinned (sorted by best-sellers); integer = pinned, shown at the given position (lowest first).

<a id="product-get"></a>
## Get product
Returns the details of a specific product, including checkout statistics.

**GET** `/api/products/:id`

**curl**

```
curl https://api.depixapp.com/api/products/prd_xxx \
  -H "Authorization: Bearer sk_live_<your-key>"
```

**Response — 200 OK**

```
{
  "product": {
    "id":           "prd_xxx",
    "name":         "T-shirt M",
    "slug":         "tshirt-m",
    "amount":       2990,
    "description":  "T-shirt size M",
    "image_url":    null,
    "callback_url": null,
    "redirect_url": null,
    "metadata":     null,
    "expires_in":   1200,
    "active":       true,
    "is_live":      true,
    "position":     0,
    "payment_url":  "https://pay.depixapp.com/joao/tshirt-m",
    "created_at":   "2025-06-01T00:00:00.000Z"
  }
}
```

`position` — integer or `null`. Display order on the public storefront. `null` = not pinned (sorted by best-sellers); integer = pinned, shown at the given position (lowest first).

<a id="product-update"></a>
## Update product
Updates one or more fields of an existing product. Only send the fields you want to change.

**PATCH** `/api/products/:id`

### Parameters (all optional)

| Field | Type | Description |
| --- | --- | --- |
| name | string | New product name. 2-80 characters. |
| slug | string | New URL identifier. Same rules as creation. |
| amount | integer | New amount in centavos. |
| description | string | New description. |
| image_url | string | New image URL. |
| callback_url | string | New webhook URL. |
| redirect_url | string | New redirect URL. |
| metadata | object | New additional data. |
| expires_in | integer | New checkout expiration time. Minimum: 300 (5min). Maximum: 1200 (20min). |

**curl**

```
curl -X PATCH https://api.depixapp.com/api/products/prd_xxx \
  -H "Authorization: Bearer sk_live_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 3490, "description": "T-shirt size M - Special Edition" }'
```

**Response — 200 OK**

```
{
  "product": {
    "id":           "prd_xxx",
    "name":         "T-shirt M",
    "slug":         "tshirt-m",
    "amount":       3490,
    "description":  "T-shirt size M - Special Edition",
    "active":       true,
    "is_live":      true,
    "payment_url":  "https://pay.depixapp.com/joao/tshirt-m"
  }
}
```

<a id="product-activate"></a>
## Activate / Deactivate product
Activates or deactivates a product. Inactive products return a 404 error when accessed via the payment link.

**POST** `/api/products/:id/activate`

**POST** `/api/products/:id/deactivate`

**curl — activate**

```
curl -X POST https://api.depixapp.com/api/products/prd_xxx/activate \
  -H "Authorization: Bearer sk_live_<your-key>"
```

**curl — deactivate**

```
curl -X POST https://api.depixapp.com/api/products/prd_xxx/deactivate \
  -H "Authorization: Bearer sk_live_<your-key>"
```

**Response — 200 OK**

```
{ "success": true }
```

<a id="product-featured"></a>
## Feature products on the storefront
Sets the ordered list of products pinned to the **top** of the merchant's public page (the "storefront"). Pinned products render first, in the given order; every product **not** in the list is unpinned and falls back to best-sellers ordering. Sending an empty array clears all pins. This request reconciles the whole pinned set in one call (it is not incremental).

**POST** `/api/products/featured`

### Parameters

| Field | Type |  | Description |
| --- | --- | --- | --- |
| productIds | array<string> | required | Ordered list of product IDs to pin to the top of the storefront. An empty array clears all pins. Maximum 50. All IDs must belong to the merchant; duplicate IDs are rejected. |

### Example

```
curl -X POST https://api.depixapp.com/api/products/featured \
  -H "Authorization: Bearer sk_live_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{ "productIds": ["prd_abc123", "prd_def456"] }'
```

**Response — 200 OK**

```
{
  "success":  true,
  "featured": ["prd_abc123", "prd_def456"]
}
```

Possible errors: **400** (`productIds` is not a list, duplicate IDs, or more than 50), **404** (a product does not belong to the merchant), and **403** (no merchant account). See the [Errors](#errors) section for the error response format.

<a id="product-checkouts"></a>
## Product checkouts
Lists the checkouts generated from a specific product. Accepts the same filters as the general checkout listing.

**GET** `/api/products/:id/checkouts`

**curl**

```
curl "https://api.depixapp.com/api/products/prd_xxx/checkouts?status=completed" \
  -H "Authorization: Bearer sk_live_<your-key>"
```

The response follows the same format as the [checkout listing](#checkout-list).

<a id="payment-links"></a>
## Payment links
DePix App generates permanent payment links for products and for the merchant page. These links create checkouts on demand when the customer accesses them.

### Link types

| Type | URL | Behavior |
| --- | --- | --- |
| Product | `https://pay.depixapp.com/{merchant_slug}/{slug}` | Fixed price. The customer sees the product and clicks "Pay with PIX". |
| Merchant | `https://pay.depixapp.com/{merchant_slug}` | Custom amount. The customer enters the amount and clicks "Pay with PIX". |

> ℹ **merchant_slug** is the public, editable URL key of the store, returned by `/api/merchants/:username/public` and `/api/products/:id/public` (the `:username` URL segment is in fact the `merchant_slug` — the parameter name is kept for back-compat). When the merchant updates the business name, the slug regenerates — payment links previously sent to customers stop working and must be resent.

### Lifecycle

- When the customer accesses the link and initiates payment, an **individual checkout** is created automatically.

- From there, the lifecycle is identical to a checkout created via API (status, webhooks, expiration).

- The `callback_url` follows the chain: product field (if set) → merchant default → null.

- The `redirect_url` follows the same chain.

> ℹ The endpoints below are **public** and do not require authentication. They are used by the payment pages but can be called directly for custom integrations.

<a id="public-product"></a>
## Public product
Returns the public data of an active product. Does not require authentication.

**GET** `/api/products/:id/public`

**curl**

```
curl https://api.depixapp.com/api/products/prd_xxx/public
```

**Response — 200 OK**

```
{
  "product": {
    "id":          "prd_xxx",
    "name":        "T-shirt M",
    "slug":        "tshirt-m",
    "amount":      2990,
    "description": "T-shirt size M",
    "image_url":   null
  },
  "merchant": {
    "name":          "Loja do Joao",
    "merchant_slug": "joao",
    "username":      "joao"
  }
}
```

<a id="public-product-checkout"></a>
## Product checkout
Creates a checkout from an active product. Does not require authentication. The amount is inherited from the product.

**POST** `/api/products/:id/checkout`

### Parameters

| Field | Type |  | Description |
| --- | --- | --- | --- |
| payer_tax_number | string | required | CPF or CNPJ of whoever pays the Pix, with or without punctuation. |

**curl**

```
curl -X POST https://api.depixapp.com/api/products/prd_xxx/checkout \
  -H "Content-Type: application/json" \
  -d '{ "payer_tax_number": "52998224725" }'
```

The response follows the same format as [create checkout](#checkout-create) (status 201).

<a id="public-merchant"></a>
## Merchant page
Returns the merchant's public data. Does not require authentication.

**GET** `/api/merchants/:username/public`

**curl**

```
curl https://api.depixapp.com/api/merchants/joao/public
```

**Response — 200 OK**

```
{
  "merchant": {
    "name":          "Loja do Joao",
    "merchant_slug": "joao",
    "username":      "joao"
  }
}
```

<a id="public-merchant-checkout"></a>
## Merchant checkout
Creates a checkout with a custom amount from the merchant page. Does not require authentication.

**POST** `/api/merchants/:username/checkout`

### Parameters

| Field | Type |  | Description |
| --- | --- | --- | --- |
| amount | integer | required | Amount in centavos. Minimum: 500. Maximum: 300000. |
| payer_tax_number | string | required | CPF or CNPJ of whoever pays the Pix, with or without punctuation. |

**curl**

```
curl -X POST https://api.depixapp.com/api/merchants/joao/checkout \
  -H "Content-Type: application/json" \
  -d '{ "amount": 5000, "payer_tax_number": "52998224725" }'
```

The response follows the same format as [create checkout](#checkout-create) (status 201).

<a id="pay-overview"></a>
## Deposit & Withdraw (wallet_* scopes)
Besides receiving through checkouts, an API key with the `wallet_*` scopes moves the "paying" side of the account: it generates personal-deposit Pix QRs (BRL → DePix on-ramp) and creates DePix → Pix withdrawals (off-ramp). It is the same surface the human UI uses — account limits, delays and verification apply by construction.

> ℹ **SDK-first flows.** Deposit and withdraw were designed to be consumed by the official SDK (in development), which mirrors the human UX: `sdk.deposit(...)` generates the QR and tracks settlement; `sdk.withdraw(...)` quotes, builds the transaction, signs client-side and tracks settlement. The REST below is the complete contract the SDK itself consumes — fully documented for anyone who prefers to integrate directly.

### The deposit flow (on-ramp)

- 1. `POST /api/deposit` → Pix QR (`qrCopyPaste`) + `id`.

- 2. The account owner pays the QR in a banking app.

- 3. Track it by polling `GET /api/deposits/:id` (5–15s) and/or via the `deposit.*` webhooks until the terminal status `depix_sent` — the DePix arrived at the given Liquid address.

Personal deposits count towards account verification. On-ramp alternative: create a [checkout](#checkout-create) against yourself — note that checkout payments do not count towards verification.

### The withdrawal flow (off-ramp)

- 1. `POST /api/withdraw` → quote with `depositAddress` (the provider's Liquid address).

- 2. Send the DePix to the `depositAddress` from **your** wallet — signing is always client-side; the API never touches private keys or holds funds.

- 3. Track via `GET /api/withdrawals/:id` and/or the `withdraw.*` webhooks until `sent` — the Pix arrived at the destination key.

### Limits

Two layers, always combined (AND):

- **Account limits** — inherited from the owner's account and always prevailing. They cover first deposit, per-transaction amount, unverified cumulative cap, a delay on the first deposits, and deposit/withdrawal hard caps. The values depend on the account's verification level and can change — do not hard-code them. On exceed → `400 account_limit_exceeded`, with `details` carrying the current `limit_cents`/`used_cents`.

- **Key limits** — the spending limits the owner set when creating the `wallet_write` key (`per_tx_limit_cents`, `daily_limit_cents`; see [Scopes and limits](#scopes)), visible via `GET /api/api-keys`. On exceed → `400 key_limit_exceeded` (`details.limit` = per_tx | daily).

### Synthetic sandbox (sk_test_)

With a `sk_test_` key, deposit and withdraw respond with synthetic payloads marked `"sandbox": true`: unpayable `SANDBOX-…-DO-NOT-PAY` strings, `sandbox_*` ids, **zero money, zero provider calls, zero rows created**. All account gates and the key's per-transaction limit are still checked — the sandbox teaches the real limits. See [Sandbox](#sandbox).

<a id="deposit-create"></a>
## Create deposit
Generates a personal-deposit Pix QR. When the Pix is paid, the DePix is delivered to the given Liquid address. Requires the `wallet_write` scope. Accepts [`Idempotency-Key`](#idempotency).

**POST** `/api/deposit`

### Parameters

| Field | Type |  | Description |
| --- | --- | --- | --- |
| amountInCents | integer | required | Amount in centavos. Minimum: 500 (R$ 5.00). Maximum: 300000 (R$ 3,000.00). Account and key limits may restrict it further. |
| depixAddress | string | required | Liquid address that receives the DePix once the Pix settles. |
| payer_tax_number | string | required | CPF or CNPJ of whoever pays the Pix, with or without punctuation. |

### Example

```
curl -X POST https://api.depixapp.com/api/deposit \
  -H "Authorization: Bearer sk_live_<your-key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: dep-order-42" \
  -d '{
    "amountInCents": 5000,
    "depixAddress": "lq1qq...",
    "payer_tax_number": "529.982.247-25"
  }'
```

**Response — 200 OK**

```
{
  "async": false,
  "response": {
    "qrCopyPaste": "00020126580014br.gov.bcb.pix...",   // EMV payload for the QR code
    "qrImageUrl":  "https://depix.eulen.app/qr/qr-id-456.png",
    "id":          "qr-id-456"                          // use it in GET /api/deposits/:id
  }
}
```

**Response — 200 OK (sandbox, sk_test_)**

```
{
  "async": false,
  "response": {
    "qrCopyPaste": "SANDBOX-DEPIX-TEST-MODE-DO-NOT-PAY-a1b2c3d4e5f60708",
    "qrImageUrl":  null,
    "id":          "sandbox_3uw_a1b2c3d4e5f60708",   // sandbox_<amount36>_<hex> — 5000 → 3uw
    "sandbox":     true
  }
}
```

> ⚠ **Provider rejections arrive with HTTP 400.** If the Pix provider refuses the operation, the response is `400` with `error.code = "validation_error"` and the provider message preserved in `response.errorMessage` (same behavior as `POST /api/withdraw`). Program against the HTTP status: `2xx` = QR issued, `4xx` = rejected. The human-readable message stays in `response.errorMessage`.

<a id="deposit-get"></a>
## Deposit status
Reads the status of a deposit created via `POST /api/deposit`. Ownership is enforced: another account's id → `404`. Poll every 5–15 seconds until a terminal status — `depix_sent` is the terminal success.

**GET** `/api/deposits/:id`

**curl**

```
curl https://api.depixapp.com/api/deposits/qr-id-456 \
  -H "Authorization: Bearer sk_live_<your-key>"
```

**Response — 200 OK**

```
{
  "id":           "qr-id-456",
  "type":         "deposit",
  "amount_cents": 5000,
  "status":       "depix_sent",
  "created_at":   "2026-07-01 12:00:00",
  "updated_at":   "2026-07-01 12:34:56",
  "rejection_reasons": []   // always present; [] when the deposit was not refused
}
```

### Possible statuses

| Status | Terminal | Meaning |
| --- | --- | --- |
| pending | — | QR generated; Pix not paid yet. |
| under_review | — | Pix paid; payment in pre-settlement review. |
| pending_pix2fa | — | Pix paid; waiting for the payer to complete the Pix 2FA. |
| approved | — | Pix approved by the provider; DePix not yet sent. |
| delayed | — | Settlement held by the delay policy (new accounts/high values). |
| will_refund | — | Refund flow started; the deposit will be refunded. |
| depix_sent | yes | **Success**: DePix delivered to the target Liquid address. |
| refunded | yes | Deposit refunded to the payer. |
| canceled | yes | Canceled by the provider. |
| error | yes | Processing error at the provider. |
| expired | yes | QR expired unpaid. |

### Refund reasons (`rejection_reasons`)

The response always carries `rejection_reasons` as an array — **`[]` when the deposit was not refused**. A polling agent can therefore read the field unconditionally, without existence checks. It is populated when the payment was refunded or held by the provider (typically on the `will_refund`, `refunded` and `error` statuses). New codes may appear — display unknown values as-is. Withdrawals have no `rejection_reasons`.

| Code | Meaning |
| --- | --- |
| `PAYER_MISMATCH` | Payment made with a CPF/CNPJ different from the one provided on the deposit. |
| `PAST_DAILY_LIMIT` | Payer's daily limit exceeded. |
| `BLOCKED_USER` | User blocked by the provider. |
| `HIGH_VELOCITY` | Too many transactions from the payer in a short period. |

> ℹ **Sandbox:** with `sk_test_`, a `sandbox_*` id always returns the fixed synthetic response `{ "id": "sandbox_3uw_a1b2c3d4e5f60708", "type": "deposit", "amount_cents": 5000, "status": "depix_sent", "created_at": "2026-01-01 00:00:00", "updated_at": "2026-01-01 00:00:00", "sandbox": true, "rejection_reasons": [] }` — same shape as the live response (includes `amount_cents`, decoded from the id, plus deterministic timestamps) so you can exercise the full polling loop in test mode. `amount_cents` is `null` only for legacy ids with no embedded amount. Any other id via `sk_test_` → `404`; `sandbox_*` ids via a live key or JWT → `404`.

<a id="withdraw-create"></a>
## Create withdrawal
Quotes a DePix → Pix withdrawal: the response carries the provider's Liquid address you send the DePix to. After broadcasting the transaction, track the status. Requires the `wallet_write` scope. Accepts [`Idempotency-Key`](#idempotency).

**POST** `/api/withdraw`

### Parameters

Send **exactly one** of `depositAmountInCents` ("you send" mode) and `payoutAmountInCents` ("you receive" mode) — the same two modes of the human UI.

| Field | Type |  | Description |
| --- | --- | --- | --- |
| pixKey | string | required | Destination Pix key (email, phone, CPF/CNPJ or random key). |
| depositAmountInCents | integer | one of the two | "You send" mode: how much DePix you deliver, in centavos. Minimum: 500. Maximum: 600000 (R$ 6,000.00). Mutually exclusive with `payoutAmountInCents`. |
| payoutAmountInCents | integer | one of the two | "You receive" mode: how much the destination key receives, in centavos. Minimum: 500. Maximum: 588000 (fee-adjusted). Mutually exclusive with `depositAmountInCents`. |
| taxNumber | string | required | CPF or CNPJ of the destination Pix key holder. |

### Example

```
curl -X POST https://api.depixapp.com/api/withdraw \
  -H "Authorization: Bearer sk_live_<your-key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: wd-order-42" \
  -d '{
    "pixKey": "someone@example.com",
    "depositAmountInCents": 10000,
    "taxNumber": "529.982.247-25"
  }'
```

**Response — 200 OK**

```
{
  "response": {
    "withdrawalId":              "wd-123",           // use it in the status GET
    "depositAddress":            "lq1qq2v9wxkyz...", // send the withdrawal DePix here
    "depositAmountInCents":      9900,               // what the provider receives
    "payoutAmountInCents":       9700,               // what the Pix key receives
    "totalDepositAmountInCents": 10000,              // gross wallet outflow (provider + fee)
    "split":                     { "address": "ex1qfee...", "amountCentavos": 100 },
    "fee_cents":                 100,                // platform fee — MANDATORY in the same transaction
    "fee_address":               "ex1qfee..."        // fee address (non-confidential form)
  }
}
```

The API-key response includes `fee_cents` and `fee_address`: the platform fee your Liquid transaction **must** pay as a second **explicit** output (unblinded, DePix asset) to `fee_address`, **in the same transaction** as the main output to `depositAddress`. Pay `fee_address` exactly as given — it comes in the non-confidential form (`ex1...`) on purpose: a confidential/blinded output cannot be verified and counts as an unpaid fee. The fee is verified automatically on the Liquid transaction that pays the withdrawal.

> ⚠ **Warning:** Sending the withdrawal funds without including, in the same transaction, the fee amount will result in: a failed withdrawal and loss of funds.

**Response — 200 OK (sandbox, sk_test_)**

```
{
  "response": {
    "withdrawalId":         "sandbox_0011223344556677",
    "depositAddress":       "SANDBOX-LIQUID-ADDRESS-DO-NOT-PAY",
    "depositAmountInCents": 10000,
    "payoutAmountInCents":  9800,                // fixed synthetic 2% rate; the real rate varies
    "fee_cents":            100,                 // deterministic synthetic 1% fee
    "fee_address":          "SANDBOX-LIQUID-FEE-ADDRESS-DO-NOT-PAY",
    "sandbox":              true
  }
}
```

<a id="withdraw-get"></a>
## Withdrawal status
Reads the status of a withdrawal created via `POST /api/withdraw`. Ownership is enforced: another account's id → `404`. Poll every 5–15 seconds until a terminal status — `sent` is the terminal success.

**GET** `/api/withdrawals/:id`

**curl**

```
curl https://api.depixapp.com/api/withdrawals/wd-123 \
  -H "Authorization: Bearer sk_live_<your-key>"
```

**Response — 200 OK**

```
{
  "id":           "wd-123",
  "type":         "withdraw",
  "amount_cents": 10000,                // sending side (depositAmountInCents)
  "status":       "sent",
  "created_at":   "2026-07-01 10:00:00",
  "updated_at":   "2026-07-01 10:00:00",
  "liquid_txid":  "abab...ab"           // present once the Liquid transfer is detected on-chain
}
```

### Possible statuses

| Status | Terminal | Meaning |
| --- | --- | --- |
| unsent | — | Created; the DePix has not reached the provider yet. |
| sending | — | DePix received; Pix payout in flight. |
| sent | yes | **Success**: Pix delivered to the destination key. |
| refunded | yes | Refunded. |
| cancelled | yes | Cancelled. |
| error | yes | Processing error at the provider. |
| expired | yes | The DePix never arrived — swept by the cron. |

> ℹ **Sandbox:** with `sk_test_`, a `sandbox_*` id always returns `{ "id": "sandbox_7ps_…", "type": "withdraw", "amount_cents": 10000, "status": "confirmed", "created_at": "2026-01-01 00:00:00", "updated_at": "2026-01-01 00:00:00", "sandbox": true }` — a fixed synthetic state, sandbox-only (`status: "confirmed"` is outside the live enum); `amount_cents` is decoded from the id (`null` for legacy ids that carry no amount). Any other id via `sk_test_` → `404`; `sandbox_*` ids via a live key or JWT → `404`.

<a id="webhooks"></a>
## Webhooks
When a checkout's status changes, the API sends a POST to the `callback_url` you provided when creating the checkout (or configured on the product/merchant). Deposits and withdrawals created **via API key** also fire webhooks (`deposit.*`/`withdraw.*` events) to the merchant's `default_callback_url` — see [Events](#webhook-events).

### How it works

- The request is sent with a **30-second timeout**.

- If it fails (non-2xx response, timeout, or network error), the API retries up to **5 more times**: after 1 minute, 10 minutes, 1 hour, 4 hours, and 12 hours (6 attempts total, spanning roughly 17 hours).

- Your endpoint must respond with a **2xx status** to confirm receipt.

- The `callback_url` must be HTTPS and publicly accessible (no private IPs).

### Request headers

- `X-DePix-Signature` — HMAC-SHA256 signature (see [Verify signature](#webhook-verify) section).

- `X-DePix-Event` — event name (e.g., `checkout.completed`).

- `X-DePix-Event-Id` — unique identifier for this event, **stable across retries** (e.g., `evt_abc123…`). This is the recommended dedupe key.

- `X-DePix-Delivery-Attempt` — the current attempt number (1, 2, … up to 6). Changes per retry; *do not* use for dedupe.

- `User-Agent` — always `DePix-Webhook/1.0`.

### At-least-once delivery and idempotency (required)

Webhooks are delivered with **at-least-once** semantics — this is the industry standard (Stripe, PayPal, Mercado Pago all work the same way). It means **the same event can reach your endpoint more than once**, even when everything is working correctly. Common scenarios:

- Your server processes the webhook but responds slowly — our API times out at 30s, marks the delivery as failed, and retries; you process the event twice.

- Your server returns 200 but the connection drops before we read the response — same effect: retry and duplicate processing.

- Our operations team manually redispatches an event (via an admin command) that you already processed.

**To avoid delivering a product twice, double-crediting balance, or triggering duplicate side effects, your endpoint must be idempotent.** The simplest and most robust pattern is to deduplicate by `X-DePix-Event-Id`: store the IDs of events you've already processed and silently ignore any event whose ID is already in your table.

```
// Dedupe example (Node.js, pseudo-code)
app.post("/webhook", async (req, res) => {
  // 1. Validate the HMAC signature first (see Verify signature section).

  const eventId = req.headers["x-depix-event-id"];

  // 2. Process AND mark-as-processed in a single DB transaction so the row is
  // only persisted if your business logic succeeds. If processCheckout throws,
  // the transaction rolls back and our retry can deliver the event again.
  try {
    await db.transaction(async (tx) => {
      await tx.query(
        "INSERT INTO processed_webhooks (event_id, received_at) VALUES (?, NOW())",
        [eventId]
      );
      await processCheckout(req.body, tx);
    });
  } catch (err) {
    if (err.code === "ER_DUP_ENTRY") {
      // Already processed — return 200 and ignore.
      return res.sendStatus(200);
    }
    throw err; // Let our API retry.
  }

  res.sendStatus(200);
});
```

If you'd rather not maintain a separate table, you can also dedupe on the `event_id` field inside the JSON payload (`data.event_id`) — it carries the same stable value as the `X-DePix-Event-Id` header. Do not use `(data.id, event)` as your dedupe key: an operator-triggered redispatch reuses the same checkout id and event name, so a tuple-based dedupe would silently swallow it.

### Reducing retries

To avoid duplicate deliveries on the happy path, respond as fast as possible — common targets are under a few seconds, to leave room for network latency before our 30s timeout. The recommended pattern: validate the signature, return 200 immediately, then process the event in the background (queue, worker, etc.). This avoids retries caused by timeouts on our side.

<a id="webhook-events"></a>
## Events
### checkout.processing

Fired when the Pix payment is received and the conversion is being processed.

```
{
  "event": "checkout.processing",
  "data": {
    "event_id":       "evt_01jxxxxxxxxxxxxxxxxxxxxxx",
    "id":             "chk_01jxxxxxxxxxxxxxxxxxxxxxx",
    "status":         "processing",
    "amount":         2990,
    "processing_at":  "2025-06-01T15:02:00.000Z",
    "metadata":       { "order_id": "ORD-123" }
  }
}
```

### checkout.approved

Fired when the payment is approved by the bank and is awaiting final settlement in DePix.

```
{
  "event": "checkout.approved",
  "data": {
    "event_id":     "evt_01jxxxxxxxxxxxxxxxxxxxxxx",
    "id":           "chk_01jxxxxxxxxxxxxxxxxxxxxxx",
    "status":       "approved",
    "amount":       2990,
    "approved_at":  "2025-06-01T15:05:00.000Z",
    "metadata":     { "order_id": "ORD-123" }
  }
}
```

### checkout.completed

Fired when the payment is confirmed and the DePix arrives in the merchant's wallet.

```
{
  "event": "checkout.completed",
  "data": {
    "event_id":      "evt_01jxxxxxxxxxxxxxxxxxxxxxx",
    "id":            "chk_01jxxxxxxxxxxxxxxxxxxxxxx",
    "status":        "completed",
    "amount":        2990,
    "completed_at":  "2025-06-01T15:22:00.000Z",
    "metadata":      { "order_id": "ORD-123" }
  }
}
```

### checkout.cancelled

Fired when the checkout payment is cancelled, charged back, or refunded by the Pix provider.

```
{
  "event": "checkout.cancelled",
  "data": {
    "event_id":      "evt_01jxxxxxxxxxxxxxxxxxxxxxx",
    "id":            "chk_01jxxxxxxxxxxxxxxxxxxxxxx",
    "status":        "cancelled",
    "amount":        2990,
    "cancelled_at":  "2025-06-01T15:05:00.000Z",
    "metadata":      { "order_id": "ORD-123" }
  }
}
```

### checkout.expired

Fired when the checkout expires without receiving payment.

```
{
  "event": "checkout.expired",
  "data": {
    "event_id":    "evt_01jxxxxxxxxxxxxxxxxxxxxxx",
    "id":          "chk_01jxxxxxxxxxxxxxxxxxxxxxx",
    "status":      "expired",
    "amount":      2990,
    "expires_at":  "2025-06-01T15:30:00.000Z",
    "metadata":    { "order_id": "ORD-123" }
  }
}
```

### deposit.* and withdraw.* events

Deposits and withdrawals **created via API key** (`wallet_write` scope) fire one event per real status transition, named 1:1 after the raw status: `deposit.` / `withdraw.`. Human operations (dashboard/SPA) never dispatch. Delivery goes to the merchant's `default_callback_url` — without it configured, nothing is sent. Sandbox operations (`sk_test_`) create no rows and therefore never fire webhooks.

Creating a deposit or withdrawal does not emit a webhook — the first event you receive is the next status change. The initial-state events (`deposit.pending`, `withdraw.unsent`) therefore only appear on the uncommon reversal back to that state.

| Event | Fired when |
| --- | --- |
| deposit.pending | Deposit awaiting Pix payment (initial state). |
| deposit.under_review | Pix paid; payment in pre-settlement review. |
| deposit.pending_pix2fa | Waiting for the payer to complete the Pix 2FA. |
| deposit.approved | Approved by the provider; DePix not yet sent. |
| deposit.delayed | Settlement held by the delay policy. |
| deposit.will_refund | Refund flow started. |
| deposit.depix_sent | **Terminal success**: DePix delivered to the target address. |
| deposit.refunded | Refunded to the payer (terminal). |
| deposit.canceled | Canceled by the provider (terminal). |
| deposit.error | Processing error at the provider (terminal). |
| deposit.expired | QR expired unpaid (terminal). |
| withdraw.unsent | Withdrawal awaiting the DePix transfer (initial state). |
| withdraw.sending | DePix received; Pix payout in flight. |
| withdraw.sent | **Terminal success**: Pix delivered to the destination key. |
| withdraw.refunded | Refunded (terminal). |
| withdraw.cancelled | Cancelled (terminal). |
| withdraw.error | Processing error at the provider (terminal). |
| withdraw.expired | The DePix never arrived — swept by the cron (terminal). |

The payload uses the same English shape as the status GETs, plus `event_id` — the dedupe key (same value as the `X-DePix-Event-Id` header, stable across retries). `deposit.*` payloads always carry `rejection_reasons` (array, `[]` when the deposit was not refused) — populated on `deposit.refunded`, `deposit.will_refund` and `deposit.error`; empty on every other event. `withdraw.*` payloads do not have this field:

```
{
  "event": "deposit.depix_sent",
  "data": {
    "id":           "qr-id-456",
    "type":         "deposit",
    "amount_cents": 5000,
    "status":       "depix_sent",
    "created_at":   "2026-07-01 12:00:00",
    "updated_at":   "2026-07-01 12:34:56",
    "rejection_reasons": [],   // e.g. ["PAYER_MISMATCH"] on deposit.refunded
    "event_id":     "evt_9f8e7d6c5b4a"
  }
}
```

```
{
  "event": "withdraw.sent",
  "data": {
    "id":           "wd-123",
    "type":         "withdraw",
    "amount_cents": 10000,
    "status":       "sent",
    "created_at":   "2026-07-01 10:00:00",
    "updated_at":   "2026-07-01 10:00:00",
    "liquid_txid":  "abab...ab",
    "event_id":     "evt_1a2b3c4d5e6f"
  }
}
```

<a id="webhook-verify"></a>
## Verify signature
Each webhook comes with an `X-DePix-Signature` header. Always validate the signature before processing the event — this ensures the request came from the DePix App API and not from a third party.

### Header format

```
X-DePix-Signature: t=1717257600,v1=abc123def456...
```

- **t** — Unix timestamp of the dispatch (seconds).

- **v1** — HMAC-SHA256 signature in hexadecimal.

### How to validate

The signature is computed over the string `timestamp.payload` using the **Webhook Secret** from your account (available in the Merchant Dashboard).

**Bash**

```
# Compute the expected signature
EXPECTED=$(echo -n "${TIMESTAMP}.${RAW_BODY}" | \
  openssl dgst -sha256 -hmac "${WEBHOOK_SECRET}" | awk '{print $2}')

# Compare with the received v1
if [ "$EXPECTED" = "$RECEIVED_V1" ]; then
  echo "Valid signature"
fi
```

> ⚠ Read the body as **raw bytes** (before JSON parsing). Any reformatting will invalidate the signature.

<a id="sandbox"></a>
## Sandbox
Use `sk_test_...` keys to test without moving real money. Checkouts created with a test key never generate a real Pix and are isolated from production checkouts.

### Test mode differences

- The `is_live` field returns `false`.

- The generated QR code is not a valid Pix — it cannot be paid with a banking app.

- Use the `/simulate-payment` endpoint to mark the checkout as paid.

- Webhooks are sent normally — great for testing your end-to-end integration.

### Deposit and withdrawal in test mode

- `POST /api/deposit` and `POST /api/withdraw` with `sk_test_` respond with synthetic payloads marked `"sandbox": true`: unpayable `SANDBOX-…-DO-NOT-PAY` strings, `sandbox_*` ids, a fixed synthetic 2% quote on withdrawals.

- **Zero money and zero rows**: no Pix provider call, nothing written — the account's economic counters are untouched.

- Real validations and limits are exercised: the account gates and the key's per-transaction limit run normally. The daily limit never accrues (nothing is stored).

- `GET /api/deposits/:id` and `GET /api/withdrawals/:id` with a `sandbox_*` id return a fixed synthetic status (`depix_sent` / `confirmed`) to train the polling loop.

- No rows → sandbox operations never fire `deposit.*`/`withdraw.*` webhooks. To test webhooks end to end, use a checkout + `simulate-payment`.

- A `sk_test_` key never reads or writes live data: any id that is not `sandbox_*` → `404`.

> ✓ The sandbox is independent from production. You can create and simulate test checkouts without risk.

<a id="simulate"></a>
## Simulate payment
Marks a test checkout as paid. Only works with `sk_test_` keys. Fires the `checkout.completed` webhook normally.

**POST** `/api/checkouts/:id/simulate-payment`

**curl**

```
# 1. Create a test checkout
curl -X POST https://api.depixapp.com/api/checkouts \
  -H "Authorization: Bearer sk_test_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 1000, "callback_url": "https://my-store.com/webhook" }'

# 2. Simulate the payment
curl -X POST https://api.depixapp.com/api/checkouts/chk_01jxxxxxxxxxxxxxxxxxxxxxx/simulate-payment \
  -H "Authorization: Bearer sk_test_<your-key>"
```

**Response — 200 OK**

```
{ "success": true }
```

After the simulation, your `callback_url` will receive the `checkout.completed` event within seconds — exactly like a real payment.

<a id="me"></a>
## Verify key (GET /api/me)
Returns the authenticated merchant's information. Useful for verifying if the API key is valid and checking account data.

**GET** `/api/me`

**curl**

```
curl https://api.depixapp.com/api/me \
  -H "Authorization: Bearer sk_live_<your-key>"
```

**Response — 200 OK**

```
{
  "merchant_id":    "mrc_xxx",
  "name":           "Loja do Joao",
  "username":       "joao",
  "merchant_slug":  "joao",
  "is_live":        true,
  "created_at":     "2025-06-01T00:00:00.000Z"
}
```

<a id="merchant-update"></a>
## Edit store profile (PATCH /api/merchants/me)
Partially updates the authenticated merchant's profile. Send only the fields you want to change. Accepts both the dashboard JWT and an API key with the `merchant_write` scope.

**PATCH** `/api/merchants/me`

> ⚠ **By API key, only these 5 light fields.** `liquid_address` (redirects money), `cnpj`, and the account password are **not editable by a key** — sending them on an API-key-authenticated request returns `400` with `error.code = "validation_error"` and `details.field` naming the rejected field. `split_address` is never editable through this endpoint (admin only). These sensitive fields only change via the web dashboard, by the account owner (the Liquid address also requires the password).

### Parameters (all optional — send only what changes)

| Field | Type | Description |
| --- | --- | --- |
| business_name | string | New business name (2–100 chars). **Changing it rotates the public `merchant_slug`** and retires the old one — any payment link or checkout URL built on the old slug will return `404`. Rename with that in mind. |
| website | string | New store website (normalized to `https://`). Sending `null` or empty clears the field. |
| logo_url | string | New HTTPS logo URL. `null` or empty clears the field. |
| default_callback_url | string | New default HTTPS webhook endpoint for `deposit.*` / `withdraw.*` events. `null` or empty clears the field. |
| default_redirect_url | string | New default HTTPS post-payment redirect for the store's customers. `null` or empty clears the field. |

> ℹ **Every PATCH made by an API key emails the owner**, naming the changed fields and the key id (compensating control). Read the current profile with `GET /api/me`.

**curl**

```
curl -X PATCH https://api.depixapp.com/api/merchants/me \
  -H "Authorization: Bearer sk_live_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{ "default_redirect_url": "https://shop.example.com/thanks" }'
```

**Response — 200 OK**

```
{
  "success":        true,
  "merchant_slug":  "loja-do-joao"       // changes only if business_name changed
}
```

**Response — 400 (owner-only field, via API key)**

```
{
  "response": { "errorMessage": "Este campo só pode ser alterado pelo dono da conta no painel web." },
  "error": {
    "code":        "validation_error",
    "message":     "This field can only be changed by the account owner in the web dashboard.",
    "request_id":  "gru1::abcd-1234",
    "docs_url":    "https://depixapp.com/docs/en/#errors",
    "details":     { "field": "liquid_address" }
  }
}
```

<a id="audit"></a>
## Key audit log
Every write operation performed with an API key is audited: action, amount, resource, `request_id`, IP, sandbox flag and idempotent replays — including authenticated denials (e.g. `insufficient_scope`, `account_blocked`, with the action suffixed `*.denied:`). The owner queries each key's history with the endpoint below. Retention: 90 days. GETs and `429` responses are not logged.

**GET** `/api/api-keys/:id/audit`

> ℹ **JWT only.** This endpoint accepts only the dashboard JWT (owner login) — never an API key. A key cannot see its own audit log.

### Query params (all optional)

| Parameter | Description |
| --- | --- |
| limit | Results per page. Default: 50. Minimum: 1. Maximum: 100. |
| offset | Pagination. Default: 0. |

**curl**

```
curl "https://api.depixapp.com/api/api-keys/a1b2c3d4e5f6/audit?limit=50&offset=0" \
  -H "Authorization: Bearer <dashboard-jwt>"
```

**Response — 200 OK**

```
{
  "audit": [
    {
      "id":           "aud_01jxxxxxxxxxxxxxxxxxxxxxx",
      "action":       "withdraw.create",
      "method":       "POST",
      "path":         "/api/withdraw",
      "status_code":  200,
      "amount_cents": 10000,
      "resource_id":  "wd-123",
      "is_sandbox":   0,
      "is_replay":    0,       // 1 = idempotent replay (handler did not run)
      "ip":           "203.0.113.9",
      "request_id":   "gru1::iad1::v9x4k-1751476800000-abc123",
      "created_at":   "2026-07-02 14:03:11"
    }
  ],
  "total": 123
}
```

<a id="rate-limits"></a>
## Rate limits
The API enforces request limits to ensure stability and protect against abuse.

| Endpoint | Limit | Scope |
| --- | --- | --- |
| POST /api/checkouts | 30 / min | per IP |
| POST /api/checkouts/:id/simulate-payment | 60 / min per IP · 30 / min per key | authenticated (merchant_write scope) |
| POST /api/products | 60 / min per IP · 30 / min per key | authenticated (merchant_write scope) |
| POST /api/products/featured | 60 / min per IP · 30 / min per key | authenticated (merchant_write scope) |
| PATCH /api/products/:id | 60 / min per IP · 30 / min per key | authenticated (merchant_write scope) |
| POST /api/products/:id/deactivate | 60 / min per IP · 30 / min per key | authenticated (merchant_write scope) |
| POST /api/products/:id/activate | 60 / min per IP · 30 / min per key | authenticated (merchant_write scope) |
| POST /api/deposit | 20 / min per IP · 2 / min per key | authenticated (wallet_write scope) |
| POST /api/withdraw | 20 / min per IP · 2 / min per key | authenticated (wallet_write scope) |
| GET /api/deposits/:id | 60 / min per IP · 30 / min per key | authenticated (wallet_read scope) |
| GET /api/withdrawals/:id | 60 / min per IP · 30 / min per key | authenticated (wallet_read scope) |
| GET /api/api-keys/:id/audit | 60 / min per IP · 30 / min per user | authenticated (JWT) |
| PATCH /api/merchants/me | 30 / min per IP · 10 / min per key | authenticated (merchant_write scope) |
| GET /api/checkout-page/:id | 30 / min | per IP (public) |
| GET /api/pay/:id | 60 / min | per IP (public) |
| POST /api/pay/:id/simulate | 5 / min | per IP (public, sandbox only) |
| POST /api/merchants/:username/checkout | 10 / min per IP · 60 / min per merchant | public |
| POST /api/products/:id/checkout | 10 / min per IP · 60 / min per merchant | public |
| GET /api/products/:id/public | 30 / min | per IP (public) |
| GET /api/merchants/:username/public | 30 / min | per IP (public) |
| Per merchant (API key) | 30 / min (default) — configurable | shared across all of the key's endpoints; raisable via support |
| Per key (rate_limit_per_min) | Configurable at key creation | additional per-API-key check (1–600 req/min) |

- For requests authenticated with an API key, an additional rate limit applies **per merchant** (default **30 req/min**, shared across all endpoints — contact support if you need it raised) and, when set at creation, **per key** (`rate_limit_per_min`).

- Authenticated **writes** reachable via API key — create/simulate checkout and the product CRUD — carry a **per-endpoint** limit (60/min per IP · 30/min per key), listed in the table above. Authenticated **reads** on the "receiving" side (list checkouts and products, `GET /api/me`, etc.) have no per-endpoint limit of their own — they are bounded by the **per-merchant** budget (default 30 req/min, configurable). The JWT/SPA path is limited too, not just the API-key path.

- On the deposit/withdraw routes, the per-user counter runs **per key** — each API key gets its own budget, never competing with the owner in the SPA.

- For public endpoints (no auth), the rate limit is applied only **per IP**.

- When the limit is reached, the API returns `429` with `error.code = "rate_limited"` (or `"merchant_rate_limited"`), the `error.retry_after` field and the `Retry-After` header — wait the indicated seconds before retrying.

- On `wallet_*` routes authenticated with an API key, an infrastructure failure in the rate-limit check responds `503 service_unavailable` with `retry_after` (fail-closed) instead of letting the request through.

- **Per-payer velocity:** at most **2 QRs per payer CPF/CNPJ** inside a sliding 30-minute window (deposits and checkouts count together). From the 3rd on, the API returns `429` with `error.code = "payer_velocity_limit"`, `details: { window_minutes, max_per_window }` and the `Retry-After` header telling the seconds until a slot frees up.

> ℹ If you need higher limits for your integration, contact support.
