Migration guides
Migrating from Stripe
If you know Stripe, you already know most of Qualy. This guide maps each Stripe concept to its Qualy equivalent and shows the code side by side.
Concept map
| Stripe | Qualy | Notes |
|---|---|---|
| Customer | Contact | Names go in a nested profile. Reference a contact by id or email. |
| Product / Price | Service | A course, a fee, an insurance product. Optional on one-off payments. |
| Payment Link | A public service + its price template | Reusable link anyone can pay. See Payment links. |
| Checkout Session | Payment portal | Hosted page for cards, bank transfers and local methods. |
| PaymentIntent | Payment intent | Same idea, but it carries line items, a dueAt, and tax. |
| Invoice | Payment intent with items | No separate invoice object. Tax invoices are generated from the payment. |
| Subscription | Subscription | Generates a payment intent per period. |
| Refund | Refund | A refund intent against the original charge. |
| Dispute | Dispute | — |
| Connect: connected account | Partnership | A school, agent or supplier you pay. |
Connect: transfers / application_fee_amount | Payment splits | Declared up front; Qualy pays out after settlement. |
metadata | custom | Visible in the dashboard once a custom field with the same name exists. |
| Smart Retries / dunning emails | Dunning | Reminders for overdue payments. |
Stripe-Account header | X-TENANT-ID header | See Multi-tenancy. |
Authentication
Stripe uses a secret key as a Bearer token. Qualy uses an API key plus your tenant id:
# Stripe
curl https://api.stripe.com/v1/customers -u sk_live_...:
# Qualy
curl https://api.qualyhq.com/v1/contacts \
-H 'Authorization: ApiKey your-api-key-here' \
-H 'X-TENANT-ID: your-tenant-id-here'
Qualy takes JSON bodies, not form-encoded ones, and writes use POST .../create and POST .../{id}/update.
Customers → contacts
const customer = await stripe.customers.create({
email: 'john.doe@example.com',
name: 'John Doe',
metadata: { crmId: 'A-1042' },
});
const res = await fetch('https://api.qualyhq.com/v1/contacts/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'ApiKey your-api-key-here',
'X-TENANT-ID': 'your-tenant-id-here',
},
body: JSON.stringify({
email: 'john.doe@example.com',
profile: { firstName: 'John', lastName: 'Doe' },
}),
});
const { data: contact } = await res.json();
Payment Links
A Stripe Payment Link is reusable and anyone can pay it. Qualy has both kinds:
- Reusable, for anyone — like Stripe: a service (the Product) marked public with
displayEcommerce: true, and a template (the Price). The link ishttps://{subdomain}.qualyhqportal.com/services/{serviceId}?action=buy-now. See Payment links. - One-off, for one student — every payment intent has its own link. Use it when you already know who owes what.
Side by side for a reusable link:
const product = await stripe.products.create({ name: 'Application fee' });
const price = await stripe.prices.create({
product: product.id,
currency: 'aud',
unit_amount: 25000,
});
const link = await stripe.paymentLinks.create({
line_items: [{ price: price.id, quantity: 1 }],
});
console.log(link.url);
const headers = {
'Content-Type': 'application/json',
'Authorization': 'ApiKey your-api-key-here',
'X-TENANT-ID': 'your-tenant-id-here',
};
// Product → service, public
const { data: service } = await fetch('https://api.qualyhq.com/v1/services/create', {
method: 'POST',
headers,
body: JSON.stringify({ name: 'Application fee', displayEcommerce: true }),
}).then((r) => r.json());
// Price → template
await fetch('https://api.qualyhq.com/v1/templates/create', {
method: 'POST',
headers,
body: JSON.stringify({
name: 'Application fee',
category: 'payment-plan-list',
services: [service._id],
displayEcommerce: true,
payments: [{ currency: 'AUD', items: [{ name: 'Application fee', amount: 25000 }] }],
}),
});
console.log(`https://yourcompany.qualyhqportal.com/services/${service._id}?action=buy-now`);
When someone pays, Qualy creates the contact, an order and the payment for you, and sends the usual webhooks. For a one-off link, create a payment intent and send its links.short instead. Either way the student picks how to pay (card, bank transfer, PIX, PayID and more, depending on their country and currency) and Qualy confirms the payment by webhook.
Pay-what-you-owe and plans
Stripe's "customer chooses price" is a template with category: "payment-ad-hoc-variable-amount". A template can also hold several payments (a deposit and instalments) and splits, so one link can pay a school and keep your commission.
Checkout and PaymentIntents
Stripe Checkout → the payment portal. Redirect to links.long (or links.short) instead of session.url. Card payments always go through the portal, as Checkout does.
Stripe PaymentIntent + Elements → the payment intent + sign. For non-card methods you can build your own UI: get the methods available to the student, then sign the chosen one to get a QR code, a boleto, a PayID or bank details. See Collecting payments.
Statuses map roughly as follows:
| Stripe PaymentIntent | Qualy payment intent |
|---|---|
requires_payment_method | due |
processing | processing |
succeeded | paid-full |
| — | paid-partial (Qualy accepts partial payments) |
| — | overdue (past dueAt) |
canceled | canceled |
refunded (on the charge) | refunded-partial / refunded-full |
Subscriptions
Stripe bills a Price on an interval. Qualy bills an amount on a schedule and creates a payment intent each period:
await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: 'price_monthly_support' }],
billing_cycle_anchor_config: { day_of_month: 1 },
});
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',
contact: 'john.doe@example.com',
service: 'Premium Support',
amount: 10000,
currency: 'AUD',
billing: { startAt: '2026-12-01T00:00:00.000Z' },
schedule: { frequency: 'every-month', dayOfMonth: 1 },
}),
});
For a fixed number of installments (a course paid in 6 parts) use an order with installment payments, or offer financing.
Refunds
await stripe.refunds.create({ payment_intent: 'pi_...', amount: 150000 });
await fetch('https://api.qualyhq.com/v1/refund-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({
currency: 'AUD',
route: {
method: 'ZAI_CC',
kind: 'native-reverse', // back to the original card
source: 'tenant',
destination: 'contact',
settlementCurrency: 'AUD',
},
items: [
{ appliesTo: '668bfc47f44334c60d96848e', grossAmount: 150000, basisCurrency: 'AUD' },
],
refundReason: 'requested_by_customer',
}),
});
Unlike Stripe, you reference the charge transaction, can preview fees and FX first, and the refund may wait for approval. See Issuing refunds for options, previews and statuses.
Connect → partnerships and splits
With Connect you collect on the platform and move money with transfer_data, application_fee_amount or separate Transfers. In Qualy you declare up front who gets what with payment splits, and Qualy pays each partner when the payment settles.
| Stripe Connect pattern | Qualy |
|---|---|
Destination charge with application_fee_amount | A keep-percentage split: the partner gets the payment, you keep your commission. |
| Separate charges and transfers | One split per recipient: fixed, percentage-on-amount or percentage-on-splits. |
on_behalf_of a single seller | suppliers on the payment intent: everything goes to the supplier unless a split says otherwise. |
You can see the payouts before you create the payment with POST /v1/payment-splits/preview/draft.
Webhooks
| Stripe event | Qualy event |
|---|---|
customer.created / customer.updated | contacts.create / contacts.update |
payment_intent.created | paymentIntents.create |
payment_intent.succeeded, invoice.paid | paymentIntents.update with status: "paid-full" |
charge.succeeded / charge.refunded | transactions.create / transactions.update |
transfer.created | splitIntents.create / splitIntents.update |
Qualy sends fewer, broader events: watch paymentIntents.update and read status rather than subscribing to one event per state. See Webhook events.
Things that work differently
- Every payment belongs to a contact. Reusable links create the contact at checkout; over the API, create the contact first (or pass an email).
- Partial payments are normal. A student can pay a deposit now and the rest later against the same payment.
- Students pay in their own currency. Set
settlementCurrency, or let the student choose on the portal. See Collecting payments. - Saved cards can't be imported. Students enter their card again on their first Qualy payment.