Skip to content
AXOWLDevelopers
Quickstart

Applications

Applications — /api/org/{slug}/applications

Section titled “Applications — /api/org/{slug}/applications”
Method · RoutePurpose
GET /List applications
GET /{appId}Get one application
POST /Create application. Needs org.app_group.app.create. Returns appId.
PUT /{appId}Update application (incl. OIDC/SAML client config). Needs org.app_group.app.update.
DELETE /{appId}Delete application. Needs org.app_group.app.delete.
PATCH /{appId}/statusToggle status: { isActive }. Needs org.app_group.app.update.
POST /{appId}/rotate-secretRotate client secret; returns the new secret. Needs org.app_group.app.update.

An application’s public key is its client key (client_..., one per platform × environment) — it is what the SDK’s clientId and the hosted login URL carry. OIDC client config (PKCE requirement, allowed grant types, scopes, post-logout redirects) and SAML SP registration (EntityID, ACS URL) live on the Application — see OIDC and SAML.

An App Group is the product axis: it holds hosted-login branding (colors, social providers, passkey/magic-link toggles, hero copy), the Passkey RP ID, and — when the group sells something — its storefront and payout links.

Method · RoutePurpose
GET / · GET /{groupId}List / get
POST / · PUT /{groupId} · DELETE /{groupId}App-group CRUD (branding, login methods, hero). Creating needs a passkey seal in stepUpAssertion (401 with errorCode STEPUP_REQUIRED or STEPUP_FAILED) and returns groupId. Updating needs org.app_group.update; deleting needs org.app_group.delete and answers 409 with errorCode DATASOURCES_ATTACHED, PROJECTS_ATTACHED or SUBSCRIPTIONS_NOT_CANCELED while data sources, projects or live own-gateway subscriptions are still attached.
PUT /{groupId} with iconCornerRadiusCorner rounding of the app icon as a percentage of its side: 0 (square, the default) to 50 (circle). Values outside the range are pulled to the nearest end. The hosted sign-in page draws the icon with it, and the public login-methods answer carries it as appGroup.iconCornerRadius.
PUT /{groupId} with botCheckHiddenWhether the bot check (Cloudflare Turnstile) box is hidden on the sign-in page. Default false: the box is always shown. true runs the check in the background and shows the box only when Cloudflare needs a click — Pro and above, otherwise 403 with error UPGRADE_REQUIRED and feature security.bot_check_hidden. The check itself is enforced either way, and setting it back to false is always allowed. The public login-methods answer carries the effective value as methods.botProtectionHidden (stored value and plan), so a downgrade shows the box again without changing what you saved.
POST /{groupId}/appsAdd app to group: { applicationType, environment, name? }. Needs org.app_group.app.create. Returns appId.
POST / with collectionRailChoose how the app group collects from its own customers: none (no payments), app_payment (Application payment integration — payments go to the brand’s own seller account, currently Stripe) or own (the brand’s own payment gateway — KG Inicis or Toss Payments through PortOne, or Razorpay — while Axowl runs products, subscriptions, vouchers and entitlements). An app group is always created with collection off: register products, confirm the collection method, then turn collection on with PUT (below). When collectionRail is omitted, monetizationEnabled: true means collectionRail: "app_payment" and anything else means none. An unknown value is 400 INVALID_COLLECTION_RAIL; collect_on_behalf is refused with 409 NOT_OFFERED. The response is groupId. Application payment integration is offered to organizations in the 44 countries where Stripe opens seller accounts. Choosing app_payment at creation is checked in this order: a local market country outside those 44 → 409 MARKET_NOT_OFFERED; owner-only (org.org.billing_manage, else 403 PERMISSION_DENIED); the plan’s collecting-group limit (else 403 UPGRADE_REQUIRED); an organization with no country set → 409 REGION_REQUIRED; an organization country outside the 44 → 409 NOT_OFFERED. Organizations in Korea and India connect their own gateway instead.
PUT /{groupId} with monetizationEnabled: trueTurn collection on. Changing monetizationEnabled either way is owner-only (org.org.billing_manage, else 403 PERMISSION_DENIED); turning it on also checks the plan’s collecting-group limit (403 UPGRADE_REQUIRED). The app group needs a confirmed collection method and at least one product: no method chosen → 409 PAYMENT_SERVICE_REQUIRED; own gateway still waiting for its test payment → 409 PAYMENT_SERVICE_NOT_CONFIRMED; Application payment integration without a ready payout account (card payments and payouts enabled) → 409 STRIPE_NOT_READY; no product → 409 PRODUCTS_REQUIRED. These come before the country check (409 NOT_OFFERED). Re-saving a group that is already collecting is not checked again. monetizationEnabled: false skips the plan and readiness checks: new payments stop and existing subscriptions keep renewing until they are canceled.
POST · DELETE /{groupId}/iconApp-group icon (multipart field icon). PNG, JPEG, WebP or SVG up to 2MB. The type is read from the file’s bytes and an SVG is cleaned before it is stored. Upload returns iconUrl. Needs org.app_group.update.

These routes lead to money (payout accounts, prices), so they sit behind the same owner-only gate as billing — hiding a button in the dashboard is not enough. Writes need the billing-manage permission (org.org.billing_manage); reads need view.billing.

