Skip to content
AIOSMarketplace

For makers

Hosted apps: integration guide

Run your product on your own servers. Connect billing, customer access and delivery events to AIOS Market.

Overview

AIOS Market bills the buyer through Stripe. On every payment, including each renewal, the platform keeps 15 percent commission. Card processing at cost comes from the maker's share. Your share is paid to your connected Stripe account after the hold period. Refunds and disputes adjust automatically.

You host the product and remain responsible for its availability, customer data, privacy policy, terms and support. Buyers open the app from their purchases with a signed, one-time sign-in link.

Setup checklist

  1. Create a listing and choose Hosted web app. For AIOS Market billing, use subscription licensing and monthly or yearly plans.
  2. Connect payouts to your Stripe account.
  3. Open Hosted app settings in your seller dashboard. Fill in app, sign-in, webhook, privacy and terms URLs, verify your domain and complete the data handling questionnaire.
  4. Create a webhook signing secret and an API key. Store both on your server, connect the integration below, and send a test webhook.
  5. Submit the listing for review. The readiness checklist must be complete; an administrator approves the listing.

Sign-in flow

  1. A buyer clicks Open on AIOS Market. We redirect to your sign-in URL with aios_token, aios_ts and aios_sig.
  2. Verify that aios_ts is within 5 minutes of the current Unix time. Check aios_sig against the hex HMAC-SHA256 of `${aios_ts}.${aios_token}`, using your signing secret as UTF-8 bytes.
  3. From your server, call POST /api/v1/hosted/sign-in/redeem with {"token":"<aios_token>"} and your bearer API key. Tokens work once and expire after 5 minutes. A used or expired token returns 410; an unknown token returns 404.
  4. Use the returned customer.id to create or find your user. Check subscription.access and start a secure session. The response supplies the customer's name and email; do not require a separate password.

Keep tokens out of logs and analytics. After redemption, redirect to a clean URL without the sign-in query parameters.

Webhooks

Requests include AIOS-Signature, AIOS-Event-Id, AIOS-Event-Type and Idempotency-Key. The last header is the event ID.

The signature header is t=<unix>,v1=<hex>. Compute HMAC-SHA256 over `${t}.${raw body}` using your signing secret as UTF-8 bytes and compare the hex digest in constant time. Preserve the raw body; parsing and reserializing JSON changes the signature. Reject timestamps outside 300 seconds.

During secret rotation the header has several v1 values. Accept if any matches. The old secret keeps signing for 24 hours. API key replacement, in contrast, stops the old key immediately.

Deduplicate durably on AIOS-Event-Id for replay protection. Atomically record the ID with the business effect, or durably queue the event before returning 2xx. Respond within 10 seconds, including for duplicates. Redirects count as failures. Consult the entitlement API when events arrive out of order.

Retries occur after about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours and another 24 hours. After these retries the delivery is marked dead and you receive an email. Inspect attempts and resend from Hosted app settings.

Subscription events and handling
EventWhat to do
subscription.createdProvision the customer account.
subscription.renewedExtend access to the new period.
subscription.past_dueWarn the customer and keep access during the grace period.
subscription.updatedUpdate the account when cancellation is scheduled or undone.
subscription.cancelledRemove access at the end of the subscription.
subscription.refundedRemove access after a refund.
subscription.disputedSuspend or remove access while the dispute is open.
subscription.dispute_closedRestore access if the dispute was won and the subscription permits access.
test.pingVerify your receiver responds successfully; do not provision a customer.

Each event contains id, type, created, livemode, api_version (2026-10-05) and data with the subscription and, where relevant, invoice, refund or dispute details.

Entitlements and licence keys

Use GET /api/v1/hosted/me to identify the listing attached to your key. API keys start with aios_sk_live_ or aios_sk_test_; send them as Authorization: Bearer <key> from your server.

GET /api/v1/hosted/entitlements takes exactly one of customer_id, email or licence_key. POST /api/v1/hosted/licences/validate takes a licence key and returns valid and, when available, subscription. Honour access; status alone is not an access decision.

curl: entitlements and licence validation

# Keep AIOS_API_KEY in your server environment.
curl --get "$AIOS_ORIGIN/api/v1/hosted/entitlements" \
  --header "Authorization: Bearer $AIOS_API_KEY" \
  --data-urlencode "customer_id=$CUSTOMER_ID"

# Alternatively select by email or licence_key, never more than one selector.
curl "$AIOS_ORIGIN/api/v1/hosted/licences/validate" \
  --header "Authorization: Bearer $AIOS_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{"licence_key":"<customer licence key>"}'

