# Qualy API — full documentation corpus > Every Qualy docs page (33) concatenated into one Markdown file. Each page is preceded by a source comment and separated by a horizontal rule. For a lighter index, see /llms.txt; the full OpenAPI spec is at https://v1-spec.qualyhq.com/. # Getting started Use the Qualy API to build an integration that can handle complex payment flows and can track a payment from creation through checkout. - [Quick start](/docs/guides/quick-start.md): End-to-end guide to creating your first payment. - [Get your API keys](/docs/api-keys.md): Create and manage your API keys. - [Set up webhooks](/docs/webhooks.md): Get notified when payments succeed or fail. - [API Reference](https://v1-spec.qualyhq.com/): Explore the full API specification. The Qualy API is organized around REST. Our API has predictable resource-oriented URLs, accepts JSON-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes, and verbs (only `GET` and `POST` are used). The Qualy API doesn't support bulk updates. You can work on only one object per request. --- ## Errors Qualy uses conventional HTTP response codes to indicate the success or failure of an API request. In general: Codes in the `2xx` range indicate success. Codes in the `4xx` range indicate an error that failed given the information provided (e.g., a required parameter was omitted, a charge failed, etc.). Codes in the `5xx` range indicate an error with Qualy’s servers. To learn more about how Qualy handles errors, [check this article](/docs/errors.md). ## Versioning Qualy's API is versioned, to support future backwards-incompatible changes. Currently, our API version is `v1`. ### API Reference For the full API Reference for Qualy's v1 API, [check this page](https://v1-spec.qualyhq.com/). --- # Quick start > Go from zero to your first collected payment with the Qualy API in about 10 minutes. This guide takes you from zero to a live payment link in about 10 minutes. You'll make your first authenticated request, create a contact, create a payment intent, and get a URL your customer can pay on. Every request below is copy-paste ready — swap in your API key and tenant ID and run it. --- ## Before you begin You need two things: - **An API key** — create one in the [Dashboard](https://dashboard.qualyhq.com) under **Settings → API keys & webhooks**. See [Creating your API keys](/docs/api-keys.md). - **Your tenant ID** — find it alongside your API keys. See [Multi-tenancy](/docs/tenants.md). Every request is authenticated with two headers: ```bash Authorization: ApiKey your-api-key-here X-TENANT-ID: your-tenant-id-here ``` > **Note — Base URL** > > All requests go to `https://api.qualyhq.com/v1`. The API only uses `GET` and `POST`, accepts and returns JSON, and works on one object per request. See [Authorization](/docs/authentication.md). --- ## Step 1: Make your first request Before writing any integration code, confirm your credentials work. This `GET` returns the user tied to your API key: **cURL** ```bash curl https://api.qualyhq.com/v1/users/user \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' ``` **JavaScript** ```javascript const res = await fetch('https://api.qualyhq.com/v1/users/user', { headers: { 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, }) const { data: user } = await res.json() console.log(user._id) ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $response = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->get('https://api.qualyhq.com/v1/users/user'); $user = $response->json('data'); // unwrap the `data` envelope echo $user['_id']; ``` A successful response looks like this: ```json { "data": { "_id": "6606ab7bfb9085579f1b5769", "type": "api-key", "email": "you@example.com" } } ``` > **Warning — Every response is wrapped in `data`** > > Qualy wraps every successful response in a `{ "data": ... }` envelope. Your actual object is always under `data` — so it's `response.data._id`, not `response._id`. List endpoints add `hasMore` and `count` next to `data` for pagination. Keep this in mind for every example below. If you get a `401`, double-check the `Authorization` header format (`ApiKey ` prefix, then the key). If you get a `400 Invalid Tenant ID`, check your `X-TENANT-ID`. --- ## Step 2: Set up your headers From here we'll use JavaScript. Define your headers once and reuse them: ```javascript const API = 'https://api.qualyhq.com/v1'; const headers = { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }; ``` See [Authorization](/docs/authentication.md) for details on API keys, tenant IDs, and user types. --- ## Step 3: Create a contact A contact is the person who will pay. You create them once and reuse them across payments. A contact requires an `email` and a `profile` object: **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/contacts/create \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "email": "john.doe@example.com", "profile": { "firstName": "John", "lastName": "Doe", "phone": "+61412345678" } }' ``` **JavaScript** ```javascript const contactRes = await fetch(`${API}/contacts/create`, { method: 'POST', headers, body: JSON.stringify({ email: 'john.doe@example.com', profile: { firstName: 'John', lastName: 'Doe', phone: '+61412345678', }, }), }); // Unwrap the `data` envelope const { data: contact } = await contactRes.json(); console.log('Contact ID:', contact._id); // e.g. "6a4e0cec9a0ac53047a253f3" ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $contact = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/contacts/create', [ 'email' => 'john.doe@example.com', 'profile' => [ 'firstName' => 'John', 'lastName' => 'Doe', 'phone' => '+61412345678', ], ])->json('data'); echo $contact['_id']; // e.g. "6a4e0cec9a0ac53047a253f3" ``` See: [Contacts API reference](https://v1-spec.qualyhq.com/#post-/v1/contacts/create) > **Note — You can skip IDs with smart references** > > Anywhere the API expects a reference (like the payment intent's `contact` field), you can pass a natural identifier such as the contact's email instead of its ObjectId. See [Smart references](/docs/smart-references.md). --- ## Step 4: Create a payment intent A payment intent defines what's owed: the contact, currency, and line items. Amounts are in **minor units** (cents), so `100000` is $1,000.00 AUD. **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/payment-intents/create \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "intentType": "ad-hoc", "contact": "john.doe@example.com", "currency": "AUD", "dueAt": "2026-08-10T14:26:07.369Z", "items": [{ "name": "Tuition fee", "category": "Course", "amount": 100000 }] }' ``` **JavaScript** ```javascript const intentRes = await fetch(`${API}/payment-intents/create`, { method: 'POST', headers, body: JSON.stringify({ intentType: 'ad-hoc', contact: contact._id, // or 'john.doe@example.com' — see Smart references currency: 'AUD', dueAt: '2026-08-10T14:26:07.369Z', items: [ { name: 'Tuition fee', category: 'Course', amount: 100000 }, ], }), }); const { data: paymentIntent } = await intentRes.json(); console.log('Payment intent ID:', paymentIntent._id); console.log('Payment link:', paymentIntent.links.short ?? paymentIntent.links.long); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $paymentIntent = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/payment-intents/create', [ 'intentType' => 'ad-hoc', 'contact' => $contact['_id'], // or 'john.doe@example.com' — see Smart references 'currency' => 'AUD', 'dueAt' => '2026-08-10T14:26:07.369Z', 'items' => [ ['name' => 'Tuition fee', 'category' => 'Course', 'amount' => 100000], ], ])->json('data'); echo $paymentIntent['links']['short'] ?? $paymentIntent['links']['long']; ``` > **Note — Which link do I send?** > > The response's `links.long` is always present and is the canonical payment URL. `links.short` is a shortened, share-friendly version that's added when available — prefer it for emails, SMS, and WhatsApp, and fall back to `links.long`. Use `intentType: 'ad-hoc'` for one-off charges. For payment plans and installments, see [Creating payment intents](/docs/guides/creating-payment-intents.md) and [Creating orders](/docs/guides/creating-orders.md). --- ## Step 5: Collect the payment Send the payment link from Step 4 to your customer. When they open it, Qualy's hosted payment page handles method selection, payer verification, and processing: ```javascript const paymentUrl = paymentIntent.links.short ?? paymentIntent.links.long; // Redirect the customer here, or send it by email/SMS/WhatsApp. ``` This hosted flow is the fastest way to start and is **required for card payments**. If you need to render non-card methods (PIX, Boleto, PayID, bank transfers) inside your own UI, see [Collecting payments](/docs/guides/collecting-payments.md). --- ## Step 6: Know when you get paid Poll the payment intent to check its status: ```javascript const statusRes = await fetch(`${API}/payment-intents/${paymentIntent._id}`, { method: 'GET', headers, }); const { data: latest } = await statusRes.json(); console.log('Status:', latest.status); // "due" → "paid-full" (or "paid-partial") ``` For production, don't poll — set up [webhooks](/docs/webhooks.md) so Qualy notifies your server the moment a payment succeeds or fails. --- ## Complete example The whole flow in one script: **JavaScript** ```javascript const API = 'https://api.qualyhq.com/v1'; const headers = { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }; // 1. Create a contact const { data: contact } = await fetch(`${API}/contacts/create`, { method: 'POST', headers, body: JSON.stringify({ email: 'john.doe@example.com', profile: { firstName: 'John', lastName: 'Doe', phone: '+61412345678' }, }), }).then((r) => r.json()); // 2. Create a payment intent const { data: paymentIntent } = await fetch(`${API}/payment-intents/create`, { method: 'POST', headers, body: JSON.stringify({ intentType: 'ad-hoc', contact: contact._id, currency: 'AUD', dueAt: '2026-08-10T14:26:07.369Z', items: [{ name: 'Tuition fee', category: 'Course', amount: 100000 }], }), }).then((r) => r.json()); // 3. Send this link to your customer console.log('Pay here:', paymentIntent.links.short ?? paymentIntent.links.long); // 4. Check the status later const { data: latest } = await fetch( `${API}/payment-intents/${paymentIntent._id}`, { headers }, ).then((r) => r.json()); console.log('Status:', latest.status); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $api = Http::baseUrl('https://api.qualyhq.com/v1')->withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ]); // 1. Create a contact $contact = $api->post('/contacts/create', [ 'email' => 'john.doe@example.com', 'profile' => ['firstName' => 'John', 'lastName' => 'Doe', 'phone' => '+61412345678'], ])->json('data'); // 2. Create a payment intent $paymentIntent = $api->post('/payment-intents/create', [ 'intentType' => 'ad-hoc', 'contact' => $contact['_id'], 'currency' => 'AUD', 'dueAt' => '2026-08-10T14:26:07.369Z', 'items' => [['name' => 'Tuition fee', 'category' => 'Course', 'amount' => 100000]], ])->json('data'); // 3. Send this link to your customer echo $paymentIntent['links']['short'] ?? $paymentIntent['links']['long']; // 4. Check the status later $latest = $api->get("/payment-intents/{$paymentIntent['_id']}")->json('data'); echo $latest['status']; ``` --- ## Next steps - **Get notified automatically** — set up [webhooks](/docs/webhooks.md) instead of polling. - **Understand the envelope and filtering** — see [Querying data](/docs/queries.md). - **Offer more payment methods** — PIX, Boleto, PayID and more in [Collecting payments](/docs/guides/collecting-payments.md). - **Bill over time** — [orders](/docs/guides/creating-orders.md) and [subscriptions](/docs/guides/creating-subscriptions.md) for plans and recurring payments. - **Make requests safely retryable** — [Idempotency](/docs/idempotency.md). - **Handle failures well** — [Errors](/docs/errors.md). - **Explore everything** — the full [API reference](https://v1-spec.qualyhq.com/). ``` --- # API keys > Use API keys to authenticate API requests to Qualy. Qualy authenticates your API requests using your account’s API keys. If a request doesn’t include a valid key or includes a deleted or expired key, Qualy returns an error. You can use the Qualy Dashboard to create, view, and delete API keys. --- ## Create an API key To create an API key: 1. Open [Qualy's Dashboard](https://dashboard.qualyhq.com) 2. Click on the Settings icon on the top-right corner. 3. Click on API keys & webhooks. 4. Enter a name in the Key name field. 5. Click Add new. 6. Save the key value. ## Delete an API key If you delete a key, any code that uses that key can no longer make API calls. Create a new key and update the code to use it. To delete a key: 1. Open [Qualy's Dashboard](https://dashboard.qualyhq.com) 2. Click on the Settings icon on the top-right corner. 3. Click on API keys & webhooks. 4. Find the Key you want to delete and click on Remove. ## Keep your keys safe Anyone can use your API key to make any API call on behalf of your account, such as creating a transaction or performing other operations. Keep your keys safe by following these best practices: - Grant access only to those who need it. - Don’t store keys in a version control system. - Control access to keys with a password manager or secrets management service. - Don’t embed a key where it could be exposed to an attacker, such as in a mobile application. - Use environment variables to store your keys. - Encrypt your keys if you need to store in your databse. --- # Webhooks > Use incoming webhooks to get real-time updates of Qualy events. Listen for events on your Qualy account so your integration can automatically trigger reactions. --- ## Why to use webhooks When building Qualy integrations, you might want your applications to receive events as they occur in your Qualy accounts, so that your backend systems can execute actions accordingly. To enable webhook events, you need to register webhook endpoints. After you register them, Qualy can push real-time event data to your application’s webhook endpoint when events happen in your Qualy account. Qualy uses HTTPS to send webhook events to your app as a JSON payload that includes the event's data. Receiving webhook events are particularly useful for listening to asynchronous events such as when a direct debit transaction gets approved, a customer transfer funds via bank transfer, a payment becomes overdue, etc. --- ## The webhook event payload Qualy generates event data that we can send you to inform you of activity in your account. When an event occurs, Qualy generates a new webhook event. A single API request might result in the creation of multiple events. For example, if a customer pays for a payment, you receive `transactions.create`, `paymentIntents.update`, `splitIntents.create`, `splitIntents.update`, and `transactions.update` events. By registering webhook endpoints in your Qualy account, you enable Qualy to automatically send webhook events as part of POST requests to the registered webhook endpoint hosted by your application. After your webhook endpoint receives the event, your app can run backend actions. ### Example event payload The following event shows a transaction created due to a PayTo direct debit transfer. ```json { "event": "transactions.create", "data": { "_id": "653fc551d14abfe63d4fd48b", "paymentIntent": "653fc43fd14abee63d4fcb63", "amount": 87700, "contact": "65351dfbde8f28fb4a757585", "transactionType": "charge", "method": "ZAI_PAYTO", "currency": "AUD", "documents": [], "status": "processing", "createdAt": "2023-10-30T15:01:37.187Z", "number": 1906 }, "version": "v1", "tenantId": "eu1-nonhozilnwbeuo1qkdftdkqg", "messageId": "8960447655358365" } ``` #### Event object structure Review the event object structure to better understand events and the underlying information they provide. | Property | Description | | --- | --- | | `event` | You receive events for all of the event types your webhook endpoint is listening for in your configuration. Use the received event type to determine what processing your application needs to perform. The `data` property content corresponds to each event type. | | `version` | The `version` property indicates the API version of the event and dictates the structure of the included `data`. | | `messageId` | Every webhook event has an unique `messageId`. You can use this field to prevent replay attacks and control what events you have processed. | | `tenantId` | Each Qualy account is a tenant in our [Multi-tenant infrastructure](/docs/tenants.md). If you have one webhook endpoint being shared by multiple Qualy accounts, use this field to distinguish what Qualy account generated this event. | | `data` | The content of the `data` property changes according the event received. | ### Why webhook events get generated This table describes different scenarios that trigger webhook events. | Source | Trigger | | --- | --- | | Dashboard | When Qualy users change payments, contacts, and other entities via our [Dashboard](https://dashboard.qualyhq.com). | | Contact portal | When end-users/customers make a payment or change an entity via the tenant's "Contact portal". A common example would be a credit card payment. | | Partner portal | When a partner changes their information, adds a bank account, etc. via their "Partner portal". | | API | When you call the API directly. | | External events | When external events are received by Qualy, examples of that would be a direct debit failure, bank transfers, and others. | --- ## How to set up your webhook integration To start receiving webhook events in your app, create and register a webhook endpoint by following the steps below. You can register and create one endpoint to handle several different event types at once, or set up individual endpoints for specific events. 1. Open [Qualy's Dashboard](https://dashboard.qualyhq.com) 2. Click on the Settings icon on the top-right corner. 3. Click on API keys & webhooks. 4. Click Add new. 5. Add your destination URL, secret and choose the events you want to receive. 6. Make sure your destination URL is ready to handle the [One time verification challenge](/docs/webhooks.md#one-time-verification-challenge). 7. Click on Save. --- ## Creating a webhook via the API To manage and create webhooks using the Qualy API, check our API reference: [Webhooks API Reference](https://v1-spec.qualyhq.com/#post-/v1/dev/webhooks/create). --- ## Security & veryfing signatures Learn how to secure your webhook integration with Qualy. ### One time verification challenge One time verification challenge validates if the webhook endpoint is controlled by you before sending webhook notifications. The challenge consists in Qualy sending a request during setup that contains a random string that should be relayed back as part of the response. 1. For your chosen "Destination URL", make sure it can listen to `GET` requests as well as `POST` requests. 2. When a `GET` request is received, check for the query param `validationToken`. 3. Return in plain text the `validationToken`. Here's an example: ```javascript app.get('/webhook', (req, res) => { const { validationToken } = req.query; if (validationToken) { res .contentType('text') .send(validationToken); } else { res .status(400) .send('Validation token is missing'); } }); ``` The creation of a webhook will fail if the validation token is not returned correctly. ### Verifiying payload Qualy signs every webhook message using the secret key you've selected plus the HMAC-SHA256 hashing algorithm, Qualy then encodes the resulting signature, and includes the signature in the webhook request as the `Signature-Header` header. To verify the payload came from Qualy, you have to repeat the same steps — signing and encoding the webhook message using the secret key — and comparing the resulting signature with the value sent in the request header. If the result matches, the request should be considered legitimate. ```javascript const signatureHeader = 'Signature-Header' const signatureAlgorithm = 'sha256' const encodeFormat = 'hex' const hmacSecret = process.env.WEBHOOK_SECRET app.post('/webhook', (req, res) => { // Create digest with payload + hmac secret const hashPayload = req.rawBody const hmac = crypto.createHmac(signatureAlgorithm, hmacSecret) const digest = Buffer.from(signatureAlgorithm + '=' + hmac.update(hashPayload).digest(encodeFormat), 'utf8') // Get hash sent by Qualy const qualySignature = Buffer.from(req.get(signatureHeader) || '', 'utf8') // Compare digest signature with signature sent by Qualy if (qualySignature.length !== digest.length || !crypto.timingSafeEqual(digest, qualySignature)) { res .status(401) .send('Unauthorized') } else { // Webhook Authenticated // process and respond... res.json({ message: "Success" }) } }) ``` > **Warning — Sign the raw body, not the parsed one** > > Always compute the HMAC over the **raw request body** (`req.rawBody` above), not over `JSON.stringify(req.body)`. Re-serializing the parsed body can reorder keys or change whitespace, which produces a different signature and makes every verification fail. In Express, capture the raw body with `express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })`. --- ## Event delivery behaviors This section helps you understand different behaviors to expect regarding how Qualy sends events to your webhook endpoint. Explicitly, this section includes documentation on event retry deliveries, and event ordering. ### Retry behavior Qualy attempts to deliver a given event to your webhook endpoint multiple times. Here are the rules Qualy use to retry event deliveries: * After first failure, Qualy waits for 15 seconds before trying again the same event. * If your webhook still fails to receive the event after the first retry, Qualy use exponential backoff between the retry attempts. * The maximum amount of time an event can be delayed after another attempt is 60 minutes. * Qualy will retry the same event delivery 50 times and then give up. New events may still be generated and Qualy will attempt to be deliver following the rules above. So be prepared to handle events out of order. If your endpoint keeps failing or stops replying for more than 24 hours, Qualy may deactivate the webhook. When this happens, Qualy stops creating new deliveries for that endpoint, and events generated while the webhook is inactive are discarded. They are not queued or backfilled later. Super admins are notified when a webhook is activated or deactivated. You can manage webhooks from [API keys & webhooks](https://dashboard.qualyhq.com/preferences/api). If your endpoint has been disabled or deleted when Qualy attempts a retry, future retries of that event are prevented. ### Event ordering Qualy doesn’t guarantee delivery of events in the order in which they’re generated. For example, a direct debit transfer might generate the following events: * `transactions.create` * `paymentIntents.update` * `splitIntents.create` * `splitIntents.update` (if there are splits to partners) * `transactions.update` Your endpoint shouldn’t expect delivery of these events in this order, and needs to handle delivery accordingly. You can also use the API to fetch any missing objects (for example, you can fetch the payment, transaction, and splits using the information from the webhook event). --- ## Best practices for using webhooks Review these best practices to make sure your webhooks remain secure and function well with your integration. ### Handle duplicate events Webhook endpoints might occasionally receive the same event more than once. You can guard against duplicated event receipts by making your event processing idempotent. One way of doing this is logging the events you’ve processed, and then not processing already-logged events. Use the property `messageId` for that. ### Only listen to event types your integration requires Configure your webhook endpoints to receive only the types of events required by your integration. Listening for extra events (or all events) puts undue strain on your server and we don’t recommend it. ### Receive events with an HTTPS server Use HTTPS in your Destination URL when creating your webhook. ### Verify events are sent from Qualy Verify webhook signatures to confirm that received events are sent from Qualy. Qualy signs webhook events it sends to your endpoints by including a signature in each event’s `Signature-Header` header. This allows you to verify that the events were sent by Qualy, not by a third party. ### Quickly return a 2xx response Your endpoint must quickly return a successful status code (2xx) prior to any complex logic that could cause a timeout. ### Do not redirect the request Qualy will not follow redirects. --- # Webhook events reference > Every webhook event type Qualy can send, when it fires, and what the payload looks like. This is the catalog of every event type Qualy can deliver to a [webhook endpoint](/docs/webhooks.md). Subscribe to only the events your integration needs — you choose them when you register the endpoint. --- ## Event naming Every event name follows the pattern `resource.action`, where `action` is one of `create`, `update`, or `delete`: - `create` — a new object was created. - `update` — an existing object changed (including status transitions like a payment moving from `due` to `paid-full`). - `delete` — an object was removed. A single API request can produce several events. For example, when a customer completes a payment you may receive `transactions.create`, `paymentIntents.update`, `splitIntents.create`, `splitIntents.update`, and `transactions.update`. Events are **not** guaranteed to arrive in order — see [event ordering](/docs/webhooks.md#event-ordering). --- ## Available events | Event | Fires when | | --- | --- | | `contacts.create` | A contact is created. | | `contacts.update` | A contact's details change. | | `paymentIntents.create` | A payment intent is created. | | `paymentIntents.update` | A payment intent changes — most importantly its `status` (e.g. `due` → `paid-full`, `paid-partial`, `overdue`). | | `paymentIntents.delete` | A payment intent is deleted. | | `transactions.create` | A transaction is created against a payment intent (a payment attempt begins). | | `transactions.update` | A transaction changes — most importantly its `status` (e.g. `processing` → `succeeded` or `failed`). | | `splitIntents.create` | A payment split is created (the portion of a payment owed to a partner). See [payment splits](/docs/guides/creating-payment-splits.md). | | `splitIntents.update` | A payment split changes. | | `splitIntents.delete` | A payment split is removed. | | `partnerships.create` | A partnership is created. | | `partnerships.update` | A partnership's details change. | > **Note — Watch status, not just the event** > > For payments, the event you almost always care about is a `status` change carried on `paymentIntents.update` and `transactions.update`. Read `data.status` to decide what to do, rather than assuming an `update` means "paid-full". --- ## Payload structure Every delivery has the same envelope. Note this is **different** from the REST API's `data` envelope — here `data` holds the object that changed, and it sits alongside delivery metadata: | Field | Description | | --- | --- | | `event` | The event type, e.g. `transactions.create`. | | `data` | The object that changed. Its shape mirrors that resource's REST representation. | | `version` | API version of the payload, e.g. `v1`. | | `tenantId` | The Qualy account that generated the event. Useful when one endpoint serves multiple tenants. | | `messageId` | A unique ID for this delivery. Use it to deduplicate and guard against replays. | ### Example: `transactions.create` ```json { "event": "transactions.create", "data": { "_id": "653fc551d14abfe63d4fd48b", "paymentIntent": "653fc43fd14abee63d4fcb63", "amount": 87700, "contact": "65351dfbde8f28fb4a757585", "transactionType": "charge", "method": "ZAI_PAYTO", "currency": "AUD", "status": "processing", "number": 1906, "createdAt": "2023-10-30T15:01:37.187Z" }, "version": "v1", "tenantId": "eu1-nonhozilnwbeuo1qkdftdkqg", "messageId": "8960447655358365" } ``` ### Example: `paymentIntents.update` A status change from `due` to `paid-full`: ```json { "event": "paymentIntents.update", "data": { "_id": "653fc43fd14abee63d4fcb63", "status": "paid-full", "amount": 100000, "due": 0, "currency": "AUD", "contact": "65351dfbde8f28fb4a757585", "number": "2216" }, "version": "v1", "tenantId": "eu1-nonhozilnwbeuo1qkdftdkqg", "messageId": "8960447655358366" } ``` --- ## Handling events Because a single action fans out into several events and they can arrive out of order, treat webhooks as **signals to reconcile**, not as the source of truth. A robust handler: 1. **Verifies the signature** — see [verifying signatures](/docs/webhooks.md#security--veryfing-signatures). 2. **Deduplicates on `messageId`** — you may receive the same event more than once. 3. **Returns `2xx` quickly** — acknowledge first, then do the work asynchronously. 4. **Re-fetches the object** when it needs the full, current state — for example `GET /v1/payment-intents/{id}` after a `paymentIntents.update`. See [Setting up webhooks](/docs/webhooks.md) for the endpoint setup, signature verification, retry behavior, and best practices. --- # Terminology > Learn key terms used by the Qualy API. When working with the Qualy API you may find in our documentation and our API specific terms that may not be used outside of the payments industry or Qualy itself. Use this table to understand what these terms mean. --- These are the most common terms used by Qualy: | Term | Meaning | | --- | --- | | Contact | Contact is the end-user, it's your customer. The one performing the payment. The term "Customer" may also be used interchangeably. | | Tenant | This is Qualy's customer, a business that is using Qualy to receive payments. | | Payment intent | A payment intent is a payment entry. It may be on different `status` and it's what Qualy uses to send reminders, and accept payments/transactions against. It's called a payment intent, as it may never become "paid". | | Transaction | A transaction is created when Qualy needs to receive money or register money has been received. It's also used for refunds. | | Method/Payment method | A payment may be paid by different methods (e.g. Credit Card, PayID, etc). Different entities in Qualy will have multiple methods associated to them. | | User | A user is created by a "super admin" on Qualy, and it has access to the Dashboard. | | Order / Order item | An order groups what a contact bought; each product or service in it is an order item. An order can be paid by one or many payment intents (e.g. a payment plan). See [Creating orders](/docs/guides/creating-orders.md). | | Service | A product or service in your catalog (e.g. "Diploma of Business"). Orders, order items, and subscriptions reference services. | | Subscription | A recurring billing arrangement that automatically generates payment intents on a schedule. See [Creating subscriptions](/docs/guides/creating-subscriptions.md). | | Partnership | Another party you transact with or on behalf of — an agent, institution, or supplier. Suppliers on orders and splits affect how money is divided. See [payment splits](/docs/guides/creating-payment-splits.md). | | Payment split | The portion of a payment owed to a partnership (also called a split intent). Splits are calculated from the payment's items and the supplier attached to them. | | Payout | Money Qualy sends out — to you (settlement) or to your partners. Distinct from a transaction, which records money coming in. | | Settlement currency | When you want a contact to pay in a currency different from the payment's currency, the settlement currency is what they actually pay in. Qualy handles the FX. See [Creating payment intents](/docs/guides/creating-payment-intents.md). | | Amounts (minor units) | Every monetary amount in the API is an integer in the currency's minor units (cents). `100000` means $1,000.00. | | Authorization | A direct-debit mandate — a contact's standing permission to debit their bank account (e.g. PayTo, PayID). Not to be confused with API authorization (your API key). | | Dispute | A chargeback: a cardholder questions a payment with their bank, and the amount is held until it resolves. See [Handling disputes](/docs/guides/handling-disputes.md). | | Dunning | Chasing overdue receivables — sending reminders (notices), logging attempts (chases), and tracking responses. Communications only; it never moves money. See [Dunning](/docs/guides/dunning.md). | | Approval | A maker-checker sign-off. An approval policy can require one or more approvers to authorize a money-moving action before it proceeds. See [Approvals](/docs/guides/approvals.md). | --- # Connect Qualy to Claude > Connect your Qualy account to Claude, ChatGPT, Le Chat or another AI assistant in about a minute — no API key, no developer needed. Ask about payments, students and commissions in plain language. Connect Qualy to Claude and you can ask about your account in plain language — *"which students still owe tuition?"*, *"how much did we collect in AUD last month?"*, *"send Camila a payment reminder"* — instead of clicking through the Dashboard. It takes about a minute. You don't need a developer, and you don't need an API key. --- ## Before you start You need a Qualy login (the same email, password and two-factor code you use for the Dashboard) and a Claude account. That's it. > **Note — Free plan** > > Claude's Free plan allows one custom connector. Pro, Max, Team and Enterprise have no practical limit. --- ## Set it up 1. In Claude, open your settings and find **Connectors** (depending on your version of Claude it sits under **Settings** or **Customize**). 2. Click the **+** button, then **Add custom connector**. 3. Paste this URL into the box, and leave every other field empty: ``` https://api.qualyhq.com/v1/mcp ``` 4. Click **Add**, then click **Connect** on the Qualy connector that appears. 5. A Qualy sign-in window opens. Sign in as you normally would, then approve the access screen. Done. Qualy is now connected to your Claude account — which means it's available in Claude chat, Claude Desktop, Cowork and Claude Code, everywhere you're signed in. You only do this once. ### Check it worked Start a new chat, open the **+** menu, switch on **Qualy**, and ask: > How much did we collect last month? If Qualy comes back with figures, you're set. Connectors are switched on per conversation, so remember the **+** menu in new chats. > **Note — Prefer to have Claude walk you through it?** > > Paste this into any Claude conversation and it will guide you step by step: > > > Set up the Qualy connector for me. Instructions: https://docs.qualyhq.com/prompt.md --- ## Setting it up for your whole team On **Team** and **Enterprise** plans, an Owner can add Qualy once for everyone under **Admin settings → Connectors**, using the same URL. Everybody else then just opens **Settings → Connectors**, clicks **Connect** on Qualy and signs in with their own Qualy login. Nobody else needs the URL, and each person's access stays their own. --- ## What Claude can and can't do The connection acts **as you**. It sees exactly what your Qualy login sees, scoped to your tenant — no more. If you can't see partner payouts in the Dashboard, neither can Claude. **It can** look up contacts, payment requests, transactions, orders, partner commissions, payouts, refunds and disputes; answer questions with real totals and trends; raise payment requests; send the same payment reminder the Dashboard sends; and draft a refund. **It can't move money.** Nothing here captures a charge, approves or executes a refund, or sends a payout. A payment request still has to be settled by the payer through a secure link, and a drafted refund still has to be approved by a human in the Dashboard. To disconnect, remove the connector in Claude — or sign out of all sessions in the [Dashboard](https://dashboard.qualyhq.com), which disconnects every AI client at once. For the full list of what Claude can do, see the [MCP server reference](/docs/mcp-server.md). --- ## If something doesn't work - **The sign-in window doesn't open, or fails.** Sign in at [dashboard.qualyhq.com](https://dashboard.qualyhq.com) first, then try connecting again. - **You added it, but Claude doesn't seem to know about Qualy.** Start a new chat and switch Qualy on under the **+** menu. - **It works in Claude chat but not in Cowork.** That's a known issue on Anthropic's side, not Qualy's — contact Anthropic support. - **Claude says it isn't allowed to do something.** Your Qualy user is missing that permission. An admin can grant it under **Settings → Users** in the Dashboard. Still stuck? [Get in touch](https://qualyhq.com/contact-us/). --- ## Other assistants Qualy works with any assistant that supports **custom remote MCP connectors**. The endpoint is always the same — `https://api.qualyhq.com/v1/mcp` — but what each assistant lets you do with it varies a lot. ### Assistants that can connect **Claude** — the fullest support, and the only one where connecting once covers chat, Desktop, Cowork and mobile. Works on the Free plan (one custom connector). This is the setup described above. **ChatGPT** — you must first switch on **Developer mode** in settings, then add the server. Read the important limitation below before you rely on it. Not available on the Free plan. **Mistral Le Chat** — Connectors → **Add Connector** → **Custom MCP Connector**. Custom connectors work on Le Chat's free tier, which makes it the cheapest way to get Qualy into an assistant. **Perplexity** — add a custom remote connector from settings. Requires Pro or Max. > **Warning — ChatGPT can currently only read your Qualy data** > > On ChatGPT **Plus** and **Pro**, custom MCP connectors are limited to read-only tools. You can ask questions — *"who hasn't paid?"*, *"what did we collect in July?"* — but Qualy's actions, like raising a payment request or sending a reminder, will not be available. > > Write support is in beta on **Business, Enterprise and Edu** plans. If you need Qualy to *do* things rather than just answer questions, use Claude or Le Chat. ### Assistants that cannot connect These are popular, and none of them can reach Qualy today. This isn't a Qualy limitation — none of them support custom connectors of any kind: - **Meta AI and WhatsApp.** WhatsApp has no way to connect Qualy, and Meta blocks third-party AI assistants from the WhatsApp Business API. If WhatsApp is where you work, you'll need to open a separate assistant app. - **The Gemini app.** Google's consumer Gemini app takes only Google's own curated connectors. Custom MCP servers are limited to Gemini Enterprise, set up by an administrator in Google Cloud. - **Microsoft 365 Copilot.** Connectors exist, but only a Global or AI Administrator can add them for the whole tenant — you can't add Qualy yourself from the chat window. - **DeepSeek.** No connector support. > **Note — This list moves fast** > > Assistants add and rename connector features constantly. If yours isn't listed, search its settings for **Connectors**, **MCP** or **Integrations** and paste `https://api.qualyhq.com/v1/mcp` — if it accepts a custom MCP server URL, Qualy will work. Tell us what you find and we'll add it here. --- ## Developer tools Cursor, VS Code, Zed, Windsurf, Replit and Claude Code all connect to the same endpoint, usually through a config file or a settings panel — see [MCP server](/docs/mcp-server.md) for per-client configuration and API-key authentication. If you're pointing an AI **coding** tool at Qualy to build an integration rather than to answer questions, start at [AI agents & coding tools](/docs/ai-tools.md) instead. --- # MCP Server > Connect AI agents to Qualy through the Model Context Protocol (MCP) server. Qualy runs a remote [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server that lets AI agents work with your Qualy data through a curated set of tools. Connect a client such as Claude Desktop, Cursor, or Windsurf — or build your own agent — and it can look up contacts, create payment requests, search your data, and more, all scoped to your tenant. ## Endpoint The server speaks the **Streamable HTTP** MCP transport at: ``` https://api.qualyhq.com/v1/mcp ``` There are two ways to authenticate: **Sign in with Qualy (OAuth)** — for clients with a connectors UI (claude.ai, Claude Desktop, Claude Code, and any client that supports MCP authorization). Add the server URL with **no credentials**; your browser opens a Qualy sign-in (email + password, plus your usual two-factor code), you review what you're connecting, and you're done. No API key to create or store. The connection acts **as you** — it can see and do exactly what your Qualy user can, nothing more. **API key** — for headless agents, scripts, and clients where you configure headers by hand. Pass your [Qualy API key](/docs/api-keys.md) as a bearer token: | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | The tenant is encoded in your login or key, so MCP needs **no `X-TENANT-ID` header**. (`Authorization: ApiKey ` is also accepted, and if your client happens to send `X-TENANT-ID` it's cross-checked against the credential.) Either way, every tool call runs with the permissions of the connected user and is scoped to their tenant. --- ## Connect a client ### Claude (sign in — no key) Add Qualy once as a **custom connector on your Claude account**, at [claude.ai/settings/connectors](https://claude.ai/settings/connectors) → **Add custom connector** → paste `https://api.qualyhq.com/v1/mcp`, leaving every other field empty. Your browser opens a Qualy sign-in and consent screen; approve it and the tools are available. Because the connector lives on your account, it follows you into **Claude chat, Claude Desktop, Cowork and Claude Code** — you set it up once. On Team and Enterprise plans an Owner can add it for the whole organisation under **Admin settings → Connectors**; everyone else then only clicks **Connect** and signs in. See [Connect Qualy to Claude](/docs/connect.md) for the step-by-step version to hand a non-technical colleague. To revoke a connection later, remove it in your client — or sign out of all sessions in the Qualy Dashboard, which disconnects every AI client at once. #### Claude Code: connector or `claude mcp add`? Claude Code can reach Qualy either way, and they don't reach the same places: ```bash claude mcp add --transport http qualy https://api.qualyhq.com/v1/mcp ``` | | Account connector | `claude mcp add` | |---|---|---| | Claude chat, Desktop, Cowork | yes | no | | Claude Code | yes | yes | | Scope | your Claude account | `local` (this folder, default), `project` (committed `.mcp.json`), or `user` (all your folders) via `-s` | | Auth | sign in with Qualy | sign in, or an [API key](/docs/api-keys.md) header | Reach for `claude mcp add` when you want Qualy pinned to a specific codebase — `-s project` writes a `.mcp.json` your teammates get on clone, which is useful for a repo whose whole job is talking to Qualy. For everything else the account connector is the better default: one setup, and it's there in Cowork and on your phone too. ### Other clients Most other MCP clients connect by adding an entry to their MCP configuration. Clients that support MCP authorization will also walk you through the same browser sign-in when you omit the header; otherwise, use an API key. #### Clients with native remote MCP support Cursor, VS Code, Zed and similar clients can point at the remote server directly: ```json { "mcpServers": { "qualy": { "url": "https://api.qualyhq.com/v1/mcp", "headers": { "Authorization": "Bearer pk_prod_your-api-key" } } } } ``` > **Warning — Windsurf uses a different key** > > Windsurf (now Devin Desktop) expects **`serverUrl`**, not `url`. Copying the block above verbatim fails silently — the server is accepted but never connects. Use: > > ```json > { > "mcpServers": { > "qualy": { > "serverUrl": "https://api.qualyhq.com/v1/mcp" > } > } > } > ``` #### Stdio-only clients Clients that only launch local (stdio) servers can bridge to the remote server with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) (drop the `--header` arguments to sign in with OAuth instead): ```json { "mcpServers": { "qualy": { "command": "npx", "args": [ "-y", "mcp-remote", "https://api.qualyhq.com/v1/mcp", "--header", "Authorization: Bearer pk_prod_your-api-key" ] } } } ``` After saving the configuration and restarting your client, the Qualy tools appear in its tool list. Any MCP-compatible client connects the same way: point it at `https://api.qualyhq.com/v1/mcp` and either complete the sign-in flow or set `Authorization: Bearer `. ChatGPT, Replit, Zed and others each have their own "add MCP server" / connectors screen — follow your client's MCP instructions (for example, [Cursor's MCP install links](https://cursor.com/docs/mcp/install-links)). For clients and tools that consume the [MCP registry](https://registry.modelcontextprotocol.io) format, Qualy publishes a machine-readable entry — name, description, logo, and the remote endpoint — at [`https://docs.qualyhq.com/server.json`](https://docs.qualyhq.com/server.json). > **Warning — Keep your key safe** > > If you configure an API key, your MCP configuration stores it in plain text on your machine. Treat it like any other secret — see [API key best practices](/docs/api-keys.md). Signing in with Qualy avoids this: the client holds short-lived tokens that you can revoke at any time. ### How it appears in your client On connect, the server tells your client who it is — the name **Qualy**, a website, and the Qualy logo — so clients that support it show proper branding rather than a bare hostname. It also returns a short set of usage instructions that guide the assistant on how to use the tools (find before acting, confirm writes, and so on). You don't configure any of this; it comes from the server. Support varies by client, so exactly how much is shown (logo, description) depends on the app you connect from. --- ## Available tools All tools are scoped to your tenant. Reads are safe to call freely; writes are rate-limited and de-duplicated. ### Contacts | Tool | What it does | |------|--------------| | `contact_list` | List contacts (customers / leads / students), newest first; filter by email. | | `contact_get` | Get one contact by id or email. | | `contact_create` | Create a contact. Returns the existing contact if the email is already on file. | | `contact_update` | Update a contact's name, phone, or email. | ### Payment intents | Tool | What it does | |------|--------------| | `payment_intent_list` | List payment requests; filter by contact or status. | | `payment_intent_get` | Get one payment intent, including its shareable payment link. | | `payment_intent_create` | Create a payment request for a contact. | | `payment_intent_cancel` | Cancel a payment intent. | | `payment_intent_remind` | Email the contact a payment reminder — the same reminder the Dashboard sends. Customer-visible; agents confirm before sending. | ### Transactions | Tool | What it does | |------|--------------| | `transaction_list` | List transactions (charges and refunds); filter by contact, payment intent, or status. | | `transaction_get` | Get one transaction by its id. | ### Orders | Tool | What it does | |------|--------------| | `order_list` | List orders; filter by contact. | | `order_get` | Get one order by its id. | ### Partnerships | Tool | What it does | |------|--------------| | `partnership_list` | List partnerships (agents / institutions / suppliers). | | `partnership_get` | Get one partnership by id, code, or name. | | `partnership_create` | Create a partnership. | ### Search | Tool | What it does | |------|--------------| | `search` | Fuzzy search across contacts or partnerships by name, email, or keyword. | ### Analytics Aggregations computed in the database — ask for totals, trends, and forecasts instead of having the agent page through lists. Amounts in analytics results are **integer minor units (cents)**, grouped by currency. | Tool | What it does | |------|--------------| | `payment_intent_stats` | Payment analytics in 11 formats: consolidated totals, month-by-month, payer behavior, by-services, calendar-by-day, cancellations, customer payments by month, cash velocity, revenue by service, receivables forecast, and FX corridor margin. | | `transaction_stats` | Per-currency transaction counts and totals, broken down by status or by type. | | `payment_split_stats` | Partner-commission analytics: owed / paid / outstanding, monthly cohorts, per-partner and per-team breakdowns, reconciliation. | ### Payouts & payment splits Payouts are read-only; splits can also be recorded. | Tool | What it does | |------|--------------| | `payout_list` | List partner / supplier payouts; filter by status, partnership, method, or currency. | | `payout_get` | Get one payout, including its FX details and failure reasons. Bank account numbers are always masked. | | `payment_split_list` | List payment splits (partner commissions / revenue shares). | | `payment_split_get` | Get one payment split, including amounts, approval, and payout linkage. | | `payment_split_create` | Record a fixed-amount commission on a payment intent. Records what is owed — paying it still happens through payouts. | ### Customer subscriptions Your customers' recurring payment schedules — not your own Qualy plan. | Tool | What it does | |------|--------------| | `subscription_list` | List customer payment schedules; filter by status, mode, or contact. | | `subscription_get` | Get one payment schedule, including cadence, amounts, and charge stats. | | `subscription_create` | Create a recurring payment schedule for a contact — Qualy raises a payment request each cycle. | ### Refunds & disputes Disputes are read-only. Refunds can be **started** — never executed: a created refund is a draft that a human must approve in the Dashboard before any money moves. | Tool | What it does | |------|--------------| | `refund_intent_list` | List refund requests and their approval / settlement state. | | `refund_intent_get` | Get one refund request with per-item amounts, fees, and taxes. | | `refund_intent_create` | Start a refund against a settled charge, as a draft for human approval. | | `dispute_list` | List disputes (chargebacks); `open: true` narrows to those still needing attention. | | `dispute_get` | Get one dispute, including its reason, evidence, and due dates. | ### Your Qualy billing Your own Qualy subscription and invoices — what you pay Qualy, not your customers' payments. Requires an admin user. | Tool | What it does | |------|--------------| | `billing_invoice_list` | List your Qualy subscription invoices. | | `billing_subscription_list` | Your Qualy subscription status and plans. | | `billing_stats` | Account balance, currency, and the upcoming invoice preview. | > **Note — What's not exposed** > > Nothing on the MCP surface **executes** money movement: no capturing charges, no approving or executing refunds, no creating or sending payouts. Creates raise requests or drafts — a payment request the payer settles through a secure link, a refund draft a human must approve. Executing operations still go through the [API](/docs/authentication.md) or the Dashboard. --- ## Smart references Wherever a tool takes a **contact** or **partnership**, you can pass a natural identifier instead of a Mongo ObjectId — Qualy resolves it to the right record before the tool runs. An agent that only knows a student's email or a partner's name doesn't need to look up an id first. This is the same [Smart references](/docs/smart-references.md) resolution the REST API uses, applied to the MCP tools' reference fields. | Field | Accepts | |-------|---------| | `contact` | a contact id, **or** the contact's email (case-insensitive) | | `partnership` | a partnership id, code, exact name, **or** a unique name prefix | ### Examples Get a contact by email instead of an id — `contact_get`: ```json { "contact": "camila@example.com" } ``` Raise a payment request straight from the payer's email, with no lookup step — `payment_intent_create`: ```json { "contact": "camila@example.com", "currency": "AUD", "items": [{ "name": "Tuition — Semester 1", "amount": 12000 }] } ``` Fetch a partner by name (or its code) — `partnership_get`: ```json { "partnership": "Sydney English College" } ``` The same `contact` resolution applies to the contact filter on `payment_intent_list`, `transaction_list`, and `order_list`. ### When a reference is ambiguous If a name or prefix matches more than one record, the tool returns an `AGENT_TOOL_AMBIGUOUS_REFERENCE` error listing the candidate records, so the agent can retry with something more specific (an exact name, the partnership code, or an id). If nothing matches, it returns `AGENT_TOOL_REFERENCE_NOT_FOUND`. For automated flows, prefer stable identifiers — email, partnership code, or id — over name prefixes, which can drift into ambiguity as new records are added. --- ## When to use MCP vs the API The MCP server and the [REST API](/docs/authentication.md) run on the same platform, the same data, and the same API-key auth — they're two front doors to the same back office. Pick by how your integration is driven: **Use the MCP server when an AI assistant is your interface.** A person or agent asks in natural language and the model decides which tools to call, in what order, with you approving anything that writes. It exposes a curated, tenant-scoped subset of the platform — ideal for the long tail of ad-hoc questions and tasks ("find this student, draft a plan, chase that invoice") without writing or maintaining integration code. **Use the REST API when you're building a programmatic integration.** You get the full endpoint surface, deterministic control over every call, [webhooks](/docs/webhooks.md), [idempotency keys](/docs/idempotency.md), and conveniences like [Smart references](/docs/smart-references.md) — sending a contact email, partnership code, or service name where an endpoint expects an ObjectId. (MCP tools accept Smart references too, so this isn't a reason to choose one over the other.) Many teams use both — an assistant for day-to-day operators, the API for their own product surface. They never conflict: every call, from either door, runs through the same tenant scoping, permissions, and platform controls. --- ## Build your own agent To drive the server from your own agent, connect with any MCP-compatible client library. With the official TypeScript SDK: ```javascript import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; const transport = new StreamableHTTPClientTransport( new URL('https://api.qualyhq.com/v1/mcp'), { requestInit: { headers: { Authorization: 'Bearer pk_prod_your-api-key', }, }, }, ); const client = new Client({ name: 'my-agent', version: '1.0.0' }); await client.connect(transport); // Discover the tools… const { tools } = await client.listTools(); // …and call one. const result = await client.callTool({ name: 'contact_list', arguments: { limit: 5 }, }); console.log(result); ``` --- ## Behavior and limits - **Tenant scoping** — every call runs against your tenant only; the tenant is taken from your credentials. - **Permissions** — each tool requires the matching view/edit permission on the connected user (for example, `payout_list` requires the splits view permission). A call the user isn't allowed to make returns `AGENT_TOOL_FORBIDDEN`. OAuth connections act as the signed-in user; API keys carry their own user's permissions. - **Authoritative totals** — every `*_list` result includes `count` (the total matching your filter) and `hasMore`. Agents should read those instead of counting pages; for sums and trends, the `*_stats` tools aggregate in the database. - **Rate limits** — calls are rate-limited per tool. Reads have a generous budget; analytics and billing are tighter. Exceeding a limit returns an `AGENT_TOOL_RATE_LIMITED` error with a retry hint. - **Idempotency** — write tools (create / update / cancel) are idempotent: repeating the same call with the same input within 24 hours returns the original result instead of creating a duplicate. - **Structured errors** — tool errors come back with a `code` and `message` so your agent can react — for example, retry on a rate limit or re-prompt on invalid input. - **OAuth session lifetime** — sign-in tokens are short-lived and refresh automatically; your client re-prompts to sign in only if the connection is revoked or long unused. Signing out of all Qualy sessions disconnects AI clients too. --- # AI agents & coding tools > Everything an AI assistant or AI coding tool (Cursor, Lovable, Bolt, v0, Replit, ChatGPT, Claude) needs to understand and build against the Qualy API. Qualy is built to be read and used by AI. Whether you're pointing an AI coding tool at Qualy to generate an integration, or connecting an assistant that calls the API on your behalf, everything it needs is published in a machine-readable form — a curated `llms.txt` index, plain-Markdown docs, an OpenAPI spec, a glossary, and a remote [MCP server](/docs/mcp-server.md). This page is the one link to hand an AI tool. Paste it (or the resources below) into your assistant and it can get from zero to a working payment on its own. --- ## Two ways AI works with Qualy There are two distinct things an AI tool might do, and they use different resources: - **Generate integration code** — a coding tool (Cursor, Lovable, Bolt, v0, Replit, GitHub Copilot) writes an app that talks to the Qualy REST API. It needs *documentation it can read*: the `llms.txt` index, the Markdown docs, and the OpenAPI spec. - **Call the API directly** — an assistant (ChatGPT, Claude, or your own agent) performs tasks against your live data: find a contact, raise a payment request, search. It connects to the [MCP server](/docs/mcp-server.md), which exposes a curated, tenant-scoped set of tools. If you just want Claude connected to your account, [Connect Qualy to Claude](/docs/connect.md) is a one-minute setup with no API key — or paste `https://docs.qualyhq.com/prompt.md` into a Claude chat and it will guide you. Many teams use both — a coding tool to build their product surface, an assistant for day-to-day operations. --- ## Machine-readable resources Every resource below is public and stable. Point any tool at these URLs. | Resource | URL | What it's for | |----------|-----|---------------| | **llms.txt** | [`docs.qualyhq.com/llms.txt`](https://docs.qualyhq.com/llms.txt) | Curated, section-grouped index of every doc page plus the OpenAPI spec, following the [llms.txt](https://llmstxt.org) convention. The best single entry point for a coding tool. | | **Markdown docs** | `docs.qualyhq.com/.md` | Every doc page is also served as clean Markdown — append `.md` to any docs URL (e.g. [`/docs/authentication.md`](https://docs.qualyhq.com/docs/authentication.md)). No HTML to parse. | | **OpenAPI spec** | [`v1-spec.qualyhq.com/swagger-spec.json`](https://v1-spec.qualyhq.com/swagger-spec.json) | The full machine-readable API surface — every endpoint, request/response shape, and auth scheme. Feed it to a tool to scaffold a client. | | **Glossary** | [`docs.qualyhq.com/glossary.json`](https://docs.qualyhq.com/glossary.json) | Machine-readable definitions of Qualy terms (payment intent, split, tenant, order…). | | **MCP server** | [`api.qualyhq.com/v1/mcp`](/docs/mcp-server.md) | Remote MCP endpoint for assistants that call the API as tools. | | **MCP registry entry** | [`docs.qualyhq.com/server.json`](https://docs.qualyhq.com/server.json) | Machine-readable [MCP registry](https://registry.modelcontextprotocol.io) entry — name, logo, and endpoint. | | **Setup prompt** | [`docs.qualyhq.com/prompt.md`](https://docs.qualyhq.com/prompt.md) | Agent-readable instructions for connecting a user's Qualy account to Claude. Hand this URL to an assistant and it walks the person through it. | > **Note — Ask AI** > > The docs site has a built-in **Ask AI** search that answers questions directly from this documentation. If you just have a question, that's often the fastest path. --- ## Point a coding tool at Qualy The fastest way to give any AI coding tool accurate context is to hand it the `llms.txt` index. From there it can follow links to the exact pages it needs. > Build against the Qualy API. Read https://docs.qualyhq.com/llms.txt for an index of the docs, and https://v1-spec.qualyhq.com/swagger-spec.json for the full OpenAPI spec. The base URL is https://api.qualyhq.com/v1 — it only uses GET and POST and returns JSON. Most tools accept a docs URL or let you paste this as context. Tools with native web access (Cursor's `@Docs`, ChatGPT, Claude) can fetch the URLs directly; for others, paste the relevant `.md` pages inline. If your tool speaks **MCP**, you can also connect the [Qualy MCP server](/docs/mcp-server.md) so the assistant can look things up against real data while it builds — Cursor, Windsurf, Replit, ChatGPT, and Claude all support remote MCP servers. --- ## The one flow that matters: accept a payment If you tell a coding tool to "accept a payment with Qualy," this is the path to generate. Create a [Payment Intent](/docs/guides/creating-payment-intents.md) for a contact, then redirect the payer to the hosted portal URL returned in `links.short`. Qualy handles method selection, payer verification, processing, and confirmation — no payment UI to build, and card payments stay PCI-compliant on the hosted portal. **cURL** ```bash curl https://api.qualyhq.com/v1/payment-intents/create \ -X POST \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "contact": "camila@example.com", "currency": "AUD", "items": [{ "name": "Tuition — Semester 1", "amount": 12000 }] }' ``` **JavaScript** ```javascript const res = await fetch('https://api.qualyhq.com/v1/payment-intents/create', { method: 'POST', headers: { 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', 'Content-Type': 'application/json', }, body: JSON.stringify({ contact: 'camila@example.com', currency: 'AUD', items: [{ name: 'Tuition — Semester 1', amount: 12000 }], }), }) const { data: intent } = await res.json() // Send this URL to the payer — email, SMS, or a redirect. console.log(intent.links.short) ``` The `contact` field takes an email directly thanks to [Smart references](/docs/smart-references.md) — no separate lookup needed. See [Collecting payments](/docs/guides/collecting-payments.md) for the full flow, including calling payment methods (PIX, Boleto, PayID, bank transfer) directly instead of using the portal. --- ## What a tool needs to know to get it right A few conventions keep generated code correct on the first try: - **Base URL** — `https://api.qualyhq.com/v1`. Only `GET` and `POST` are used; each request works on a single object (no bulk updates). - **Auth** — two headers on every request: `Authorization: ApiKey ` and `X-TENANT-ID: `. Create both in the [Dashboard](https://dashboard.qualyhq.com) under **Settings → API keys & webhooks**. See [API keys](/docs/api-keys.md) and [Multi-tenancy](/docs/tenants.md). (The [MCP server](/docs/mcp-server.md) needs no tenant header — and with a client that supports sign-in, no key at all.) - **Responses** are wrapped in a `data` envelope — read `res.data`, not the top-level object. - **Errors** come back with a machine-readable `code` and `message` — see [Errors](/docs/errors.md) so your tool can branch on them. - **Idempotency** — send an [idempotency key](/docs/idempotency.md) on writes so retries never double-charge. - **Smart references** — wherever an endpoint takes a contact, partnership, or service, you can pass a natural identifier (email, code, name) instead of an ObjectId. See [Smart references](/docs/smart-references.md). --- ## Test mode Generated code should run against a sandbox before it touches real money. Test mode lets you simulate payments — including [test cards](/docs/testing.md) — without moving funds. Sandbox access is granted on request; [get in touch](https://qualyhq.com/contact-us/) to have it enabled for your tenant. > **Warning — Never use real card details when testing** > > Use the documented [test cards](/docs/testing.md) only. Real card numbers must never be entered in test mode. --- # Tenants > Understand the multi-tenant architecture of Qualy. A tenant is a group of users who share a common access with specific privileges to the software instance. In Qualy's context, it's a company, institution, or franchisee. --- ## Multi-tenancy A multi-tenant system represents an architecture where a single instance of software serves multiple users or tenants (such as businesses or customers), each of whom operates independently within the same system. This allows Qualy to the segregate the data of multiple tenants. In essence, this allows us to keep our customer's data and interactions isolated and secure from other customers. ### Tenant ID In a multi-tenant system like Qualy, the Tenant ID plays a pivotal role in distinguishing and identifying each tenant accessing the system. For security and proper data segregation, it is essential that every request made to the system contains the `X-TENANT-ID` header. Example of including the Tenant ID header in API requests: ```js const makeRequest = async () => { const requestConfig = { method: 'GET', headers: { 'X-TENANT-ID': 'eu1-unique-tenant-id-here', 'Authorization': 'ApiKey pk_prod_api-key-here' }, }; try { const response = await fetch('https://api.qualyhq.com/v1/users/user', requestConfig); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } }; makeRequest(); ``` > **Warning — Requests without Tenant ID** > > Requests without a valid `X-TENANT-ID` header will throw an error. You can also use the header `x-tenant-id` (in lowercase). ## Retrieve the Tenant ID To retrieve your account's Tenant ID: 1. Open [Qualy's Dashboard](https://dashboard.qualyhq.com) 2. Click on the Settings icon on the top-right corner. 3. Click on API keys & webhooks. 4. Copy the Tenant ID displayed. --- # Authorization > Authorizing API requests to Qualy. Qualy authenticates your API requests using your account’s API keys. If a request doesn’t include a valid key, Qualy returns an `Unauthorized` error. --- ## Authenticating requests Qualy uses the `Authorization` header to authenticate your API call. You will need both the API key and Tenant ID to successfully authenticate an API call. ### What you will need Requests to Qualy requires at minimum two headers, one for your API Key, and one for your Teanant ID. While in very few cases the API key may not be required, Tenant ID is always required #### A valid API key To generate and retrieve the API keys for your account follow [this guide](/docs/api-keys.md). #### Your Tenant ID To retrieve the Tenant ID follow [this guide](/docs/tenants.md). Example of an authenticated request: ```js const makeRequest = async () => { const requestConfig = { method: 'GET', headers: { 'X-TENANT-ID': 'eu1-unique-tenant-id-here', 'Authorization': 'ApiKey pk_prod_api-key-here' }, }; try { const response = await fetch('https://api.qualyhq.com/v1/users/user', requestConfig); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } }; makeRequest(); ``` > **Warning — Requests without Tenant ID or API Key** > > Requests without a valid `X-TENANT-ID` header or without a valid API key in the `Authorization` header will throw an error. You can also use the header `x-tenant-id` (in lowercase). ## Types of users When interacting with Qualy's API, different user types have specific permissions and access levels. Understanding these user types is crucial for proper authentication and utilization of the available endpoints. ### User As a standard user, you have unrestricted access to all available API endpoints. This user type is used to all users of the tenant, and have the least amount of restrictions, but are still bound by roles/permissions. ### Contact Contacts logging in through the contact portal will be designated as such. While they enjoy access to the API, certain endpoints may be limited or scoped to ensure security and relevance to the contact's data. ### Partner Partners, similar to contacts, may experience limitations on certain endpoints or functionalities. These restrictions aim to tailor the API experience to the specific needs of Qualy's partners. ### API Key API Keys provide programmatic access to Qualy's API. While most endpoints are accessible, some administrative actions, like generating new API keys, may be restricted. This user type is suitable for automated processes and system-to-system integrations. Understanding the nuances of each user type ensures that authentication aligns with your intended use case and helps maintain a secure and efficient interaction with Qualy's API. ## Roles We use roles to control user permissions. Each role defines specific actions that users can perform. API keys have super admin access by default. --- # Querying data > Querying data via the Qualy API. Most of our endpoints allows you to retrieve specific data through query requests using various parameters. Our API also lets you select what properties to return (like an SQL `SELECT` command), limit the quantity, sort and skip results. --- ## The response envelope Every successful response from the Qualy API is wrapped in a `data` envelope. Your object — or array of objects — is always under the `data` key: ```json { "data": { "_id": "653fc651d14vbfe63d4fd49c", "email": "email@email.com" } } ``` This means you access fields as `response.data._id`, **not** `response._id`. In JavaScript, destructure it as you parse: ```javascript const { data: contact } = await response.json(); console.log(contact._id); ``` List endpoints (`GET` requests that return an array) add two fields **next to** `data` for pagination: * `count` — the number of items in `data` for this page. * `hasMore` — `true` when more pages are available (the returned count equals your `limit`). ```json { "count": 14, "hasMore": true, "data": [ /* … */ ] } ``` > **Note — Two exceptions** > > The health check (`/health`) and public keys (`/public-keys`) endpoints return their body unwrapped. Every other endpoint uses the envelope. --- ## Query paramaters | Paramater | Example | Description | | --- | --- | --- | | `filter` | `status[0]=due&status[1]=paid-partial` | Defines a query object to filter the data based on the specified criteria. | | `projection` | `profile.firstName email` | Selects which properties to return, like an SQL `SELECT`. Space-separated field names; supports dot notation for nested fields. Prefix a field with `-` to exclude it (e.g. `-_id`). | | `skip` | `skip=10` | Specifies the number of documents to skip in the query. This parameter is useful for pagination, allowing you to navigate through large sets of data. | | `limit` | `limit=10` | Defines the number of documents to include in the API response. It enables users to limit the quantity of data retrieved. | | `sort` | `createdAt` | Let's you sort the response based on different properties. Ascending or descending order, etc. To sort on a descending order, a hyphen character before the property name should be added (e.g. `-createdAt`). | | `population` | `0[path]=contact&0[select]=profile` | Allows the expansion of specific properties of the response by specifying which fields to expand. Read more on on [Expanding Responses](#expanding-responses). | --- ## Example query request The following request and response payload is an example of a query made to the [Contacts API](https://v1-spec.qualyhq.com/#get-/v1/contacts). ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/contacts?projection=number%20profile.firstName%20profile.lastName%20profile.picture%20email%20profile.phone%20tags%20owners&sort=number&limit=14&skip=0', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, }); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` ### Query response Note that the `_id` is automatically included, unless specified otherwise in the `projection` paramater (e.g. `-_id`). ```json { "count": 1, "hasMore": false, "data": [{ "_id": "653fc651d14vbfe63d4fd49c", "email": "email@email.com", "number": "123", "profile": { "firstName": "First name", "lastName": "Test", "phone": "+61 415 374 585" }, "owners": [ "64e382f1c79fe62f6fd1546c" ], "tags": [], }] } ``` ## Enconding query paramaters ### Objects You need to encode JavaScript objects by converting key-value pairs into a query string format. ```javascript // this object { key1: 'value1', key2: 'value2' } // becomes 'key1=value1&key2=value2' ``` ### Arrays Arrays are handled by encoding their indices or keys. ```javascript // this array [ 'value1', 'value2' ] // becomes '0=value1&1=value2' // or using the brackets notation 'arr[]=value1&arr[]=value2' ``` ### Nested objects and arrays Nested objects or arrays are encoded using dot notation. ```javascript // this object { key: { nestedKey: 'value' } } // becomes 'key.nestedKey=value' ``` ### Special characters and URI component #### Encoding special characters You need to encode special characters, to allower their inclusion in query strings without causing parsing issues. For example, spaces are replaced with `%20`. #### URI component encoding Use URI component encoding for preserving the integrity of URL query parameters. This encoding helps represent characters that have special meanings in URLs by converting them to a valid format. > **Note — NPM packages can help you** > > The NPM package `qs` can help you encode all query paramaters automatically. It's the same package internally used by Qualy to stringify and parse query paramaters. > > [Go to the NPM package page](https://www.npmjs.com/package/qs) ## Expanding responses Many objects allow you to request additional information as an expanded response by using the `population` paramater. This parameter is available on most API query requests, and applies to the response of that request only. To expand responses, add an extra paramater to the URL called `population`, and using th same enconding as explained above, choose which properties you want to expand. ```javascript // this object [{ path: 'contact', select: 'profile email' }] // becomes '0[path]=contact&0[select]=profile email' ``` ## Filtering results You can filter results of your query. Qualy is compatible with a subset of the MongoDB query syntax. [You can learn more about how to create MongoDB-compatible object queries here.](https://www.mongodb.com/docs/manual/tutorial/query-documents/) All filter inputs are sanitised before execution. The following sections describe what is supported and what is not. ### Supported filter operators These comparison and logical operators are available for use in filters: | Category | Operators | Example | | --- | --- | --- | | Comparison | `$eq`, `$gt`, `$gte`, `$lt`, `$lte`, `$ne`, `$in`, `$nin` | `age[$gte]=18` | | Logical | `$or`, `$and`, `$not`, `$nor` | `$or[0][status]=active&$or[1][status]=pending` | | Element | `$exists`, `$type` | `email[$exists]=true` | | Array | `$elemMatch`, `$size`, `$all` | `tags[$elemMatch][$eq]=important` | ### Disallowed operations The following operations are explicitly **not supported** and will be silently stripped from your query: * **`$regex` and `$options`** — Regular expression matching is not available through query filters. Use the [Search API](#search) for free-text and pattern-based searching instead. * **`$where`** — Server-side JavaScript execution is not permitted. * **`$expr`** — Aggregation expressions within queries are not supported. * **`$lookup`, `$unionWith`, `$merge`, `$out`** — Aggregation pipeline stages and cross-collection operations are not supported. * **`$function`, `$accumulator`** — Custom server-side functions are not permitted. * **`$comment`** — Query comments are stripped. > **Warning — Filtering on sensitive fields** > > Queries that attempt to filter on sensitive fields (such as `password`, `apiKey`, `secret`, `accessToken`, `refreshToken`, or `privateKey`) will have those fields removed from the filter. This applies at any nesting depth. ### Query limits * The `limit` parameter accepts values between `0` and `1000`. * The `sort` and `projection` parameters only accept space-separated field names (e.g. `name email createdAt`). Dot notation for nested fields (e.g. `profile.firstName`) and a leading hyphen for descending order (e.g. `-createdAt`) are supported. JSON objects, special characters, and expressions are not accepted. * Filter nesting depth is limited. Deeply nested filter structures beyond a reasonable depth will be truncated. ## Search Some top-level API resources have support for retrieval via our [Search API](https://v1-spec.qualyhq.com/#post-/v1/search/-index-) methods. It differs from a "query", as it supports typo-tolerance, free-text search, and more. For example, you can search **contacts**, and search **partnerships**. ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/search/contacts', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ "query": "test", }), }); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` ### Example search results payload The following payload shows the result of a query request on the **contacts** entity. ```json { "page": 0, "nHits": 1, "nPages": 1, "hitsPerPage": 14, "processingTimeMs": 1, "hits": [{ "_id": "653fc651d14vbfe63d4fd49c", "email": "email@email.com", "number": "123", "profile": { "firstName": "First name", "lastName": "Test", }, "partnersReference": {}, "owners": [ "64e382f1c79fe62f6fd1546c" ], "primaryTeams": [ "64e38320c79fe62f6fd154b8" ], "tags": [], "tenantId": "eu1-xpdqxiiphqsdtkpazqdqnraqi", "_highlightResult": { "email": { "value": "email@email.com", "matchLevel": "none", "matchedWords": [] }, "number": { "value": "123", "matchLevel": "none", "matchedWords": [] }, "profile": { "firstName": { "value": "First name", "matchLevel": "none", "matchedWords": [] }, "lastName": { "value": "Test", "matchLevel": "full", "fullyHighlighted": true, "matchedWords": [ "test" ] } } }, }] } ``` For the full API reference for the Search endpoint, [click here](https://v1-spec.qualyhq.com/#post-/v1/search/-index-). --- # Smart references > Use ObjectIds or natural identifiers in supported reference fields. Many Qualy API endpoints include fields that point to another record, such as `contact`, `service`, `supplier`, `tax`, or `template`. When the API Reference describes one of these fields as accepting a reference, you can send either the record's ObjectId or a supported natural identifier. Use ObjectIds when you already store them. Use natural identifiers when your integration is driven by human-entered names, emails, partnership codes, or AI-generated payloads. --- ## Supported identifiers | Record type | Accepted reference values | | --- | --- | | Contact | ObjectId or contact email. Email matching is case-insensitive. | | Partnership | ObjectId, partnership code, exact partnership name, or partnership name prefix. | | Service | ObjectId, exact service name, or service name prefix. | | Tax | ObjectId, exact tax name, or tax name prefix. | | Template | ObjectId, exact template name, or template name prefix. | Only fields documented as reference fields support this behavior. If the API Reference says a field requires an ObjectId, continue sending an ObjectId. --- ## Example This payment intent uses natural identifiers for the contact, supplier, item service, tax rate, and template: ```javascript const response = await fetch('https://api.qualyhq.com/v1/payment-intents/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ intentType: 'ad-hoc', contact: 'jane@example.com', currency: 'AUD', suppliers: ['usyd'], tax: 'GST 10%', template: 'Standard Tuition Plan', items: [ { name: 'Tuition fee', amount: 100000, service: 'Diploma of Business', }, ], }), }); ``` Qualy resolves each reference before the endpoint runs. The stored object still contains ObjectIds, and response payloads keep the same shape as ObjectId-based requests. --- ## Matching rules ObjectId values are accepted directly. Natural identifiers are resolved against the tenant's records before the request is processed. Reference values must be strings between 3 and 256 characters. Arrays of references use the same rules for each item. For exact names and prefixes, Qualy must find exactly one matching record. A unique prefix is accepted; a prefix that matches multiple records is rejected so you can disambiguate the request. > **Note — Prefer stable identifiers** > > For automated integrations, store ObjectIds or unique natural identifiers such as contact email and partnership code when possible. Prefix matching is convenient, but it can become ambiguous when similarly named records are added later. --- ## Errors | Scenario | HTTP status | Result | | --- | --- | --- | | No record matches the reference | `404` | The request fails with `Reference not found`. | | More than one record matches the reference | `409` | The request fails with `Reference is ambiguous` and includes matching candidate identifiers and display fields. | | A reference value is invalid | `400` | The request fails before the endpoint runs. | | Multiple reference fields fail in one request | `422` | The response includes the field-level reference errors. | --- ## Idempotent requests For idempotent endpoints, Qualy fingerprints the raw request body before smart references are resolved. A retry with the same `Idempotency-Key` and the same body returns the cached response, even if a natural identifier would resolve differently later. If you want Qualy to resolve the reference again after a contact email, partnership name, service name, tax name, or template name changes, send a new `Idempotency-Key`. --- # Idempotency > Prevent duplicate financial operations with idempotent API requests. Qualy supports idempotent requests for financial mutation endpoints, allowing you to safely retry requests without the risk of performing the same operation twice. This is especially important for payment operations where network issues or timeouts may leave you uncertain whether a request was processed. --- ## How it works Financial endpoints such as transactions, payment intents, payment splits, and orders support idempotency. Qualy uses two mechanisms to prevent duplicates: 1. **Idempotency key** (recommended) -- You provide an explicit key via the `Idempotency-Key` header. 2. **Automatic fingerprinting** -- If no key is provided, Qualy automatically generates a fingerprint based on the request body to detect identical requests. When an idempotency key is provided, it takes precedence over automatic fingerprinting. --- ## Using the Idempotency-Key header Include the `Idempotency-Key` header in your request with a unique value (e.g. a UUID) that identifies the intended operation: ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/payment-intents/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', 'Idempotency-Key': '550e8400-e29b-41d4-a716-446655440000', }, body: JSON.stringify({ "intentType": "ad-hoc", "contact": "656b9ef1a3258074a705433b", "dueAt": "2025-05-10T14:26:07.369Z", "currency": "AUD", "items": [ { "name": "Tuition fee", "description": "", "category": "Course", "amount": 100000 } ], }), }); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` --- ## Behavior | Scenario | Result | | --- | --- | | First request with a given key | Request is processed normally and the response is cached. | | Retry with the same key and the same body | The cached response from the original request is returned. The operation is not executed again. | | Same key but different request body | HTTP `422 Unprocessable Entity` is returned. This prevents replay attacks where a previously used key is reused with a modified amount or other parameters. | | No `Idempotency-Key` header provided | Qualy automatically detects duplicate requests by fingerprinting the request body. Identical requests within the deduplication window are treated as retries. | | Retry while the first request is still in flight | HTTP `409 Conflict` (`IDM-1003`) is returned while the original request is still processing. Wait a moment and retry with the same key — once the original completes, you'll get the cached response. | | Key longer than 256 characters (or empty) | HTTP `400 Bad Request` (`IDM-1001`). Keys must be 1–256 characters. | > **Note — Detecting a replayed response** > > When Qualy returns a cached response instead of executing the operation again, it sets the `Idempotent-Replayed: true` response header. Use it to tell a fresh execution apart from a replay in your logs. > **Warning — Key reuse with different body** > > If you send a request with the same `Idempotency-Key` but a different request body, Qualy rejects it with HTTP `422` (`IDM-1002`). Always generate a new key for each unique operation. --- ## A safe-retry recipe The point of idempotency is that you can retry a request after a timeout or network error **without** risking a double charge. The rule: generate one key per operation, then reuse that **same** key on every retry. Back off between attempts, and treat `409` (still processing) as retryable. **JavaScript** ```javascript import { randomUUID } from 'node:crypto'; async function createPaymentIntent(body) { const idempotencyKey = randomUUID(); // one key for this operation… for (let attempt = 0; attempt < 5; attempt++) { const res = await fetch('https://api.qualyhq.com/v1/payment-intents/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', 'Idempotency-Key': idempotencyKey, // …reused on every retry }, body: JSON.stringify(body), }); // 409 = the first attempt is still processing; 5xx = transient. Retry both. if (res.status === 409 || res.status >= 500) { await new Promise((r) => setTimeout(r, 2 ** attempt * 250)); // backoff continue; } const { data } = await res.json(); if (!res.ok) throw new Error(`Failed: ${JSON.stringify(data ?? {})}`); return data; } throw new Error('Exhausted retries'); } ``` **PHP (Laravel)** ```php use Illuminate\Support\Str; use Illuminate\Support\Facades\Http; function createPaymentIntent(array $body): array { $idempotencyKey = (string) Str::uuid(); // one key for this operation… for ($attempt = 0; $attempt < 5; $attempt++) { $res = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', 'Idempotency-Key' => $idempotencyKey, // …reused on every retry ])->post('https://api.qualyhq.com/v1/payment-intents/create', $body); // 409 = first attempt still processing; 5xx = transient. Retry both. if ($res->status() === 409 || $res->serverError()) { usleep((2 ** $attempt) * 250_000); // backoff continue; } $res->throw(); return $res->json('data'); } throw new \RuntimeException('Exhausted retries'); } ``` --- ## Smart references Some idempotent endpoints accept [smart references](/docs/smart-references.md), such as a contact email instead of a contact ObjectId. Idempotency uses the raw request body before those references are resolved. That means a retry with the same key and the exact same body returns the cached response, even if the underlying record was renamed, deleted, or would now resolve to a different ObjectId. To force Qualy to resolve references again, send a new `Idempotency-Key`. --- ## Supported endpoints Idempotency is enforced on financial mutation endpoints, including: * **Payment intents** -- `POST /v1/payment-intents/create` * **Transactions** -- `POST /v1/transactions/create` * **Payment splits** -- `POST /v1/payment-splits/create` * **Orders** -- `POST /v1/orders/create` * **Refunds** -- `POST /v1/refund-intents/create` * **Bank accounts** -- `POST /v1/bank-accounts/create` * **Authorizations** and **payment gateway charges** Endpoints marked as `header` strategy require you to send an `Idempotency-Key`; those marked `both` also fall back to automatic body fingerprinting when no key is sent. --- ## Best practices * **Generate a unique key per operation** -- Use a UUID or another unique identifier for each distinct payment or transaction. Do not reuse keys across different operations. * **Store keys alongside your records** -- Save the idempotency key with the corresponding record in your system so you can reliably retry with the same key if needed. * **Retry safely on network errors** -- If a request times out or you receive a network error, retry with the same `Idempotency-Key` to safely determine whether the original request was processed. * **Use a new key when changing reference inputs** -- If you switch from an email/name/code to an ObjectId, or want a natural identifier to be resolved again, create a new key for that request. * **Keys expire after 24 hours** -- Idempotency keys are valid for 24 hours after the first request. After that, the same key can be reused for a new operation. --- # Currencies and countries > Learn how Qualy handles curencies and countries. When working with the Qualy API you soon will need to specify currencies and country codes. In this article we explain how Qualy deals with currencies and countries across its API. --- ## Currencies In any API call where a currency must be specified, the currency code must be a valid ISO 4217 currency code. To see the list of all currencies in the ISO 4217 standard, [check this page](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes). ### Currency unit API requests require amounts to be specified in the smallest unit of the currency. For instance, if you want to charge 10 AUD, you should provide an amount value of 1000 (equivalent to 1000 cents), we arrived at this value by multiplying 10 AUD times 100. --- ## Countries In any API call where a country must be specified, the country code must be a valid ISO 3166-1 alpha-2 code. To see the list of all country codes in the ISO 3166-1 alpha-2 standard, [check this page](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2#Officially_assigned_code_elements). We may support some countries that are not officially suppored by the ISO 3166-1 alpha-2 standard. Countries such as Kosovo, Montenegro and others are examples of this exception to the rule. --- ## Percentages In any API call where a percentage must be specified, use a value from 0 to 1. Example: a 10% percentage rate would be 0.1. --- # Errors > Learn how Qualy handles error codes, messages and more. Qualy uses conventional HTTP response codes to indicate the success or failure of an API request. In general: Codes in the `2xx` range indicate success. Codes in the `4xx` range indicate an error that failed given the information provided (e.g., a required parameter was omitted, a charge failed, etc.). Codes in the `5xx` range indicate an error with Qualy’s servers. --- ## HTTP status code summary | Number | Error | Description | | --- | --- | --- | | 200 | Ok | Everything worked as expected. | | 400 | Bad Request | The request was unacceptable, often due to missing a required parameter. | | 401 | Unauthorized | No valid API key provided. | | 403 | Forbidden | The API key doesn’t have permissions to perform the request. | | 404 | Not Found | The requested resource doesn’t exist. | | 409 | Conflict | The request conflicts with another request. | | 422 | Unprocessable Entity | The request could not be processed. This is returned when an idempotency key is reused with a different request body. See [Idempotency](/docs/idempotency.md). | | 429 | Too Many Requests | You have exceeded the rate limit. The response includes a `Retry-After` header indicating how many seconds to wait before retrying. See [Rate limiting](/docs/fees-limits.md#rate-limiting). | | 500, 502, 503, 504 | Server Errors | Something went wrong on Qualy's end. | | 599 | Maintenance | Qualy is undergoing scheduled maintenance. The response includes a `Retry-After` header. Check [status page](https://qualyhq.statuspage.io/) for updates. | ## Maintenance mode In rare cases, Qualy may enter maintenance mode for scheduled infrastructure work. When this happens, the API responds with HTTP status code `599` and includes a `Retry-After` header indicating the estimated time until the maintenance window ends. These windows are communicated in advance and you can monitor real-time status at [qualyhq.statuspage.io](https://qualyhq.statuspage.io/). During maintenance: * New payment attempts will fail. * Dashboard login will be unavailable. * Background operations (notifications, bank-related events) are queued and processed automatically once maintenance is over. * Health checks, webhook subscriptions, and payment gateway callbacks continue to operate normally. ## Error codes In addition to HTTP status codes, Qualy returns application-level error codes to help you identify the specific issue. Each error code follows the format `PREFIX-NUMBER`, where: * **PREFIX** is a 3-letter module identifier (e.g., `PAY` for Payouts, `TXN` for Transactions) * **NUMBER** is a numeric code unique within that module For example, `PAY-1001` means "Payout not found" and `TXN-2008` means "You cannot refund more than the total amount". These codes remain stable and can be used to programmatically handle specific error scenarios, even if the human-readable message changes over time. ### Module prefixes #### Payments & money movement | Prefix | Module | | --- | --- | | `PIN` | Payment intents | | `TXN` | Transactions | | `PSP` | Payment splits | | `BLK` | Bulk payment splits | | `ORD` | Orders | | `SUB` | Subscriptions | | `RFI` | Refund intents | | `PAY` | Payouts | | `BNK` | Bank accounts | | `TAX` | Tax | | `FXX` | FX (foreign exchange) | | `ATH` | Authorizations (direct-debit mandates) | | `DUN` | Dunning | | `WFL` | Approvals (workflow) | | `BIL` | Billing | #### Core resources | Prefix | Module | | --- | --- | | `CON` | Contacts | | `USR` | Users | | `TEM` | Teams | | `PRT` | Partnerships | | `TNT` | Tenant | | `FLD` | Custom fields | | `SVC` | Services | #### Platform & tooling | Prefix | Module | | --- | --- | | `AUT` | Authentication | | `IDM` | Idempotency | | `REF` | Reference resolution (smart references) | | `GTW` | Gateway | | `NTF` | Notifications | | `STG` | Storage | | `SET` | Settings | | `IMP` | Imports | | `CMP` | Compliance | | `DVL` | Developer | | `GEN` | General | #### Payment gateways & providers | Prefix | Module | | --- | --- | | `ZAI` | Zai | | `PGB` | PagBank | | `ASA` | Asaas | | `KLR` | Klarna | | `NPY` | NexPay | | `WSE` | Wise | | `TRM` | TransferMate | | `BLS` | BlueSnap | | `XEX` | XE | Other internal modules follow the same `PREFIX-NUMBER` convention; the prefix always identifies the module that produced the error. ## Error response Error responses include the HTTP status code, a human-readable message, and when available, a specific error `code` and additional `details`: ```javascript { "statusCode": 404, "code": "PAY-1001", "message": "Payout not found" } ``` ### Validation errors When a request fails input validation (a missing required field, a wrong type, an invalid email), Qualy returns a `400` with `error: "Bad Request"` and a `message` that is an **array** of human-readable problems — one entry per failed field: ```javascript { "statusCode": 400, "error": "Bad Request", "message": [ "email must be an email", "profile must be an object" ] } ``` > **Warning — `message` can be a string or an array** > > Application errors return `message` as a single string, while validation errors return it as an array. If you surface error messages to users or logs, normalize both — for example `[].concat(body.message).join(', ')`. Some errors may include a `details` field with additional context about the issue: ```javascript { "statusCode": 400, "code": "BNK-6011", "message": "Country and currency combination not supported.", "details": { "country": "BR", "currency": "USD" } } ``` If the request failed due to the lack of permissions, Qualy will return what roles are necessary to perform the requested operation. ```javascript { "statusCode": 403, "message": "This action is forbidden because the user lacks necessary roles.", "data": { "roles": [ "paymentIntents:edit:all", "paymentIntents:edit:me", "paymentIntents:edit:team" ]}, "error": "Forbidden" } ``` --- # Creating a payment intent > Creating a payment using Qualy's API. Qualy's API provides robust functionality for creating payments, allowing developers to implement a wide range of payment scenarios. This guide will take you through the process step by step, starting with a basic example and gradually introducing more complex payment scenarios. --- ## Before you start You need to have an existing Contact to create a Payment Intent. The `contact` field accepts either the Contact ObjectId or the contact email. See [Smart references](/docs/smart-references.md) for all supported reference fields. ## Creating a payment To create a payment on Qualy, you will have to use the [Payment Intent API](https://v1-spec.qualyhq.com/#post-/v1/payment-intents/create). When you create the Payment Intent, you can specify options like the amount, currency, and more: ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/payment-intents/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ "intentType": "ad-hoc", "contact": "656b9ef1a3258074a705433b", "dueAt": "2025-05-10T14:26:07.369Z", "tax": "GST 10%", "currency": "AUD", "items": [ { "name": "Tuition fee", "description": "", "category": "Course", "amount": 100000 }, { "name": "Material fee", "description": "", "category": "Course", "amount": 10000 }, { "name": "Enrollment fee", "description": "", "category": "Course", "amount": 25000 } ], }), }); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` ### Intent types Payment intents can have different types. The `intentType` property helps customers in their reporting and analytics activities, and it's currently not used in any other feature. Make sure to select the correct `intentType` when creating a payment. > **Note — A quick note** > > While Qualy supports many types of `intentType` values, as a user of the API, you may probably want to use either `ad-hoc` or `installment` as other values are used for other purposes. If you believe you have a valid use-case, feel free to choose a different value. | Intent type | Description | | --- | --- | | `ad-hoc` | For payments that are not part of payment plan, such as specific one-off fees. | | `installment` | For payments part of a [payment plan](#payment-plan-orders). | | `step` | Reserved use. | | `portal` | Used by Qualy when a contact uses Qualy's to purchase a service from the Contact portal. | | `ecommerce` | Used by Qualy when purchase is made via Qualy, and the contact didn't previously existed in the tenant's account. | | `subscription` | Used by Qualy for payments generated from a recurring [subscription](/docs/guides/creating-subscriptions.md). | | `early-payoff` | Used by Qualy for a settlement payment that bundles a contact's remaining installments so they can be cleared at once. See [Financing & installments](/docs/guides/financing-options.md). | --- ### Tax calculation Qualy's `v1` API calculates the payment's tax based on the total amount. If you need to apply different tax rates based on the payment item, you must create different payments. Check our [Tax API](https://v1-spec.qualyhq.com/#post-/v1/tax/create) to retrieve, and create taxes. Use the Tax ObjectId or tax name in the payment creation to get the tax calculated automatically by Qualy. Learn more about Tax calculation in our [dedicated guide](/docs/handling-using-tax-rates). ### Settlement currency and FX (Currency exchange) Sometimes you may want to create a payment in a specific currency (e.g. AUD) but you want the end-user to pay in another (e.g. EUR), this is useful in many cases, especially when you want Qualy to pay the suppliers in the original currency, and to convert it automatically at the time of payout. Set the property `settlementCurrency` as your desired currency. When the end-user tries to pay, it will only display the payment options of the specificed `settlementCurrency`. Qualy will automatically fetch the most up-to-date FX rate, but if you want to specify a static FX rate, you can add the property `quote` when creating a Payment Intent, here's an of a Payment Intent creation with the `quote` specified: ```javascript try { const response = await fetch('', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ "intentType": "ad-hoc", "contact": "656b9ef1a3258074a705433b", "dueAt": "2025-05-10T14:26:07.369Z", "tax": "GST 10%", "currency": "AUD", "items": [ { "name": "Tuition fee", "description": "", "category": "Course", "amount": 100000 }, { "name": "Material fee", "description": "", "category": "Course", "amount": 10000 }, { "name": "Enrollment fee", "description": "", "category": "Course", "amount": 25000 } ], "quote": { "sourceCurrency": "AUD", "targetCurrency": "EUR", "sourceAmount": 135000, "targetAmount": 67500, "rate": 0.5, "markup": { "type": "percentage-on-amount", "amount": 0.3 } } }), }); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` ### Creating a payment plan (Orders) If you are creating a payment that's part of a payment plan, or that is an installment, you should associate this payment with an "Order". Orders are how Qualy handles when services are sold, and they will be paid in multiple payments. It's possible to create a payment with the property `intentType` using the value `installment` without an Order. We do not recommend, as users will get confused when extracting reports. #### How to create a payment plan The steps above should be follow just as usual, but you will need to add a new property. 1. Create an Order: follow the guide [Creating an Order](/docs/guides/creating-orders.md#creating-an-order) 2. Create an Order Item: follow the guide [Creating an Order Item](/docs/guidescreating-orders#creating-an-order-item) 3. With the resulting `_id` of both Order and Order Item, add the following properties when creating a PaymentIntent: `order` and `orderItems` (array). Here's an example of a Payment Intent creation payload that includes the Orders and Order Items properties. ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/payment-intents/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ "intentType": "ad-hoc", "contact": "656b9ef1a3258074a705433b", "dueAt": "2025-05-10T14:26:07.369Z", "tax": "GST 10%", "order": "4123b9cc1b3228074l705433b", "orderItems": ["677d24f98bd3d800483852f2"], "currency": "AUD", "items": [ { "name": "Tuition fee", "description": "", "category": "Course", "amount": 100000 }, { "name": "Material fee", "description": "", "category": "Course", "amount": 10000 }, { "name": "Enrollment fee", "description": "", "category": "Course", "amount": 25000 } ], }), }); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` For more information on how to create an Order and Order Items, following the following guide: [Creating Orders](/docs/guides/creating-orders.md). ## Collecting the payment Once the Payment Intent is created, you need to collect the actual payment. You have two options: - **Payment portal** - Redirect your contact to Qualy's hosted payment page using the `links` URL returned in the Payment Intent response. Qualy handles method selection, payer verification, and processing. Required for card payments. - **Direct API** - For non-card methods (PIX, Boleto, PayID, bank transfers), call the sign endpoint to get payment details (QR codes, barcodes, bank account numbers) and display them in your own UI. See the full guide: [Collecting payments](/docs/guides/collecting-payments.md). ## After the payment creation After the payment is created, it is a best practice for your server to monitor [webhooks](/docs/webhooks.md) to detect when the payment successfully completes or fails. A PaymentIntent might have more than one Transaction object associated with it if there were multiple payment attempts, or if there were multiple partial payments. For each Transaction you can retrieve the status and details of the payment method used. ### Sending payment reminders Qualy will automatically send payment reminders via email and SMS based on the frequency selected using the [Settings API](https://v1-spec.qualyhq.com/#get-/v1/settings). But if you want to send a payment reminder right away, you can use the Notifications API. You will need the `PaymentIntent` _id and the the `Contact`_id to send the notification: ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/notifications/queue/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ "category": "payment-reminder", "comment": "", "data": {}, "from": "user", "paymentIntent": "6634f56c7dbbc16e07b5025e", "recipientId": "6606ab8ffb9085579f1b5844", "recipientType": "contact", "templateId": "reminder" }), }); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` --- # Creating an order > Creating a payment plan (Order) using Qualy's API. Qualy's API provides robust functionality for creating payments that are part of a multi-product/service order. You can use the Orders API to control when a product/service was purchased, and what are the specific Items of a specific Order. For example, in an ecommerce website, you may have a "Cart" with multiple items, once the customer purchases the items, all of the items purchased together are part of an "Order" and each specific item, is an "Order Item". An Order/Order Item may have as many Payment Intents as you want. --- ## Before you start You need to have an existing Contact to create an Order. The `contact` field accepts either the Contact ObjectId or the contact email. See [Smart references](/docs/smart-references.md). ## Creating an order To create an order on Qualy, you will have to use the [Orders API](https://v1-spec.qualyhq.com/#post-/v1/orders/create). This is an example of creating an Order: **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/orders/create \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "contact": "john.doe@example.com", "source": "manual", "services": ["Diploma of Business"] }' ``` **JavaScript** ```javascript const res = await fetch('https://api.qualyhq.com/v1/orders/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ contact: 'john.doe@example.com', source: 'manual', services: ['Diploma of Business'], }), }); const { data: order } = await res.json(); console.log(order._id); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $order = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/orders/create', [ 'contact' => 'john.doe@example.com', 'source' => 'manual', 'services' => ['Diploma of Business'], ])->json('data'); echo $order['_id']; ``` ### Services An order may be associated with multiple services. You can use the [Services API](https://v1-spec.qualyhq.com/#post-/v1/services/create) to create or retrieve services, then pass each service ObjectId or service name in the `services` array. When `services` are provided, Qualy creates the corresponding Order Items. ### Source stypes Orders can have different source types. The `source` property helps customers in their reporting and analytics activities, and it's currently used internally to trigger actions and analyse the order payload. Make sure to select the correct `source` when creating an Order. | Source type | Description | | --- | --- | | `manual` | Set the source type as "manual" on all your calls to the `Create Orders API`. | | `portal` | Reserved use. | | `ecommerce` | Reserved use. | | `workflow` | Reserved use. | --- ## Creating an Order Item An Order may have multiple Order Items, each Order Item is a product/service bought by the customer. This is an example of a request to create an Order Item: **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/orders/677d24f98bd3d800483852f2/items/create \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "service": "Diploma of Business", "supplier": "usyd" }' ``` **JavaScript** ```javascript const res = await fetch( 'https://api.qualyhq.com/v1/orders/677d24f98bd3d800483852f2/items/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ service: 'Diploma of Business', supplier: 'usyd', }), }, ); const { data: orderItem } = await res.json(); console.log(orderItem._id); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $orderItem = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/orders/677d24f98bd3d800483852f2/items/create', [ 'service' => 'Diploma of Business', 'supplier' => 'usyd', ])->json('data'); echo $orderItem['_id']; ``` ### Supplier An Order Item may be associated with one "Supplier". The `supplier` field accepts a Partnership ObjectId, partnership code, exact name, or name prefix. You should attach the supplier to the Order Item creation if the product/service being provided in the Order Item is actually provided by another company. A common use case is the International Education industry, where an international educationa agency may have sold a program, but the program (course) is provided by a college or university. It's important the Suppliers are properly associated, including in the Payment Intent, as they affect how Payment Splits are calculated. ### The "Service" property Using the [Services API](https://v1-spec.qualyhq.com/#post-/v1/services/create) you can create a catalog of services your customer provides. Once the service exists, use its ObjectId or name when creating an Order or Order Item. You need to have an existing Service to create an Order Item. --- # Creating a payment split > Creating a payment split using Qualy's API. Qualy lets you to split the payout of a Payment Intent to multiple beneficiaries. That means, when a contact pays a Payment Intent, the money can be sent to multiple bank accounts. Using the Payment Split API, you can specify how much to send and to whom. --- ## Before you start You need to have a Payment Intent `_id` to create a Payment Split, follow [this guide](/docs/guides/creating-payment-intents.md) to create a Payment Intent. The `partnership`, `contact`, and `tax` fields accept [smart references](/docs/smart-references.md). The `paymentIntent`, `item`, `split`, `ref`, `order`, and `orderItems` fields still require ObjectIds. ## Creating a payment split To create a payment split on Qualy, you will have to use the [Payment Splits API](https://v1-spec.qualyhq.com/#post-/v1/payment-splits/create). When you create the Payment Split, you can specify options like the amount, partnership, and more: **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/payment-splits/create \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "type": "percentage-on-amount", "currency": "AUD", "amount": 3000, "partnership": "usyd", "item": "661e9bc0e0eb901d6b2f2682", "contact": "john.doe@example.com", "split": "63fdfda0c7ca4bb64baa50a3", "dueAt": "2024-08-20T17:39:31.219Z", "tax": "GST 10%", "percentage": 0.3, "paymentIntent": "661e9bbfe0eb901d6b2f2670" }' ``` **JavaScript** ```javascript const res = await fetch('https://api.qualyhq.com/v1/payment-splits/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ type: 'percentage-on-amount', currency: 'AUD', amount: 3000, partnership: 'usyd', item: '661e9bc0e0eb901d6b2f2682', contact: 'john.doe@example.com', split: '63fdfda0c7ca4bb64baa50a3', dueAt: '2024-08-20T17:39:31.219Z', tax: 'GST 10%', percentage: 0.3, paymentIntent: '661e9bbfe0eb901d6b2f2670', }), }); const { data: paymentSplit } = await res.json(); console.log(paymentSplit._id); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $paymentSplit = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/payment-splits/create', [ 'type' => 'percentage-on-amount', 'currency' => 'AUD', 'amount' => 3000, 'partnership' => 'usyd', 'item' => '661e9bc0e0eb901d6b2f2682', 'contact' => 'john.doe@example.com', 'split' => '63fdfda0c7ca4bb64baa50a3', 'dueAt' => '2024-08-20T17:39:31.219Z', 'tax' => 'GST 10%', 'percentage' => 0.3, 'paymentIntent' => '661e9bbfe0eb901d6b2f2670', ])->json('data'); echo $paymentSplit['_id']; ``` > **Note — All Payment Splits are associated with a Payment Item** > > When creating a Payment Split you will need the Payment Intent `_id` and the [Payment Item](https://v1-spec.qualyhq.com/#post-/v1/payment-intents/-paymentIntentId-/items/create) `_id` for which the Payment Split should be associated with. Use the property `item` to do that. ### Types Payment splits can have different types. The `type` property helps customers in their reporting and analytics activities, and it's used by Qualy when calculating and handling payouts. Make sure to select the correct `type` when creating a payment split. | Type | Description | | --- | --- | | `percentage-on-amount` | Choose this type when the `amount` and `percentage` fields of the Payment Split are being calculated based on the `amount` of the [Payment Item](https://v1-spec.qualyhq.com/#post-/v1/payment-intents/-paymentIntentId-/items/create). | | `percentage-on-splits` | Choose this type when the Payment Split's `amount` and `percentage` is being calculated based on the `amount` of a different Payment Split. | | `fixed` | Choose this type whne the `amount` of the Payment Split is a flat amount. | | `keep-percentage` | This type has special rules on how Qualy treats payouts, calculation of taxes and more. Choose this type when you want to instruct Qualy to keep an specific percentage. It differs from `percentage-on-amount` due to internal rules. Use the field `keep` to specifiy how much you want to keep. The field amount will still represent how much the partnership is receiving. Learn more below, on what to expect when using this type. | Qualy also support the values `self` and `supplier`, but these are used internally and we don't recommend their usage, as it can create unexpected payout conflicts. --- #### Understanding the "keep-percentage" behavior Here are some of the rules Qualy applies when a Payment Split is of type `keep-percentage`. * The field `taxTotal` will be calculated based on the `keep` field, instead of the `amount` field. * If the Payment Intent has a `supplier` associated with it, the amount the Tenant would receive of Payout will be impacted. If no `keep-percentage` Payment Split is defined, the whole amount will be sent to the supplier and other partnerships. * If a Payment Split type `percentage-on-splits` is referecing a Payment Split type `keep-percentage`, the amount the Tenant will receive will be affected, especially if the payment has a supplier associated with it. This Payment Split type exists to accommodate needs of Tenants that commercialize a product they don't own, and need to retain their commission and send most of the Payment Intent amount to their supplier. A common example of this use case would be education agents in the international education industry. ### Tax calculation Qualy's `v1` API calculates the payment split tax based on the total amount. If you need to apply different tax rates based on the payment item, you must create different payments. Check our [Tax API](https://v1-spec.qualyhq.com/#post-/v1/tax/create) to retrieve, and create taxes. Use the tax ObjectId or tax name in the payment split creation to get the tax calculated automatically by Qualy. --- # Bulk payment splits > Group many partner payment splits into one settlement, mark it paid, and generate a statement PDF. When you owe a partner across many payments, settling each [payment split](/docs/guides/creating-payment-splits.md) individually is tedious. A **bulk operation** groups multiple splits owed to a single partnership into one batch you can track, mark paid together, and export as a statement. --- ## Before you begin A bulk operation is scoped to one **partnership** and settles that partner's [payment splits](/docs/guides/creating-payment-splits.md). So you'll need: - A **partnership** — see the [Partnerships API](https://v1-spec.qualyhq.com/#post-/v1/partnerships/create). - One or more **payment splits** owed to that partnership. Bulk operations live under the `/v1/bulk` endpoint and always return under a `data.bulk` (list) or `data` (single) envelope. See [the response envelope](/docs/queries.md#the-response-envelope). --- ## Create a bulk operation Group splits by passing the `partnership`, a `currency`, a `type`, and the `paymentSplits` you want to include: **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/bulk/create \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "name": "March partner settlement", "currency": "AUD", "type": "group", "partnership": "6607de740cf5287d324ddba7", "paymentSplits": ["68762663f57e66320fad2c82", "68762541a4b0db2ef994e8d5"], "dueAt": "2026-03-31T00:00:00.000Z" }' ``` **JavaScript** ```javascript const res = await fetch('https://api.qualyhq.com/v1/bulk/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ name: 'March partner settlement', currency: 'AUD', type: 'group', partnership: '6607de740cf5287d324ddba7', paymentSplits: ['68762663f57e66320fad2c82', '68762541a4b0db2ef994e8d5'], dueAt: '2026-03-31T00:00:00.000Z', }), }); const { data: bulk } = await res.json(); console.log(bulk._id, bulk.status); // e.g. "…9568" "pending" ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $bulk = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/bulk/create', [ 'name' => 'March partner settlement', 'currency' => 'AUD', 'type' => 'group', 'partnership' => '6607de740cf5287d324ddba7', 'paymentSplits' => ['68762663f57e66320fad2c82', '68762541a4b0db2ef994e8d5'], 'dueAt' => '2026-03-31T00:00:00.000Z', ])->json('data'); echo $bulk['_id'] . ' ' . $bulk['status']; ``` | Field | Required | Description | | --- | --- | --- | | `name` | Yes | A human-readable label for the batch. | | `currency` | Yes | The settlement currency (e.g. `AUD`). All included splits should share it. | | `type` | Yes | `group` for a batch you settle internally, or `remittance` for a cross-border/partner remittance. | | `partnership` | Yes | The partnership being settled. Accepts an ObjectId or a [smart reference](/docs/smart-references.md). | | `paymentSplits` | No | ObjectIds of the splits to include in the batch. | | `dueAt` | No | When the settlement is due. | A new bulk operation starts with `status: "pending"`. --- ## List and retrieve List bulk operations (returned under `data.bulk`), or fetch one by ID: **cURL** ```bash # List curl "https://api.qualyhq.com/v1/bulk?limit=20" \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' # Retrieve one curl "https://api.qualyhq.com/v1/bulk/69b2cff0d0882b77ac419568" \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' ``` **JavaScript** ```javascript const headers = { 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }; const { data } = await fetch('https://api.qualyhq.com/v1/bulk?limit=20', { headers }) .then((r) => r.json()); console.log(data.bulk); // array const { data: one } = await fetch( 'https://api.qualyhq.com/v1/bulk/69b2cff0d0882b77ac419568', { headers }, ).then((r) => r.json()); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $api = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ]); $list = $api->get('https://api.qualyhq.com/v1/bulk', ['limit' => 20])->json('data.bulk'); $one = $api->get('https://api.qualyhq.com/v1/bulk/69b2cff0d0882b77ac419568')->json('data'); ``` To see which actions are available for a given batch in its current state, call `GET /v1/bulk/{bulkOperationId}/options`. --- ## Mark a bulk operation as paid When you've settled the batch, update its `status`. Valid statuses are `pending`, `due`, `paid`, and `canceled`. Set `paidAt` when marking it `paid`: **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/bulk/69b2cff0d0882b77ac419568/update \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "status": "paid", "paidAt": "2026-03-31T10:00:00.000Z" }' ``` **JavaScript** ```javascript const res = await fetch( 'https://api.qualyhq.com/v1/bulk/69b2cff0d0882b77ac419568/update', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ status: 'paid', paidAt: '2026-03-31T10:00:00.000Z' }), }, ); const { data: bulk } = await res.json(); console.log(bulk.status); // "paid" ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $bulk = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/bulk/69b2cff0d0882b77ac419568/update', [ 'status' => 'paid', 'paidAt' => '2026-03-31T10:00:00.000Z', ])->json('data'); echo $bulk['status']; // "paid" ``` You can also send any other creatable field (for example `name` or `paymentSplits`) to the update endpoint to amend the batch. --- ## Generate a statement PDF Produce a settlement statement for the batch — handy to send the partner as a record of what was paid: ```bash curl -X POST https://api.qualyhq.com/v1/bulk/69b2cff0d0882b77ac419568/pdf \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' ``` --- ## Next steps - Understand the splits that go into a batch: [Creating payment splits](/docs/guides/creating-payment-splits.md). - Use [smart references](/docs/smart-references.md) to pass a partnership by natural key. - Filter and paginate batches with [Querying data](/docs/queries.md). --- # Creating a subscription > Creating a subscription using Qualy's API. Qualy's API provides robust functionality for creating subscriptions, allowing developers to implement recurring payment systems with customizable schedules. This guide will take you through the process step by step, starting with a basic example and gradually introducing more complex subscription scenarios. --- ## Before you start You need to have an existing Contact to create a Subscription. The `contact` field accepts either the Contact ObjectId or the contact email. The `service` field accepts either the Service ObjectId or service name. See [Smart references](/docs/smart-references.md). ## Creating a subscription To create a subscription on Qualy, you will have to use the [Subscription API](https://v1-spec.qualyhq.com/#post-/v1/subscriptions/create). When you create a Subscription, you can specify options like the name, amount, currency, billing details, and schedule: **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/subscriptions/create \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "name": "Monthly Service Plan", "mode": "schedule", "description": { "internal": "Internal notes about this subscription", "external": "Monthly service fee for Premium Support" }, "contact": "john.doe@example.com", "amount": 10000, "service": "Premium Support", "currency": "USD", "billing": { "startAt": "2026-10-01T00:00:00.000Z", "endAt": "2027-10-01T00:00:00.000Z" }, "schedule": { "frequency": "every-month", "dayOfMonth": 1 } }' ``` **JavaScript** ```javascript const res = await fetch('https://api.qualyhq.com/v1/subscriptions/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ name: 'Monthly Service Plan', mode: 'schedule', description: { internal: 'Internal notes about this subscription', external: 'Monthly service fee for Premium Support', }, contact: 'john.doe@example.com', amount: 10000, // amount in cents (100.00) service: 'Premium Support', currency: 'USD', billing: { startAt: '2026-10-01T00:00:00.000Z', endAt: '2027-10-01T00:00:00.000Z', }, schedule: { frequency: 'every-month', // bill once a month… dayOfMonth: 1, // …on the 1st }, }), }); const { data: subscription } = await res.json(); console.log(subscription._id); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $subscription = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/subscriptions/create', [ 'name' => 'Monthly Service Plan', 'mode' => 'schedule', 'description' => [ 'internal' => 'Internal notes about this subscription', 'external' => 'Monthly service fee for Premium Support', ], 'contact' => 'john.doe@example.com', 'amount' => 10000, // amount in cents (100.00) 'service' => 'Premium Support', 'currency' => 'USD', 'billing' => [ 'startAt' => '2026-10-01T00:00:00.000Z', 'endAt' => '2027-10-01T00:00:00.000Z', ], 'schedule' => [ 'frequency' => 'every-month', // bill once a month… 'dayOfMonth' => 1, // …on the 1st ], ])->json('data'); echo $subscription['_id']; ``` ### Subscription Scheduling Options When creating a subscription, you need to define how frequently the payments should occur. This is controlled through the `schedule` property in your request: > **Note — Important** > > Once a subscription is created, new payment intents will be automatically generated following the subscription schedule. Each payment intent is editable until it's paid or canceled. To manage the [Payment Intents](https://v1-spec.qualyhq.com/#get-/v1/payment-intents) created by the subsription, check the [Payment Intents API](https://v1-spec.qualyhq.com/#get-/v1/payment-intents). The `schedule.frequency` field is **required** and sets the cadence. Then, depending on the frequency, you pin the exact billing day with `dayOfMonth` or `dayOfWeek`. #### Frequency values | `frequency` | Description | | --- | --- | | `every-week` | Bills weekly. Pair with `dayOfWeek`. | | `every-2-weeks` | Bills every two weeks. Pair with `dayOfWeek`. | | `every-month` | Bills monthly. Pair with `dayOfMonth`. | #### Billing-day parameters | Parameter | Description | | --- | --- | | `dayOfMonth` | Which day of the month to bill (1-28). Example: `dayOfMonth: 15` bills on the 15th. Use with `every-month`. | | `dayOfWeek` | Which day of the week to bill (0-6, where 0 is Sunday). Example: `dayOfWeek: 1` bills every Monday. Use with `every-week` / `every-2-weeks`. | > **Note — The `mode` field is required** > > Every subscription needs a `mode`. Use `schedule` for standard recurring billing on a fixed cadence (covered here). The other modes — `amount-cap` and `payment-intent-partial` — support advanced billing scenarios. ### Subscription Billing Period Every subscription must have a defined billing period through the `billing` object, which contains: * `startAt`: When the subscription begins (required) * `endAt`: When the subscription ends (optional - if not provided, the subscription will continue indefinitely) ```javascript "billing": { "startAt": "2025-10-01T00:00:00.000Z", "endAt": "2026-10-01T00:00:00.000Z" // Optional } ``` --- ### Tax calculation Qualy's `v1` API calculates the tax for each payment intent generated from a subscription based on the total amount. The tax settings you define when creating the subscription will be applied to all future payment intents generated from this subscription. Check our [Tax API](https://v1-spec.qualyhq.com/#post-/v1/tax/create) to retrieve and create taxes. Learn more about Tax calculation in our [dedicated guide](/docs/guides/handling-using-tax-rates.md). ### Settlement currency and FX (Currency exchange) Sometimes you may want to create a subscription in a specific currency (e.g. USD) but you want the end-user to pay in another (e.g. EUR). This is useful in many cases, especially when you want Qualy to pay the suppliers in the original currency, and to convert it automatically at the time of payout. Set the property `settlementCurrency` as your desired currency. When payment intents are generated from the subscription and the end-user tries to pay, it will only display the payment options of the specified `settlementCurrency`. Qualy will automatically fetch the most up-to-date FX rate for each payment intent generated from the subscription. Here's an example of creating a subscription with a different settlement currency: **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/subscriptions/create \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "name": "Annual Service Plan", "mode": "schedule", "description": { "internal": "Premium support subscription", "external": "Annual subscription for Premium Support" }, "contact": "john.doe@example.com", "amount": 120000, "service": "Premium Support", "currency": "USD", "settlementCurrency": "EUR", "billing": { "startAt": "2026-10-01T00:00:00.000Z", "endAt": "2027-10-01T00:00:00.000Z" }, "schedule": { "frequency": "every-month", "dayOfMonth": 1 } }' ``` **JavaScript** ```javascript const res = await fetch('https://api.qualyhq.com/v1/subscriptions/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ name: 'Annual Service Plan', mode: 'schedule', description: { internal: 'Premium support subscription', external: 'Annual subscription for Premium Support', }, contact: 'john.doe@example.com', amount: 120000, // 1,200.00 in USD service: 'Premium Support', currency: 'USD', settlementCurrency: 'EUR', // customer pays in EUR billing: { startAt: '2026-10-01T00:00:00.000Z', endAt: '2027-10-01T00:00:00.000Z', }, schedule: { frequency: 'every-month', dayOfMonth: 1, }, }), }); const { data: subscription } = await res.json(); console.log(subscription._id); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $subscription = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/subscriptions/create', [ 'name' => 'Annual Service Plan', 'mode' => 'schedule', 'description' => [ 'internal' => 'Premium support subscription', 'external' => 'Annual subscription for Premium Support', ], 'contact' => 'john.doe@example.com', 'amount' => 120000, // 1,200.00 in USD 'service' => 'Premium Support', 'currency' => 'USD', 'settlementCurrency' => 'EUR', // customer pays in EUR 'billing' => [ 'startAt' => '2026-10-01T00:00:00.000Z', 'endAt' => '2027-10-01T00:00:00.000Z', ], 'schedule' => [ 'frequency' => 'every-month', 'dayOfMonth' => 1, ], ])->json('data'); echo $subscription['_id']; ``` ### Monitoring subscription status After creating a subscription, you can monitor its status through the Qualy dashboard or via API. The subscription object contains important tracking statistics: ```javascript "stats": { "nPaymentsCreated": 2, // Number of payment intents created so far "total": 240000, // Total amount expected over the subscription lifetime "lastPaymentCreatedAt": "2025-11-01T00:00:00.000Z", // When the last payment was created "nextPaymentDueAt": "2025-12-01T00:00:00.000Z" // When the next payment will be created } ``` ### Linking services to subscriptions When creating a subscription, you should specify the `service` field to indicate what service the subscription is for. This helps with reporting and organization within your Qualy account. ```javascript "service": "Premium Support" // A Service ObjectId or name ``` ## Managing payment intents from subscriptions Once a subscription is created, Qualy will automatically generate payment intents based on the subscription schedule. These payment intents behave like any other payment intent in the system and can be managed through the standard Payment Intent API. > **Note — Important** > > Each payment intent generated from a subscription is editable until it's paid or canceled. This allows you to make adjustments to individual payments without affecting the overall subscription. ### Subscription lifecycle 1. **Creation**: When you create a subscription, no payment intents are immediately created 2. **First payment**: On the schedule start date, the first payment intent is generated 3. **Recurring payments**: Subsequent payment intents are automatically created based on your frequency settings 4. **End date**: If specified, no new payment intents will be created after the subscription end date ### Updating a subscription You can update certain subscription parameters after creation by using the Subscription Update API. --- # Financing & installments > Retrieve installment and pay-later financing options for a payment, then simulate a specific plan to get the per-installment amount, total cost, and fees. Financing lets a contact pay over time instead of all at once. Qualy supports two financing providers: **PagBank** for Brazilian credit-card installments (_parcelamento_), and **Klarna** for pay-later and pay-over-time options in supported regions. Working with financing is a two-step flow: 1. **Retrieve options** — find out which financing types are available for an amount or Payment Intent, and the maximum number of installments. 2. **Simulate a plan** — pick a number of installments and get the exact per-installment amount, the total with interest, and the fees. > **Note — Amounts are in cents** > > Every monetary value in these endpoints is an integer in minor currency units (cents). `5000` means `R$ 50.00`. Rates such as `feePercentage` and `interestRate` are decimals — `0.0405` means `4.05%`. --- ## Gateways & methods Each request must name the `gateway` and its financing `method`. The valid combinations are: | Gateway | `gateway` | `method` | Region | Financing types | Simulate? | | --- | --- | --- | --- | --- | --- | | PagBank | `pagbank` | `PGBNK_CC` | Brazil (BRL only) | `installments` (up to 18x) | Yes | | Klarna | `klarna` | `KLARNA_PYMT` | Klarna-supported regions | `pay-later`, `installments` (4x) | No | --- ## Retrieving financing options Call `POST /v1/payment-gateways/financing/options` to see what is available. You can describe the amount in one of two ways: - Pass a `paymentIntent` ID — Qualy uses the Payment Intent's outstanding `due` amount. - Pass an `amount` and `currency` directly — useful before a Payment Intent exists (e.g. on a checkout page). > **Note — Klarna requires a Payment Intent** > > The `amount` + `currency` shortcut works only with PagBank. Klarna always needs a `paymentIntent`, since available categories depend on the payer's country and the settlement currency. ### Request fields | Field | Required | Description | | --- | --- | --- | | `gateway` | Yes | `pagbank` or `klarna`. | | `method` | Yes | `PGBNK_CC` for PagBank, `KLARNA_PYMT` for Klarna. | | `paymentIntent` | Conditional | A Payment Intent ID. Required when `amount`/`currency` are omitted, and always required for Klarna. | | `amount` | Conditional | Amount to finance, in cents. Required (with `currency`) when `paymentIntent` is omitted. PagBank only. | | `currency` | Conditional | ISO 4217 currency code. Must be `BRL` for PagBank. Required when `paymentIntent` is omitted. | ### Example (PagBank) **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/payment-gateways/financing/options \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "gateway": "pagbank", "method": "PGBNK_CC", "amount": 135000, "currency": "BRL" }' ``` **JavaScript** ```javascript const res = await fetch( 'https://api.qualyhq.com/v1/payment-gateways/financing/options', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ gateway: 'pagbank', method: 'PGBNK_CC', amount: 135000, // R$ 1,350.00 currency: 'BRL', }), }, ); const { data } = await res.json(); console.log(data.options); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $data = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/payment-gateways/financing/options', [ 'gateway' => 'pagbank', 'method' => 'PGBNK_CC', 'amount' => 135000, // R$ 1,350.00 'currency' => 'BRL', ])->json('data'); print_r($data['options']); ``` The response lists the available financing types. For PagBank, `nInstallments` is the **maximum** number of installments the buyer can choose from (1 up to this value): ```json { "data": { "options": [ { "type": "installments", "amount": 135000, "currency": "BRL", "nInstallments": 12 } ] } } ``` > **Note — Minimum amount** > > PagBank only offers multiple installments above `R$ 20.00` (`2000` cents). Below that, the response returns a single option with `nInstallments: 1`. ### Example (Klarna) **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/payment-gateways/financing/options \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "gateway": "klarna", "method": "KLARNA_PYMT", "paymentIntent": "6634f56c7dbbc16e07b5025e" }' ``` **JavaScript** ```javascript const res = await fetch( 'https://api.qualyhq.com/v1/payment-gateways/financing/options', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ gateway: 'klarna', method: 'KLARNA_PYMT', paymentIntent: '6634f56c7dbbc16e07b5025e', }), }, ); const { data } = await res.json(); console.log(data.options); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $data = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/payment-gateways/financing/options', [ 'gateway' => 'klarna', 'method' => 'KLARNA_PYMT', 'paymentIntent' => '6634f56c7dbbc16e07b5025e', ])->json('data'); print_r($data['options']); ``` Klarna returns one entry per available category. `pay-later` settles the full amount on a later date; `installments` splits it into four payments: ```json { "data": { "options": [ { "type": "pay-later", "amount": 135000, "currency": "AUD" }, { "type": "installments", "amount": 135000, "currency": "AUD", "nInstallments": 4 } ] } } ``` ### Option fields | Field | Description | | --- | --- | | `type` | `installments` (pay in parts) or `pay-later` (pay the full amount later). | | `amount` | The amount being financed, in cents. | | `currency` | The settlement currency the contact pays in. | | `nInstallments` | For PagBank, the maximum number of installments available. For Klarna `installments`, the fixed count (4). Absent for `pay-later`. | --- ## Simulating a plan Once you know the maximum number of installments, call `POST /v1/payment-gateways/financing/simulate` with a specific `nInstallments` to get the exact cost breakdown. > **Warning — PagBank only** > > Simulation is supported for PagBank (`PGBNK_CC`) only. Klarna calculates and presents its own plans at checkout, so there is no simulate step for Klarna. Each simulation is **persisted** and returns an `_id`. Keep this ID — it is the reference to the chosen plan and appears on the resulting transaction's financing details. ### Request fields Same as the options endpoint, plus a required `nInstallments`: | Field | Required | Description | | --- | --- | --- | | `gateway` | Yes | Must be `pagbank`. | | `method` | Yes | Must be `PGBNK_CC`. | | `nInstallments` | Yes | The number of installments to simulate (minimum `1`). Must be within the maximum returned by the options endpoint. | | `paymentIntent` | Conditional | A Payment Intent ID. Required when `amount`/`currency` are omitted. | | `amount` | Conditional | Amount to finance, in cents. Required (with `currency`) when `paymentIntent` is omitted. | | `currency` | Conditional | Must be `BRL`. Required when `paymentIntent` is omitted. | ### Example **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/payment-gateways/financing/simulate \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "gateway": "pagbank", "method": "PGBNK_CC", "amount": 135000, "currency": "BRL", "nInstallments": 12 }' ``` **JavaScript** ```javascript const res = await fetch( 'https://api.qualyhq.com/v1/payment-gateways/financing/simulate', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ gateway: 'pagbank', method: 'PGBNK_CC', amount: 135000, // R$ 1,350.00 currency: 'BRL', nInstallments: 12, }), }, ); const { data } = await res.json(); console.log(data); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $data = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/payment-gateways/financing/simulate', [ 'gateway' => 'pagbank', 'method' => 'PGBNK_CC', 'amount' => 135000, // R$ 1,350.00 'currency' => 'BRL', 'nInstallments' => 12, ])->json('data'); print_r($data); ``` ### Response ```json { "data": { "_id": "6634f8a17dbbc16e07b50312", "total": 153090, "currency": "BRL", "method": "PGBNK_CC", "nInstallments": 12, "installmentAmount": 12758, "feePercentage": 0.1340, "interestRate": 0.1182 } } ``` | Field | Description | | --- | --- | | `_id` | The persisted simulation ID. Reference this plan when collecting the payment; it also appears on the transaction's financing details. | | `total` | Total amount the contact pays across all installments, in cents (original amount + interest + fees). | | `installmentAmount` | The amount of each individual installment, in cents. `total ÷ nInstallments`, with each installment rounded up to the cent. | | `nInstallments` | The number of installments simulated. | | `feePercentage` | The **effective uplift** over the original amount: `(total − amount) / amount`. This is the number to show the buyer as the real cost of financing. | | `interestRate` | The raw provider rate used to build the plan. Usually lower than `feePercentage` (see below). | | `currency` | The currency of the plan (`BRL`). | | `paymentIntent` | Echoed back when the simulation was created from a Payment Intent. | | `operationFee` | An optional fixed operation fee, in cents, when applicable. | > **Note — feePercentage vs. interestRate** > > `interestRate` is the raw, fee-on-total rate PagBank applies internally (`total = (amount + flat fee) / (1 − rate)`). `feePercentage` is the effective increase the buyer actually pays versus the original amount — it is higher because it also includes a flat per-transaction fee and per-installment cent rounding. **Show `feePercentage` to the buyer** as the true cost of paying in installments. --- ## Partial amounts When financing against a Payment Intent, you can finance less than the full `due` by also passing an `amount` (in cents). Qualy clamps it to the outstanding amount so the contact is never overcharged. Omit `amount` to finance the full balance. --- ## Cross-currency financing A Payment Intent can be denominated in one currency (say `AUD`) while the contact pays in another. PagBank financing always settles in **BRL**, so when you finance an `AUD` Payment Intent: - The Payment Intent's settlement currency must resolve to `BRL`. - A locked FX quote must exist on the Payment Intent — Qualy converts the `due` to BRL at that rate before building the plan. If the settlement currency is not BRL, or no FX quote is available, the request is rejected. See [Collecting payments](/docs/guides/collecting-payments.md#understanding-currency-vs-settlement-currency) for how settlement currency is determined. --- ## Next steps After simulating a plan, collect the payment using the chosen financing method. See [Collecting payments](/docs/guides/collecting-payments.md) for the sign flow, and [Setting up webhooks](/docs/webhooks.md) to be notified when each installment is captured. --- # Manage tax rates on Qualy > Learn how Qualy handles different types of Tax Rates, including inclusive/exclusive rates. Learn how Qualy handles different types of Tax Rates, including inclusive/exclusive rates. --- ## Before you start Tax is an essential part of handling payments. Qualy's [Tax API](https://v1-spec.qualyhq.com/#post-/v1/tax/create) calculates all the amounts for you automatically. ## Creating a Tax rate To create a Tax rate on Qualy, you will have to use the [Tax API](https://v1-spec.qualyhq.com/#post-/v1/tax/create). You can also use the [Dashboard](https://dashboard.qualyhq.com/preferences/tax?taxType=sales-tax). This is an example of a request creating a Tax rate using Qualy's API. ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/tax/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ "name":"Example tax rate", "description":"User-defined tax rate", "country":"AU", "state":"New South Wales", "taxType":"sales-tax", "inclusive":true, "percentage":0.1 }), }); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` The above request will return the following payload: ```json { "data": { "_id": "669a7b211f74b42e14d63693", "name": "Example tax rate", "description": "User-defined tax rate", "country": "AU", "status": "active", "taxType": "sales-tax", "inclusive": true, "state": "New South Wales", "percentage": 0.1, "createdBy": { "_id": "6606ab7bfb9085579f1b5769", "profile": { "firstName": "Your API Key", "lastName": "Name", "picture": "https://gravatar.com/avatar/?s=450&d=mp" } } } } ``` ### Using Tax rates Whenever you have to specify the `tax` field, you will need to specify an entity of Qualy's [Tax API](https://v1-spec.qualyhq.com/#post-/v1/tax/create). Supported `tax` fields accept either the Tax ObjectId or tax name. See [Smart references](/docs/smart-references.md). Once you send the request specifying the desired `tax` Qualy will return the payload with the correct tax calculations and amounts adjusted. ### Understanding tax calculation in Qualy In Qualy, tax rates can be applied to payments either inclusively or exclusively. Below is an explanation of how tax calculations are performed. #### Inclusive vs. Exclusive Tax rates - **Inclusive Tax**: The tax amount is included in the total payment amount. - **Exclusive Tax**: The tax amount is added to the total payment amount. #### Tax rate calculation The following is a detailed explanation of how Qualy calculates Tax amounts. 1. **Tax calculation**: - If you set a `tax` property, the tax amount is calculated: `taxAmount = amount * tax.percentage`. 2. **Inclusive Tax**: - If the tax is inclusive: - The amount without tax is calculated: `amountWithoutTax = amount / (1 + tax.percentage)`. - The total tax amount is determined: `taxTotal = amount - amountWithoutTax`. - The total amount and the total tax amount are updated in the original entity: - `total = amount` - `taxTotal = taxTotal` 3. **Exclusive Tax**: - If the tax is exclusive: - The total payment amount is increased by the tax amount: `total = amount + taxAmount`. - The tax amount and the total tax amount are updated in the payment object: - `tax.amount = taxAmount` - `taxTotal = taxAmount` 4. **No Tax**: - If no `tax` is present, the total amount remains the same as the payment amount: `total = amount`. ### Updating Tax rates When updating a [Tax rate](https://v1-spec.qualyhq.com/#post-/v1/tax/create), Qualy will archive the previous rate, but not delete it. All entities currently using the Tax which has been archive **will continue** using it. You will need to manually update which entity to the new Tax rate. --- # Issuing transaction/payment refunds > Learn how Qualy can issue refunds to payments. In this guide you will learn how to issue refunds using Qualy. --- ## Before you start You need to have a PaymentIntent `_id`, and Transaction `_id` of which you want to refund. Use the [PaymentIntent API](https://v1-spec.qualyhq.com/#get-/v1/payment-intents) to retrieve the PaymentIntent, once you retrieve the PaymentIntent, the property `transactions` will list all Transactions associated with the PaymentIntent, you will need the `_id` of Transaction you want to refund to proceed. ## Creating a refund To create a refund on Qualy, you will have to use the [Transactions API](https://v1-spec.qualyhq.com/#post-/v1/transactions/create). When you create the refund Transaction, you can specify the total amount of the refund. The amount **has to be negative** and cannot be higher than the amount of the original Transaction which you will need to specifify in the `ref` property. **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/transactions/create \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "amount": -150000, "currency": "AUD", "method": "ZAI_PAYID", "paymentIntent": "668b8dbec8fab6acd7b41cdd", "ref": "668bfc47f44334c60d96848e" }' ``` **JavaScript** ```javascript const res = await fetch('https://api.qualyhq.com/v1/transactions/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ amount: -150000, // negative: a refund currency: 'AUD', method: 'ZAI_PAYID', paymentIntent: '668b8dbec8fab6acd7b41cdd', ref: '668bfc47f44334c60d96848e', // the original transaction _id }), }); const { data: refund } = await res.json(); console.log(refund._id, refund.transactionType); // "…" "refund" ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $refund = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/transactions/create', [ 'amount' => -150000, // negative: a refund 'currency' => 'AUD', 'method' => 'ZAI_PAYID', 'paymentIntent' => '668b8dbec8fab6acd7b41cdd', 'ref' => '668bfc47f44334c60d96848e', // the original transaction _id ])->json('data'); echo $refund['_id'] . ' ' . $refund['transactionType']; ``` The above request will return the following payload: ```json { "data": { "_id": "669a74931f74b42e14d633c2", "paymentIntent": "668b8dbec8fab6acd7b41cdd", "amount": -150000, "contact": "6606ab8ffb9085579f1b5844", "transactionType": "refund", "method": "ZAI_PAYID", "currency": "AUD", "documents": [], "status": "processing", "disputes": [], "ref": "668bfc47f44334c60d96848e", "createdAt": "2024-07-19T14:13:39.780Z", "number": 295 } } ``` You will receive updates on this refund via [Webhooks](/docs/webhooks.md). Refund transactions will have the `transactionType` set to `refund` instead of `charge`. ### What's the workflow of a refund Transcation Once a Transaction type `refund` is created, Qualy will attempt to direct debit the [Bank Account](https://v1-spec.qualyhq.com/#post-/v1/bank-accounts/create) of the the tenant for the amount specified and will automatically return to the contact using the same transaction `method`. For example, if you are refunding a Credit Card (e.g. method `ZAI_CC`) transaction, the refund will be issued to the same credit card. If you are refunding the a PayID (e.g. method `ZAI_PAYID`) transaction, the refund will be issued to the same bank account as the original transaction. This behavior is essential to comply with the best practices and legistaltion on Anti-Money Laundering and Financing Terrorism. ### Refunding "EXTERNAL" transactions Qualy supports issuing refunds for transactions made outside of Qualy. When refunding this type of transaction, you will need to supply the bank account on which the funds should be disbursed, and the address of the student. **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/transactions/create \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "amount": -150000, "currency": "AUD", "method": "EXTERNAL", "paymentIntent": "668b8dbec8fab6acd7b41cdd", "address": { "line1": "Line 1 of the address", "state": "Hauts-de-France", "postalCode": "00123", "country": "FR" }, "bankAccount": "668b8dfec8fab6acd7b41cde", "ref": "668bfc47f44334c60d96848e" }' ``` **JavaScript** ```javascript const res = await fetch('https://api.qualyhq.com/v1/transactions/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ amount: -150000, currency: 'AUD', method: 'EXTERNAL', paymentIntent: '668b8dbec8fab6acd7b41cdd', address: { line1: 'Line 1 of the address', state: 'Hauts-de-France', postalCode: '00123', country: 'FR', }, bankAccount: '668b8dfec8fab6acd7b41cde', ref: '668bfc47f44334c60d96848e', }), }); const { data: refund } = await res.json(); console.log(refund._id); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $refund = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/transactions/create', [ 'amount' => -150000, 'currency' => 'AUD', 'method' => 'EXTERNAL', 'paymentIntent' => '668b8dbec8fab6acd7b41cdd', 'address' => [ 'line1' => 'Line 1 of the address', 'state' => 'Hauts-de-France', 'postalCode' => '00123', 'country' => 'FR', ], 'bankAccount' => '668b8dfec8fab6acd7b41cde', 'ref' => '668bfc47f44334c60d96848e', ])->json('data'); echo $refund['_id']; ``` --- # Handling disputes > List, inspect, and respond to payment disputes (chargebacks) with the Qualy API. A dispute (also called a chargeback) happens when a cardholder questions a payment with their bank. When a dispute is opened, the disputed amount is typically withheld until it's resolved. This guide covers how to find disputes and respond to them with evidence. --- ## Before you begin You don't **create** disputes — Qualy opens them automatically when a payment provider notifies us of a chargeback, and links each one to the original transaction, payment intent, and contact. Your job is to detect open disputes and respond in time. > **Note — Disputes are time-sensitive** > > A dispute in `needs-response` (or `warning-needs-response`) has a deadline. Submit your evidence before it passes, or the dispute is decided against you. --- ## Dispute lifecycle A dispute's `status` tells you where it stands and whether it still needs you: | Status | Meaning | | --- | --- | | `warning-needs-response` | An early warning was raised. You can respond, but no funds have been withdrawn yet. | | `warning-under-review` | Your response to a warning is being reviewed. | | `warning-closed` | The warning was resolved and closed. **Closed.** | | `needs-response` | A formal dispute is open and requires your evidence before the deadline. | | `under-review` | Your evidence has been submitted and the bank is reviewing it. | | `won` | Resolved in your favor — funds are retained. **Closed.** | | `lost` | Resolved against you — funds are returned to the cardholder. **Closed.** | `won`, `lost`, and `warning-closed` are the closed states; everything else is an open dispute. You can only refund a transaction once any dispute on it is closed. ### Dispute reasons The `reason` field explains why the cardholder disputed the payment. Possible values: `fraudulent`, `unrecognized`, `duplicate`, `credit-not-processed`, `debit-not-authorized`, `customer-initiated`, `bank-cannot-process`, `incorrect-account-details`, `insufficient-funds`, `general`, and `other`. Tailor your evidence to the reason. --- ## List disputes Fetch disputes with the standard [query parameters](/docs/queries.md). Filter by `status` to find the ones that need action. The array is returned under `data.disputes`: **cURL** ```bash curl "https://api.qualyhq.com/v1/disputes?status[0]=needs-response&status[1]=warning-needs-response" \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' ``` **JavaScript** ```javascript const params = new URLSearchParams(); params.append('status[0]', 'needs-response'); params.append('status[1]', 'warning-needs-response'); const res = await fetch(`https://api.qualyhq.com/v1/disputes?${params}`, { headers: { 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, }); const { data } = await res.json(); console.log(data.disputes); // array of open disputes ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $disputes = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->get('https://api.qualyhq.com/v1/disputes', [ 'status' => ['needs-response', 'warning-needs-response'], ])->json('data.disputes'); ``` Retrieve a single dispute with `GET /v1/disputes/{disputeId}`. The dispute object references the `transaction`, `paymentIntent`, and `contact` it relates to, plus its `amount`, `currency`, `status`, and `reason`. --- ## Respond with evidence Responding takes two steps: **upload each evidence file**, then **submit** the dispute with those files attached. ### 1. Upload your evidence files Upload each supporting document (receipts, delivery proof, customer communication) through the [Storage API](https://v1-spec.qualyhq.com/#post-/v1/storage/files/upload/sign) and keep the resulting file ID. ### 2. Submit the dispute Send a `POST` to `/v1/disputes/{disputeId}/update` with `submit: true` and an `evidence` array. Each evidence item references an uploaded `document` (its storage file ID), a `type`, and optional `comments`: **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/disputes/653fc551d14abfe63d4fd48b/update \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "submit": true, "evidence": [ { "document": "507f1f77bcf86cd799439011", "type": "service-documentation", "comments": "Signed enrollment agreement and course access logs." } ] }' ``` **JavaScript** ```javascript const res = await fetch( 'https://api.qualyhq.com/v1/disputes/653fc551d14abfe63d4fd48b/update', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ submit: true, evidence: [ { document: '507f1f77bcf86cd799439011', type: 'service-documentation', comments: 'Signed enrollment agreement and course access logs.', }, ], }), }, ); const { data: dispute } = await res.json(); console.log(dispute.status); // "under-review" ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $dispute = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/disputes/653fc551d14abfe63d4fd48b/update', [ 'submit' => true, 'evidence' => [ [ 'document' => '507f1f77bcf86cd799439011', 'type' => 'service-documentation', 'comments' => 'Signed enrollment agreement and course access logs.', ], ], ])->json('data'); echo $dispute['status']; // "under-review" ``` The `type` of each evidence item must be one of `service-documentation`, `customer-communication`, `activity-log`, or `uncategorized-file`. After a successful submit, the dispute moves to `under-review`. > **Note — Saving a draft vs. submitting** > > Set `submit: false` to attach evidence without sending it to the bank yet — useful for assembling your response over time. Once you set `submit: true`, the evidence is final and the dispute is handed off for review. ### Conceding a dispute If you don't intend to contest the charge, concede it by sending `status: "lost"`. This closes the dispute and returns the funds to the cardholder — do this instead of ignoring a dispute you can't win, to avoid unnecessary fees. --- ## Refunds and disputes You cannot refund a transaction while a dispute on it is open. Refunds are only allowed once the dispute reaches a closed state (`won`, `lost`, or `warning-closed`). See [Issuing refunds](/docs/guides/issuing-transaction-refunds.md). --- ## Next steps - Reconcile disputes against the original payment with [Querying data](/docs/queries.md). - Learn how funds move in [payment splits](/docs/guides/creating-payment-splits.md). - Handle [errors](/docs/errors.md) from the dispute endpoints. --- # Dunning (chasing overdue payments) > Chase overdue receivables with Qualy's dunning API — send notices, track chases, and record outcomes. Dunning is how you chase overdue money. You point Qualy at a set of overdue obligations and it consolidates them into **notices**, sends the reminders, and keeps an immutable log of every **chase**. Dunning is **communications only** — it sends reminders and records responses, but it never moves money. --- ## Concepts Four objects make up the dunning platform: | Object | What it is | | --- | --- | | **Obligation** | The overdue thing being chased. Either a **payment intent** (a contact's receivable) or a **payment split** (a partner's keep-percentage). A [bulk operation](/docs/guides/bulk-payment-splits.md) can stand in for its member splits. | | **Notice** | A single communication, consolidated by `(debtor, currency)`. Duning several obligations for the same debtor produces one notice, not many. | | **Chase** | An immutable attempt log — one entry per obligation per notice. This is where you record what happened (the debtor answered, promised to pay, disputed, …). | | **Control** | The per-obligation dunning run: its state (active, paused, …), its knobs (style, channels, guidance), and a rollup summary. Duning an obligation creates or reuses its control. | --- ## Chase overdue obligations `POST /v1/dunning/dun` starts a chase. Pass **exactly one** of `paymentSplits`, `paymentIntents`, or `bulkOperation` — a single dun can't mix obligation types. **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/dunning/dun \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "paymentIntents": ["6a4e190b9a0ac53047a25479"], "style": "firm", "payBy": "2026-07-20T00:00:00Z", "comment": "Second reminder — invoice 2216" }' ``` **JavaScript** ```javascript const res = await fetch('https://api.qualyhq.com/v1/dunning/dun', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ paymentIntents: ['6a4e190b9a0ac53047a25479'], // contact receivables style: 'firm', // 'gentle' | 'neutral' | 'firm' payBy: '2026-07-20T00:00:00Z', // "Pay by" date shown in the email comment: 'Second reminder — invoice 2216', }), }); const { data } = await res.json(); console.log(data.notices); // consolidated communications sent console.log(data.chased); // obligations chased this run console.log(data.skipped); // [{ id, reason }] — anything not chased ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $data = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/dunning/dun', [ 'paymentIntents' => ['6a4e190b9a0ac53047a25479'], 'style' => 'firm', 'payBy' => '2026-07-20T00:00:00Z', 'comment' => 'Second reminder — invoice 2216', ])->json('data'); print_r($data['notices']); print_r($data['skipped']); ``` The response reports exactly what happened: ```json { "data": { "notices": [], "chased": [], "skipped": [ { "id": "6a4e0d119a0ac53047a25415", "reason": "not-a-dunnable-payment-intent" } ] } } ``` Obligations that aren't eligible (not overdue, already paid, paused, …) come back under `skipped` with a `reason` — the call still succeeds. Nothing under `chased` was skipped. ### Body fields | Field | Description | | --- | --- | | `paymentIntents` / `paymentSplits` / `bulkOperation` | The obligations to chase. Provide **exactly one**. Arrays accept up to 200 ids; `bulkOperation` expands to its member splits. | | `style` | Tone of the message: `gentle`, `neutral`, or `firm`. | | `payBy` | A "Pay by" deadline shown in the email, ISO 8601 (e.g. `2026-07-20T00:00:00Z`). Applies to this send only. | | `comment` | An operator note stored on the notices (max 2000 chars). | | `recipientContacts` | Partner (split) duns only: which contacts to notify, intersected with each partnership's notifiable contacts. Omit to notify all of them. Contact duns always reach the contact. | | `aiGuidance` | Freeform guidance persisted on each control to steer future follow-ups. | > **Note — `payBy` rejects milliseconds** > > `payBy` is validated as an ISO 8601 date string. Use `2026-07-20T00:00:00Z` or `2026-07-20` — the millisecond form `…00.000Z` is rejected. > **Warning — Dunning never moves money** > > Duning sends reminders and records responses. It never charges, refunds, or settles anything — the underlying payment intent or split is untouched. Capturing money still goes through the [payments API](/docs/guides/creating-payment-intents.md). This endpoint is [idempotent](/docs/idempotency.md) — repeating the same dun within the window returns the original result instead of sending twice. --- ## Manage the run: controls Each obligation under dunning has a **control**. List them, inspect one, or adjust its knobs. Controls are returned under `data.controls`: ```bash # List controls curl "https://api.qualyhq.com/v1/dunning/controls?limit=20" \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' # Retrieve one curl "https://api.qualyhq.com/v1/dunning/controls/{controlId}" \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' ``` ### Control statuses | Status | Meaning | | --- | --- | | `active` | Being chased on schedule. | | `paused` | Operator hold — the eligibility gate skips it until you resume. | | `suspended` | Held with a reason (e.g. disputed, incorrect amount). | | `promise-to-pay` | Frozen until the promised date (plus grace). A broken promise re-arms it. | | `resolved` | Terminal — paid, canceled, offset, or written off. | | `superseded` | Terminal — replaced by a restarted run. | ### Update knobs Change the `style`, allowed `channels`, or `aiGuidance` with `POST /v1/dunning/controls/{controlId}/update`. State changes do **not** go through update — use the explicit actions below. ```bash curl -X POST https://api.qualyhq.com/v1/dunning/controls/{controlId}/update \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "style": "gentle", "channels": ["email"] }' ``` Channels is an allow-list drawn from `sms`, `push`, and `email` (empty means no restriction). ### State actions State transitions are explicit, auditable actions — each is a `POST` with an empty body (except `restart`): | Action | Endpoint | | --- | --- | | Pause | `POST /v1/dunning/controls/{controlId}/pause` | | Resume | `POST /v1/dunning/controls/{controlId}/resume` | | Restart | `POST /v1/dunning/controls/{controlId}/restart` | Restarting supersedes the current run and mints a fresh one; history and chases stay intact, and delivered-attempt numbering carries forward so partner-facing counts never reset. --- ## Record what the debtor said: chase outcomes Chases are the attempt log (`GET /v1/dunning/chases`, returned under `data.chases`). When a debtor responds, record it against the chase with `POST /v1/dunning/chases/{chaseId}/outcome`: **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/dunning/chases/{chaseId}/outcome \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "outcome": "promised-to-pay", "promisedAt": "2026-07-25T00:00:00Z", "note": "Partner replied: paying next Friday." }' ``` **JavaScript** ```javascript const res = await fetch( 'https://api.qualyhq.com/v1/dunning/chases/{chaseId}/outcome', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ outcome: 'promised-to-pay', // answered | promised-to-pay | disputed | no-response promisedAt: '2026-07-25T00:00:00Z', // required for promised-to-pay note: 'Partner replied: paying next Friday.', }), }, ); const { data: chase } = await res.json(); console.log(chase); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $chase = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/dunning/chases/{chaseId}/outcome', [ 'outcome' => 'promised-to-pay', 'promisedAt' => '2026-07-25T00:00:00Z', 'note' => 'Partner replied: paying next Friday.', ])->json('data'); ``` Recordable outcomes and their side effects: | Outcome | Effect on the control | | --- | --- | | `answered` | Logged; no state change. | | `promised-to-pay` | Freezes the control until `promisedAt` (required). A broken promise re-arms chasing. | | `disputed` | Suspends the control. | | `no-response` | Logged; chasing continues on schedule. | Delivery outcomes (`sent`, `delivered`, `bounced`, `failed`) arrive automatically from the transport and aren't user-recordable. --- ## Notices List sent notices with `GET /v1/dunning/notices` (returned under `data.notices`) or fetch one with `GET /v1/dunning/notices/{noticeId}`. A notice groups every obligation chased for one debtor in one currency, along with the `comment` and `payBy` from the dun that created it. --- ## Next steps - Chase partner shares grouped by settlement with [bulk payment splits](/docs/guides/bulk-payment-splits.md). - Make repeat duns safe with [idempotency](/docs/idempotency.md). - Filter controls, notices, and chases with [Querying data](/docs/queries.md). --- # Approvals > Gate money-moving actions behind maker-checker approval policies with the Qualy API. Approvals add a maker-checker layer to Qualy. You define **policies** that gate money-moving actions; when an action matches a policy, Qualy holds it and opens an approval **request** that one or more approvers must decide before it proceeds. --- ## How it works 1. You create a **policy** — a rule that says "actions of this type, matching these conditions, need approval from these people." 2. When a matching entity is created (a payout, a transaction, …), Qualy **holds** it and opens a **request** in `pending`. 3. Approvers **approve** or **reject** the request. 4. On approval the held action resumes; on rejection it's stopped. Policies can gate these entity types: `transaction`, `payout`, `payment-split`, `authorization`, and `refund-intent`. --- ## Create a policy `POST /v1/approvals/policies/create`. At minimum a policy needs a `name`, an `entityType`, a `strategy`, and (for every strategy except `auto-approve`) a list of `approvers`. `conditions` narrow *which* entities the policy applies to — omit them to match all entities of that type. **cURL** ```bash curl -X POST https://api.qualyhq.com/v1/approvals/policies/create \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "name": "Large payouts need sign-off", "entityType": "payout", "strategy": "any-of", "approvers": ["6606ab7bfb9085579f1b5769"], "conditions": { "amountMin": 500000, "currencyMode": "any" } }' ``` **JavaScript** ```javascript const res = await fetch('https://api.qualyhq.com/v1/approvals/policies/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ name: 'Large payouts need sign-off', entityType: 'payout', strategy: 'any-of', approvers: ['6606ab7bfb9085579f1b5769'], conditions: { amountMin: 500000, // cents — only payouts ≥ $5,000.00 currencyMode: 'any', }, }), }); const { data: policy } = await res.json(); console.log(policy._id); ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; $policy = Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post('https://api.qualyhq.com/v1/approvals/policies/create', [ 'name' => 'Large payouts need sign-off', 'entityType' => 'payout', 'strategy' => 'any-of', 'approvers' => ['6606ab7bfb9085579f1b5769'], 'conditions' => [ 'amountMin' => 500000, // cents — only payouts ≥ $5,000.00 'currencyMode' => 'any', ], ])->json('data'); echo $policy['_id']; ``` ### Policy fields | Field | Description | | --- | --- | | `name` | Required. Human-readable label (max 200 chars). | | `entityType` | Required. What to gate: `transaction`, `payout`, `payment-split`, `authorization`, or `refund-intent`. | | `strategy` | Required. How approval is reached — see [Strategies](#strategies). | | `approvers` | Required unless `strategy` is `auto-approve`. User ids who can decide (1–100). | | `conditions` | Optional. Narrows which entities match — see [Conditions](#conditions). Omit to match all entities of the type. | | `priority` | Optional. When several policies match, higher priority wins. | | `enabled` | Optional. Set `false` to keep a policy without enforcing it. | | `partnership` | Optional. Scope the policy to one partnership. | ### Strategies | Strategy | How it resolves | | --- | --- | | `any-of` | Approved as soon as **one** approver approves. | | `all-of` | Every listed approver must approve. | | `sequential` | Approvers act in listed order, one after another. | | `round-robin` | The request is assigned to approvers in rotation. | | `auto-approve` | Approved automatically — no approvers, no waiting. Use to explicitly *exempt* a matching set from approval. | ### Conditions All conditions are optional and combine with AND. An empty or missing condition matches everything. | Condition | Matches on | | --- | --- | | `amountMin` / `amountMax` | Amount range, in cents (`amountMax` must be ≥ `amountMin`). | | `currencyMode` | `same-currency`, `fx`, or `any` — whether the entity involves a currency conversion. | | `sourceCurrencies` / `targetCurrencies` | Specific currencies (e.g. `["AUD","USD"]`). | | `methods` | Payment methods to gate (e.g. bank-transfer methods). | | `actions` | Entity actions to gate. | | `recipientTypes` | Who's on the receiving end: `supplier`, `partnership`, or `self`. | | `primaryTeams` | Team ids the entity belongs to. | ### Manage policies | Action | Endpoint | | --- | --- | | List | `GET /v1/approvals/policies` (returned under `data.policies`) | | Retrieve | `GET /v1/approvals/policies/{policyId}` | | Update | `POST /v1/approvals/policies/{policyId}/update` | | Delete | `POST /v1/approvals/policies/{policyId}/delete` | --- ## Approval requests When a gated entity is created, Qualy opens a request. List them with `GET /v1/approvals/requests` (returned under `data.requests`) or fetch one with `GET /v1/approvals/requests/{requestId}`. ### Request statuses | Status | Meaning | | --- | --- | | `pending` | Awaiting a decision. The underlying action is held. | | `approved` | Approved — the held action resumes. | | `rejected` | Rejected — the held action is stopped. | | `canceled` | The request was withdrawn (e.g. the underlying entity was canceled). | To check whether a specific entity is waiting on approval, use its summary: `GET /v1/approvals/summary/{entityType}/{entityId}`. --- ## Approve or reject a request An approver acts on a pending request. Both endpoints accept an optional `comment`: **cURL** ```bash # Approve curl -X POST https://api.qualyhq.com/v1/approvals/requests/{requestId}/approve \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "comment": "Verified against the signed invoice." }' # Reject curl -X POST https://api.qualyhq.com/v1/approvals/requests/{requestId}/reject \ -H 'Authorization: ApiKey your-api-key-here' \ -H 'X-TENANT-ID: your-tenant-id-here' \ -H 'Content-Type: application/json' \ -d '{ "comment": "Amount does not match the order." }' ``` **JavaScript** ```javascript const decide = (requestId, action, comment) => fetch(`https://api.qualyhq.com/v1/approvals/requests/${requestId}/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ comment }), }).then((r) => r.json()); // action is 'approve' or 'reject' const { data: request } = await decide('REQUEST_ID', 'approve', 'Looks good.'); console.log(request.status); // "approved" ``` **PHP (Laravel)** ```php use Illuminate\Support\Facades\Http; function decide(string $requestId, string $action, ?string $comment = null): array { return Http::withHeaders([ 'Authorization' => 'ApiKey your-api-key-here', 'X-TENANT-ID' => 'your-tenant-id-here', ])->post( "https://api.qualyhq.com/v1/approvals/requests/{$requestId}/{$action}", ['comment' => $comment], )->json('data'); } // $action is 'approve' or 'reject' $request = decide('REQUEST_ID', 'approve', 'Looks good.'); echo $request['status']; // "approved" ``` With an `all-of` or `sequential` policy the request stays `pending` until the required approvers have all approved; with `any-of` the first approval resolves it. A single rejection rejects the request under every strategy. --- ## Next steps - Get notified when requests resolve with [webhooks](/docs/webhooks.md). - The entities you can gate: [transactions/refunds](/docs/guides/issuing-transaction-refunds.md), [payment splits](/docs/guides/creating-payment-splits.md), and payouts. - Filter policies and requests with [Querying data](/docs/queries.md). --- # Creating a bank account > Creating a bank account using Qualy's API. Qualy is capabable to automatically payout [Partnerships](https://v1-spec.qualyhq.com/#post-/v1/partnerships/create) automatically. A bank account--amongst other things such as legal information, address, and partnership's contacts--is necessary. Different countries and currencies have different requirements, this guides walks you through adding a bank account. --- ## Before you start If you are creating a bank account for a specific Partnershio, you will need to have the Partnership's `_id` to create the Bank account, check our [Partnership's API Reference](https://v1-spec.qualyhq.com/#post-/v1/partnerships/create) to create a Partnership. ## Listing bank account requirements Different **countries** and **currencies** may have different information requirements. Before creating the bank account, it's necessary to fetch the fields for the currency and country of the bank account. Use this GET endpoint to learn what datapoints are required to send a payment to your partnershop. This this example, we are fetching the bank account requirements for the currency "AUD" and country "AU": ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/bank-accounts/requirements/AU/AUD', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, }); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` The above `GET` request may return a response like the below: ```json { "data": { "requirements": [ { "type": "text", "name": "accountName", "validation": "required" }, { "type": "text", "name": "bankName", "validation": "required", "requirementType": "australian" }, { "requirementType": "australian", "validation": "required", "name": "holderType", "min": null, "max": null, "regexp": null, "type": "select", "options": [ { "value": "personal" }, { "value": "business" } ] }, { "requirementType": "australian", "validation": "required", "name": "routingNumber", "min": 6, "max": 7, "regexp": "^\\d{3}\\-?\\d{3}$", "placeholder": "802985", "type": "text" }, { "requirementType": "australian", "validation": "required", "name": "accountNumber", "min": 4, "max": 28, "regexp": "^\\d{4,9}$", "placeholder": "123456789", "type": "text" } ] }, "hasMore": false, "count": 5 } ``` ### Requirement types Qualy organizes and groups the different requirements that may be necessary by `requirementTypes`. When creating a bank account you will need to supply this value. | Requirement name | Description | | --- | --- | | `australian` | This requirement type is used for local Australian bank accounts. | | `euro` | This requirement type is used for local European bank accounts. | | `european` | This requirement type is used for local European bank accounts. | | `colombia` | This requirement type is used for local Colombian bank accounts. | | `brazil` | This requirement type is used for local Brazilian bank accounts. | | `united-kingdom` | This requirement type is used for local United Kingdom bank accounts. | | `new-zealand` | This requirement type is used for local New Zealand bank accounts. | | `united-states` | This requirement type is used for local United States bank accounts. | | `india` | This requirement type is used for local Indian bank accounts. | | `pakistan` | This requirement type is used for local Pakistani bank accounts. | | `swift_code` | This requirement type is used for SWIFT code bank accounts. | | `turkey` | This requirement type is used for local Turkish bank accounts. | | `thailand` | This requirement type is used for local Thai bank accounts. | | `romania` | This requirement type is used for local Romanian bank accounts. | | `argentina` | This requirement type is used for local Argentine bank accounts. | | `mexico` | This requirement type is used for local Mexican bank accounts. | | `china` | This requirement type is used for local Chinese bank accounts. | | `canada` | This requirement type is used for local Canadian bank accounts. | ## Creating a bank account Once you fetched the bank account requirements, and you now have the necessary information, you can create a bank account entity, calling the [Bank Accounts API](https://v1-spec.qualyhq.com/#post-/v1/bank-accounts/create). ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/bank-accounts/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ "accountName": "Account name", "accountNumber": "12345678", "bankName": "Bank name", "country": "AU", "currency": "AUD", "holderType": "business", "requirementType": "australian", "routingNumber": "123456", }), }); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` ### Creating a bank account for a partnership You can create a bank account and associate to a partnership by providing the parthership `_id` using the property `partnership` when calling the [Bank Accounts API](https://v1-spec.qualyhq.com/#post-/v1/bank-accounts/create). Here's an example: ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/bank-accounts/create', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ "accountName": "Account name", "accountNumber": "12345678", "bankName": "Bank name", "country": "AU", "currency": "AUD", "holderType": "business", "partnership": "6608fa09ae372f75deb3c6ad", "requirementType": "australian", "routingNumber": "123456", }), }); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` ## Things to know Qualy has some rules, features and restrictons when creating a bank account, here's what you need to know: * Only one Bank Account per currency is allowed. * You may need to provide a `bankName`, but Qualy may overwrite it. Qualy fetches the `bankName` based on `accountNumber` and `routingNumber` for certain countries. * Qualy may automatically add `address` property automatically. Qualy uses the `routingNumber` to fetch the address of the Bank Account's branch automatically. Only available for certain countries. ### Data masking For security purposes, sensitive bank account fields are masked in most API responses. Fields such as account numbers, routing numbers, IBANs, and similar identifiers are returned with only the last 4 characters visible (e.g. `••••••5678`). This masking is applied consistently across all endpoints that return bank account data. --- # Public demo endpoints > Embeddable API endpoints for FX rate comparisons and financing simulations — no backend or reverse proxy required. Qualy provides a set of public demo endpoints that you can call directly from your marketing pages, landing pages, or student-facing websites. These let you dynamically enrich your content with live FX rate comparisons and financing simulations without setting up a backend, reverse proxy, or any server-side infrastructure. The same data is also available through the authenticated Qualy API for use in your own applications. --- ## Authentication To use the demo endpoints, you need a demo integration API key. [Contact us](https://qualyhq.com/contact-us/) to request one. Once you have your key, pass it in the `Authorization` header with every request: ``` Authorization: ApiKey pk_demo_integration_xxxx ``` > **Note — Authenticated API** > > These same endpoints are also available at `https://api.qualyhq.com` using standard [API key authentication](/docs/authentication.md) with an `Authorization` and `X-TENANT-ID` header. The response format is identical. --- ## FX rates Aggregates foreign exchange rates from multiple providers and returns them ranked by competitiveness. Use this to display a rate comparison widget showing which provider offers the best conversion rate. ### Endpoint ``` GET https://demos.qualyhq.com/fx-rates ``` ### Request parameters | Parameter | Type | Description | | --- | --- | --- | | `sourceAmount` | number (required) | The amount to convert, in the source currency's minor unit (cents). For example, `50000` for 500.00 BRL. | | `sourceCurrency` | string (required) | ISO 4217 currency code for the source currency. For example, `BRL`. | | `targetCountry` | string (required) | ISO 3166-1 alpha-2 country code for the country where the money is being sent to. For example, `BR` for Brazil. | | `targetCurrency` | string (required) | ISO 4217 currency code for the target currency. For example, `AUD`. | ### Example request ```javascript // Using your demo integration key const params = new URLSearchParams({ sourceAmount: '50000', sourceCurrency: 'BRL', targetCountry: 'BR', targetCurrency: 'AUD', }); const response = await fetch( `https://demos.qualyhq.com/fx-rates?${params.toString()}`, { headers: { 'Authorization': 'ApiKey pk_demo_integration_xxxx', }, } ); const result = await response.json(); console.log(result); ``` ### Response structure The response returns a `data` object where each key is a gateway (provider) name, containing rate details and provider metadata. ```json { "data": { "qualy": { "gateway": "qualy", "targetAmount": 23456.78, "rate": 2.1234, "comparison": { "rank": 1, "isBestRate": true, "differencePercentage": 0 }, "provider": { "name": "Qualy", "logos": { "normal": { "svgUrl": "https://...", "pngUrl": "https://..." } } } }, "wise": { "gateway": "wise", "targetAmount": 23100.00, "rate": 2.1050, "comparison": { "rank": 2, "isBestRate": false, "differencePercentage": -0.87 }, "provider": { "name": "Wise", "logos": { "normal": { "svgUrl": "https://...", "pngUrl": "https://..." } } } } } } ``` ### Response fields | Field | Type | Description | | --- | --- | --- | | `gateway` | string | Internal identifier for the provider. | | `targetAmount` | number | The converted amount the recipient would receive in the target currency. | | `rate` | number | The exchange rate applied for this provider. | | `comparison.rank` | number | Ranking position among all providers (1 = best). | | `comparison.isBestRate` | boolean | Whether this provider offers the best rate. | | `comparison.differencePercentage` | number | Percentage difference compared to the best rate. Negative means worse. | | `provider.name` | string | Display name of the provider. | | `provider.logos.normal.svgUrl` | string or null | URL to the provider's SVG logo, if available. | | `provider.logos.normal.pngUrl` | string or null | URL to the provider's PNG logo, if available. | ### Providers Not all providers return logos. Some providers do not have logos available, so your UI should handle this gracefully (e.g. by showing the provider name as a text fallback). | Gateway | Display name | Has logo | | --- | --- | --- | | `qualy` | Qualy | Yes | | `transfermate` | TransferMate | Yes | | `xe` | XE | Yes | | `wise` | Wise | Yes | | `edwallet` | EdWallet | No | | `nexpay` | Nexpay | No | | `abraseeio` | Abraseeio | No | > **Note — Displaying a single rate** > > If you don't want to show a competitive comparison between providers, use the `qualy` provider entry from the response. This represents the rate that Qualy is able to offer and can be displayed on its own as "your" exchange rate — without needing to reference other providers. ### Supported currencies The endpoint supports 26+ currencies including: AUD, BRL, EUR, GBP, USD, CAD, NZD, CHF, CNY, JPY, INR, KRW, HKD, SGD, TWD, VND, COP, MXN, CLP, ARS, PEN, PLN, DKK, SEK, NOK, TRY, ZAR, and AED. --- ## Financing simulation Returns available installment plans for a given amount, showing how a payment can be split into multiple installments along with any applicable interest rates. ### Endpoint ``` GET https://demos.qualyhq.com/financing-rates ``` ### Request parameters | Parameter | Type | Description | | --- | --- | --- | | `amount` | number (required) | The total amount in cents. For example, `100000` for R$1,000.00. | | `currency` | string (required) | ISO 4217 currency code. Currently supports `BRL`. | | `gateway` | string (required) | The payment gateway to simulate with. For example, `pagbank`. | | `method` | string (required) | The payment method identifier for the gateway. For example, `PGBNK_CC` for PagBank credit card. | ### Example request ```javascript // Using your demo integration key const amountInCents = 1000000; // R$10,000.00 const response = await fetch( `https://demos.qualyhq.com/financing-rates?amount=${amountInCents}¤cy=BRL&gateway=pagbank&method=PGBNK_CC`, { headers: { 'Authorization': 'ApiKey pk_demo_integration_xxxx', }, } ); const result = await response.json(); console.log(result); ``` ### Response structure The response returns an array of installment simulations, each representing a different payment plan option. ```json { "success": true, "data": { "simulations": [ { "nInstallments": 1, "installmentAmount": 100000, "total": 100000, "interestRate": 0, "feePercentage": 0, "gateway": "pagbank", "method": "PGBNK_CC" }, { "nInstallments": 3, "installmentAmount": 34500, "total": 103500, "interestRate": 0.035, "feePercentage": 0.035, "gateway": "pagbank", "method": "PGBNK_CC" }, { "nInstallments": 6, "installmentAmount": 18200, "total": 109200, "interestRate": 0.092, "feePercentage": 0.092, "gateway": "pagbank", "method": "PGBNK_CC" } ] } } ``` ### Response fields | Field | Type | Description | | --- | --- | --- | | `nInstallments` | number | Number of installments (1 = lump sum payment). | | `installmentAmount` | number | Amount per installment, in cents. | | `total` | number | Total amount to be paid across all installments, in cents. | | `interestRate` | number | Interest rate as a decimal. For example, `0.035` means 3.5%. A value of `0` means interest-free. | | `feePercentage` | number | Fee percentage applied to the total. May be used as an alternative to `interestRate`. | | `gateway` | string | The payment gateway handling this financing option. | | `method` | string | The payment method (e.g., `PGBNK_CC`). | ### Error handling When the request fails, the endpoint returns a `400 Bad Request` HTTP status code. ### Things to know - Amounts are always in **cents** (minor currency unit). Divide by 100 for display. - The 1-installment option represents a lump sum (full amount, no interest). - Interest rates vary by installment count — more installments typically means a higher rate. - This endpoint currently supports **BRL** (Brazilian Real) only. --- # Collecting payments > Two ways to collect payments - redirect to Qualy's payment portal, or call the API directly for PIX, Boleto, PayID, bank transfers, and more. After [creating a Payment Intent](/docs/guides/creating-payment-intents.md), you need to collect the actual payment from your contact. Qualy gives you two options: redirect them to the hosted payment portal, or call the API directly and build your own payment experience. --- ## Via payment portal The simplest way to collect a payment. When you create a Payment Intent, Qualy returns a `links` object with a portal URL. Redirect your contact there and Qualy handles everything: method selection, payer verification, payment processing, and confirmation. ```json { "_id": "6634f56c7dbbc16e07b5025e", "links": { "short": "https://qualyhqpay.com/abc123", "long": "https://yourcompany.qualyhqportal.com/payments/6634f56c7dbbc16e07b5025e/pay" } } ``` The portal URL follows this format: ``` https://{subdomain}.qualyhqportal.com/payments/{paymentIntentId}/pay ``` Where `{subdomain}` is your tenant's nickname or tenant ID. If you have a custom domain configured, the link will use that instead. Use the `links.short` URL when sending to contacts (email, SMS, WhatsApp). Use `links.long` if you need to construct the URL yourself. > **Note — When to use the portal** > > The portal is the best choice when you want a fully managed experience, need to support card payments (credit/debit), or don't want to build a payment UI. Card payments require the hosted portal for PCI compliance. --- ## Via API (direct) For non-card payment methods, you can call the API directly, get the payment details (a QR code, a barcode, a PayID address, or bank account details), and display them in your own UI. This gives you full control over the payment experience. ### How it works 1. **Create a Payment Intent** - defines the amount, currency, and contact. 2. **Get available payment options** - check which methods the contact can use. 3. **Sign the payment** - call the sign endpoint with the chosen method. The response contains everything you need to display to your contact. 4. **Contact completes payment** - they scan a QR code, pay a boleto, transfer to a PayID, etc. 5. **Webhook confirms payment** - Qualy notifies your server when the payment succeeds or fails. --- ### Understanding currency vs. settlement currency When fetching payment options, you pass a `settlementCurrency` parameter. This is the currency your contact will actually pay in, which can differ from the Payment Intent's `currency` (the currency the invoice is denominated in). For example, you might create a Payment Intent in AUD (your accounting currency), but the contact pays in BRL (their local currency). Qualy handles the FX conversion automatically. The priority for determining settlement currency is: 1. If the Payment Intent has a `settlementCurrency` set, it is always used (forced). 2. Otherwise, the value you pass to the payment-options endpoint is used. 3. If neither is set, the Payment Intent's `currency` is used as default. > **Warning — Settlement currency matters** > > The settlement currency determines which payment methods are available. For example, `AS_PIX` and `AS_BOLETO` only appear when the settlement currency is `BRL`. `ZAI_PAYID` and `ZAI_BSBACC` only appear for `AUD`. ### Getting available payment options ```javascript const paymentIntentId = '6634f56c7dbbc16e07b5025e'; // Use the PI's settlementCurrency, or fall back to its currency const settlementCurrency = 'BRL'; try { const response = await fetch( `https://api.qualyhq.com/v1/payment-gateways/payment-options/${paymentIntentId}/${settlementCurrency}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, } ); const data = await response.json(); console.log(data); } catch (error) { console.error(error); } ``` The response lists every available method with its status, fees, and estimated processing time: ```json { "options": [ { "type": "AS_PIX", "gateway": "asaas", "status": "active", "currency": "BRL", "amount": 135000, "eta": "seconds", "fees": { "gateway": 199 } }, { "type": "AS_BOLETO", "gateway": "asaas", "status": "active", "currency": "BRL", "amount": 135000, "eta": "3-days", "fees": { "gateway": 349 } } ], "settlementCurrency": "BRL", "settlementCurrencies": ["BRL"] } ``` Only methods with `"status": "active"` can be used. The `amount` is in cents (minor currency units), and `fees` show any additional charges. --- ### Signing the payment Use the `POST /v1/payment-gateways/sign` endpoint with the chosen method. The response gives you everything you need to display to your contact. #### Payer object Every sign request requires a `payer` object that identifies who is making the payment. | `payer.type` | When to use | | --- | --- | | `myself` | The contact is paying for themselves. Qualy can resolve address and ID from the contact record, so `email`, `profile`, and `address` are optional. | | `parent` | A parent or guardian is paying on behalf of the contact. | | `partner` | A business partner is paying. | | `family` | A family member is paying. | | `friend` | A friend is paying. | | `colleague` | A work colleague is paying. | | `other` | Someone else not fitting the above categories. | When `type` is anything other than `myself`, the `email` and `profile` (firstName, lastName, phone) fields become **required** since Qualy cannot resolve the payer's identity from the contact record. #### Handling 424 errors (missing information) The sign endpoint may return an HTTP `424` status when it needs more information to process the payment. This is common for Brazilian methods (AS_PIX, AS_BOLETO) which require a valid address and tax ID. ```json { "statusCode": 424, "message": "We need more information to complete your payment.", "data": { "missingInformation": ["address", "id"] } } ``` The `missingInformation` array tells you exactly which fields to collect from the payer. Common values: | Missing field | What to provide | | --- | --- | | `full-payer` | The full `payer` object is missing. Collect all payer details (email, profile, address, id). | | `address` | A valid address is required. For Brazilian methods, the `postalCode` (CEP) must be a valid format. | | `id` | A tax identification number is required. For Brazil this is CPF (individuals) or CNPJ (companies) in the `payer.id.number` field. | When you receive a 424, collect the missing information from the payer and retry the sign request with the complete data. --- ## PIX (Brazil) PIX payments are instant. The sign response returns a QR code and a copy-paste code that your contact can use in any Brazilian banking app. ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/payment-gateways/sign', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ gateway: 'asaas', signatureType: 'AS_PIX', entity: 'payment-intent', paymentIntentId: '6634f56c7dbbc16e07b5025e', contact: { contactId: '6606ab8ffb9085579f1b5844', }, payer: { type: 'myself', email: 'john@example.com', profile: { firstName: 'John', lastName: 'Doe', phone: '+5511999999999', }, address: { country: 'BR', line1: 'Rua Example 123', city: 'Sao Paulo', state: 'SP', postalCode: '01001000', }, id: { country: 'BR', number: '12345678901', }, }, }), }); if (response.ok) { const data = await response.json(); // data.encodedQrCode - Base64 QR code image // data.qrCodeLink - PIX copy-paste code (copia e cola) console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` ### PIX response ```json { "encodedQrCode": "iVBORw0KGgoAAAANSUhEUg...", "qrCodeLink": "00020126580014br.gov.bcb.pix..." } ``` | Field | Description | | --- | --- | | `encodedQrCode` | Base64-encoded QR code image. Render it as an `` tag: `` | | `qrCodeLink` | The PIX payload string (known as "copia e cola"). Your contact can paste this directly in their banking app. | Show the QR code image and a "copy code" button with the `qrCodeLink` value. PIX payments typically confirm within seconds. Supports partial payments via the `amount` field. --- ## Boleto (Brazil) Boleto generates a bank slip that can be paid at any bank, lottery house, or banking app in Brazil. Processing takes 1-3 business days. ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/payment-gateways/sign', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ gateway: 'asaas', signatureType: 'AS_BOLETO', entity: 'payment-intent', paymentIntentId: '6634f56c7dbbc16e07b5025e', contact: { contactId: '6606ab8ffb9085579f1b5844', }, payer: { type: 'myself', email: 'john@example.com', profile: { firstName: 'John', lastName: 'Doe', phone: '+5511999999999', }, address: { country: 'BR', line1: 'Rua Example 123', city: 'Sao Paulo', state: 'SP', postalCode: '01001000', }, id: { country: 'BR', number: '12345678901', }, }, }), }); if (response.ok) { const data = await response.json(); // data.bankSlipUrl - URL to view/download the boleto PDF // data.identificationField - 47-digit boleto number // data.barCode - Barcode number console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` ### Boleto response ```json { "bankSlipUrl": "https://www.asaas.com/b/pdf/abc123", "identificationField": "23793.38128 60000.000003 00000.000400 1 84340000010000", "nossoNumero": "1234567", "barCode": "23791843400000100000038126000000000000000040" } ``` | Field | Description | | --- | --- | | `bankSlipUrl` | URL to download or view the boleto PDF. You can link or embed this directly. | | `identificationField` | The formatted 47-digit boleto number. This is what payers type into their banking app. | | `nossoNumero` | The bank's internal reference number for the boleto. | | `barCode` | The raw barcode number. Use this to render a barcode image if needed. | Show the `identificationField` with a copy button, and link to the `bankSlipUrl` so they can download the full bank slip. Boleto payments take 1-3 business days to confirm. Supports partial payments via the `amount` field. --- ## PayID (Australia) PayID is Australia's real-time payment addressing system. The sign response returns a PayID email address that your contact can pay using their banking app. > **Note — PayID availability** > > PayID is only available for AUD payments in Australia. ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/payment-gateways/sign', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ gateway: 'zai', signatureType: 'ZAI_PAYID', entity: 'payment-intent', paymentIntentId: '6634f56c7dbbc16e07b5025e', contact: { contactId: '6606ab8ffb9085579f1b5844', }, payer: { type: 'myself', email: 'jane@example.com', profile: { firstName: 'Jane', lastName: 'Smith', phone: '+61412345678', }, }, }), }); if (response.ok) { const data = await response.json(); // data.email - PayID email address to pay to // data.details - Merchant name info console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` ### PayID response ```json { "email": "pay@merchant.payid", "userId": "abc123", "details": { "name": "Merchant Pty Ltd", "legalName": "Merchant Pty Ltd" } } ``` | Field | Description | | --- | --- | | `email` | The PayID address. Your contact enters this in their banking app to send the payment. | | `details.name` | The merchant display name that will appear when the contact looks up the PayID. | | `details.legalName` | The legal name of the receiving entity. | Show the PayID `email` with a copy button, the expected amount, and the merchant name so they can verify the recipient. PayID transfers are typically instant during business hours. Does not support partial payments. --- ## BSB & Account Number (Australia) For contacts who prefer traditional bank transfers, you can provide BSB and account number details. ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/payment-gateways/sign', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ gateway: 'zai', signatureType: 'ZAI_BSBACC', entity: 'payment-intent', paymentIntentId: '6634f56c7dbbc16e07b5025e', contact: { contactId: '6606ab8ffb9085579f1b5844', }, payer: { type: 'myself', email: 'jane@example.com', profile: { firstName: 'Jane', lastName: 'Smith', phone: '+61412345678', }, }, }), }); if (response.ok) { const data = await response.json(); // data.accountName - Account name to pay to // data.routingNumber - BSB number // data.accountNumber - Account number console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` ### BSB & Account response ```json { "accountName": "Merchant Pty Ltd", "routingNumber": "062000", "accountNumber": "12345678" } ``` | Field | Description | | --- | --- | | `accountName` | The name of the receiving bank account. | | `routingNumber` | The BSB (Bank-State-Branch) number. Display this formatted as `XXX-XXX`. | | `accountNumber` | The bank account number. | Show all three fields along with the exact amount to transfer. Remind the contact to use the payment reference if applicable. Bank transfers can take 1-2 business days to clear. Does not support partial payments. --- ## Bank Transfer (International) Bank transfers via Transfermate support 60+ currencies, making this the most versatile method for international payments. The sign response returns bank account details (IBAN, SWIFT, etc.) that your contact uses to make a wire transfer. ```javascript try { const response = await fetch('https://api.qualyhq.com/v1/payment-gateways/sign', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, body: JSON.stringify({ gateway: 'transfermate', signatureType: 'TM_BANK_TRANSFER', entity: 'payment-intent', paymentIntentId: '6634f56c7dbbc16e07b5025e', countryOfPayment: 'DE', contact: { contactId: '6606ab8ffb9085579f1b5844', }, payer: { type: 'myself', email: 'hans@example.com', profile: { firstName: 'Hans', lastName: 'Mueller', phone: '+4915123456789', }, }, }), }); if (response.ok) { const data = await response.json(); console.log(data); } else { throw new Error(`Request failed with status: ${response.status}`); } } catch (error) { console.error(error); } ``` > **Note — Country of payment** > > `TM_BANK_TRANSFER` requires the `countryOfPayment` field (ISO 3166-1 alpha-2 code). This determines which bank details are returned - for example, a German payer gets IBAN + BIC, while a Brazilian payer may get PIX or ISPB details. ### Bank Transfer response The response fields vary depending on the payer's country and the settlement currency, but the structure is: ```json { "reference": "QLY-PYMT-1234", "amount": 135000, "currency": "EUR", "bankName": "Deutsche Bank AG", "bankAddress": "Taunusanlage 12, 60325 Frankfurt, Germany", "iban": "DE89370400440532013000", "swiftCode": "DEUTDEFF", "accountNumber": "0532013000", "accountName": "TransferMate Global Payments", "sortCode": "", "routingNumber": "", "fees": [{ "name": "Transfer fee", "amount": 500 }], "documents": ["receipt"] } ``` | Field | Description | | --- | --- | | `reference` | The payment reference your contact must include in their bank transfer. This is how Qualy matches the incoming payment. | | `amount` | Amount to transfer, in minor currency units (cents). | | `currency` | The currency the contact should send. | | `iban` | IBAN (International Bank Account Number). Available for most European and international transfers. | | `swiftCode` | SWIFT/BIC code for international wire transfers. | | `accountNumber` | Bank account number. | | `accountName` | Account holder name. | | `sortCode` | Sort code (for GBP transfers). | | `routingNumber` | Routing number (for specific regions). | | `documents` | Array of required document types (e.g., `"receipt"`) that must be uploaded for compliance. | Display the bank details, the exact `amount` and `currency`, and prominently show the `reference` - without the correct reference, the payment cannot be automatically matched. Bank transfers typically take 2-5 business days depending on the corridor. Does not support partial payments. --- ## Partial payments Some payment methods support partial payments. Pass the `amount` field (in cents) in the sign payload to allow a contact to pay less than the full amount: ```javascript const signPayload = { gateway: 'asaas', signatureType: 'AS_PIX', entity: 'payment-intent', paymentIntentId: '6634f56c7dbbc16e07b5025e', contact: { contactId: '6606ab8ffb9085579f1b5844', }, amount: 50000, // Pay 500.00 of the total (in cents) payer: { // ... payer details }, }; ``` | Method | Partial payments | | --- | --- | | `AS_PIX` | Supported | | `AS_BOLETO` | Supported | | `ZAI_PAYID` | Not supported | | `ZAI_BSBACC` | Not supported | | `TM_BANK_TRANSFER` | Not supported | --- ## Handling webhooks After the contact completes the payment, Qualy sends a webhook to your server. Set up your webhook endpoint to listen for transaction events. See [Setting up webhooks](/docs/webhooks.md) for details. A Payment Intent can have multiple transactions (e.g. partial payments or retries). Always check the Payment Intent status to determine if the full amount has been collected: ```javascript try { const response = await fetch( 'https://api.qualyhq.com/v1/payment-intents/6634f56c7dbbc16e07b5025e', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Authorization': 'ApiKey your-api-key-here', 'X-TENANT-ID': 'your-tenant-id-here', }, } ); const { data: paymentIntent } = await response.json(); console.log(paymentIntent.status); } catch (error) { console.error(error); } ``` --- ## Method summary | Method | Gateway | `signatureType` | Region | Speed | Partial | What you display | | --- | --- | --- | --- | --- | --- | --- | | PIX | `asaas` | `AS_PIX` | Brazil | Instant | Yes | QR code + copy-paste code | | Boleto | `asaas` | `AS_BOLETO` | Brazil | 1-3 days | Yes | Bank slip link + 47-digit code | | PayID | `zai` | `ZAI_PAYID` | Australia | Instant | No | PayID email address | | BSB & Account | `zai` | `ZAI_BSBACC` | Australia | 1-2 days | No | BSB + account number | | Bank Transfer | `transfermate` | `TM_BANK_TRANSFER` | 60+ currencies | 2-5 days | No | IBAN, SWIFT, reference | --- # Simulating payments > Simulate payments to test your integration. To confirm that your integration works correctly, simulate transactions without moving any money using special values in test mode. > **Note — You will need sandbox access** > > To test your integration in test mode, where no real money is used you will need access to our sandbox enviroment. To get access to our sandbox environment get in touch with us. Because of rate limits, we don’t recommend using the sandbox to load-test your integration. --- ## Testing payments with cards Test cards let you simulate several scenarios: - Successful payments by card brand - Card errors due to declines, fraud, or invalid data > **Warning — Common mistake** > > Do not under any circumstance use real card details when testing your integration. To test a card payment you will need to create a payment via the Dashboard or API, and go to our Contact portal (payment link) to input the card information. It's not possible to start a payment with card via the API. ### Australian credit cards Use the cards below when testing the payment method id `ZAI_CC`. If you are not sure which method are you using, check the URL of the payment link. ### Brazilian credit cards Use the cards below when testing the payment method id `PGBNK_CC`. If you are not sure which method are you using, check the URL of the payment link. ### Multi-currency credit cards Use the cards below when testing the payment method id `BLUE_CC`. If you are not sure which method are you using, check the URL of the payment link. #### Card Data - **Expiration Date**: - Month: 01 (January) - Year: Current year + 3 years (e.g., In 2026, use 01/2029) - **Security Code (CVV)**: any 3-digit code - **Challenge Response**: - If challenged, use: - User name: test1 (prefilled) - Password: 1234 ### Credit cards with redirect When testing credit card payments using the `TM_CC` payment method, you will be redirected to a different environment. You can use any test data to simulate the payment. ## Multi-currency direct debit Use the below accounts to simulate specific behaviours when using the payment method `BLUE_DD`: ## Other payment methods Testing other payment methods works similarly. Each payment method may have its own special values. You will need to create a payment via the Dashboard or API, after that go to our Contact portal (payment link) to proceed with the payment. --- # API fees and limits > Learn about what fees and limits apply for the Qualy API usage. When working with the Qualy API, it's important to understand the aspects related to fees and usage limits. --- ## Fees One of the key advantages of using Qualy's API is that it comes with no associated fees. Qualy believes in supporting developers by providing access to its API without the burden of additional costs. It's essential to note that, despite the absence of explicit fees, Qualy reserves the right to monitor and manage API usage. This ensures fair and equitable access to resources for all users. Developers should be aware that unusual or excessive usage that deviates from typical patterns may be subject to review. --- ## Limits While Qualy does not impose strict quantitative limits on general API usage, it's important to understand that there are guidelines in place to maintain the quality of service for all users. Qualy reserves the right to slow down or block requests that fall outside the normal usage patterns. To ensure a positive experience for everyone, developers are encouraged to adhere to best practices and optimize their API calls. This includes efficient use of resources, avoiding unnecessary requests, and being mindful of the potential impact of high-frequency or resource-intensive operations (e.g. complex queries and high limit for returned objects). ### Rate limiting Certain security-sensitive endpoints enforce rate limits to protect against brute-force and abuse. These include authentication-related endpoints such as login, two-factor authentication verification, password reset, and magic link validation. When you exceed a rate limit, the API responds with HTTP `429 Too Many Requests` and includes a `Retry-After` header indicating how many seconds you should wait before making another request. > **Warning — Adaptive blocking** > > Repeated rate limit violations from the same IP address may result in temporary blocks with escalating durations. Ensure your integration respects `Retry-After` headers and implements exponential backoff to avoid extended blocks. ### Stress Testing Stress testing or load testing of our API endpoints is strictly prohibited without prior authorization in both production and sandbox environments. This policy helps maintain consistent performance and system stability for all users. If your business case requires performance testing, please review our [stress testing policy](/docs/stress-testing.md) for proper procedures and alternatives. Developers should regularly check for updates and guidelines regarding API usage, as Qualy may refine its policies to better serve the growing developer community. --- # Stress testing policy > Important information about stress testing and load testing our API. As your business grows, you might be interested in understanding how our API performs under heavy load. This guide explains our policies regarding stress testing and load testing, and provides information about our infrastructure's capability to handle high-volume transactions. We've designed our system to be reliable and scalable, eliminating the need for individual performance testing while ensuring your integration remains robust. > **Warning — Stress testing is not allowed** > > Stress testing or load testing our API endpoints is strictly prohibited in both production and sandbox environments without prior authorization. ## Understanding our policy We maintain this policy to ensure: - Consistent performance for all our customers - System stability and reliability - Fair resource allocation - Protection against potential DDoS-like behavior ## Infrastructure and reliability Our platform is built on a robust, elastic infrastructure designed to handle varying loads efficiently: - **Auto-scaling architecture**: Our services automatically scale based on demand - **Event queuing system**: All events are properly queued and processed with guaranteed delivery - **Event replay capability**: In case of any issues, events can be replayed to ensure data consistency - **Disaster recovery**: Multiple redundancy layers and automated failover mechanisms - **Geographic distribution**: Services are distributed across multiple regions for optimal performance > **Note — Event Processing** > > Our event processing system ensures that every transaction is processed exactly once, even during high load periods or system maintenance. Events are persisted before processing, allowing for reliable recovery if needed. ## Sandbox environment limitations While our sandbox environment is designed for testing your integration, it is not meant for: - Load testing - Stress testing - Performance benchmarking - Automated mass request testing Our sandbox environment has the same rate limits as production. These limits are in place to maintain system stability and ensure fair usage for all developers. > **Note — Sandbox performance** > > Please note that our sandbox environment may experience slower response times and slightly lower reliability compared to production. This is because sandbox resources are optimized for testing functionality rather than performance. Do not use sandbox performance metrics as an indicator of production system performance. ## Alternatives for performance testing If you need to evaluate the performance of your integration with our API, we recommend: 1. **Integration testing**: Focus on testing your implementation's correctness rather than performance 2. **Unit testing**: Test your code's behavior with mocked API responses 3. **Staged testing**: Gradually increase your transaction volume in production ## Getting authorization for load testing If your business case requires performance testing: 1. Contact our support team with: - Your use case - Expected request volumes - Preferred testing window - Test plan details 2. We will review your request and may: - Provide a dedicated testing environment - Schedule a supervised testing window - Offer alternative solutions > **Note — Enterprise customers** > > If you're an enterprise customer with specific performance testing requirements, please contact your account manager to discuss custom solutions. ## Best practices for production When going live with your integration: 1. **Gradual ramp-up**: Increase your transaction volume gradually 2. **Monitor response times**: Keep track of API response times and error rates 3. **Implement retries**: Use exponential backoff for failed requests 4. **Cache when possible**: Cache responses where appropriate to reduce API calls > **Note — Need help?** > > If you're unsure about your integration's performance requirements or need guidance, don't hesitate to reach out to our support team.