Method · RoutePurpose
POST /{groupId}/gateway-linkupRegister the app group as a brand with the partner storefront (Bullmark); returns a one-time redirectUrl. The partner app comes from configuration, never from the request. Refused for a group with no products (409 NO_PRODUCTS) or when the plan’s collecting-group limit is reached (403 UPGRADE_REQUIRED).
POST /{groupId}/gateway-listingOne-time session to the brand’s listing screen (review status, voucher economy); returns redirectUrl. No re-login; the destination is fixed server-side.
POST /{groupId}/payout-sessionEmbedded payout onboarding (Stripe Connect) — returns brandSlug, accountId, publishableKey, payoutsEnabled and a short-lived clientSecret, never cached. The brand is resolved server-side; the optional body field brandSlug is used only when the app group has no brand yet and the caller owns several unbound ones (409 PICK_BRAND with brands). The organization owner must have signed the seller policy first (409 SELLER_POLICY_REQUIRED). 409 REGION_REQUIRED when the organization has no country set, 409 NOT_OFFERED outside the Stripe seller countries, 409 MARKET_NOT_OFFERED for a local market outside them.
GET /{groupId}/payout-statusOnboarding progress and remaining requirements.
GET /{groupId}/referral-programThe brand’s referral program on the partner storefront: brandSlug, brandName, listed, activeContracts, openOffers, creditBalance (always 0 for a caller without view.billing) and reachable (false when the storefront could not be read). Open to any member who can see the group.
POST /{groupId}/products · GET /{groupId}/productsCreate a price / list what the group sells. Reading is open to any member who can see the group; writing a price is owner-only. Create body: name, type (Subscription · Usage · OneTimeAddon), retailPriceUsd, priceUrl, and optionally currency, productKey, yearlyDiscountPercent (subscriptions), usageUnitLabel (required for Usage) and description. Answers 201 with id, synced and syncError; a productKey already used in the group is 409.
POST /{groupId}/products/{productId}/syncPush a price to the partner storefront; idempotent (already-registered rows are not re-sent). Answers id, synced, detail; 502 with synced: false when the push fails.
POST /{groupId}/products/importImport the partner’s existing prices; skips ones already present (by productKey, else name). Answers imported, brandSlug, currency and skipped.
GET /{groupId}/products/template.xlsxDownload the bulk-registration template (Products sheet with headers and two example rows, Guide sheet). The example currency is the connected gateway’s currency when the group has its own gateway, else USD. Needs view.billing.
POST /{groupId}/products/bulkRegister many prices at once from a multipart file (.xlsx or .csv, up to 200 rows, 2MB). Columns: name, type (Subscription · OneTimeAddon · Usage), currency (USD, or the connected gateway’s currency), price, yearly_discount_percent, price_url, product_key, usage_unit_label, description. Each row goes through the same path as POST /{groupId}/products; a bad row does not stop the others. Returns { total, created, registered, drafts, failed, rows: [{ row, name, id, status, detail }] } with status registered, draft or error. 400 with NO_FILE, FILE_TOO_LARGE, UNREADABLE_FILE, NO_ROWS or TOO_MANY_ROWS. Owner-only.
POST /{groupId}/products/{productId}/repriceChange a price: retailPriceUsd (required), and optionally yearlyDiscountPercent, name, description, priceUrl and usageUnitLabel (omit a field to keep it; send yearlyDiscountPercent: null to remove the yearly discount; the key and type cannot change — to change the key, delete the product and create it again). Responds changed: false without touching anything when nothing differs. A registered product is withdrawn and published again: 502 WITHDRAW_FAILED when the old price could not be withdrawn (nothing is changed), and 202 with synced: false when the new price was saved but could not be published — it is reported as “not registered” until synced again.
DELETE /{groupId}/products/{productId}Remove a price (owner-only). A product with subscribers can be removed — new sales stop and existing subscriptions keep renewing until canceled (read the count first with GET …/payment-gateway/live-subscriptions?productId=). Removing the last product turns collection off (collectionStopped: true); the collection method stays. Answers id, withdrawn, collectionStopped.

Every app group has a stage (Development or Production), returned by GET / and GET /{groupId}. New app groups start in Development; app groups created before 2026-11-04 are Production.

In DevelopmentDetail
Testers onlyA new person can’t sign up, even when public sign-up is on. Testers are the members you create or import into the group (POST /api/org/{slug}/endusers, …/bulk-import); they sign in normally. At most 100 active testers: beyond that, create answers 409 TESTER_LIMIT_REACHED and import returns the row as limit.
Test paymentsTest gateway keys (rzp_test_…, PortOne isTest) can be connected even on the live platform. Starting a purchase (POST /api/public/apps/{applicationKey}/checkout, marketplace checkout) without a test key answers 409 LIVE_PAYMENTS_REQUIRE_PRODUCTION.
[DEV] emailsMember emails (sign-in link, invitation, guest order lookup, access removed) carry [DEV] before the app group name.
Method · RoutePurpose
GET /{groupId}/promotion-checksThe promotion checklist: { stage, ok, checks: [{ key, ok, blocking, detail, appId }] }. appId names the first client to fix for callbacks and legal (null otherwise). Keys: domain (a verified custom login domain), callbacks (every callback URL is public HTTPS), legal (privacy and terms URLs on every application; domain and legal pass as not applicable when every client is API or machine-to-machine), payment (a live gateway key, only when the group collects payments), social (your own OAuth key for every enabled social provider). Needs org.app_group.update.
POST /{groupId}/promoteBody { stepUpAssertion } — a passkey seal for this action (401 with errorCode STEPUP_REQUIRED or STEPUP_FAILED). Promotes to Production when every blocking check passes, else 409 with errorCode: PROMOTION_BLOCKED and checks. Cannot be undone; an app group that is already in production answers alreadyProduction: true. Needs org.app_group.promote (Team Lead and above).

Your own OAuth client per social provider — see Social login → Your own OAuth keys.

Method · RoutePurpose
GET /{groupId}/social-credentials{ redirectUri, items: [{ provider, clientId, secretSet, privateKeySet, teamId, keyId, version, updatedAt }] }. Secrets are never returned.
PUT /{groupId}/social-credentialsSave a key: { provider, clientId, clientSecret?, teamId?, keyId?, privateKey? }. Leave a secret empty to keep the stored one. 400 INVALID_SOCIAL_CREDENTIAL with detail when a required field is missing.
DELETE /{groupId}/social-credentials/{provider}Remove the key; the app group goes back to Axowl’s shared key.

All three need org.app_group.update.