Copy-paste code

Set your origin, signing secret and API key from server environment variables. The handlers below take your durable event processor as an argument. Implement that processor against your database before using them; an in-memory set does not survive restarts. Register the webhook route with raw body middleware before any JSON parser.

Node: webhook signature verification

import { createHmac, timingSafeEqual } from "node:crypto";

function validMac(secret, signed, hex) {
  if (typeof hex !== "string" || !/^[a-f0-9]{64}$/i.test(hex)) return false;
  const expected = createHmac("sha256", Buffer.from(secret, "utf8"))
    .update(signed).digest();
  return timingSafeEqual(expected, Buffer.from(hex, "hex"));
}
function fresh(t) {
  return typeof t === "string" && /^\d+$/.test(t) &&
    Math.abs(Date.now() / 1000 - Number(t)) <= 300;
}
// Mount with an Express raw body middleware BEFORE any JSON parser.
// processEventOnce must atomically persist a unique event ID and its effects,
// or durably enqueue the event before returning. It must accept duplicates.
export function makeWebhookHandler(secret, processEventOnce) {
  return async (req, res) => {
    const parts = String(req.get("AIOS-Signature") || "").split(",");
    const timestamps = parts.filter(p => p.startsWith("t="));
    const t = timestamps.length === 1 ? timestamps[0].slice(2) : "";
    const signatures = parts.filter(p => p.startsWith("v1=")).map(p => p.slice(3));
    if (!Buffer.isBuffer(req.body) || !fresh(t)) return res.sendStatus(401);
    const signed = Buffer.concat([Buffer.from(t + ".", "utf8"), req.body]);
    if (!signatures.some(sig => validMac(secret, signed, sig))) return res.sendStatus(401);
    let event;
    try { event = JSON.parse(req.body.toString("utf8")); }
    catch { return res.sendStatus(400); }
    if (!event || typeof event !== "object" || !event.id || event.id !== req.get("AIOS-Event-Id") ||
        event.id !== req.get("Idempotency-Key") ||
        event.type !== req.get("AIOS-Event-Type")) return res.sendStatus(400);
    try { await processEventOnce(event.id, event); }
    catch { return res.sendStatus(503); } // A retry is safe.
    return res.sendStatus(200);
  };
}

Node: entitlement API

export async function getEntitlements(origin, apiKey, customerId) {
  const url = new URL("/api/v1/hosted/entitlements", origin);
  url.searchParams.set("customer_id", customerId); // Exactly one selector.
  const response = await fetch(url, {
    headers: { Authorization: "Bearer " + apiKey },
    signal: AbortSignal.timeout(10000),
  });
  if (!response.ok) throw new Error("Entitlement check failed: " + response.status);
  return response.json();
}

Node: sign-in check and redemption

import { createHmac, timingSafeEqual } from "node:crypto";

export async function redeemSignIn(query, origin, secret, apiKey) {
  // query is URLSearchParams from the sign-in request.
  for (const field of ["aios_token", "aios_ts", "aios_sig"])
    if (query.getAll(field).length !== 1) throw new Error("Invalid sign-in link");
  const token = query.get("aios_token"), t = query.get("aios_ts"), sig = query.get("aios_sig");
  if (!token || !/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300 ||
      !/^[a-f0-9]{64}$/i.test(sig)) throw new Error("Invalid sign-in link");
  const expected = createHmac("sha256", Buffer.from(secret, "utf8"))
    .update(t + "." + token, "utf8").digest();
  if (!timingSafeEqual(expected, Buffer.from(sig, "hex"))) throw new Error("Invalid sign-in link");
  const response = await fetch(new URL("/api/v1/hosted/sign-in/redeem", origin), {
    method: "POST", headers: { Authorization: "Bearer " + apiKey, "Content-Type": "application/json" },
    body: JSON.stringify({ token }), signal: AbortSignal.timeout(10000),
  });
  if (!response.ok) throw new Error("Sign-in could not be redeemed: " + response.status);
  const result = await response.json();
  if (!result.subscription?.access) throw new Error("Subscription has no access");
  // Find or create your local user by result.customer.id, then start a session.
  return result;
}

Python / Flask: webhook signature verification

import hashlib, hmac, json, re, time
from flask import request

def fresh(t):
    return bool(re.fullmatch(r"[0-9]+", t)) and abs(time.time() - int(t)) <= 300

