DePix App API
Introduction
The DePix App API lets you create and manage Pix checkouts programmatically. It is the same API that powers the BTCPay Server plugin, the Merchant Dashboard, and now AI agents — over MCP and the SDK.
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.
Authentication
Use an API key in the Authorization header on all authenticated requests.
Authorization: Bearer sk_live_<your-key>
In the curl examples throughout these docs, the key comes from the DEPIX_API_KEY environment variable. Set it once in your shell — export DEPIX_API_KEY=sk_live_... (or your sk_test_ key in sandbox) — instead of pasting the key inline into every command.
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. 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.
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.
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_writedoes not includemerchant_read, and thewallet_*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 →
403witherror.code = "insufficient_scope"anddetails.required_scope(see 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. |
{
"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
}
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/depositPOST /api/withdrawPOST /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. 5xxand429responses are never stored — those retries re-execute. Deterministic4xx(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.
Example
curl -X POST https://api.depixapp.com/api/deposit \ -H "Authorization: Bearer $DEPIX_API_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": { "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"
}
}
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.
{
"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 theX-Request-Idheader. Quote it in support requests.retry_after— seconds to wait before retrying; present on every429,503and on409 idempotency_in_flight. Mirrored in theRetry-AfterHTTP 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 keepsblocked: true. - Legacy handlers outside the agent surface may still respond with only
response.errorMessage, without theerrorobject.
Code catalog
Complete, closed catalog. Every row has a stable anchor in the form #error-<code> (e.g. #error-rate_limited).
| 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. |
| invalid_password | 401 | The account password provided is incorrect. |
| password_required | 400 | The account password is required for this security-sensitive operation. |
| agent_account_no_password | 403 | Agent accounts have no password — authenticate with the agent keypair instead. |
| oauth_account_not_linked | 403 | The Google/GitHub identity is not linked to a DePix account. Link it in the dashboard, then retry. |
| account_already_linked | 409 | The account is already linked to a different OAuth identity. Unlink it first. |
| workos_identity_in_use | 409 | The OAuth identity is already linked to another DePix account. |
| registration_blocked | 403 | The registration cannot proceed. |
| operator_oauth_failed | 502 | The operator identity could not be verified with the provider. Retry. |
| 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. |
| first_withdraw_tax_number_mismatch | 403 | Until the account has one completed withdrawal (status sent), every withdrawal must go to the same CPF/CNPJ that paid the account's first completed deposit. After that first withdrawal, the account withdraws to any Pix key. An account with no completed deposit carrying a document is not locked, and sk_test_ (sandbox) keys are exempt. details.anchor_tax_number carries the expected document, masked. |
| 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 | An account limit was reached. details.limit says which: "receive_cap" — the rolling-window receive cap, with { current_level, cap_cents, used_cents, amount_cents, window_days, resets_at, next_level_requirements, kyc_url }; "first_deposit" — the account has not completed a personal deposit yet; "per_tx" — the legacy per-transaction cap, applied only while the per-level cap is not in force. ("cumulative", the lifetime cap, was removed on 2026-07-31.) None of them depend on the account being verified: verifying unlocks the merchant tools and changes no limit. See Limits. |
| 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. |
Create checkout
Creates a new Pix checkout. Returns the QR code and the payment URL to display to your customer.
Parameters
| Field | Type | Description | |
|---|---|---|---|
| amount | integer | required | Amount in centavos. Minimum: 500 (R$ 5.00). Maximum: 600000 (R$ 6,000.00). On the depix rail this is the face amount, before the merchant's discount. |
| 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. Must be a real, registered CPF/CNPJ — the payment processor validates beyond the checksum when generating the QR. Required on the pix rail only; on the depix rail it is ignored. |
| payment_method | string | optional | pix (default) or depix. With depix the charge is paid directly in DePix on the Liquid network, with no Pix QR — see Receive DePix directly. If the merchant has not enabled direct DePix, creation fails with depix_not_enabled (400). |
| expected_discount_pct | integer | optional | On the depix rail only: the discount (0–90) your page showed the customer. If the merchant changed it in the meantime, creation fails with discount_changed (409) carrying the current values, instead of charging a price different from the one displayed. |
| description | string | optional | Order description. Maximum 500 characters. Displayed on the payment page. |
| expires_in | integer | optional | Expiration time in seconds. pix rail: default 1200 (20min), minimum 300 (5min), maximum 1200 (20min). depix rail: default 1800 (30min), minimum 300 (5min), maximum 3600 (1h). |
| 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. |
payer_tax_number beyond the checksum when generating the QR. A checksum-valid but unregistered number fails at creation with the generic error "Error generating QR Code. Please contact an admin." — if you get that error on create, the payer's CPF/CNPJ is almost certainly not a real registered one.
Example
curl -X POST https://api.depixapp.com/api/checkouts \ -H "Authorization: Bearer $DEPIX_API_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" } }'
const res = await fetch("https://api.depixapp.com/api/checkouts", { method: "POST", headers: { "Authorization": "Bearer sk_live_<your-key>", "Content-Type": "application/json", }, body: JSON.stringify({ 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" }, }), }); const data = await res.json(); console.log(data.id, data.payment_url);
import requests resp = requests.post( "https://api.depixapp.com/api/checkouts", headers={"Authorization": "Bearer sk_live_<your-key>"}, json={ "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"}, }, ) data = resp.json() print(data["id"], data["payment_url"])
$ch = curl_init("https://api.depixapp.com/api/checkouts"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer sk_live_<your-key>", "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "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 = curl_exec($ch); $data = json_decode($response, true); echo $data["id"] . " " . $data["payment_url"];
using var client = new HttpClient(); client.DefaultRequestHeaders.Add("Authorization", "Bearer sk_live_<your-key>"); var payload = new { 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 = new { order_id = "ORD-123" } }; var res = await client.PostAsync( "https://api.depixapp.com/api/checkouts", new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") ); var json = await res.Content.ReadAsStringAsync(); Console.WriteLine(json);
body := `{"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"}}` req, _ := http.NewRequest("POST", "https://api.depixapp.com/api/checkouts", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer sk_live_<your-key>") req.Header.Set("Content-Type", "application/json") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() io.Copy(os.Stdout, resp.Body)
require "net/http" require "json" uri = URI("https://api.depixapp.com/api/checkouts") req = Net::HTTP::Post.new(uri, { "Authorization" => "Bearer sk_live_<your-key>", "Content-Type" => "application/json", }) req.body = { 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" } }.to_json res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) } puts JSON.parse(res.body)
HttpClient client = HttpClient.newHttpClient(); String json = """ {"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"}}"""; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.depixapp.com/api/checkouts")) .header("Authorization", "Bearer sk_live_<your-key>") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body());
{
"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
}
}
Charge directly in DePix: send "payment_method": "depix" to the same endpoint. The response swaps the pix block for a depix block with the address, the exact amount and a payment link — see Receive DePix directly.
Response shape: POST /api/checkouts returns the checkout flat, at the root of the JSON (as above). GET /api/checkouts/:id returns the same object wrapped in { "checkout": { ... } } — see Get checkout.
payment_url to your customer or generate a QR code from pix.qr_code. The QR code is compatible with any banking app.
Get checkout
Returns the details of a specific checkout.
curl https://api.depixapp.com/api/checkouts/chk_01jxxxxxxxxxxxxxxxxxxxxxx \ -H "Authorization: Bearer $DEPIX_API_KEY"
{
"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
}
}
Response shape: here the checkout comes wrapped in { "checkout": { ... } }, while POST /api/checkouts returns the object flat at the root of the JSON — mind the difference when parsing both.
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. |
List checkouts
Lists the merchant's checkouts with filters and pagination.
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 "https://api.depixapp.com/api/checkouts?status=completed&limit=20" \ -H "Authorization: Bearer $DEPIX_API_KEY"
{
"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,
"payment_method": "depix", // "pix" or "depix" — the rail this sale settled on
"depix_discount_pct": 10, // depix rail only: discount offered, in %
"depix_due_cents": 2691, // depix rail only: the amount the payer actually sends
"rejection_reasons": [], // array of reasons when the payment was refunded/held
"delay_until": null, // when the money is released, if the sale is held — ISO-8601 WITH offset ("2025-06-15T09:03:00-03:00")
"vault_hours": 0 // hours this sale was booked to wait (0 = none; null = no decision recorded)
}
],
"stats": {
"total": 47,
"pending": 2,
"completed": 40,
"completed_amount": 189500 // centavos — R$ 1,895.00
},
"limit": 20,
"offset": 0
}
amount is always the list price. When payment_method is "depix", the amount the customer actually sends is depix_due_cents — the discounted price, moved down by a few cents, which is how we tell which payment belongs to which sale. Summing amount over discounted sales overstates every one of them. Both depix_* fields appear on the depix rail only; a Pix sale does not carry them.
processing. What tells you that it is held is vault_hours (the wait it was booked for at creation); what tells you when the amount lands is delay_until, and only that. Until the provider answers, delay_until is null and there simply is no release date yet — created_at + vault_hours is not that date: the wait runs from the payment, which is later than creation, so that arithmetic always lands early.
vault_hours has three distinct answers: a number greater than zero (it was held), 0 (the policy looked and imposed no wait), and null (no decision was recorded for that row — a sandbox checkout, or one created before/while the mechanism was switched off). null is not zero.
Mind the format of
delay_until: it is relayed verbatim from the settlement provider as ISO-8601 with an offset ("2025-06-15T09:03:00-03:00"), unlike created_at, expires_at and processing_at, which are naive UTC ("2025-06-01 15:00:00"). Parse it as a full ISO-8601 instant: a parser that assumes the naive shape and appends a Z produces an invalid date for every real value.
To reconcile "what I have already sold but not yet received", list with
?status=processing and sum amount over the rows that have a delay_until or a vault_hours greater than zero. And remember the list is paginated (limit caps at 100): compare against stats.total, which is counted over the whole filter rather than the page, or walk the remaining pages with offset — summing one page and calling it a total is how a reconciliation silently comes out smaller than reality.
Create product
Creates a new product with a fixed price. Each product generates a permanent payment link that can be shared with your customers.
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: 600000. |
| 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). |
| kind | string | optional | product (default) or charge. A charge is a payment link with a due date and late fees, served at pay.depixapp.com/c/{id} — it never appears on the public store, the cart, the vitrine or the default product listing. Immutable once created. |
| due_date | string | charge | Required when kind=charge. First due date, YYYY-MM-DD. It anchors the recurrence. It may be in the past — a retroactive charge starts already overdue, which is what billing last month looks like. |
| recurrence | string | charge | null (one-time) or weekly, monthly, quarterly, semiannual, yearly. Monthly and above anchor on the due day, clamping to the last day of shorter months (31st → Feb 28/29). |
| late_fine_bps | integer | charge | One-time late fine in basis points of the base amount (200 = 2%). Default: 0. Maximum: 2000 (20%). |
| late_interest_monthly_bps | integer | charge | Monthly interest in basis points (100 = 1% per month), accrued pro-rata per day late. Default: 0. Maximum: 1000 (10% per month). |
Example
curl -X POST https://api.depixapp.com/api/products \ -H "Authorization: Bearer $DEPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "T-shirt M", "slug": "tshirt-m", "amount": 2990, "description": "T-shirt size M" }'
const res = await fetch("https://api.depixapp.com/api/products", { method: "POST", headers: { "Authorization": "Bearer sk_live_<your-key>", "Content-Type": "application/json", }, body: JSON.stringify({ name: "T-shirt M", slug: "tshirt-m", amount: 2990, description: "T-shirt size M", }), }); const data = await res.json(); console.log(data.product.payment_url);
import requests resp = requests.post( "https://api.depixapp.com/api/products", headers={"Authorization": "Bearer sk_live_<your-key>"}, json={ "name": "T-shirt M", "slug": "tshirt-m", "amount": 2990, "description": "T-shirt size M", }, ) data = resp.json() print(data["product"]["payment_url"])
$ch = curl_init("https://api.depixapp.com/api/products"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer sk_live_<your-key>", "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "name" => "T-shirt M", "slug" => "tshirt-m", "amount" => 2990, "description" => "T-shirt size M", ]), ]); $response = curl_exec($ch); $data = json_decode($response, true); echo $data["product"]["payment_url"];
using var client = new HttpClient(); client.DefaultRequestHeaders.Add("Authorization", "Bearer sk_live_<your-key>"); var payload = new { name = "T-shirt M", slug = "tshirt-m", amount = 2990, description = "T-shirt size M" }; var res = await client.PostAsync( "https://api.depixapp.com/api/products", new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") ); Console.WriteLine(await res.Content.ReadAsStringAsync());
body := `{"name":"T-shirt M","slug":"tshirt-m","amount":2990,"description":"T-shirt size M"}` req, _ := http.NewRequest("POST", "https://api.depixapp.com/api/products", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer sk_live_<your-key>") req.Header.Set("Content-Type", "application/json") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() io.Copy(os.Stdout, resp.Body)
require "net/http" require "json" uri = URI("https://api.depixapp.com/api/products") req = Net::HTTP::Post.new(uri, { "Authorization" => "Bearer sk_live_<your-key>", "Content-Type" => "application/json", }) req.body = { name: "T-shirt M", slug: "tshirt-m", amount: 2990, description: "T-shirt size M" }.to_json res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) } puts JSON.parse(res.body)
HttpClient client = HttpClient.newHttpClient(); String json = """ {"name":"T-shirt M","slug":"tshirt-m","amount":2990,"description":"T-shirt size M"}"""; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.depixapp.com/api/products")) .header("Authorization", "Bearer sk_live_<your-key>") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body());
{
"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"
}
}
List products
Lists the merchant's products with filters and pagination.
Query params (all optional)
| Parameter | Description |
|---|---|
| kind | Filter by row kind: product (default), charge or all. The default keeps charges out of integrations written before they existed; use charge to list charges (each row carries charge_state). |
| 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 "https://api.depixapp.com/api/products?active=1" \ -H "Authorization: Bearer $DEPIX_API_KEY"
{
"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).
Get product
Returns the details of a specific product, including checkout statistics.
curl https://api.depixapp.com/api/products/prd_xxx \ -H "Authorization: Bearer $DEPIX_API_KEY"
{
"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).
Update product
Updates one or more fields of an existing product. Only send the fields you want to change.
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. Minimum: 500. Maximum: 600000. |
| 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 -X PATCH https://api.depixapp.com/api/products/prd_xxx \ -H "Authorization: Bearer $DEPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 3490, "description": "T-shirt size M - Special Edition" }'
{
"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"
}
}
Activate / Deactivate product
Activates or deactivates a product. Inactive products return a 404 error when accessed via the payment link.
curl -X POST https://api.depixapp.com/api/products/prd_xxx/activate \ -H "Authorization: Bearer $DEPIX_API_KEY"
curl -X POST https://api.depixapp.com/api/products/prd_xxx/deactivate \ -H "Authorization: Bearer $DEPIX_API_KEY"
{ "success": true }
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).
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 $DEPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "productIds": ["prd_abc123", "prd_def456"] }'
const res = await fetch("https://api.depixapp.com/api/products/featured", { method: "POST", headers: { "Authorization": "Bearer sk_live_<your-key>", "Content-Type": "application/json", }, body: JSON.stringify({ productIds: ["prd_abc123", "prd_def456"], }), }); const data = await res.json(); console.log(data.featured);
import requests resp = requests.post( "https://api.depixapp.com/api/products/featured", headers={"Authorization": "Bearer sk_live_<your-key>"}, json={ "productIds": ["prd_abc123", "prd_def456"], }, ) data = resp.json() print(data["featured"])
$ch = curl_init("https://api.depixapp.com/api/products/featured"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer sk_live_<your-key>", "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "productIds" => ["prd_abc123", "prd_def456"], ]), ]); $response = curl_exec($ch); $data = json_decode($response, true); print_r($data["featured"]);
using var client = new HttpClient(); client.DefaultRequestHeaders.Add("Authorization", "Bearer sk_live_<your-key>"); var payload = new { productIds = new[] { "prd_abc123", "prd_def456" } }; var res = await client.PostAsync( "https://api.depixapp.com/api/products/featured", new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") ); Console.WriteLine(await res.Content.ReadAsStringAsync());
body := `{"productIds":["prd_abc123","prd_def456"]}` req, _ := http.NewRequest("POST", "https://api.depixapp.com/api/products/featured", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer sk_live_<your-key>") req.Header.Set("Content-Type", "application/json") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() io.Copy(os.Stdout, resp.Body)
require "net/http" require "json" uri = URI("https://api.depixapp.com/api/products/featured") req = Net::HTTP::Post.new(uri, { "Authorization" => "Bearer sk_live_<your-key>", "Content-Type" => "application/json", }) req.body = { productIds: ["prd_abc123", "prd_def456"] }.to_json res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) } puts JSON.parse(res.body)
HttpClient client = HttpClient.newHttpClient(); String json = """ {"productIds":["prd_abc123","prd_def456"]}"""; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.depixapp.com/api/products/featured")) .header("Authorization", "Bearer sk_live_<your-key>") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body());
{
"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 section for the error response format.
Product checkouts
Lists the checkouts generated from a specific product. Accepts the same filters as the general checkout listing.
curl "https://api.depixapp.com/api/products/prd_xxx/checkouts?status=completed" \ -H "Authorization: Bearer $DEPIX_API_KEY"
The response follows the same format as the checkout listing.
Charges
A charge is a product with kind=charge: same endpoint, same checkout, same webhooks, with three differences — it has a due date, it can carry a late fine and interest, and it lives on a private link that never shows up on your public store. It is the right shape for rent, tuition, an instalment — anything that falls due on a date and can be paid late.
Create it with POST /api/products passing kind: "charge" and due_date; list with GET /api/products?kind=charge. The link comes back in payment_url as https://pay.depixapp.com/c/prd_xxx — addressed by id (not by slug), served with noindex, and its link preview in messaging apps shows your store name and the charge title, never the amount. Every other product endpoint (GET, PATCH, activate/deactivate, product checkouts) works on charges unchanged.
If you have direct DePix settlement enabled, the charge page also offers Pagar com DePix next to Pix — the same flow as the store and product pages. On the DePix rail the amount charged is the day's total (original amount + late fine + interest), with your discount applied to that total, and settlement joins the charge's queue exactly like a Pix payment. That rail asks the payer for no CPF/CNPJ.
How the amount is computed
The amount is not fixed: it is computed when the payer opens the link and generates the QR.
days_late = calendar days after the due date (America/Sao_Paulo timezone) fine = base_amount × late_fine_bps / 10000 // one time only interest = base_amount × late_interest_monthly_bps / 10000 × days_late / 30 total = min(base_amount + fine + interest, 600000) // per-transaction cap
The due date itself does not count as late — lateness starts the next day. There is no rolling to the next business day: Pix runs 24/7. Interest is linear (pro-rata per day), never compounded. There is no inflation adjustment.
Recurrence and FIFO settlement
With recurrence, a charge becomes a series of due dates anchored on due_date, and the same link works forever. Each payment settles the oldest unpaid cycle; the next visit shows the following one. The cycle each checkout settled is stamped in metadata.charge_cycle, and the webhook carries data.product_id — together they tell you exactly which month was paid.
Two rules decide which cycle a QR bills, and they are worth reading before you integrate:
- A live QR reserves its cycle. Amount and cycle are frozen when the QR is minted, but the position in the series is resolved at settlement. If two live QRs were priced as the same cycle and both got paid, the second payer would carry the fine and interest of a cycle that was not late. So an un-expired QR counts toward the position: the next QR is priced for the cycle it will actually settle. Expired QRs do not count.
- The position never moves backwards. What counts is a cycle that ever settled. A reversal (MED, payer-mismatch refund) therefore does not make the link re-bill a cycle that is still paid, nor apply the wrong cycle's late fees — and it does not re-bill the reversed cycle on its own. That is the merchant's decision: you receive
checkout.cancelled, you see the sale cancelled, and you decide.
charge_state
Present on every row of GET /api/products?kind=charge and in the 201 response of a charge checkout.
{
"settled": false, // true = one-time charge already paid (other fields absent)
"cycle_due_date": "2026-08-05", // current cycle (oldest unpaid)
"days_late": 10,
"base_cents": 250000,
"fine_cents": 5000,
"interest_cents": 833,
"total_today_cents": 255833, // what a QR created now charges
"capped": false, // true = base + fees exceeded the cap and was clamped
"status": "late", // late | due_today | upcoming
"open_past_due_cycles": 1, // > 1 = cycles piled up
"in_flight": false // a paid Pix for this charge is settling
}
Charge-specific errors
| Code | HTTP | When |
|---|---|---|
charge_already_paid | 409 | One-time charge already settled — there is no cycle left to pay. |
charge_payment_in_progress | 409 | A paid Pix for this charge is still settling. Issuing another QR now would mean paying twice. |
charge_payment_pending | 409 | An un-expired QR already reserves the only cycle left (one-time charge). This is not "already paid": nobody has paid anything yet. |
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". |
/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_urlfollows the chain: product field (if set) → merchant default → null. - The
redirect_urlfollows the same chain.
Public product
Returns the public data of an active product. Does not require authentication.
curl https://api.depixapp.com/api/products/prd_xxx/public
{
"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"
}
}
Product checkout
Creates a checkout from an active product. Does not require authentication. The amount is inherited from the product.
Parameters
| Field | Type | Description | |
|---|---|---|---|
| payer_tax_number | string | required | CPF or CNPJ of whoever pays the Pix, with or without punctuation. Must be a real, registered CPF/CNPJ — the payment processor validates beyond the checksum when generating the QR. Required on the pix rail only. |
| payment_method | string | optional | pix (default) or depix — see Receive DePix directly. The depix rail takes no CPF/CNPJ. |
| expected_discount_pct | integer | optional | On the depix rail only: the discount (0–90) your page displayed. Different from the current one? discount_changed (409). |
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 (status 201).
error.details when it describes the payer, what the payer typed, or the thing being paid — which covers every code listed above. Refusals that belong to the merchant's account (receiving limits, onboarding gates, account configuration) answer a fixed neutral message with error.details omitted. The error.code is always the real one. If your integration needs the numbers, make the authenticated merchant call at POST /api/checkouts.
Merchant page
Returns the merchant's public data. Does not require authentication.
curl https://api.depixapp.com/api/merchants/joao/public
{
"merchant": {
"name": "Loja do Joao",
"merchant_slug": "joao",
"username": "joao"
}
}
Merchant checkout
Creates a checkout with a custom amount from the merchant page. Does not require authentication.
Parameters
| Field | Type | Description | |
|---|---|---|---|
| amount | integer | required | Amount in centavos. Minimum: 500. Maximum: 600000. |
| payer_tax_number | string | required | CPF or CNPJ of whoever pays the Pix, with or without punctuation. Must be a real, registered CPF/CNPJ — the payment processor validates beyond the checksum when generating the QR. Required on the pix rail only. |
| payment_method | string | optional | pix (default) or depix — see Receive DePix directly. The depix rail takes no CPF/CNPJ. |
| expected_discount_pct | integer | optional | On the depix rail only: the discount (0–90) your page displayed. Different from the current one? discount_changed (409). |
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 (status 201).
error.details. The full numbers are only returned on the authenticated call, at POST /api/checkouts.
Receive DePix directly (Liquid)
Every checkout can be charged on one of two rails, picked with the payment_method field. The default (pix) is the usual Pix QR. The alternative (depix) charges directly in DePix: the payer sends DePix wallet to wallet on the Liquid network, to an address dedicated to that merchant, and DePix App watches the network to confirm the payment. There is no Pix QR, no payer tax number is asked for, and the money lands straight in the merchant's wallet.
How the merchant enables it
Direct receiving is switched on by the account owner inside DePix App, under Receive with DePix (password confirmation). On activation the app creates a dedicated address for these receipts — separate from the address that receives Pix settlements — and the merchant picks a discount from 0% to 90% for whoever pays on this rail. There is no API-key endpoint to enable the rail or to change the discount: it is always the owner, logged in, with a password.
Until the merchant enables it, any creation with payment_method: "depix" answers depix_not_enabled (400) — the Pix rail keeps working normally.
Create a DePix charge
The same endpoints as always (POST /api/checkouts, product checkout and merchant checkout), only the rail changes.
curl -X POST https://api.depixapp.com/api/checkouts \ -H "Authorization: Bearer $DEPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 9990, "payment_method": "depix", "description": "Order #124", "expires_in": 1800, "expected_discount_pct": 10 }'
const res = await fetch("https://api.depixapp.com/api/checkouts", { method: "POST", headers: { "Authorization": "Bearer sk_live_<your-key>", "Content-Type": "application/json", }, body: JSON.stringify({ amount: 9990, payment_method: "depix", description: "Order #124", expires_in: 1800, expected_discount_pct: 10, }), }); const data = await res.json(); console.log(data.depix.amount, data.depix.uri);
import requests resp = requests.post( "https://api.depixapp.com/api/checkouts", headers={"Authorization": "Bearer sk_live_<your-key>"}, json={ "amount": 9990, "payment_method": "depix", "description": "Order #124", "expires_in": 1800, "expected_discount_pct": 10, }, ) data = resp.json() print(data["depix"]["amount"], data["depix"]["uri"])
{
"id": "chk_01jxxxxxxxxxxxxxxxxxxxxxx",
"status": "pending",
"amount": 9990,
"description": "Order #124",
"image_url": null,
"expires_at": "2026-07-29 12:30:00",
"is_live": true,
"payment_url": "https://pay.depixapp.com/chk_01jxxxxxxxxxxxxxxxxxxxxxx",
"payment_method": "depix",
"depix": {
"address": "lq1qqw8re6vg9dqfazzsx4h9pkq6trxfmk8n0h0ykr7v9k8xn7pdrjq...",
"amount_cents": 8991,
"amount": "89.91",
"asset_id": "02f22f8d9c76ab41661a2729e4752e2c5d1a263012141b86ea98af5472df5189",
"uri": "liquidnetwork:lq1qqw8re6...?amount=89.91&assetid=02f22f8d...&depixid=chk_01jxxx...",
"discount_pct": 10,
"original_amount_cents": 9990,
"detected": false
}
}
A DePix checkout carries no pix block — it simply does not exist on this rail. A Pix checkout, in turn, carries no depix block. Always read payment_method before reading the payment payload.
The depix object
| Field | Type | Description |
|---|---|---|
address | string | Confidential Liquid address (lq1…) dedicated to that merchant's direct receipts. In test mode it is a fake address: never send anything to it. |
amount_cents | integer | Exact amount to send, in centavos. It is the face amount minus the merchant's discount and minus an adjustment of up to 99 centavos (always downwards) that makes this value unique among the merchant's open charges. |
amount | string | The same amount in the format a wallet signs ("89.91"). Display and transmit it exactly like that, never rounded. |
asset_id | string | Identifier of DePix on the Liquid network. Sending any other coin to that address loses the money. |
uri | string | Payment link with address, amount, coin and this checkout's id already embedded (liquidnetwork:…?amount=…&assetid=…&depixid=…). This is what you hand to a wallet — so nobody types an amount by hand. Any BIP21 wallet ignores depixid; the DePix App uses it to re-read the checkout and confirm the address and status before offering to pay, so a URI written by someone else is refused rather than paid. The link never carries the store name: a name inside the URI is a name in the payer's clipboard, which anyone can write. null in test mode. |
discount_pct | integer | The merchant's discount applied on this rail, from 0 to 90. |
original_amount_cents | integer | Face amount, before the discount and the centavo adjustment — the checkout's own amount. |
detected | boolean | true once a matching payment has shown up on the network but is not confirmed yet. It only feeds the "received, confirming" screen; the status stays pending and no webhook fires yet. |
Status flow
| Status | When it happens | Webhook |
|---|---|---|
pending | Awaiting payment. depix.detected turns true as soon as the transaction shows up on the network (seconds). | — |
approved | First confirmation on the network (~1 minute) with the amount matched to this charge. The money is already in the merchant's wallet — this is the safe point to release the order. | checkout.approved |
completed | Second confirmation. Terminal. | checkout.completed |
expired | Terminal, fired 15 minutes after expires_at: that is the grace window so a payment broadcast in the last seconds can still confirm and be credited. | checkout.expired |
The processing and cancelled statuses are not used on this rail. While the countdown shows 0 and the status is still pending, keep polling: that is the grace window above, not a stuck charge.
Why does the amount have odd centavos?
The payment is identified by its exact amount. So that two open charges of the same merchant never share a value, the API may shave a few centavos — the amount can vary by up to R$ 0.99 downwards, always in the payer's favour. That is why the charged amount (depix.amount_cents) can be a few centavos below the face amount minus the discount. Always charge and reconcile by the amount the API returned, never by an amount you recomputed.
One practical consequence: a payment for a different amount is not credited automatically — it becomes an unmatched receipt (the merchant sees it in the app and gets the checkout.unmatched_payment webhook). The money is in the merchant's wallet; only the automatic link to the order did not happen.
Webhooks and reconciliation
In the checkout.* events of this rail, amount is still the face value, and what was actually paid comes in amount_received (with discount_pct and payment_method next to it). Release the order by amount_received. If you reconcile by reading the charge instead of listening for the webhook, the depix block on GET /api/checkouts/{id} stays there after payment — depix.amount_cents is the amount that arrived (uri comes back null, since there is nothing left to pay). Receipts that match no charge produce the checkout.unmatched_payment event, sent to the merchant's default_callback_url.
depix_not_enabled 400 merchant does not accept direct DePix receiving depix_busy 409 no unique amount available right now — offer Pix discount_changed 409 the discount changed (details carries the current values) depix_address_unsupported 400 receiving address is not a confidential lq1... address depix_address_conflict 400 the address must be dedicated to this receiving rail invalid_blinding_key 400 the view key does not match the address
The last three only show up in the activation flow performed by the account owner in the app; an API-key integration never meets them.
sk_test_) the DePix rail issues no payable destination: the address is a placeholder, uri comes back null and there is no QR. Complete a test charge with simulate payment, just like on the Pix rail.
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.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.
Fees
Fees are deducted from the amount — what the payer sends is not what the destination receives. Plan around the net figure:
| Flow | Fee | R$ 100,00 becomes |
|---|---|---|
| Deposit (BRL → DePix) | 2% + R$ 0,99 | R$ 97,01 in DePix |
| Withdrawal up to R$ 100,00 | 1% + R$ 1,00 | R$ 98,00 in the Pix key |
| Withdrawal above R$ 100,00 | 2% | — |
The two withdrawal tiers meet without a step: at exactly R$ 100,00 both rules cost R$ 2,00. Rates can change — the canonical, always-current table is the fee panel on depixapp.com; treat the values above as illustrative of the shape, not as a contract.
The DePix asset on Liquid
DePix is an issued asset on Liquid mainnet, 8 decimals, pegged 1:1 to BRL. Its asset id is:
02f22f8d9c76ab41661a2729e4752e2c5d1a263012141b86ea98af5472df5189
You need it whenever you build a Liquid transaction yourself instead of letting the SDK do it — in particular the withdrawal fee output, which must pay fee_cents to fee_address as an explicit (unblinded) DePix output in the same transaction. Paying the wrong asset, or paying it blinded, makes the withdrawal fail and can lose the funds.
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 thedeposit.*webhooks until the terminal statusdepix_sent— the DePix arrived at the given Liquid address.
Personal deposits count towards account verification. On-ramp alternative: create a checkout against yourself — note that checkout payments do not count towards verification.
The withdrawal flow (off-ramp)
- 1.
POST /api/withdraw→ quote withdepositAddress(the provider's Liquid address). - 2. Send the DePix to the
depositAddressfrom your wallet — signing is always client-side; the API never touches private keys or holds funds. - 3. Track via
GET /api/withdrawals/:idand/or thewithdraw.*webhooks untilsent— 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, 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, withdetailscarrying the currentlimit_cents/used_cents. - Key limits — the spending limits the owner set when creating the
wallet_writekey (per_tx_limit_cents,daily_limit_cents; see Scopes and limits), visible viaGET /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.
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.
Parameters
| Field | Type | Description | |
|---|---|---|---|
| amountInCents | integer | required | Amount in centavos. Minimum: 500 (R$ 5.00). Maximum: 600000 (R$ 6,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. Must be a real, registered CPF/CNPJ — the payment processor validates beyond the checksum when generating the QR. |
payer_tax_number beyond the checksum when generating the QR. A checksum-valid but unregistered number fails at creation with the generic error "Error generating QR Code. Please contact an admin." — if you get that error on create, the payer's CPF/CNPJ is almost certainly not a real registered one.
Example
curl -X POST https://api.depixapp.com/api/deposit \ -H "Authorization: Bearer $DEPIX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: dep-order-42" \ -d '{ "amountInCents": 5000, "depixAddress": "lq1qq...", "payer_tax_number": "529.982.247-25" }'
const res = await fetch("https://api.depixapp.com/api/deposit", { method: "POST", headers: { "Authorization": "Bearer sk_live_<your-key>", "Content-Type": "application/json", "Idempotency-Key": "dep-order-42", }, body: JSON.stringify({ amountInCents: 5000, depixAddress: "lq1qq...", payer_tax_number: "529.982.247-25", }), }); const data = await res.json(); console.log(data.response.qrCopyPaste, data.response.id);
import requests resp = requests.post( "https://api.depixapp.com/api/deposit", headers={ "Authorization": "Bearer sk_live_<your-key>", "Idempotency-Key": "dep-order-42", }, json={ "amountInCents": 5000, "depixAddress": "lq1qq...", "payer_tax_number": "529.982.247-25", }, ) data = resp.json() print(data["response"]["qrCopyPaste"], data["response"]["id"])
$ch = curl_init("https://api.depixapp.com/api/deposit"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer sk_live_<your-key>", "Content-Type: application/json", "Idempotency-Key: dep-order-42", ], CURLOPT_POSTFIELDS => json_encode([ "amountInCents" => 5000, "depixAddress" => "lq1qq...", "payer_tax_number" => "529.982.247-25", ]), ]); $response = curl_exec($ch); $data = json_decode($response, true); echo $data["response"]["qrCopyPaste"];
using var client = new HttpClient(); client.DefaultRequestHeaders.Add("Authorization", "Bearer sk_live_<your-key>"); client.DefaultRequestHeaders.Add("Idempotency-Key", "dep-order-42"); var payload = new { amountInCents = 5000, depixAddress = "lq1qq...", payer_tax_number = "529.982.247-25" }; var res = await client.PostAsync( "https://api.depixapp.com/api/deposit", new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") ); var json = await res.Content.ReadAsStringAsync(); Console.WriteLine(json);
body := `{"amountInCents":5000,"depixAddress":"lq1qq...","payer_tax_number":"529.982.247-25"}` req, _ := http.NewRequest("POST", "https://api.depixapp.com/api/deposit", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer sk_live_<your-key>") req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", "dep-order-42") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() io.Copy(os.Stdout, resp.Body)
require "net/http" require "json" uri = URI("https://api.depixapp.com/api/deposit") req = Net::HTTP::Post.new(uri, { "Authorization" => "Bearer sk_live_<your-key>", "Content-Type" => "application/json", "Idempotency-Key" => "dep-order-42", }) req.body = { amountInCents: 5000, depixAddress: "lq1qq...", payer_tax_number: "529.982.247-25" }.to_json res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) } puts JSON.parse(res.body)
HttpClient client = HttpClient.newHttpClient(); String json = """ {"amountInCents":5000,"depixAddress":"lq1qq...","payer_tax_number":"529.982.247-25"}"""; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.depixapp.com/api/deposit")) .header("Authorization", "Bearer sk_live_<your-key>") .header("Content-Type", "application/json") .header("Idempotency-Key", "dep-order-42") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body());
{
"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
}
}
{
"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
}
}
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.
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.
curl https://api.depixapp.com/api/deposits/qr-id-456 \ -H "Authorization: Bearer $DEPIX_API_KEY"
{
"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. |
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.
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.
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 $DEPIX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: wd-order-42" \ -d '{ "pixKey": "someone@example.com", "depositAmountInCents": 10000, "taxNumber": "529.982.247-25" }'
const res = await fetch("https://api.depixapp.com/api/withdraw", { method: "POST", headers: { "Authorization": "Bearer sk_live_<your-key>", "Content-Type": "application/json", "Idempotency-Key": "wd-order-42", }, body: JSON.stringify({ pixKey: "someone@example.com", depositAmountInCents: 10000, taxNumber: "529.982.247-25", }), }); const data = await res.json(); console.log(data.response.withdrawalId, data.response.depositAddress);
import requests resp = requests.post( "https://api.depixapp.com/api/withdraw", headers={ "Authorization": "Bearer sk_live_<your-key>", "Idempotency-Key": "wd-order-42", }, json={ "pixKey": "someone@example.com", "depositAmountInCents": 10000, "taxNumber": "529.982.247-25", }, ) data = resp.json() print(data["response"]["withdrawalId"], data["response"]["depositAddress"])
$ch = curl_init("https://api.depixapp.com/api/withdraw"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer sk_live_<your-key>", "Content-Type: application/json", "Idempotency-Key: wd-order-42", ], CURLOPT_POSTFIELDS => json_encode([ "pixKey" => "someone@example.com", "depositAmountInCents" => 10000, "taxNumber" => "529.982.247-25", ]), ]); $response = curl_exec($ch); $data = json_decode($response, true); echo $data["response"]["depositAddress"];
using var client = new HttpClient(); client.DefaultRequestHeaders.Add("Authorization", "Bearer sk_live_<your-key>"); client.DefaultRequestHeaders.Add("Idempotency-Key", "wd-order-42"); var payload = new { pixKey = "someone@example.com", depositAmountInCents = 10000, taxNumber = "529.982.247-25" }; var res = await client.PostAsync( "https://api.depixapp.com/api/withdraw", new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") ); var json = await res.Content.ReadAsStringAsync(); Console.WriteLine(json);
body := `{"pixKey":"someone@example.com","depositAmountInCents":10000,"taxNumber":"529.982.247-25"}` req, _ := http.NewRequest("POST", "https://api.depixapp.com/api/withdraw", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer sk_live_<your-key>") req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", "wd-order-42") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() io.Copy(os.Stdout, resp.Body)
require "net/http" require "json" uri = URI("https://api.depixapp.com/api/withdraw") req = Net::HTTP::Post.new(uri, { "Authorization" => "Bearer sk_live_<your-key>", "Content-Type" => "application/json", "Idempotency-Key" => "wd-order-42", }) req.body = { pixKey: "someone@example.com", depositAmountInCents: 10000, taxNumber: "529.982.247-25" }.to_json res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) } puts JSON.parse(res.body)
HttpClient client = HttpClient.newHttpClient(); String json = """ {"pixKey":"someone@example.com","depositAmountInCents":10000,"taxNumber":"529.982.247-25"}"""; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.depixapp.com/api/withdraw")) .header("Authorization", "Bearer sk_live_<your-key>") .header("Content-Type", "application/json") .header("Idempotency-Key", "wd-order-42") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body());
{
"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.
{
"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
}
}
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.
curl https://api.depixapp.com/api/withdrawals/wd-123 \ -H "Authorization: Bearer $DEPIX_API_KEY"
{
"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. |
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.
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.
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_urlmust be HTTPS and publicly accessible (no private IPs).
Request headers
X-DePix-Signature— HMAC-SHA256 signature (see Verify signature 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— alwaysDePix-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.
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",
"product_id": null,
"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",
"product_id": null,
"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",
"product_id": null,
"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",
"product_id": null,
"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",
"product_id": null,
"status": "expired",
"amount": 2990,
"expires_at": "2025-06-01T15:30:00.000Z",
"metadata": { "order_id": "ORD-123" }
}
}
checkout.unmatched_payment
Direct DePix rail only: a payment landed on the merchant's address that matches no charge (an amount different from the one quoted, a payment after the grace window, or a second payment for an already settled charge). The money is in the merchant's wallet — only the automatic link to the order did not happen, and reconciling it is a human decision. Since there is no checkout, delivery goes to the merchant's default_callback_url.
{
"event": "checkout.unmatched_payment",
"data": {
"event_id": "evt_01jxxxxxxxxxxxxxxxxxxxxxx",
"id": "dout_9f8e7d6c5b4a",
"type": "depix_output",
"status": "unattributed",
"amount_cents": 8997,
"txid": "abab…",
"vout": 1,
"first_seen_at": "2026-07-29 12:07:00",
"reason": "no_matching_checkout"
}
}
status is either unattributed (no open charge with that exact amount) or duplicate (a second payment for an already settled charge). first_seen_at is when the payment was seen on the merchant's address — possibly a few minutes before this notification — and it is the same time the app shows. reason spells out why it was not credited (no_matching_checkout, duplicate_payment, ambiguous_candidates, value_not_whole_cents, max_attributions_per_tx, transition_lost); reconcile on id, not on that string.
checkout.* events of the direct DePix rail also carry payment_method, amount_received (what was actually paid, in centavos) and discount_pct. The amount field is still the face value — release the order by amount_received.
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.<status> / withdraw.<status>. 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"
}
}
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).
# 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
import crypto from "node:crypto"; function verifyWebhook(rawBody, sigHeader, secret) { const parts = Object.fromEntries( sigHeader.split(",").map(p => p.split("=", 2)) ); const timestamp = parts["t"]; const received = parts["v1"]; const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex"); // Use timingSafeEqual to prevent timing attacks const a = Buffer.from(expected, "hex"); const b = Buffer.from(received, "hex"); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } // Example with Express app.post("/webhook/depix", express.raw({ type: "application/json" }), (req, res) => { const sig = req.headers["x-depix-signature"]; if (!verifyWebhook(req.body.toString(), sig, process.env.DEPIX_WEBHOOK_SECRET)) { return res.status(401).send("Invalid signature"); } const { event, data } = JSON.parse(req.body); // process the event... res.sendStatus(200); });
import hmac, hashlib def verify_webhook(raw_body: str, sig_header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in sig_header.split(",")) timestamp = parts["t"] received = parts["v1"] expected = hmac.new( secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, received)
function verifyWebhook(string $rawBody, string $sigHeader, string $secret): bool { $parts = []; foreach (explode(",", $sigHeader) as $pair) { [$k, $v] = explode("=", $pair, 2); $parts[$k] = $v; } $expected = hash_hmac("sha256", $parts["t"] . "." . $rawBody, $secret); return hash_equals($expected, $parts["v1"]); }
static bool VerifyWebhook(string rawBody, string sigHeader, string secret) { var parts = sigHeader.Split(',') .ToDictionary(p => p.Split('=', 2)[0], p => p.Split('=', 2)[1]); using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var expected = Convert.ToHexString( hmac.ComputeHash(Encoding.UTF8.GetBytes($"{parts["t"]}.{rawBody}")) ).ToLower(); return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(parts["v1"]) ); }
func verifyWebhook(rawBody, sigHeader, secret string) bool { parts := make(map[string]string) for _, p := range strings.Split(sigHeader, ",") { kv := strings.SplitN(p, "=", 2) parts[kv[0]] = kv[1] } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(parts["t"] + "." + rawBody)) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(parts["v1"])) }
def verify_webhook(raw_body, sig_header, secret) parts = sig_header.split(",").to_h { |p| p.split("=", 2) } expected = OpenSSL::HMAC.hexdigest("sha256", secret, "#{parts['t']}.#{raw_body}") Rack::Utils.secure_compare(expected, parts["v1"]) end
static boolean verifyWebhook(String rawBody, String sigHeader, String secret) throws Exception { Map<String, String> parts = new HashMap<>(); for (String p : sigHeader.split(",")) { String[] kv = p.split("=", 2); parts.put(kv[0], kv[1]); } Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256")); String expected = HexFormat.of().formatHex( mac.doFinal((parts.get("t") + "." + rawBody).getBytes()) ); return MessageDigest.isEqual(expected.getBytes(), parts.get("v1").getBytes()); }
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_livefield returnsfalse. - The generated QR code is not a valid Pix — it cannot be paid with a banking app.
- Use the
/simulate-paymentendpoint 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/depositandPOST /api/withdrawwithsk_test_respond with synthetic payloads marked"sandbox": true: unpayableSANDBOX-…-DO-NOT-PAYstrings,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/:idandGET /api/withdrawals/:idwith asandbox_*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 notsandbox_*→404.
Simulate payment
Marks a test checkout as paid. Only works with sk_test_ keys. Fires the checkout.completed webhook normally.
# 1. Create a test checkout curl -X POST https://api.depixapp.com/api/checkouts \ -H "Authorization: Bearer $DEPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 1000, "payer_tax_number": "529.982.247-25", "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 $DEPIX_API_KEY"
{ "success": true }
After the simulation, your callback_url will receive the checkout.completed event within seconds — exactly like a real payment.
Verify key (GET /api/me)
Returns the authenticated merchant's information. Useful for verifying if the API key is valid and checking account data.
curl https://api.depixapp.com/api/me \ -H "Authorization: Bearer $DEPIX_API_KEY"
{
"merchant_id": "mrc_xxx",
"name": "Loja do Joao",
"username": "joao",
"merchant_slug": "joao",
"is_live": true,
"created_at": "2025-06-01T00:00:00.000Z"
}
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.
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. |
GET /api/me.
curl -X PATCH https://api.depixapp.com/api/merchants/me \ -H "Authorization: Bearer $DEPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "default_redirect_url": "https://shop.example.com/thanks" }'
{
"success": true,
"merchant_slug": "loja-do-joao" // changes only if business_name changed
}
{
"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" }
}
}
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:<code>). The owner queries each key's history with the endpoint below. Retention: 90 days. GETs and 429 responses are not logged.
Query params (all optional)
| Parameter | Description |
|---|---|
| limit | Results per page. Default: 50. Minimum: 1. Maximum: 100. |
| offset | Pagination. Default: 0. |
curl "https://api.depixapp.com/api/api-keys/a1b2c3d4e5f6/audit?limit=50&offset=0" \ -H "Authorization: Bearer <dashboard-jwt>"
{
"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
}
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
429witherror.code = "rate_limited"(or"merchant_rate_limited"), theerror.retry_afterfield and theRetry-Afterheader — wait the indicated seconds before retrying. - On
wallet_*routes authenticated with an API key, an infrastructure failure in the rate-limit check responds503 service_unavailablewithretry_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
429witherror.code = "payer_velocity_limit",details: { window_minutes, max_per_window }and theRetry-Afterheader telling the seconds until a slot frees up.
Open a ticket
Opens a support ticket. The /api/tickets endpoints work for both human users (the dashboard JWT) and AI agents (an sk_live_/sk_test_ key) — no scope is required. A signed sk_ request behaves exactly like the JWT one. Each session or key only ever sees the tickets it created; another principal's tickets are invisible.
GET /api/tickets/{id} every few minutes, not seconds. For an agent, polling that endpoint is how it reads the answer.
Parameters
| Field | Type | Description | |
|---|---|---|---|
| subject | string | required | Short summary. 4–120 characters. |
| body | string | required | The message. 1–4000 characters. |
| category | string | optional | One of bug, question, account, payment, other. Default other. |
Ticket fields
| Field | Values | Description |
|---|---|---|
status | awaiting_reply · answered · closed | awaiting_reply = support's turn to reply (never auto-closes). answered = support replied and is waiting on you (auto-closes after 2 business days with no user reply). closed = terminal, though an auto-closed ticket reopens if you reply within 7 days. |
opener_type | human · agent | Who opened the ticket. |
closed_reason | null · user · admin · auto | Why it closed. null while open. |
category | bug · question · account · payment · other | Set at creation. |
curl -X POST https://api.depixapp.com/api/tickets \ -H "Authorization: Bearer $DEPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "subject": "Withdrawal stuck as pending", "category": "payment", "body": "Withdrawal wtd_123 has been pending for 2 hours." }'
{
"ticket": {
"id": "tkt_ab12cd34ef",
"opener_type": "human",
"status": "awaiting_reply",
"subject": "Withdrawal stuck as pending",
"category": "payment",
"created_at": "2026-07-22 12:00:00",
"last_activity_at": "2026-07-22 12:00:00",
"closed_reason": null,
"closed_at": null
}
}
validation_error 400 bad field (details.field; legacy response.errors[] sibling) ticket_open_cap 429 too many open tickets (details.max_open) unauthorized 401 missing / invalid credential
The open-ticket cap is 5 simultaneously open tickets (1 for suspended accounts); error.details.max_open carries the ceiling. Validation errors also expose the legacy response.errors[] sibling alongside error.details.field.
List your tickets
Lists the tickets created by the calling principal (this JWT session or this API key), most-recent activity first. Paginated.
Query parameters
| Field | Type | Description | |
|---|---|---|---|
| limit | integer | optional | Page size. Default 50. |
| offset | integer | optional | Rows to skip. Default 0. |
curl "https://api.depixapp.com/api/tickets?limit=50&offset=0" \ -H "Authorization: Bearer $DEPIX_API_KEY"
{
"tickets": [
{
"id": "tkt_ab12cd34ef",
"opener_type": "human",
"status": "awaiting_reply",
"subject": "Withdrawal stuck as pending",
"category": "payment",
"created_at": "2026-07-22 12:00:00",
"last_activity_at": "2026-07-22 12:00:00",
"closed_reason": null,
"closed_at": null
}
],
"total": 1,
"limit": 50,
"offset": 0
}
Ticket detail & messages
Returns one ticket plus its full message thread, oldest first. Polling this endpoint is how you (or an agent) read a support reply.
curl https://api.depixapp.com/api/tickets/tkt_ab12cd34ef \ -H "Authorization: Bearer $DEPIX_API_KEY"
{
"ticket": {
"id": "tkt_ab12cd34ef",
"opener_type": "human",
"status": "answered",
"subject": "Withdrawal stuck as pending",
"category": "payment",
"created_at": "2026-07-22 12:00:00",
"last_activity_at": "2026-07-22 13:15:00",
"closed_reason": null,
"closed_at": null
},
"messages": [
{ "id": "tmsg_1", "sender": "user", "body": "Withdrawal wtd_123 has been pending for 2 hours.", "created_at": "2026-07-22 12:00:00" },
{ "id": "tmsg_2", "sender": "admin", "body": "It settled just now — can you confirm?", "created_at": "2026-07-22 13:15:00" }
]
}
A message's sender is one of user, admin or system.
not_found 404 no such ticket — or it belongs to another principal
A ticket you do not own returns the same 404 not_found as one that does not exist — ownership is never disclosed.
Post a reply
Appends a user message to the thread. Replying to an answered ticket flips it back to awaiting_reply. Replying to an auto-closed ticket within 7 days reopens it. Tickets closed by a user or an admin are terminal — a reply there fails with 409 ticket_closed.
Parameters
| Field | Type | Description | |
|---|---|---|---|
| body | string | required | The reply. 1–4000 characters. |
curl -X POST https://api.depixapp.com/api/tickets/tkt_ab12cd34ef/messages \ -H "Authorization: Bearer $DEPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "body": "Confirmed, the funds arrived. Thanks!" }'
{
"message": { "id": "tmsg_3", "sender": "user", "body": "Confirmed, the funds arrived. Thanks!", "created_at": "2026-07-22 13:20:00" },
"ticket": {
"id": "tkt_ab12cd34ef",
"opener_type": "human",
"status": "awaiting_reply",
"subject": "Withdrawal stuck as pending",
"category": "payment",
"created_at": "2026-07-22 12:00:00",
"last_activity_at": "2026-07-22 13:20:00",
"closed_reason": null,
"closed_at": null
}
}
validation_error 400 bad body (details.field) not_found 404 no such ticket (or not yours) ticket_closed 409 ticket was closed by a user or an admin (terminal)
Attach a file
Uploads one file (image, PDF, log or JSON) to the support team — ideal for a screenshot or a diagnostics file when reporting a bug. The bytes go in file_b64 (base64, no data: URI prefix), up to ~3 MB. The file is forwarded to support, not stored or served back — the response records only its name and type. Attaching counts as a reply: an answered ticket returns to awaiting_reply, and a ticket auto-closed within 7 days reopens.
Parameters
| Field | Type | Description | |
|---|---|---|---|
| filename | string | required | Filename shown to support. 1–200 characters. |
| content_type | string | required | One of: image/png, image/jpeg, image/webp, application/pdf, text/plain, application/json. |
| file_b64 | string | required | The file bytes, base64-encoded (no data: URI prefix). Max ~3 MB decoded. |
| caption | string | optional | Short note shown with the file. Max 400 characters. |
curl -X POST https://api.depixapp.com/api/tickets/tkt_ab12cd34ef/attachments \ -H "Authorization: Bearer $DEPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "filename": "checkout-error.png", "content_type": "image/png", "file_b64": "iVBORw0KGgo…" }'
{
"message": {
"id": "tmsg_5",
"sender": "user",
"body": "checkout-error.png",
"created_at": "2026-07-22 14:05:00",
"attachment": { "name": "checkout-error.png", "mime": "image/png" }
},
"ticket": {
"id": "tkt_ab12cd34ef",
"opener_type": "human",
"status": "awaiting_reply",
"subject": "Withdrawal stuck as pending",
"category": "payment",
"created_at": "2026-07-22 12:00:00",
"last_activity_at": "2026-07-22 14:05:00",
"closed_reason": null,
"closed_at": null
}
}
validation_error 400 bad content_type/file_b64 (details.field) attachment_too_large 413 file above ~3 MB not_found 404 no such ticket (or not yours) ticket_closed 409 ticket was closed by a user or an admin (terminal) attachment_unavailable 503 no support channel right now — retry or use text
Close a ticket
Closes the ticket yourself, setting closed_reason to user. A user-closed ticket is terminal — to continue the conversation you open a new ticket.
curl -X POST https://api.depixapp.com/api/tickets/tkt_ab12cd34ef/close \ -H "Authorization: Bearer $DEPIX_API_KEY"
{
"ticket": {
"id": "tkt_ab12cd34ef",
"opener_type": "human",
"status": "closed",
"subject": "Withdrawal stuck as pending",
"category": "payment",
"created_at": "2026-07-22 12:00:00",
"last_activity_at": "2026-07-22 13:25:00",
"closed_reason": "user",
"closed_at": "2026-07-22 13:25:00",
"rating": null,
"rated_at": null
}
}
not_found 404 no such ticket (or not yours) ticket_closed 409 already closed
Rate the support
Scores the support on an already closed ticket from 0 to 10 (0 = worst, 10 = best). Rating is write-once: a second call returns 409 already_rated, so the first score is never silently overwritten.
Rating only records the datapoint — it does not reopen the ticket, does not count as a message, and does not change last_activity_at.
| Field | Type | Required | Description |
|---|---|---|---|
rating | integer | yes | Whole number from 0 to 10. Out-of-range, fractional or non-numeric values return 400 validation_error. |
curl -X POST https://api.depixapp.com/api/tickets/tkt_ab12cd34ef/rating \ -H "Authorization: Bearer $DEPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"rating": 10}'
{
"ticket": {
"id": "tkt_ab12cd34ef",
"opener_type": "human",
"status": "closed",
"subject": "Withdrawal stuck as pending",
"category": "payment",
"created_at": "2026-07-22 12:00:00",
"last_activity_at": "2026-07-22 13:25:00",
"closed_reason": "admin",
"closed_at": "2026-07-22 13:25:00",
"rating": 10,
"rated_at": "2026-07-22 13:31:00"
}
}
validation_error 400 rating missing, outside 0–10, or not a whole number not_found 404 no such ticket (or not yours) ticket_open 409 the ticket is still open already_rated 409 this ticket has already been rated
Agent accounts
An agent account is a merchant that an AI agent creates and runs on its own — no email, WhatsApp or captcha. It is provisioned and driven entirely through the /api/agents/* endpoints below. Most agents reach them through the DePix SDK (the DepixAgent class), which signs every request for you.
Signed-request authentication
The agent endpoints do not use a static API key. Every request is signed with the account's Ed25519 key (generated and kept locally by the agent). Send these headers on each call:
| Header | Value |
|---|---|
x-agent-public-key | 64-hex raw Ed25519 public key |
x-agent-signature | 128-hex Ed25519 signature of the canonical string |
x-agent-nonce | Unique per request — single-use, valid ~11 min |
x-agent-timestamp | Unix seconds, within ±300s of server time |
depix-agent-auth:v1 api.depixapp.com <METHOD> <path-without-query> <timestamp> <nonce> <sha256hex(raw-request-body)>
DepixAgent.create() generates the keypair and every agent.* call is signed automatically. The reference below is for building your own client.
Onboarding & graduation
A new agent starts with a sk_test_ sandbox key and a wallet-only starter sk_live_ key capped at R$100/tx and R$500/day. The account graduates — and can then mint full sk_live_ keys — once it is verified. For an agent, verifying the account means proving a domain over DNS: it is the same domain that unlocks receiving from third parties (checkouts, merchant_* scopes), so one unlocks the other.
The 24h delay (inter_deposit_delay_hours) applied to deposits 2–5 holds the settlement (the DePix payout) of those deposits — it does not block creating the next deposit, which can be created right away.
Deposit position is counted per account, not per channel: a Pix checkout counts exactly like a personal QR. And the delay is exact — no other rule extends the 24h on those deposits. Deposit 1 settles immediately; from deposit 6 on, the receive cap and the instant lane govern. A checkout paid over the DePix rail (Liquid) is outside all of these rules, since it never touches the Pix rail.
agent_invalid_signature 401 signature does not verify agent_signature_expired 401 timestamp outside ±300s agent_replay_detected 401 nonce already used agent_unknown_key 401 key not registered (authed routes) account_suspended 403 account paused (mutating routes) agents_disabled 503 agent onboarding kill-switch is on
Register an agent
Creates an agent account — a merchant, a Liquid receive address and the starter keys. Requires an operator token (op_…) that a human obtains once by connecting an identity (GitHub/Google) in the dashboard — the anti-abuse anchor.
Parameters
| Field | Type | Description | |
|---|---|---|---|
| name | string | required | Display name. 2–100 characters. |
| operator_token | string | required | The op_… token from the human operator. |
| operator_email | string | required | Notification email (never a login). |
| liquid_address | string | required | Wallet receive address. Immutable after register. |
| username | string | optional | Lowercase handle. Defaults to agent_<pubkey-prefix>. |
| default_callback_url | string | optional | HTTPS webhook URL. |
| ref | string | optional | Referral username, for attribution only. |
curl -X POST https://api.depixapp.com/api/agents/register \ -H "x-agent-public-key: <64hex>" \ -H "x-agent-signature: <128hex>" \ -H "x-agent-nonce: <unique>" -H "x-agent-timestamp: <unix>" \ -H "Content-Type: application/json" \ -d '{ "name": "My Agent", "operator_token": "op_...", "operator_email": "me@example.com", "liquid_address": "lq1..." }'
{
"response": {
"agent": { "username": "agent_9f3c2a1b0d", "public_key": "<64hex>", "account_type": "agent" },
"merchant": { "id": "mrc_...", "merchant_slug": "agent_9f3c2a1b0d", "liquid_address": "lq1...", "webhook_secret": "whsec_..." },
"keys": {
"test": { "id": "...", "key": "sk_test_...", "scopes": "merchant_read merchant_write wallet_read wallet_write" },
"live_starter": { "id": "...", "key": "sk_live_...", "scopes": "wallet_read wallet_write", "per_tx_limit_cents": 10000, "daily_limit_cents": 50000, "starter": true }
},
"graduation": {
"requires": "domain_proof", // what is missing: prove a domain
"verify_domain_endpoint": "POST /api/agents/verify-domain", // where to prove it
"allowed_tlds_endpoint": "GET /api/agents/domain-tlds" // accepted TLDs — check first
},
"pacing": {
"first_deposit_max_cents": 2000,
"unverified_per_tx_max_cents": 10000,
"inter_deposit_delay_hours": 24, // delays settlement of deposits 2–5 — does not block creating the next one
"payer_velocity": { "max_per_window": 2, "window_minutes": 30 },
"verified_per_tx_deposit_max_cents": 600000,
"verified_per_tx_withdraw_max_cents": 600000
}
}
}
The key plaintexts and webhook_secret are returned only once.
validation_error 400 bad field (details.field) invalid_operator_token 401 operator token not valid operator_token_revoked 403 agent_pubkey_exists 409 this key already has an account username_taken 409 agents_disabled 503
Create a key
Mints a new API key for the agent's account. Live keys require graduation; merchant_* scopes require a verified domain.
Parameters
| Field | Type | Description | |
|---|---|---|---|
| live | boolean | optional | Default false. true mints sk_live_ (requires graduation). |
| scopes | string[] | optional | Subset of merchant_read, merchant_write, wallet_read, wallet_write. Default ["merchant_read","merchant_write"]. |
| label | string | optional | Up to 100 characters. |
| per_tx_limit_cents | integer | optional | Min 100. Mandatory when the key has wallet_write (defaults to 10000). |
| daily_limit_cents | integer | optional | Min 100. Defaults to 50000 with wallet_write. |
{
"response": {
"id": "...", "key": "sk_live_...", "prefix": "sk_live_",
"is_live": true, "scopes": "wallet_read wallet_write",
"per_tx_limit_cents": 10000, "daily_limit_cents": 50000
}
}
The key plaintext is returned once. Max 5 active keys per kind (live / test).
validation_error 400 bad scopes / label / limits, or 5-key cap reached graduation_pending 403 live:true before graduation domain_required 403 merchant_* scope without a verified domain
Revoke a key
Revokes one of the account's keys. Idempotent — revoking an already-revoked key still succeeds.
Parameters
| Field | Type | Description | |
|---|---|---|---|
| id | string | required | The key id to revoke. Must belong to this agent. |
{ "response": { "id": "...", "revoked": true } }
not_found (404) when the key is not owned by this agent's merchant.
Account status
Reads the account state and graduation progress. Stays available even while suspended, so an agent can read the reason.
Important asymmetry: a suspension of your account keeps this read available (200, with the reason field). The global agent kill-switch, however, answers 503 agents_disabled even for this read — treat 503 agents_disabled as a platform-level pause, not as your own suspension.
{
"response": {
"account_status": "active", // active | suspended
"graduated": false,
"graduation": { "blocked_on": "domain_proof" }, // "domain_proof": prove a domain (your move)
// "gate_review": verified, waiting on our review
// null: already graduated
"keys": [
{ "id": "...", "prefix": "sk_test_", "is_live": false, "starter": false, "scopes": "...", "revoked_at": null }
]
// "reason": "..." — present only when suspended
}
}
Webhook delivery logs
Read-only audit of the deliveries of checkout.*, deposit.* and withdraw.* events to this account's callback URLs — what was delivered, retried or failed. The list returns the 50 most recent attempts (newest first) without bodies; fetch one log by id for the request/response payloads. A log owned by another account answers 404.
curl https://api.depixapp.com/api/agents/webhook-logs \ -H "x-agent-public-key: <64hex>" \ -H "x-agent-signature: <128hex>" \ -H "x-agent-nonce: <unique>" -H "x-agent-timestamp: <unix>"
{
"logs": [
{ "id": "...", "checkout_id": null, "event": "deposit.depix_sent", "url": "https://...", "status_code": 200, "error": null, "attempt": 1, "sent_at": "..." }
]
}
{
"log": {
"id": "...", "event": "checkout.completed", "url": "https://...", "status_code": 200,
"request_body": "{...}", // the signed payload sent (X-DePix-Signature covers these bytes)
"response_body": "{...}", // what the receiver answered
"error": null, "attempt": 1, "next_retry_at": null, "sent_at": "..."
}
}
merchant_required 403 no ACTIVE merchant profile (e.g. account suspended) not_found 404 log does not exist or belongs to another account
Verify a domain
Two-phase DNS TXT proof. Unlocks receiving from third parties (checkouts / merchant_* scopes). The domain is normalized to its registrable root (e.g. shop.acme.com.br → acme.com.br).
Parameters
| Field | Type | Description | |
|---|---|---|---|
| domain | string | required | The domain to verify. Its TLD must be in the allowlist. |
| confirm | boolean | optional | Omit for phase 1 (get the token). Send true for phase 2 (check the TXT record). |
confirm) — 200 OK{
"record_name": "_depix-verify.acme.com.br",
"record_value": "depix-verify=<32-hex token>"
}
confirm: true, after adding the TXT record) — 200 OK{ "verified_domain": "acme.com.br", "verified": true } // verified: the account is now verified — graduation follows
validation_error 400 details.field: "domain" domain_tld_not_allowed 422 details.allowed_tlds: [...] domain_free_host 422 vercel.app / netlify.app / github.io / ... denied domain_txt_not_found 422 TXT missing/mismatch — retry after DNS propagates
Domain TLD allowlist
Public. Returns the TLD suffixes accepted by domain verification.
curl https://api.depixapp.com/api/agents/domain-tlds
{ "allowed_tlds": [".com", ".net", ".org", ".io", ".ai", ".dev", ".app", ".com.br", ".br", ".store", ".shop", "..."] }
MCP gateway
The DePix MCP gateway is a hosted Model Context Protocol server that lets any MCP client — Claude, Cursor, ChatGPT — receive Pix without custody: create checkouts and products, and read payment status. It is a thin, stateless client in front of this same REST API; it holds no keys and never moves money.
Transport is MCP Streamable HTTP (stateless). Package @depixapp/mcp also runs locally over stdio (npx -y @depixapp/mcp).
depix-wallet-mcp, stdio) exposing the agent's wallet as tools. Two servers, different blast radius: this gateway cannot move funds, that one can. Instead.
Connect a client
Authenticate with a DePix API key in the Authorization header — sk_test_ for sandbox, sk_live_ for production. The key is forwarded verbatim to the API per request and never stored.
claude mcp add --transport http depix https://mcp.depixapp.com/mcp \ --header "Authorization: Bearer sk_test_YOUR_KEY"
{
"mcpServers": {
"depix": {
"url": "https://mcp.depixapp.com/mcp",
"headers": { "Authorization": "Bearer sk_test_YOUR_KEY" }
}
}
}
{
"mcpServers": {
"depix": {
"command": "npx",
"args": ["-y", "@depixapp/mcp"],
"env": { "DEPIX_API_KEY": "sk_test_YOUR_KEY" }
}
}
}
claude.ai / ChatGPT connect over OAuth (no custom header). Sign in through the connector, then link that login to your DePix account in the dashboard. OAuth sessions are capped to read + merchant scopes and can never move money — use an sk_ key for that. Test any connection with the get_account tool.
Scopes
Each tool needs a scope on the key: merchant_read, merchant_write, wallet_read. A call missing a scope returns insufficient_scope naming what it needs.
Tools
Sixteen tools. All amounts are in centavos. The gateway never creates deposits or withdrawals — that is the Wallet SDK's job.
| Tool | Scope | Does |
|---|---|---|
create_checkout | merchant_write | Create a Pix checkout (needs payer_tax_number). |
get_checkout | merchant_read | Fetch one checkout by id. |
list_checkouts | merchant_read | List/filter checkouts. |
wait_for_checkout | merchant_read | Server-side poll until terminal; streams progress. |
simulate_checkout_payment | merchant_write | Sandbox-only: mark a checkout paid. |
create_product | merchant_write | Create a reusable payment link. |
list_products | merchant_read | List/filter products. |
get_product | merchant_read | Fetch one product + aggregates. |
update_product | merchant_write | Patch product fields. |
activate_product / deactivate_product | merchant_write | Toggle a product on/off. |
set_featured_products | merchant_write | Pin products on the storefront. |
list_product_checkouts | merchant_read | Checkouts for a product. |
get_account | merchant_read | Verify the key / connection. |
get_deposit_status | wallet_read | Read a deposit's status. |
get_withdrawal_status | wallet_read | Read a withdrawal's status. |
Source and full reference: github.com/depixapp/depix-mcp (@depixapp/mcp).
Wallet SDK
@depixapp/sdk is a non-custodial Liquid wallet for Node — the seed is generated and encrypted locally, every signature happens on the agent's side, and the backend never holds a key. It ships two classes:
| Class | Purpose |
|---|---|
DepixWallet | Move money: deposit, withdraw, convert, send, balances. |
DepixAgent | Self-onboarding: register an account and manage API keys (drives the agent endpoints). |
npm install @depixapp/sdk
ESM-only. Node ≥ 22.4, Linux / macOS. Amounts on-chain are bigint 8-decimal sats (R$1.00 = 100_000_000n DePix); Pix amounts are integer BRL cents.
Quickstart
Create a wallet, fund it with Pix, convert to L-BTC — all client-side.
import { DepixWallet } from "@depixapp/sdk"; // create + back up (headless) + open the receive gate const { wallet } = await DepixWallet.create({ passphrase: process.env.DEPIX_WALLET_PASSPHRASE, // ≥ 12 chars mnemonicSecured: true, }); await wallet.confirmBackup(); // deposit: the owner pays the QR (needs DEPIX_API_KEY) const dep = await wallet.deposit({ amountCents: 1000, payerTaxNumber: "OWNER_CPF" }); console.log("Pay this:", dep.qrCopyPaste); await wallet.waitForDeposit(dep.id, { timeoutMs: 15 * 60_000 }); // always bound human waits // convert R$5 of DePix to L-BTC (client-side) await wallet.convert({ from: "DEPIX", to: "LBTC", amount: 500_000_000n }); await wallet.close();
Wallet reference
DepixWallet.create() / open() / restore() return a wallet. Its money and read methods:
| Method | Does |
|---|---|
deposit({ amountCents, payerTaxNumber }) | Create a Pix deposit → { id, qrCopyPaste }. Owner pays; credited net of fees. |
withdraw({ pixKey, recipientTaxNumber, amountCents, mode }) | Pix withdrawal. mode: "send" | "payout". |
convert({ from, to, amount, … }) | Convert between DEPIX / LBTC / USDT / BTC across networks. amount is bigint sats. |
| Lightning pay / receive | Pay and receive Lightning invoices. Settlement goes through Boltz submarine swaps — DePix itself is not issued on Lightning, so a Lightning leg is always a swap against L-BTC. Gift-card purchases settle the same way. |
send({ asset, amountSats, address }) | On-chain send of DEPIX / USDT / LBTC. |
quote(intent) | Read-only route quotes for a conversion. |
getBalances() | Per-asset balances + a BRL estimate. |
getReceiveAddress() | A fresh Liquid receive address (after backup is confirmed). |
waitForDeposit(id, { timeoutMs }) | Poll a deposit to settlement. Always pass timeoutMs. |
custodial: true.
Agent onboarding
DepixAgent drives the agent endpoints — it generates the Ed25519 identity and signs every request. Create it with DepixAgent.create() (or open() to reload).
| Method | Does |
|---|---|
register({ name, operatorToken, operatorEmail, liquidAddress, … }) | Create the account → merchant + starter keys. |
status() | Account status + graduation progress + keys. |
createKey({ live, scopes, … }) | Mint a new key (returned once). |
revokeKey(id) | Revoke a key. |
rotateWebhookSecret() | Rotate the webhook signing secret. |
Errors & runtime
Every error extends DepixSdkError with a stable .code; narrow with isDepixSdkError(err, code?). Common codes:
BACKUP_REQUIRED no receive/deposit until the backup is confirmed API_KEY_REQUIRED deposit/withdraw need DEPIX_API_KEY graduation_pending live key requested before graduation domain_required merchant scope without a verified domain GUARDRAIL_PER_TX_LIMIT amount over the per-tx guardrail GUARDRAIL_DAILY_LIMIT amount over the rolling-24h guardrail MULTIPLE_ROUTES_AVAILABLE pick a route id from quote() and retry POLL_TIMEOUT a wait*() hit its timeoutMs
The agent-facing spec (exact signatures, routing table, recipes) lives in AGENTS.md; full source at github.com/depixapp/depix-sdk (@depixapp/sdk).