By default Axowl sends end-user verification texts (sign-in, MFA) and charges $0.05 per text to your credit. Connect your own Twilio or Telnyx account to send through it instead; Axowl doesn’t charge for those texts, and the $5 minimum for requiring MFA doesn’t apply. Your provider is used only after a test text is verified. If it fails, Axowl does not send in its place — the request fails (send_failed, invalid_number, region_not_supported) so no unexpected charge appears.

Method & pathWhat it does
GET /{groupId}/sms-provider{ configured, provider, accountId, fromNumber, senderId, secretSet, verified, verifiedAt, version, perMessageUsd } — the secret is never returned.
PUT /{groupId}/sms-provider{ provider: "twilio" | "telnyx", accountId?, secret?, fromNumber?, senderId? }. Twilio needs the Account SID (AC…) and Auth Token; Telnyx needs the API key. Give a sending number (+E.164) or a Messaging Service SID (MG…) / Messaging Profile ID. Leave secret empty to keep the saved one. Saving clears verification. 400 INVALID_SMS_PROVIDER with detail when a required field is missing.
POST /{groupId}/sms-provider/test{ phone } — sends a 6-digit code through your provider (valid 10 minutes). 502 PROVIDER_REJECTED with reason when your provider refuses the text.
POST /{groupId}/sms-provider/verify{ code } — on a match, your provider is used from now on. 400 CODE_INVALID when the code is wrong or expired.
DELETE /{groupId}/sms-providerDisconnect; Axowl sends again.

All need org.app_group.update. Destination country limits and daily caps apply the same way with your provider.

Own payment gateway (owner-only, view.billing gate, passkey seal)

Section titled “Own payment gateway (owner-only, view.billing gate, passkey seal)”

A brand can connect its own payment gateway instead of using Application payment integration: customers pay straight into the brand’s gateway account while Axowl runs products, subscriptions, vouchers and entitlements.

Gateway (provider)CountryCurrencySubscriptions
portone (recommended for Korea) — pgProvider: kg_inicis (KG Inicis) or toss_payments (Toss Payments)KoreaKRW (whole won)Card registered once (billing key); Axowl charges each period
razorpayIndiaINR (2 decimals)Razorpay Subscriptions charges each period

Korea: PortOne with KG Inicis or Toss Payments. In Korea you connect through PortOne and pick the card-payment contract (PG) behind it: KG Inicis or Toss Payments. Both take one-time payments and subscriptions. Other PortOne PG values (nhn_kcp, nice, smartro, ksnet, kpn, welcome, kicc, kakaopay, naverpay, tosspay) are refused for new connections with 409 PG_NOT_OFFERED before Axowl contacts PortOne, and …/payment-gateway/reuse refuses to copy such a connection to another app group. A connection that already exists keeps working: its payments, renewals, refunds and webhooks are not affected.

A gateway (portone, razorpay) can be connected by an organization in any country — use the merchant account you already have. Only an organization with no country set is refused (409 REGION_REQUIRED). When the app group has a local market (marketScope: "local"), only that country’s gateway can be connected — Korea → portone, India → razorpay; any other local market answers 409 MARKET_NOT_OFFERED (don’t collect payments, or change the market). Application payment integration (a Stripe payout account: POST …/payout-session, monetizationEnabled: true, POST …/payment-gateway/payment-service with mode: "app_payment") is offered to organizations in the 44 Stripe seller countries and answers 409 NOT_OFFERED elsewhere.

Switching an app group between Application payment integration and its own gateway (…/payment-gateway/payment-service, or a pending connection that replaces the current one) is refused while the app group has products (409 PRODUCTS_EXIST) or live subscribers on the service it leaves (409 LIVE_SUBSCRIPTIONS, 409 SUBSCRIPTIONS_UNKNOWN when they can’t be counted) — subscribers are never asked to subscribe again. Choosing a method for an app group that had none is not a switch; it is refused only when the app group already has products priced in another currency (409 PRODUCTS_EXIST). …/payment-service only chooses the method and turns collection off; turning collection on again checks the payout account (409 STRIPE_NOT_READY) and products (see PUT /{groupId} above). When a connection passes its test payment, collection is on only if the app group already had products and was collecting; otherwise it stays off — add products, then turn it on.

Products can be added before a method is chosen and while your own gateway is waiting for its test payment. Prices are fixed to the collection method’s currency (409 CURRENCY_FIXED): the gateway’s currency, pending or confirmed, or USD with Application payment integration. With no method chosen, the first product sets the currency for the rest. The same rule applies to POST …/products/bulk (per row) and POST …/products/import: imported rows priced in another currency are skipped and listed under skipped with reason: "CURRENCY_FIXED". Preparing a test payment (POST …/payment-gateway/test-payment) for a pending connection that would replace the current method runs the switch checks first (409 PRODUCTS_EXIST / LIVE_SUBSCRIPTIONS / SUBSCRIPTIONS_UNKNOWN), so you are never charged for a test that can’t be confirmed.