def make_webhook_handler(secret, process_event_once):
    # process_event_once atomically stores a unique event ID and its effects,
    # or durably queues the event. Already processed IDs must succeed.
    def webhook():
        parts = request.headers.get("AIOS-Signature", "").split(",")
        timestamps = [p[2:] for p in parts if p.startswith("t=")]
        t = timestamps[0] if len(timestamps) == 1 else ""
        signatures = [p[3:] for p in parts if p.startswith("v1=")]
        if not fresh(t):
            return "Invalid timestamp", 401
        try:
            body = request.get_data(cache=True).decode("utf-8")
        except UnicodeDecodeError:
            return "Invalid body", 400
        expected = hmac.new(secret.encode("utf-8"),
                            f"{t}.{body}".encode("utf-8"), hashlib.sha256).hexdigest()
        if not any(re.fullmatch(r"[a-fA-F0-9]{64}", sig) and
                   hmac.compare_digest(expected, sig.lower()) for sig in signatures):
            return "Invalid signature", 401
        try:
            event = json.loads(body)
        except ValueError:
            return "Invalid JSON", 400
        if not isinstance(event, dict) or not event.get("id") or \
           event["id"] != request.headers.get("AIOS-Event-Id") or \
           event["id"] != request.headers.get("Idempotency-Key") or \
           event.get("type") != request.headers.get("AIOS-Event-Type"):
            return "Invalid event", 400
        try:
            process_event_once(event["id"], event)
        except Exception:
            return "Try again", 503
        return "OK", 200
    return webhook
# Register the returned handler as a Flask POST route on your webhook URL.

Python: entitlement API

import json
from urllib.parse import urlencode
from urllib.request import Request, urlopen

def get_entitlements(origin, api_key, customer_id):
    query = urlencode({"customer_id": customer_id})  # Exactly one selector.
    req = Request(origin.rstrip("/") + "/api/v1/hosted/entitlements?" + query,
                  headers={"Authorization": "Bearer " + api_key})
    with urlopen(req, timeout=10) as response:
        return json.load(response)

Python / Flask: sign-in check and redemption

import hashlib, hmac, json, re, time
from urllib.request import Request, urlopen

def redeem_sign_in(query, origin, secret, api_key):
    # query is Flask request.args, preserving duplicate query parameters.
    for field in ("aios_token", "aios_ts", "aios_sig"):
        if len(query.getlist(field)) != 1:
            raise ValueError("Invalid sign-in link")
    token, t, sig = (query.get(k, "") for k in ("aios_token", "aios_ts", "aios_sig"))
    if not token or not re.fullmatch(r"[0-9]+", t) or \
       abs(time.time() - int(t)) > 300 or not re.fullmatch(r"[a-fA-F0-9]{64}", sig):
        raise ValueError("Invalid sign-in link")
    expected = hmac.new(secret.encode("utf-8"), f"{t}.{token}".encode("utf-8"),
                        hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig.lower()):
        raise ValueError("Invalid sign-in link")
    req = Request(origin.rstrip("/") + "/api/v1/hosted/sign-in/redeem",
                  data=json.dumps({"token": token}).encode("utf-8"), method="POST",
                  headers={"Authorization": "Bearer " + api_key,
                           "Content-Type": "application/json"})
    with urlopen(req, timeout=10) as response:
        result = json.load(response)
    if not result.get("subscription", {}).get("access"):
        raise ValueError("Subscription has no access")
    # Find or create your local user by result["customer"]["id"], then start a session.
    return result

Health checks and sales pause

We check your app URL hourly. After about 3 consecutive failures, new sign-ups pause and you and the administrator receive an email. Two good checks resume sales automatically. Existing subscribers keep access.

A daily check also verifies the privacy policy, terms and domain. Keep those pages reachable and your verification file or DNS record in place. The dashboard shows health, the last check and any reported error.

Referral listings

Choose referral delivery when selling on your own site. The tracked link /go/<slug> adds aios_click and utm_source=aiosmarket to your app URL. Store the click ID with the sale, then report it as click_id through POST /api/v1/hosted/referrals/conversions.

Send a stable external_id, amount_cents and currency. You may include occurred_at and click_id. A new report returns 201, a repeat returns 200, and a non-referral listing returns 409. The commission is the same 15 percent. Your dashboard shows clicks, reported conversions and commission owed, grouped by currency.

Create an API key in the Referral section of Hosted app settings. Keep it on your server.

curl: report a referral conversion

curl "$AIOS_ORIGIN/api/v1/hosted/referrals/conversions" \
  --header "Authorization: Bearer $AIOS_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{"external_id":"<your stable sale id>","amount_cents":1000,"currency":"usd","click_id":"<aios_click from landing URL>"}'

OpenAPI spec

Download the OpenAPI 3.1 spec for the maker API, schemas and webhook headers.