Method · RoutePurpose
GET /{groupId}/payment-gatewayCurrent connection: linked, provider, pgProvider, pgName, public id (storeId), channel keys (PortOne), currency, isTest, connectedAt, confirmedAt, webhookUrl, problems, and pending — a new connection waiting for its test payment (provider, pgName, storeId, isTest, pendingSince, webhookUrl), or null. Secrets are never returned. Needs view.billing.
POST /{groupId}/payment-gatewayConnect: provider (portone when omitted), apiSecret, webhookSecret, assertionResponseJson (passkey seal bound to this group), and per gateway — PortOne: pgProvider, storeId, paymentChannelKey, optional billingChannelKey, webhookSecret = whsec_…, isTest. Razorpay: storeId = Key ID (rzp_test_… / rzp_live_…), apiSecret = Key Secret, webhookSecret = the secret you set on the Razorpay webhook; test mode follows the Key ID. The keys are checked with the gateway before anything is stored; secrets are stored encrypted. The new connection is pending: it is not used for sales and the current collection (an earlier gateway or Application payment integration) keeps running until the connection passes its test payment (below) — the answer carries it under pending. When the same store and channel already passed a test payment on another app group of the organization, the connection is confirmed at once. A pending connection that replaces another gateway or store answers 409 PRODUCTS_EXIST when the app group has products, and 409 LIVE_SUBSCRIPTIONS / 409 SUBSCRIPTIONS_UNKNOWN when it has live subscribers (or they can’t be counted); rotating keys for the same store is not affected, and a first connection is refused only when the app group already has products in another currency. The organization owner must have signed the seller policy first, else 409 SELLER_POLICY_REQUIRED (the dashboard opens the signing modal in place). A second gateway account (a different store) in the same organization needs the Business plan or higher, and the plan’s collecting-group limit applies (403 UPGRADE_REQUIRED). Test keys (rzp_test_… or PortOne isTest) can be connected; on the live platform a test connection is confirmed only for an app group in Development. A Razorpay connection (new or reused from another app group) needs the addon.razorpay_gateway add-on ($3/month), else 402 ADDON_REQUIRED with addon — checked after the key format and before Razorpay is contacted.
POST /{groupId}/payment-gateway/reuseCopy another app group’s connection of the same organization to this group: { sourceGroupId, assertionResponseJson } (passkey seal). Same seller-policy rule as connecting: 409 SELLER_POLICY_REQUIRED until signed. The copy goes through the same product and subscriber checks as a new connection, and is confirmed at once when that store already passed its test payment — otherwise it is pending. 404 SOURCE_NOT_LINKED when the source app group has no active connection.
POST /{groupId}/payment-gateway/payment-serviceChoose the collection method: { mode, assertionResponseJson } (passkey seal). mode is app_payment (Application payment integration; axowl is accepted as the same value) or own. Chooses the method and turns collection off; answers mode, with unchanged: true when that method was already chosen. The refusals are described above (409 NOT_OFFERED, MARKET_NOT_OFFERED, PRODUCTS_EXIST, LIVE_SUBSCRIPTIONS, SUBSCRIPTIONS_UNKNOWN).
POST /{groupId}/payment-gateway/test-paymentStart the connection’s test payment (owner or billing manager). Uses the pending connection if there is one, else the current one. PortOne: returns paymentId, storeId, channelKey, amountKrw, currency, orderName and customer (name, email, phoneNumber) to open the PortOne window. The amount is 100 KRW, or the PG’s card minimum when higher (KG Inicis: 1,000 KRW). KG Inicis needs the buyer’s name, email and phone: when the profile lacks them the call answers 409 CUSTOMER_INFO_REQUIRED with missing; send name / phoneNumber in the body to use them for this payment only (the profile is not changed; the phone needs 8–15 digits). Razorpay: returns keyId, orderId, amountMinor (₹1 = 100 paise), currency and customer for Razorpay Checkout.
POST /{groupId}/payment-gateway/test-payment/{paymentId}/verifyAfter the window closes. Axowl re-reads the payment with the brand’s keys and checks store, channel, test/live and amount, then refunds it in full at once (PortOne: same-day cancel; Razorpay: captured then refunded — 5–7 working days back to the card). Razorpay: send razorpayPaymentId, razorpayOrderId, razorpaySignature. Answer: ok, paid, canceled, paymentId, channelType (TEST/LIVE), pgProvider, pgMerchantId, problems, and for a pending connection wasPending, confirmed and confirmError. When it passes, the pending connection replaces the current collection in one step — the two are never both off. confirmError: TEST_CHANNEL_CANNOT_CONFIRM (test keys on the live platform for an app group that is not in Development), GATEWAY_MIRROR_FAILED (the marketplace ledger could not be told), PRODUCTS_EXIST, LIVE_SUBSCRIPTIONS, SUBSCRIPTIONS_UNKNOWN, PENDING_GONE (the pending connection was discarded meanwhile) — in every case the current collection is unchanged. Test-payment ids start with axtest-; their webhooks are ignored after the signature check and never count as sales.
POST /{groupId}/payment-gateway/pending/discardDrop the pending connection; answers discarded (how many). The current collection is unchanged.
GET /{groupId}/payment-gateway/live-subscriptionsknown, stripe, own (live subscriptions on Application payment integration and on the own gateway; cancellations already requested are not counted), products, rail (none, app_payment, own, collect_on_behalf), currency (the price currency, null when no method is chosen), collectionOn and ownConfirmed. Optional ?productId= adds product — live subscriptions on that one product, for a warning before it is removed. The dashboard locks switching while any count is above zero; known: false means they could not be counted and switching is locked too. Needs view.billing.
POST /{groupId}/payment-gateway/disconnectDisconnect (passkey seal required). Only when the app group has no products and no live subscribers — else 409 PRODUCTS_EXIST, 409 LIVE_SUBSCRIPTIONS or 409 SUBSCRIPTIONS_UNKNOWN, checked before the seal. After disconnecting, the app group has no collection method and collection is off. Axowl first ends every live Razorpay subscription created through this connection at the end of its current period; if any cannot be ended the connection is kept and the answer is 409 SUBSCRIPTIONS_NOT_CANCELED with reason (gateway_unreachable, gateway_refused, cancel_needs_brand_dashboard) and pending (how many). On success the response carries disconnected, subscriptionsEnded and sunset. The brand’s webhook URL stops working at once. The other live subscriptions on this gateway (PortOne card keys) are also set to end at the end of their paid period — subscribers get a cancellation notice instead of “payment failed” emails; sunset: false means that step did not go through.
POST /api/org/{slug}/payment-gateway/testCheck a key pair with the gateway without saving anything: { provider, storeId, apiSecret } → { ok, reason } with reason OK, GATEWAY_REFUSED, GATEWAY_UNREACHABLE, INVALID_PROVIDER, INVALID_STORE_ID, INVALID_KEY_ID or INVALID_API_SECRET. Needs view.billing.
GET /api/org/{slug}/payment-gatewaysThe organization’s confirmed connections, one per app group — the candidates for …/reuse: groupId, groupName, provider, pgProvider, pgName, currency, storeId, isTest, connectedAt, state (confirmed or pending) and collectionOn. ?withPending=true adds connections still waiting for their test payment. Needs view.billing.
POST /api/v1/webhooks/{provider}/brand/{token}Per-brand webhook (portone or razorpay — the path must match the connected gateway). PortOne: Standard Webhooks signature (±5 min), idempotent per webhook-id. Razorpay: X-Razorpay-Signature (HMAC-SHA256 of the raw body with your webhook secret), idempotent per x-razorpay-event-id; subscribe to order.paid, subscription.charged, subscription.pending, subscription.halted, subscription.cancelled, subscription.completed, refund.processed. A webhook is never trusted on its own: Axowl re-reads the payment with the brand’s keys and checks it belongs to the order before recording it.

Events appgroup.payment_gateway.connected / .disconnected are sealed in the audit timeline; they carry the gateway id and store id, never a secret.

What changes once a gateway is connected. Confirming the connection also tells the marketplace ledger which store and channel the brand sells through (no secrets travel); if the ledger cannot be told, the connection stays pending (confirmError: GATEWAY_MIRROR_FAILED). From then on:

  • Prices in the gateway’s currency — one currency per app group. An app group has one payment service, so its prices have one currency: the connected gateway’s (KRW whole won for PortOne, INR up to 2 decimals for Razorpay), or USD with Application payment integration. POST /{groupId}/products answers 409 CURRENCY_FIXED with currency for any other value. The retailPriceUsd field carries the price in that currency — the field name is the same for every currency. GET …/products returns currency per row. To change the payment service, delete the products first, switch, then register them again in the new currency.
  • Checkout answers differ. POST /api/org/{slug}/marketplace/apps/{groupKey}/checkout returns provider and an intent (paymentId, orderId, orderName, amount, currency, storeId, channelKey, billingChannelKey, issueBillingKey, mode, billingCycle) instead of a Stripe redirectUrl.
    • PortOne: the buyer’s browser opens the PortOne window; one-time purchases are paid in the window, subscriptions register a card (billing key).
    • Razorpay: the answer also carries razorpay (keyId, orderId for a one-time purchase or subscriptionId for a subscription, amountPaise, currency) — Axowl created the Razorpay order or subscription with the brand’s keys. Open Razorpay Checkout with those values, then send what its handler returns to POST /api/org/{slug}/marketplace/apps/{groupKey}/own-gateway/razorpay/confirm (orderId, paymentId, razorpayPaymentId, razorpayOrderId or razorpaySubscriptionId, razorpaySignature). Axowl checks the signature with the Key Secret and re-reads the payment before recording it. A voucher that changes the first payment can’t be combined with a Razorpay subscription (409 first_amount_unsupported).
  • Subscriptions (PortOne). POST /api/org/{slug}/marketplace/apps/{groupKey}/own-gateway/billing-key with orderId, paymentId and the billingKey the window returned (the buying organization’s Owner, or a member given the billing-manage permission). Axowl checks the key with the brand’s gateway, stores it encrypted, charges the first period and records the payment in the same ledger as the webhook; the answer is ok, already, cardLast4. Renewals are charged by Axowl every period with that key; a declined card marks the subscription past due on the marketplace and the installed app ends after 21 unpaid days. A declined renewal is retried after 1, 2, 4, 8 and 16 hours, then once every 24 hours.
  • Subscriptions (Razorpay). Razorpay charges every period (subscription.charged). subscription.pending / halted mark the subscription past due; cancelled / completed end it. Removing the app cancels the Razorpay subscription at the end of the current cycle.
  • Plan changes are not available for own-gateway subscriptions: POST …/marketplace/apps/{groupKey}/change-plan answers 409 PLAN_CHANGE_NOT_SUPPORTED before anything is charged. Cancel and subscribe to the other plan.
  • Refunds. Refund in your gateway’s dashboard. The refund webhook (PortOne Transaction.Cancelled / PartialCancelled, Razorpay refund.processed) reverses the marketplace commission of that payment in proportion to the amount refunded. A one-time purchase refunded in full ends the order and removes the app, as it does with Application payment integration; refunding one period of a subscription does not end the subscription.
  • There is no take rate on this path; the marketplace commission is accrued per payment and billed monthly in arrears.

App member purchases (your app’s signed-in members)

Section titled “App member purchases (your app’s signed-in members)”

Your app’s own members can buy the app group’s products — subscriptions and one-time add-ons — with the payment path the app group already uses (Application payment integration or your own gateway). There is no marketplace commission on these sales.

Method · RoutePurpose
POST /api/public/apps/{applicationKey}/checkoutStart a purchase for the signed-in member. Header Authorization: Bearer <the member's Axowl token for this application>. Body: productKey (or productId), billingCycle (monthly | annual), returnUrl — must be on an origin registered on the application (Allowed origins or Callback URLs; https, or http://localhost for development). Answers provider: "stripe" with redirectUrl, or provider: "portone" / "razorpay" with payUrl (the Axowl payment page, valid 40 minutes) — send the member to that URL either way. Both answers carry orderId. 401 invalid member token · 404 PRODUCT_NOT_AVAILABLE · 400 RETURN_URL_NOT_ALLOWED · 409 ALREADY_SUBSCRIBED (the member already has a live subscription to that product) · 409 LIVE_PAYMENTS_REQUIRE_PRODUCTION (an app group in Development without a test key on the live platform).
GET /api/public/apps/{applicationKey}/plansPublic. Answers plans: what a member can buy — the same products checkout accepts (at most 100, cheapest first). Each: id, productKey, name, description, mode (subscription | payment), price and currency (zero-decimal currencies such as KRW are whole units), yearlyDiscountPercent and annualMonthlyPrice when the seller offers a yearly discount. 404 APP_NOT_FOUND.
GET /api/public/apps/{applicationKey}/me/entitlementsAnswers entitlements: the signed-in member’s purchases in this app, newest first (at most 100). Header Authorization: Bearer <the member's token> — your server can forward the same token and get the same answer. Each: productKey, productId, orderId, mode, status (active · past_due · cancel_requested · canceled · refunded), active, provider, currentPeriodEnd, cancelAt, grantedAt, endedAt. Decide access with active: true while active, in past-due grace, or canceled at period end before that time. 401 invalid member token.
POST /api/public/apps/{applicationKey}/me/subscriptions/{orderId}/cancelCancel one of the signed-in member’s subscriptions at the end of the current period. Header Authorization: Bearer <the member's token>; orderId from me/entitlements. Nothing ends now: the entitlement stays active until cancelEffectiveAt (the paid period’s end) and the member is not charged again — Stripe is set to cancel at period end, a PortOne card subscription is dropped from the renewal schedule, a Razorpay subscription is canceled at cycle end. Answers ok, orderId, status: "cancel_requested", cancelEffectiveAt, currentPeriodEnd, already (true when it was already canceled — same answer, nothing changed). 401 invalid member token · 404 SUBSCRIPTION_NOT_FOUND (not this member’s purchase in this app) · 409 NOT_A_SUBSCRIPTION (a one-time purchase) · 409 SUBSCRIPTION_ENDED · 502 SUBSCRIPTION_CANCEL_FAILED (the seller’s gateway could not be reached — nothing changed, try again).
GET /api/org/{slug}/app-groups/{groupId}/member-entitlements?filter=&take=Seller view: every member purchase in this app group, newest first. Owner-only (view.billing, else 403 PERMISSION_DENIED); 404 for an app group of another organization. filter=active keeps only purchases the member can use now (same rule as active above); take 1–500, default 200. Answers total, activeCount, memberCount (members with at least one usable purchase) and items — each endUserId, orderId, email, displayName, productName, productKey, mode, status, active, provider, currentPeriodEnd, cancelAt, grantedAt, endedAt. The dashboard shows it on App Group ▸ Payments.
POST /api/org/{slug}/app-groups/{groupId}/member-entitlements/{orderId}/cancelSeller side of the same cancellation: end a member’s subscription at the end of its paid period. Owner-only (org.org.billing_manage, else 403 PERMISSION_DENIED). Same answer and error codes as the member call; 404 SUBSCRIPTION_NOT_FOUND when the order is not in this app group. The dashboard’s “Cancel subscription” button on App Group ▸ Payments calls it.
GET /api/public/apps/{applicationKey}/checkout/return?to=Where the payment provider sends the member back; redirects to to only when it is a registered origin of the application, else 400 RETURN_URL_NOT_ALLOWED.
GET /api/public/app-pay/{token} · POST …/billing-key · POST …/razorpay/confirmUsed by the Axowl payment page behind payUrl: reads the payment window values (no secrets), then hands the gateway’s result back to Axowl (PortOne card registration for subscriptions, Razorpay Checkout signature). The token is single-purpose — bound to that order and member — and stops working once the payment is confirmed or after 40 minutes (404 PAY_LINK_EXPIRED). After paying, the member returns to your returnUrl with checkout=complete.

The member’s entitlement is granted when the payment is confirmed, not by the checkout call, and follows the subscription afterwards (renewed, past due, cancel requested, ended, refunded).

Where a member token works. A member’s token is for your app’s public surface only: everything under /api/public/** — sign-in, OIDC, me/entitlements, checkout, cancel, the payment page. The same token sent to anything else (dashboard, /api/org/{slug}/…, /api/v1/…, admin routes) is refused with 401 ENDUSER_TOKEN_SCOPE before the route runs, whatever the route’s own rules say. Why: a member is a customer of your app, not a member of your organization, and the two must never be confused by a token that happens to pass the same login check. Every member call in this table is under /api/public.

Deleting an end user (DELETE /api/org/{slug}/endusers/{endUserId}) also cancels that member’s live subscriptions at the end of their paid period and revokes the member’s stored card keys; the response carries subscriptions: { canceled, failed, billingKeysRevoked }. If any subscription cannot be canceled, nothing is deleted and the answer is 409 MEMBER_SUBSCRIPTIONS_NOT_CANCELED with detail and failed (orderId, error each) — try again; the ones already canceled are not sent twice. Removing a member from one app group (DELETE /api/org/{slug}/endusers/{endUserId}/groups/{groupId}) does the same for that app group’s subscriptions only, with the same 409.

Collection and settlement rules for what an app group sells are in Billing & Monetization.

Marketplace (owner or admin, passkey seal)

Section titled “Marketplace (owner or admin, passkey seal)”

An app group can be listed on marketplace.axowl.com. The owner decides here; nothing is listed until it is published. The marketplace lists web apps (SaaS and PaaS) only. The server checks these conditions every time you publish:

  • Plan. The organization is on the Pro plan or higher (feature feature.marketplace.listing). Publishing, updating the listing and translating return 403 UPGRADE_REQUIRED on Free. Unpublishing works on any plan. If an organization moves below Pro, its apps drop out of the public list until it upgrades again.
  • Service model, scope and field. The app group is marked SaaS or PaaS, has a service scope (global, or local with a country) and has a field (category) chosen.
  • Pricing. An app group whose only priced products are Usage products cannot be listed.
  1. The app group has at least one active Web application. Groups with only mobile, desktop or server-to-server apps cannot be listed, and a published group drops out of the list if its last web app is removed.
  2. A domain for this app group is verified in Axowl (the Custom Domain card).
  3. The app address you enter is on that company domain — app.acme.com when login.acme.com is verified. Under a two-letter country domain the common second level (co, com, or, ne, ac, go …) is kept, so acme.co.kr never widens to co.kr.
  4. The app address opens over https (no 4xx/5xx). Addresses that resolve to private or internal networks are refused.

Registering payments is a condition only for a paid listing (free: false, below).

Method · RoutePurpose
GET /{groupId}/marketplaceCurrent app address (appUrl), publishedAt, verifiedDomain, hasWebApp, category, serviceModel, marketScope, marketCountry, screenshots, free
POST /{groupId}/marketplace/checkRun the publishing checks for an address without publishing: { appUrl } → passed and checks. Each check has key (plan, pricing, model, scope, category, saas, domain, sameSite, opens), ok (true, false, or null when it was not reached) and detail.
PUT /{groupId}/marketplacePublish or unpublish. Body: appUrl, publish, assertionResponseJson, free, and optionally description (up to 1,000 characters, 400 DESCRIPTION_TOO_LONG) and translations (below). Publishing says whether the app is free or paid: free is required the first time (400 FREE_REQUIRED), later it defaults to the saved choice. free: true — no payment conditions, no plans or vouchers on the marketplace. free: false — the app group needs a confirmed collection method, collection turned on and at least one product, else 409 MARKETPLACE_PAID_NOT_READY with missing (codes from PAYMENT_SERVICE_REQUIRED, PAYMENT_SERVICE_NOT_CONFIRMED, STRIPE_NOT_READY, NOT_OFFERED, COLLECTION_OFF, PRODUCTS_REQUIRED). Going from paid to free needs zero live subscribers (409 LIVE_SUBSCRIPTIONS / 409 SUBSCRIPTIONS_UNKNOWN). These checks run before the seal. The answer is appUrl, publishedAt, free. The seal is bound to this group and to publish-or-unpublish (step-up purpose appgroup.marketplace). A publish that fails a check returns 409 CONDITIONS_NOT_MET with the checks.
POST /{groupId}/marketplace/translateAI translation draft for review. Returns source (the texts to translate), langs (language → the same keys, translated), reviewed and cached. Pro or higher. The model is called only when the source changed since the last translation, at most 10 times a day per app group (429 TRANSLATE_LIMIT); otherwise the saved draft is returned with cached: true. 400 NOTHING_TO_TRANSLATE when the app group has no description; 502 TRANSLATE_FAILED when the translation could not be produced.
POST /{groupId}/marketplace/screenshotsAdd a screenshot or a mockup to the listing (multipart field image). Up to 3 per app group, under 3MB each. PNG, JPEG, WebP or SVG. The type is read from the file’s bytes, not from the declared content type, and an SVG is cleaned before it is stored (scripts, event attributes, javascript: links and foreignObject are removed). Saved at once, no seal. 409 SCREENSHOTS_FULL, 400 SCREENSHOT_TYPE, 400 SCREENSHOT_TOO_LARGE. Returns screenshots.
DELETE /{groupId}/marketplace/screenshots/{index}Remove one screenshot by its position (0-based). Returns the remaining screenshots. 404 when the position does not exist.
GET /{groupId}/marketplace/salesPayments for this app, one line per payment: gross, take rate, marketplace commission, net, first payment or renewal. Query days (default 30, up to 365).

The listing uses the app group’s name, description and icon. Up to three screenshots or mockups, uploaded on the Marketplace card, are shown at the top of the product page. If the app group has a Bullmark introduction that has passed review, its text, logo and photos are added. Bullmark does not decide what is listed.

Translation, reviewed before it goes live. Publishing and updating the listing start with a translation into eight languages (en, ko, ja, zh-Hant, de, es, fr, pt). The texts translated are the description, the Bullmark introduction and details, and each plan’s description. The app name and plan names are fixed values and are not translated; plan names and descriptions are edited on the Products tab, the introduction in Bullmark. The owner can also rewrite the description during review: /translate accepts description (not saved yet) and translates that text, and the sealed publish saves it through the normal app group update. The owner reviews each language, edits it or leaves a field empty (that field then shows the original), and sends the result as translations, with description, in the sealed publish. The draft from /translate is never shown on the marketplace on its own. If the source text changes later, the public list shows the original until the listing is updated again.

Published apps are readable without authentication at GET /api/public/marketplace/apps (cached for five minutes; publishing or unpublishing clears the cache). Each app carries plans: the products you registered on the app group that can be bought there (connected to payments, price above zero, type Subscription or OneTimeAddon). Each app also carries i18n: language → description, introDescription, details, plan.{id}.description (plan id without dashes), present only when the owner reviewed the translation and the source has not changed since. A missing key means the original text. Each app also carries screenshots (up to three image URLs, empty when none were uploaded) and publisherSlug (the selling organization). Each app carries free (the publisher listed it as free — plans is then empty; installing needs no plan and …/checkout / …/change-plan answer 409 APP_IS_FREE) and salesPaused (an app listed as paid — free: false — whose collection is off, that has no products, or whose plans list is empty: it stays listed, but buying and vouchers are hidden; existing installs and subscriptions keep running, and sales resume when collection is turned on again). An app with no free/paid choice saved is never paused, and its empty plans list means it is free.

Across your organizations. The console’s System home has a Marketplace tab with two lists. Both cover every organization where you are an active owner or admin; other organizations are not included. Publishing from there opens the same Marketplace card, so the checks and the seal above are unchanged.

Method · RoutePurpose
GET /api/me/marketplace/appsPer organization (slug, name), its app groups with id, key, name, iconUrl, brandColor, webApp (name of an active web app, or null), verifiedDomain, appUrl, publishedAt. webApp and verifiedDomain follow checks 0 and 1; they are a hint, and the server runs every check again when you publish.
GET /api/me/marketplace/subscriptionsPer organization, what it turned on or installed from the marketplace: kind (service for an Axowl service, app for another organization’s app), key (app: + group key for apps), name, publisher, openUrl, productId (the product page id on the marketplace), enabledAt, paid. Connectors such as Slack are not included.

Both return 401 when the token has no user.

Another organization installs your app from its product page. The price comes from the products you register on the app group. The marketplace does not keep its own price list.

  • Free app (no plans): an owner or admin of the buying organization installs it with a passkey seal, using the same call as turning on any integration.
  • Paid app: turning it on directly returns 402 PAYMENT_REQUIRED. The buyer picks a plan, can enter a voucher code, seals with a passkey and pays by card. The app is installed when the payment is confirmed. A voucher applies to the first payment only. Subscription plans charge every period until canceled — monthly, or yearly when the plan has a yearly discount and the buyer picks billingCycle: "annual". OneTimeAddon plans charge once.
  • After installing: the buyer can move to another Subscription plan of the same app (the difference is charged at once, prorated) and can buy a OneTimeAddon while the app stays installed.
  • Uninstalling a paid app ends the subscription at the end of the period already paid for. The app stays installed until that date. If there is no running subscription, it is removed at once.

Money goes to your own Stripe account. The marketplace commission is taken at the source, in addition to your plan’s take rate. See Billing & Monetization.

Method · RoutePurpose
POST /api/org/{slug}/marketplace/apps/{groupKey}/checkoutStart a purchase for the organization in slug — the Owner, or a member given the billing-manage permission (an admin alone is not enough). Body: productId, billingCycle (monthly when omitted, or annual — 409 NO_ANNUAL_PRICE when the plan has no yearly discount), voucherCode (optional), assertionResponseJson. The seal is the same one as installing (integration.enable, subject slug|app:{groupKey}|enable). Returns provider, redirectUrl (the card payment page), mode, billingCycle, priceUsd, firstAmountUsd. 404 PRODUCT_NOT_AVAILABLE. 409 ALREADY_INSTALLED when the app is already installed and the product is a Subscription; a OneTimeAddon can be bought while installed. 409 APP_IS_FREE for an app listed as free. 409 LIVE_PAYMENTS_REQUIRE_PRODUCTION when the seller’s app group is still in Development without a test key.
POST /api/org/{slug}/marketplace/apps/{groupKey}/billing-portalOwner of the buying organization, or a member given the billing-manage permission. Opens the card-payment provider’s billing page for this app’s subscription: change the card, see receipts, cancel (at the end of the period) or take a cancellation back. Body: assertionResponseJson — the same seal as uninstalling (integration.enable, subject slug|app:{groupKey}|disable). Returns url. 404 NO_ACTIVE_SUBSCRIPTION for a one-time or free install.
GET /api/org/{slug}/marketplace/apps/{groupKey}/subscriptionOwner or admin of the buying organization. The plan this organization is subscribed to (subscription, with productId) and the one-time add-ons it bought (addons). subscription is null for a free or one-time install.
POST /api/org/{slug}/marketplace/apps/{groupKey}/change-planMove the running subscription to another Subscription plan of the same app (Owner, or billing-manage permission). Body: productId, assertionResponseJson — the same seal as installing (integration.enable, subject slug|app:{groupKey}|enable). The difference is invoiced at once, prorated. 404 NOT_INSTALLED, 404 PRODUCT_NOT_AVAILABLE, 409 SAME_PLAN, 409 APP_IS_FREE.
GET /api/org/{slug}/marketplace/revenueOwner or admin of the selling organization. What buyers paid for this organization’s apps and vouchers: thisMonth, thisYear and total (each grossUsd and netUsd), byDay (30 days), byMonth (12 months), byYear, byApp and byProduct. gatewayOk: false with zeros when payments cannot be read. Those fields are US dollars (Application payment integration). currencies repeats the same shape once per currency sold — own-gateway sales in KRW or INR stay in their own currency and are never added to the dollars — plus fx.krwPerUnit (Korea Exim rates, KRW per unit) to convert a total on screen, and displayCurrencies (US$, the currencies sold, and the currencies of connected gateways). See Billing & Monetization.
GET /api/org/{slug}/marketplace/apps/{groupKey}/installedOwner or admin of the buying organization. The app this organization has installed: key, name, publisher, publisherSlug, iconUrl, brandColor, appUrl, enabledAt, paid. 404 NOT_INSTALLED.
DELETE /api/org/{slug}/integrations/app:{groupKey}/enableUninstall. For a running subscription this answers 202 with cancelRequested: true and endsAt, and leaves the app installed until then. If payments cannot be reached it answers 502 SUBSCRIPTION_CANCEL_FAILED and changes nothing.
GET /api/org/{slug}/app-groups/{groupId}/marketplace/customersOwner or admin of the selling organization. The organizations that installed this app, now or before: name, installed or removed, paid or free, since, until. The same list appears on the Marketplace card in the console.

Test tools (test environment only). On the test environment, GET /api/public/marketplace/test-tools returns enabled: true. The buying organization’s owner or admin can then use two endpoints:

  • POST /api/org/{slug}/marketplace/apps/{groupKey}/test-clock with action:
    • status: show where the subscription stands.
    • advance_month: move the subscription’s Stripe test clock to the next renewal.
    • fail_next: make the next renewal payment fail.
    • succeed_next: make the next renewal payment go through.
  • GET /api/org/{slug}/marketplace/apps/{groupKey}/reconcile: compare Stripe’s payments with the payment ledger line by line, and show Axowl’s install state.

Both answer 404 everywhere else, and GET /api/public/marketplace/test-tools returns enabled: false.

Emails. Access events are sent by Axowl as an in-app notification and an email:

  • App installed: to the owners and admins of the buying organization, and to the owners of the selling organization (“new customer”).
  • App removed: to the same people.
  • Buying organization deleted: its running marketplace subscriptions are canceled by the payment engine and end at the end of the period already paid for. The person who paid gets the cancellation email.
  • App deleted by its publisher: to the owners and admins of every organization that had it installed. The install is removed at once. A running subscription is canceled by the payment engine and ends at the end of the period already paid for. If the publisher connected its own gateway, Axowl first ends that gateway’s own subscriptions (Razorpay) at the end of the current cycle and removes the gateway keys; if the gateway can’t be reached, the delete stops with 409 SUBSCRIPTIONS_NOT_CANCELED and nothing is deleted.
  • Listed on or taken off the marketplace: to the owners of the selling organization.

Money events are sent by the payment engine to the person who paid and to the seller:

  • Receipts, including every monthly renewal
  • A failed renewal, with a link to pay. Axowl also sends an in-app notification to the owners and admins of both organizations.
  • A plan change, with the prorated amount. Both organizations also get an in-app notification.
  • A cancellation that is scheduled, with the end date
  • The subscription ending
  • Refunds