> ## Documentation Index
> Fetch the complete documentation index at: https://developers.appstle.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Partner Integration Framework

> Build a zero-configuration integration between your app and Appstle Subscriptions, with scoped API tokens per merchant.

Build a seamless, zero-configuration integration between your app and Appstle Subscriptions. Once connected, your app gets a scoped API token for each merchant — no manual key exchange needed.

<Note>
  **Why become a Partner?**

  * **Frictionless merchant onboarding** — one-click connect from either dashboard
  * **No API paywall** — merchants don't need a paid API plan to use your integration
  * **Scoped tokens** — each merchant gets an isolated API key; revocable at any time
  * **Automatic cleanup** — when a merchant disconnects or uninstalls, access is revoked instantly
</Note>

## How it works

The Partner Integration Framework uses a secure handshake protocol. Either side — your app or Appstle — can initiate the connection. Both flows end with your app receiving a scoped API token.

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant P as Partner App
    participant A as Appstle Subscriptions

    rect rgb(240, 248, 255)
    Note over P,A: Flow A — Partner initiates
    P->>A: POST /api/partner/{id}/connect<br/>(shop_domain, callback_nonce, secret)
    A->>P: POST {your_base_url}/appstle/verify<br/>(nonce check)
    P-->>A: { "verified": true }
    A-->>P: { "status": "pending_merchant_approval" }
    Note over P,A: Merchant approves in Appstle dashboard
    A->>P: POST {your_base_url}/appstle/approved<br/>(access_token)
    end

    rect rgb(245, 245, 250)
    Note over P,A: Flow B — Appstle initiates
    A->>P: POST {your_base_url}/appstle/connect<br/>(shop_domain, app, callback_url, nonce)
    P->>A: POST /api/partner/{id}/verify<br/>(shop_domain, callback_nonce, secret)
    A-->>P: { "access_token": "..." }
    end
```

<Info>
  **Merchant approval:** When your app initiates a connection (Flow A), the merchant must approve it from their Appstle dashboard before you receive an API token. When the merchant initiates from Appstle's side (Flow B), the connection is approved instantly because the merchant is the one clicking "Connect."
</Info>

## Getting started

### Step 1: Get onboarded

To get started, reach out to the Appstle team at [support@appstle.com](mailto:support@appstle.com) with the information below. Our team will set up your partner account and send you your credentials.

#### What you'll need to provide

| # | Field                     | Required? | Description                                                                                                                                                                                                                                                                                | Example                     |
| - | ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------- |
| 1 | **App / Company Name**    | Required  | Your app or company name. Displayed to merchants when they browse available partner integrations in the Appstle dashboard.                                                                                                                                                                 | `SearchPie`                 |
| 2 | **Partner ID**            | Required  | A unique, lowercase slug that identifies your app in API URLs. Use only lowercase letters and hyphens. Once set, this cannot be changed.                                                                                                                                                   | `search-pie`                |
| 3 | **Base URL**              | Required  | The HTTPS base URL where Appstle will send callback requests (connect, verify, approved). Must be publicly accessible — Appstle will not call HTTP or localhost URLs.                                                                                                                      | `https://api.searchpie.com` |
| 4 | **Contact Email**         | Required  | The email address where we'll send your Partner Secret and any onboarding follow-ups. Use a team email if possible — the secret is shown only once.                                                                                                                                        | `dev-team@searchpie.com`    |
| 5 | **Authentication Mode**   | Optional  | How your API calls are authenticated. Choose one: **Partner Secret** (simpler — pass secret in a header) or **HMAC-SHA256** (more secure — sign each request). Defaults to Partner Secret if not specified.                                                                                | `Partner Secret`            |
| 6 | **Connect Mode**          | Optional  | How merchant connections are established. Choose one: **Nonce Handshake** (full two-way verification — you receive an Appstle API key) or **Simple Token Exchange** (streamlined — you provide your own token for Appstle to call your API). Defaults to Nonce Handshake if not specified. | `Nonce Handshake`           |
| 7 | **Custom Endpoint Paths** | Optional  | By default, Appstle calls `/appstle/connect` and `/appstle/verify` on your Base URL. If you need different paths (e.g., `/webhooks/appstle/connect`), specify them here.                                                                                                                   | `/webhooks/appstle/connect` |
| 8 | **Sync Path**             | Optional  | If you want Appstle to push subscription data to your app (e.g., when contracts are created, updated, or cancelled), provide the path on your server where Appstle should send these payloads.                                                                                             | `/appstle/sync`             |
| 9 | **App Logo**              | Optional  | A square logo (PNG or SVG, at least 128×128px) displayed next to your app name in the merchant's Appstle dashboard. If not provided, a placeholder icon is used.                                                                                                                           | —                           |

<Tip>
  Not sure about some of these? Only the first four fields are required to get started. You can always reach out to [support@appstle.com](mailto:support@appstle.com) to change your authentication mode, connect mode, or add a sync path later.
</Tip>

<Tip>
  **Recommended default for new partners: Simple Token Exchange + Partner Secret.** This is the lowest-friction setup — your app generates a single access token per merchant, hands it to Appstle, and authenticates calls with an `X-Partner-Secret` header. No nonce storage, no HMAC computation, no `/appstle/verify` endpoint to implement. Pick this unless you specifically need Appstle to call your API on behalf of a merchant (use Nonce Handshake) or your security review mandates request signing (use HMAC-SHA256).
</Tip>

#### API namespaces — what you call vs. what is internal

Three URL namespaces appear in this codebase. **As a third-party partner, you only ever call the first one.** The others exist for Appstle's merchant portal and inter-app integrations and are documented here so the surface area is unambiguous:

| Namespace                        | Who calls it               | Purpose                                                                                                                                        |
| -------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/partner/...`               | **You** (the partner)      | Connect, verify, disconnect, status. Authenticated with your Partner Secret or HMAC.                                                           |
| `/api/integrations/partner/...`  | Appstle merchant-portal UI | Drives the merchant-facing "Connect / Disconnect" buttons in the Appstle dashboard. Session-authenticated; not part of the public partner API. |
| `/api/integrations/callback/...` | Other Appstle apps         | Receiver-side callbacks for app-to-app integrations between Appstle products. Not used by third-party partners.                                |

If you see an example referring to `/api/integrations/...`, it's an internal Appstle flow and doesn't apply to your integration.

#### What you'll receive

Once onboarded, you'll receive three values:

| Credential         | Example                                  | Description                                                                   |
| ------------------ | ---------------------------------------- | ----------------------------------------------------------------------------- |
| **Partner ID**     | `search-pie`                             | Your unique identifier, as requested. Becomes part of the API URL.            |
| **Partner Secret** | `xK9mQ2vL...` (48 chars)                 | A secret key used to authenticate your API calls. Treat this like a password. |
| **Base URL**       | `https://subscription-admin.appstle.com` | Appstle's API base URL. Same for all partners.                                |

<Warning>
  Your Partner Secret is shown **only once** during onboarding. Copy it immediately and store it in a secure location (environment variable, secrets manager, etc.). If you lose it, contact Appstle to rotate it — the old secret will be invalidated immediately.
</Warning>

Store your credentials as environment variables:

```bash .env theme={null}
# never commit this file
APPSTLE_PARTNER_ID=search-pie
APPSTLE_PARTNER_SECRET=xK9mQ2vLa8nR3pY...       # if using Partner Secret auth
APPSTLE_HMAC_KEY=your-hmac-key-here               # if using HMAC-SHA256 auth
APPSTLE_BASE_URL=https://subscription-admin.appstle.com
```

### Step 1b: Choose your authentication mode

Appstle supports two ways to authenticate partner API calls. Your auth mode is configured during onboarding.

#### Option A: Partner Secret (default)

The simplest approach. Pass your secret in a header with every request:

```
X-Partner-Secret: your-partner-secret
```

That's it. No computation needed. Good for getting started quickly.

#### Option B: HMAC-SHA256

A more secure approach where requests are signed with a shared HMAC key. Instead of sending the secret directly, you compute a signature over the request body.

Headers required:

```
X-Partner-Timestamp: 1709856000
X-Partner-Signature: 5a3c1f2e9b8d7a6c...
```

How to compute the signature:

1. Get the current Unix timestamp (seconds, not milliseconds)
2. Concatenate the timestamp and the raw JSON request body: `timestamp + body`
3. Compute HMAC-SHA256 of that string using your HMAC key
4. Send the hex-encoded result in `X-Partner-Signature`

<Warning>
  **Timestamp validation:** Appstle rejects requests where the timestamp is more than **5 minutes** from the server's current time. Make sure your server clock is synced (NTP).
</Warning>

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  function signRequest(body, hmacKey) {
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const data = timestamp + body;
    const signature = crypto
      .createHmac('sha256', hmacKey)
      .update(data)
      .digest('hex');

    return {
      'X-Partner-Timestamp': timestamp,
      'X-Partner-Signature': signature,
      'Content-Type': 'application/json',
    };
  }

  // Usage
  const body = JSON.stringify({ shop_domain: 'cool-store.myshopify.com' });
  const headers = signRequest(body, process.env.APPSTLE_HMAC_KEY);
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import time
  import json

  def sign_request(body: str, hmac_key: str) -> dict:
      timestamp = str(int(time.time()))
      data = timestamp + body
      signature = hmac.new(
          hmac_key.encode('utf-8'),
          data.encode('utf-8'),
          hashlib.sha256,
      ).hexdigest()

      return {
          'X-Partner-Timestamp': timestamp,
          'X-Partner-Signature': signature,
          'Content-Type': 'application/json',
      }

  # Usage
  body = json.dumps({"shop_domain": "cool-store.myshopify.com"})
  headers = sign_request(body, os.environ["APPSTLE_HMAC_KEY"])
  ```

  ```bash curl theme={null}
  # Compute signature: HMAC-SHA256(timestamp + body, key)
  TIMESTAMP=$(date +%s)
  BODY='{"shop_domain":"cool-store.myshopify.com"}'
  SIGNATURE=$(echo -n "${TIMESTAMP}${BODY}" | openssl dgst -sha256 -hmac "your-hmac-key" | awk '{print $2}')

  curl -X POST "https://subscription-admin.appstle.com/api/partner/your-partner-id/connect" \
    -H "X-Partner-Timestamp: $TIMESTAMP" \
    -H "X-Partner-Signature: $SIGNATURE" \
    -H "Content-Type: application/json" \
    -d "$BODY"
  ```
</CodeGroup>

<Info>
  **Which should I choose?**

  * **Partner Secret** — simpler to implement, fine for most integrations
  * **HMAC-SHA256** — better security (secret never sent over the wire), recommended for high-volume or security-sensitive integrations

  Both are equally supported. You can switch modes later by contacting Appstle.
</Info>

### Step 1c: Choose your connect mode

Appstle supports two ways to establish merchant connections. Your connect mode is configured during onboarding.

#### Option A: Nonce Handshake (default)

The full two-way verification flow described in this guide. Both sides verify each other using a one-time nonce. After the handshake, your app receives an **Appstle API key** (`apst_...`) to call Appstle's External API.

**Best for:** Partners who want to read/write data in Appstle (subscription contracts, billing attempts, product swaps, etc.)

#### Option B: Simple Token Exchange

A streamlined flow where your app sends its own access token to Appstle (or Appstle calls your connect endpoint and you return one). No nonce, no verify endpoint needed. Appstle stores your token and uses it to call **your** API when needed.

**Best for:** Partners where Appstle needs to call the partner's API (e.g., syncing data to the partner's platform), rather than the partner calling Appstle's API.

**Key difference from Nonce Handshake:** In Simple Token Exchange, your app provides its own access token to Appstle. Appstle stores this token and uses it to push data to your API (via your `sync_path` — see [Data sync](#data-sync-push-model) below). Your app does **not** receive an Appstle API key in this mode.

<Tip>
  **Need both directions?** If you need to both push data to Appstle AND have Appstle push data to you, use the Nonce Handshake mode and provide a `sync_path` during onboarding. Contact [support@appstle.com](mailto:support@appstle.com) to discuss your use case.
</Tip>

**How Simple Token Exchange works:**

*Partner-initiated:*

```bash theme={null}
curl -X POST "https://subscription-admin.appstle.com/api/partner/your-partner-id/connect" \
  -H "X-Partner-Timestamp: 1709856000" \
  -H "X-Partner-Signature: 5a3c1f2e..." \
  -H "Content-Type: application/json" \
  -d '{
    "shop_domain": "cool-store.myshopify.com",
    "access_token": "your-apps-token-for-this-merchant"
  }'
```

Response:

```json theme={null}
{
  "status": "pending_merchant_approval"
}
```

<Note>
  Your `access_token` is stored securely but will not be activated until the merchant approves the connection from their Appstle dashboard. Once approved, Appstle calls your `/appstle/approved` endpoint to confirm (see [Handling the approval callback](#handling-the-approval-callback)).
</Note>

*Appstle-initiated:*
Appstle calls your `/appstle/connect` endpoint with `{ "shop_domain": "..." }`. Your app responds with:

```json theme={null}
{
  "success": true,
  "access_token": "your-apps-token-for-this-merchant"
}
```

<Note>
  With Simple Token Exchange, your app does NOT receive an Appstle API key. If you also need to call Appstle's External API, use the Nonce Handshake mode instead.
</Note>

### Step 2: Understand the callback nonce

<Info>
  This section applies to Nonce Handshake mode only. If you're using Simple Token Exchange, skip to [Step 4](#step-4-implement-the-connect-flow-your-dashboard).
</Info>

Before implementing, you need to understand the **callback nonce** — it's the core security mechanism of the handshake.

#### What is a callback nonce?

A **nonce** (number used once) is a random, single-use string that proves both sides of the connection are who they claim to be. It prevents replay attacks and ensures the handshake can't be forged.

#### Requirements

| Requirement    | Detail                                                                                                                             |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| **Length**     | At least 32 bytes (64 hex characters)                                                                                              |
| **Randomness** | Must be **cryptographically random** — do NOT use `Math.random()`, `rand()`, timestamps, or UUIDs                                  |
| **Single-use** | Each nonce must be used exactly once, then deleted                                                                                 |
| **Expiry**     | Nonces expire after **5 minutes** on Appstle's side. Your storage should also expire them.                                         |
| **Storage**    | Store temporarily with a TTL. Redis, DynamoDB, or any key-value store with expiry works. Database with a cleanup job is also fine. |

#### How to generate a nonce

Use your language's cryptographically secure random number generator.

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  // Generate a 32-byte (64 hex character) cryptographically random nonce
  const nonce = crypto.randomBytes(32).toString('hex');
  // Result: "a1b2c3d4e5f6...64 characters total"
  ```

  ```python Python theme={null}
  import secrets

  # Generate a 32-byte (64 hex character) cryptographically random nonce
  nonce = secrets.token_hex(32)
  # Result: "a1b2c3d4e5f6...64 characters total"
  ```

  ```ruby Ruby theme={null}
  require 'securerandom'

  # Generate a 32-byte (64 hex character) cryptographically random nonce
  nonce = SecureRandom.hex(32)
  # Result: "a1b2c3d4e5f6...64 characters total"
  ```

  ```php PHP theme={null}
  // Generate a 32-byte (64 hex character) cryptographically random nonce
  $nonce = bin2hex(random_bytes(32));
  // Result: "a1b2c3d4e5f6...64 characters total"
  ```

  ```java Java theme={null}
  import java.security.SecureRandom;

  SecureRandom secureRandom = new SecureRandom();
  byte[] bytes = new byte[32];
  secureRandom.nextBytes(bytes);
  StringBuilder sb = new StringBuilder(64);
  for (byte b : bytes) {
      sb.append(String.format("%02x", b));
  }
  String nonce = sb.toString();
  // Result: "a1b2c3d4e5f6...64 characters total"
  ```

  ```go Go theme={null}
  import (
      "crypto/rand"
      "encoding/hex"
  )

  bytes := make([]byte, 32)
  rand.Read(bytes)
  nonce := hex.EncodeToString(bytes)
  // Result: "a1b2c3d4e5f6...64 characters total"
  ```
</CodeGroup>

<Warning>
  **Common mistakes:**

  * `Math.random().toString(36)` — not cryptographically random, predictable
  * `uuid.v4()` — UUIDs are not designed as security tokens (some implementations use weak RNG)
  * `Date.now().toString()` — trivially guessable
  * Reusing nonces across multiple connect attempts

  Always use your language's `crypto` / `secrets` / `SecureRandom` module.
</Warning>

#### How to store a nonce

Store the nonce temporarily, keyed by shop domain, with a 5-minute expiry. Delete it after verification.

<CodeGroup>
  ```javascript Node.js + Redis theme={null}
  const Redis = require('ioredis');
  const redis = new Redis();

  // Store nonce with 5-minute TTL
  async function storeNonce(shopDomain, nonce) {
    const key = `appstle:nonce:${shopDomain}`;
    await redis.set(key, nonce, 'EX', 300); // 300 seconds = 5 minutes
  }

  // Retrieve and delete nonce (single atomic operation)
  async function verifyAndDeleteNonce(shopDomain, nonceToCheck) {
    const key = `appstle:nonce:${shopDomain}`;
    const storedNonce = await redis.get(key);

    if (!storedNonce || storedNonce !== nonceToCheck) {
      return false;
    }

    await redis.del(key);
    return true;
  }
  ```

  ```python Python + database theme={null}
  from datetime import datetime, timedelta
  from your_app.models import PartnerNonce  # your ORM model

  def store_nonce(shop_domain: str, nonce: str):
      # Delete any existing nonce for this shop (prevent duplicates)
      PartnerNonce.objects.filter(shop_domain=shop_domain).delete()

      PartnerNonce.objects.create(
          shop_domain=shop_domain,
          nonce=nonce,
          expires_at=datetime.utcnow() + timedelta(minutes=5),
      )

  def verify_and_delete_nonce(shop_domain: str, nonce_to_check: str) -> bool:
      try:
          record = PartnerNonce.objects.get(
              shop_domain=shop_domain,
              nonce=nonce_to_check,
              expires_at__gt=datetime.utcnow(),  # not expired
          )
          record.delete()
          return True
      except PartnerNonce.DoesNotExist:
          return False
  ```
</CodeGroup>

### Step 3: Implement your endpoints

Your app must expose two HTTP endpoints that Appstle calls during the connection handshake. The paths default to `/appstle/connect` and `/appstle/verify` but can be customized during onboarding.

**Both endpoints must:**

* Accept `POST` requests with a JSON body
* Return JSON responses
* Be accessible over **HTTPS** (Appstle will not call HTTP endpoints)
* Respond within **10 seconds** (or the request will time out)
* **Be idempotent.** Appstle may retry a callback on transient failure, and a merchant flipping connect/disconnect repeatedly will exercise the same endpoint with the same `(shop_domain, partnerId)` pair. Treat every call as an upsert keyed by `(shop_domain, partnerId)` — never blindly insert. The same rule applies to your `/appstle/approved` and `/appstle/disconnect` endpoints described later.

#### Endpoint 1: `POST /appstle/connect`

**When is this called?** Appstle calls this when a merchant initiates the connection from **Appstle's dashboard** (Flow B).

**What does it receive?**

```json theme={null}
{
  "shop_domain": "cool-store.myshopify.com",
  "app": "subscriptions",
  "callback_url": "https://subscription-admin.appstle.com/api/partner/your-partner-id/verify",
  "callback_nonce": "7f3a9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a"
}
```

| Field            | Type   | Description                                                                                                                                                                                                                                            |
| ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `shop_domain`    | string | The merchant's Shopify domain (e.g. `cool-store.myshopify.com`)                                                                                                                                                                                        |
| `app`            | string | Always `"subscriptions"` — identifies which Appstle app is connecting                                                                                                                                                                                  |
| `callback_url`   | string | The exact URL your app must call to complete the handshake. **The `{partnerId}` embedded in this URL is *Appstle's* identifier for this Appstle app on your side, not your Partner ID.** Use the URL verbatim — don't parse or substitute the segment. |
| `callback_nonce` | string | A one-time-use token generated by Appstle. **Expires in 5 minutes.**                                                                                                                                                                                   |

**What should your app do?**

1. **Validate the shop** — check that this `shop_domain` exists in your system. If you don't recognize the shop, return an error.
2. **Store the nonce and callback URL** — save `callback_nonce` and `callback_url` associated with this `shop_domain`. You'll need them to complete the handshake.
3. **Call back to Appstle** — either immediately (auto-approve) or after merchant confirmation, call the `callback_url` to complete the connection. See [Completing the handshake](#completing-the-handshake-flow-b) below.
4. **Return a success response** — any `2xx` status code tells Appstle the request was received.

```javascript Node.js (Express) theme={null}
const express = require('express');
const axios = require('axios');
const router = express.Router();

router.post('/appstle/connect', async (req, res) => {
  const { shop_domain, app, callback_url, callback_nonce } = req.body;

  // 1. Validate: does this shop exist in your system?
  const shop = await db.shops.findOne({ domain: shop_domain });
  if (!shop) {
    return res.status(400).json({ error: 'Shop not found in our system' });
  }

  // 2. Store the nonce and callback URL for this shop
  await db.pendingConnections.upsert({
    shopDomain: shop_domain,
    callbackUrl: callback_url,
    callbackNonce: callback_nonce,
    createdAt: new Date(),
    expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
  });

  // 3. Option A: Auto-approve (call back immediately)
  try {
    const response = await axios.post(callback_url, {
      shop_domain: shop_domain,
      callback_nonce: callback_nonce,
    }, {
      headers: {
        'X-Partner-Secret': process.env.APPSTLE_PARTNER_SECRET,
        'Content-Type': 'application/json',
      },
    });

    if (response.data.verified && response.data.access_token) {
      // 4. Store the access token for this merchant
      await db.appstleTokens.upsert({
        shopDomain: shop_domain,
        accessToken: response.data.access_token,
        connectedAt: new Date(),
      });
    }
  } catch (err) {
    console.error('Failed to complete Appstle handshake:', err.message);
  }

  // 5. Return success to Appstle
  res.json({ success: true });
});
```

#### Endpoint 2: `POST /appstle/verify`

**When is this called?** Appstle calls this when a merchant initiates the connection from **your app's dashboard** (Flow A). Appstle is asking your app: "Did you actually send this nonce?"

**What does it receive?**

```json theme={null}
{
  "shop_domain": "cool-store.myshopify.com",
  "callback_nonce": "a1b2c3d4e5f6...the-nonce-you-generated"
}
```

| Field            | Type   | Description                                               |
| ---------------- | ------ | --------------------------------------------------------- |
| `shop_domain`    | string | The merchant's Shopify domain                             |
| `callback_nonce` | string | The nonce your app originally sent in the `/connect` call |

**What should your app do?**

1. Look up the stored nonce for this `shop_domain`
2. Compare the `callback_nonce` from the request against your stored nonce
3. If they match: delete the stored nonce (it's single-use) and return `{ "verified": true }`
4. If they don't match: return `{ "verified": false }`

```javascript Node.js (Express) theme={null}
router.post('/appstle/verify', async (req, res) => {
  const { shop_domain, callback_nonce } = req.body;

  // 1. Look up the stored nonce for this shop
  const isValid = await verifyAndDeleteNonce(shop_domain, callback_nonce);

  // 2. Return the result
  res.json({ verified: isValid });
});
```

### Step 4: Implement the connect flow (your dashboard)

Now build the merchant-facing "Connect Appstle Subscriptions" button in your app's dashboard.

#### Partner-initiated connect (Flow A) — step by step

This is the flow where the merchant clicks "Connect Appstle" in **your** dashboard.

<Steps>
  <Step title="Merchant clicks &#x22;Connect Appstle&#x22; in your dashboard">
    The merchant initiates the connection from inside your app's UI.
  </Step>

  <Step title="Your app generates a cryptographically random nonce">
    Store it keyed by `shop_domain` with a 5-minute TTL.
  </Step>

  <Step title="Your app calls POST /api/partner/{id}/connect">
    Send the `shop_domain`, the `callback_nonce`, and your Partner Secret.
  </Step>

  <Step title="Appstle validates your Partner Secret">
    Appstle also confirms the shop has Appstle Subscriptions installed.
  </Step>

  <Step title="Appstle calls YOUR /appstle/verify endpoint">
    Payload: the `shop_domain` and the same `callback_nonce`.
  </Step>

  <Step title="Your /appstle/verify checks the nonce">
    Confirm it matches, delete it, return `{ "verified": true }`.
  </Step>

  <Step title="Appstle returns pending_merchant_approval">
    The connection is pending — no access token has been issued yet.
  </Step>

  <Step title="Merchant approves in their Appstle dashboard">
    They open **Settings → Partner Connections** and click **Approve**.
  </Step>

  <Step title="Appstle creates a scoped API key">
    The token is POSTed to YOUR `/appstle/approved` endpoint.
  </Step>

  <Step title="Your app stores the access_token">
    Show "Connected!" to the merchant. You're done.
  </Step>
</Steps>

**Full implementation (Node.js):**

```javascript theme={null}
const crypto = require('crypto');
const axios = require('axios');

const PARTNER_ID = process.env.APPSTLE_PARTNER_ID;
const PARTNER_SECRET = process.env.APPSTLE_PARTNER_SECRET;
const APPSTLE_BASE = process.env.APPSTLE_BASE_URL; // https://subscription-admin.appstle.com

// Called when merchant clicks "Connect Appstle" in your dashboard
async function connectToAppstle(shopDomain) {

  // Step 2: Generate a cryptographically random nonce
  const nonce = crypto.randomBytes(32).toString('hex');

  // Store it so your /appstle/verify endpoint can look it up later
  await storeNonce(shopDomain, nonce); // see nonce storage examples above

  // Step 3: Call Appstle's partner connect endpoint
  const response = await axios.post(
    `${APPSTLE_BASE}/api/partner/${PARTNER_ID}/connect`,
    {
      shop_domain: shopDomain,
      callback_nonce: nonce,
    },
    {
      headers: {
        'X-Partner-Secret': PARTNER_SECRET,
        'Content-Type': 'application/json',
      },
    }
  );

  // Steps 4-6 happen automatically (Appstle calls your /appstle/verify)

  // Step 7: Appstle returns pending status — token is NOT delivered yet
  const { status } = response.data;

  if (status === 'pending_merchant_approval') {
    // The merchant needs to approve in their Appstle dashboard.
    // Once approved, Appstle will POST the token to your /appstle/approved endpoint.
    await markConnectionPending(shopDomain);
    return { pending: true };
  }

  throw new Error('Connection failed');
}
```

**curl equivalent:**

```bash theme={null}
curl -X POST "https://subscription-admin.appstle.com/api/partner/search-pie/connect" \
  -H "X-Partner-Secret: xK9mQ2vLa8nR3pY..." \
  -H "Content-Type: application/json" \
  -d '{
    "shop_domain": "cool-store.myshopify.com",
    "callback_nonce": "a1b2c3d4e5f67890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890"
  }'
```

**Success response:**

```json theme={null}
{
  "status": "pending_merchant_approval"
}
```

<Info>
  **What happens next?** The merchant will see a "Pending Request" in their Appstle dashboard under **Settings → Partner Connections**. (This menu appears automatically once a partner initiates a connection request — it is not visible before any partner has connected.) When they click "Approve," Appstle creates a scoped API key and delivers it to your `/appstle/approved` endpoint (see [Handling the approval callback](#handling-the-approval-callback) below). Pending requests expire after **30 days** if not acted on.
</Info>

<Tip>
  **Deep link to approval screen:** You can redirect the merchant directly to the approval screen to minimize friction:

  ```
  https://admin.shopify.com/store/{shop-handle}/apps/subscription-by-rhem/settings/partner-connections
  ```

  Replace `{shop-handle}` with the merchant's store handle (the part before `.myshopify.com`). This takes them straight to the pending connection for one-click approval. You can trigger this redirect in your UI immediately after receiving the `pending_merchant_approval` response.
</Tip>

#### Appstle-initiated connect (Flow B) — step by step

This is the flow where the merchant clicks "Connect" in **Appstle's** dashboard.

<Steps>
  <Step title="Merchant clicks &#x22;Connect {YourApp}&#x22; in Appstle's dashboard">
    The connection is initiated from inside Appstle, not your UI.
  </Step>

  <Step title="Appstle calls POST /api/partner/{id}/initiate-connect">
    Internal Appstle call — your app is not involved yet.
  </Step>

  <Step title="Appstle generates a nonce (5-min TTL) and calls YOUR /appstle/connect">
    Payload: `shop_domain`, `app`, `callback_url`, and `callback_nonce`.
  </Step>

  <Step title="Your /appstle/connect stores the nonce and callback_url">
    Persist them keyed by `shop_domain` for the verify step.
  </Step>

  <Step title="Your app calls the callback_url (Appstle's /verify endpoint)">
    Send `shop_domain`, `callback_nonce`, and your Partner Secret.
  </Step>

  <Step title="Appstle verifies the nonce and issues a scoped API key">
    The `access_token` is returned in the response body.
  </Step>

  <Step title="Your app stores the access_token">
    Connection complete.
  </Step>
</Steps>

##### Completing the handshake (Flow B)

After your `/appstle/connect` endpoint receives the nonce and callback URL, your app completes the connection by calling Appstle's verify endpoint:

```bash theme={null}
curl -X POST "https://subscription-admin.appstle.com/api/partner/search-pie/verify" \
  -H "X-Partner-Secret: xK9mQ2vLa8nR3pY..." \
  -H "Content-Type: application/json" \
  -d '{
    "shop_domain": "cool-store.myshopify.com",
    "callback_nonce": "the-nonce-appstle-sent-in-the-connect-call"
  }'
```

**Success response:**

```json theme={null}
{
  "verified": true,
  "access_token": "apst_AbCdEfGhIjKlMnOpQrStUvWxYz123456789012"
}
```

**Failed response (nonce expired or mismatched):**

```json theme={null}
{
  "verified": false
}
```

<Warning>
  You must call the verify endpoint within **5 minutes** of receiving the nonce. After that, the nonce expires and the merchant will need to try again.
</Warning>

## Using the API token

After a successful connection, your app has an `access_token` (prefixed with `apst_`). For **Appstle-initiated connections** (Flow B), the token is returned immediately in the verify response. For **partner-initiated connections** (Flow A), the token is delivered asynchronously to your `/appstle/approved` endpoint after the merchant approves (see [Handling the approval callback](#handling-the-approval-callback) below).

Use this token exactly like a merchant API key — pass it in the `X-API-Key` header:

```bash theme={null}
curl -X GET \
  "https://subscription-admin.appstle.com/api/external/v2/subscription-customers/{customerId}" \
  -H "X-API-Key: apst_AbCdEfGhIjKlMnOpQrStUvWxYz123456789012"
```

### Token properties

| Property       | Detail                                                                                         |
| -------------- | ---------------------------------------------------------------------------------------------- |
| **Format**     | Starts with `apst_` followed by 40 alphanumeric characters                                     |
| **Scope**      | One token per merchant per partner                                                             |
| **Permission** | `READ_ONLY` or `READ_WRITE` (set during partner onboarding)                                    |
| **Billing**    | Partner tokens **bypass the paid API plan** — merchants are never billed for partner API usage |
| **Revocation** | Revoked instantly when the merchant disconnects or uninstalls Appstle                          |
| **Expiry**     | Tokens do not expire on their own. They remain valid until explicitly revoked.                 |

### Available endpoints

Partner tokens grant access to the same [External API endpoints](/subscription/integration-guide) as merchant API keys:

* **Subscription contracts** — `GET /api/external/v2/subscription-contract-details`
* **Customer subscriptions** — `GET /api/external/v2/subscription-customers/{customerId}`
* **Update subscription status** — `PUT /api/external/v2/subscription-contracts-update-status` *(requires READ\_WRITE)*
* **Skip/Reschedule orders** — `POST /api/external/v2/subscription-billing-attempts/skip-order/{id}` *(requires READ\_WRITE)*
* **Update line items** — `PUT /api/external/v2/subscription-contracts-update-line-item` *(requires READ\_WRITE)*
* **Apply discounts** — `POST /api/external/v2/subscription-contracts-apply-discount` *(requires READ\_WRITE)*
* And all other `/api/external/v2/*` endpoints

See the full [Integration guide](/subscription/integration-guide) for complete endpoint documentation.

## Data sync (push model)

Some integrations work best when **Appstle pushes data to your app**, rather than your app pulling from Appstle's API. For example, a search platform might need Appstle to push subscription data so it can be indexed alongside other store data.

### How it works

During onboarding, you can configure a `sync_path` on your server (e.g., `/appstle/sync`). When subscription events occur (new subscriptions, cancellations, billing attempts, etc.), Appstle calls your endpoint with the relevant data.

| Config Field      | Example               | Description                                          |
| ----------------- | --------------------- | ---------------------------------------------------- |
| `sync_path`       | `/appstle/sync`       | Your endpoint where Appstle pushes subscription data |
| `disconnect_path` | `/appstle/disconnect` | Your endpoint called when a merchant disconnects     |

### Authentication

When Appstle calls your endpoints, it authenticates using the auth mode configured for your partner:

* **Partner Secret mode:** No additional headers (your endpoints are responsible for validating the source — consider IP allowlisting)
* **HMAC-SHA256 mode (recommended):** Appstle signs every request with `X-Partner-Timestamp` and `X-Partner-Signature` headers. Your app should verify the HMAC signature to confirm the request came from Appstle.

**Verifying incoming HMAC signatures (Node.js):**

```javascript theme={null}
const crypto = require('crypto');

function verifyAppstleSignature(req, hmacKey) {
  const timestamp = req.headers['x-partner-timestamp'];
  const signature = req.headers['x-partner-signature'];

  if (!timestamp || !signature) return false;

  // Reject if timestamp is more than 5 minutes old
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) return false;

  // Compute expected signature
  const data = timestamp + req.rawBody; // make sure you capture raw body
  const expected = crypto
    .createHmac('sha256', hmacKey)
    .update(data)
    .digest('hex');

  // Constant-time comparison
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}
```

### Pull vs push — which model do I need?

| Model              | Connect Mode                 | Your App Calls Appstle?   | Appstle Calls Your App?           | Use Case                                            |
| ------------------ | ---------------------------- | ------------------------- | --------------------------------- | --------------------------------------------------- |
| **Pull** (default) | Nonce Handshake              | Yes (via `apst_` API key) | No                                | Helpdesks, CRMs reading subscription data on demand |
| **Push**           | Simple Token Exchange        | No                        | Yes (via your token + sync\_path) | Search platforms, analytics tools that index data   |
| **Bidirectional**  | Nonce Handshake + sync\_path | Yes                       | Yes                               | Full two-way integrations                           |

## Disconnecting

### Merchant disconnects from Appstle

Merchants can disconnect your integration anytime from **Appstle Dashboard → Settings → Partner Connections**. When they do:

* Your API token for that merchant is **revoked immediately**
* Subsequent API calls will return `401 Unauthorized`
* **Appstle sends a disconnect webhook** to your app (if you configured a `disconnect_path` during onboarding — see below)
* Your app should handle this gracefully and show a "Reconnect" option

**Best practice:** In your API client, check for `401` responses and update your UI to show the connection as disconnected:

```javascript theme={null}
async function callAppstleApi(shopDomain, endpoint) {
  const token = await getAccessToken(shopDomain);

  try {
    const response = await axios.get(`${APPSTLE_BASE}${endpoint}`, {
      headers: { 'X-API-Key': token },
    });
    return response.data;
  } catch (err) {
    if (err.response?.status === 401) {
      // Token was revoked — merchant disconnected
      await markAsDisconnected(shopDomain);
      throw new Error('Appstle connection was revoked. Merchant needs to reconnect.');
    }
    throw err;
  }
}
```

### Disconnect webhook (first-class endpoint)

Configure a `disconnect_path` during onboarding (defaults to `/appstle/disconnect`). Appstle calls this whenever a merchant disconnects — from the Appstle dashboard, from your app, or by uninstalling Appstle entirely. **You should implement this endpoint for every integration** — it is the only reliable signal that the merchant has revoked access on Appstle's side. Polling `401` responses as a fallback works but lags behind.

**Request body from Appstle:**

```json theme={null}
{
  "shop_domain": "cool-store.myshopify.com"
}
```

If your partner uses HMAC-SHA256 auth, the webhook includes signed headers (`X-Partner-Timestamp`, `X-Partner-Signature`) so you can verify it came from Appstle.

**Your endpoint must:**

1. **Look up the connection without filtering on status.** Don't `WHERE status = 'active'` — if the merchant rapid-clicks disconnect twice, the second call may arrive when the row is already inactive. Find by `(shop_domain, partnerId)` only.
2. **Revoke the Appstle access token idempotently.** If the token is already revoked or absent, return success — don't error. Revocation must be safe to call repeatedly.
3. **Mark the local connection inactive.** Clear or null out the stored Appstle token so subsequent API calls don't try to use it.
4. **Return `2xx` even when there was nothing to do.** A no-op disconnect is a successful disconnect from Appstle's perspective.

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  router.post('/appstle/disconnect', async (req, res) => {
    const { shop_domain } = req.body;

    // Optional: verify HMAC signature if using HMAC auth
    // if (!verifyAppstleSignature(req, HMAC_KEY)) {
    //   return res.status(401).json({ error: 'Invalid signature' });
    // }

    // 1. Status-agnostic lookup — don't filter on .where({ status: 'active' })
    const connection = await db.connections.findOne({ shopDomain: shop_domain });

    if (connection) {
      // 2. Idempotent token revoke — clearing a null token is a no-op
      await db.appstleTokens.delete({ shopDomain: shop_domain });

      // 3. Mark inactive (upsert-style — safe if already inactive)
      await db.connections.update(
        { shopDomain: shop_domain },
        { status: 'disconnected', disconnectedAt: new Date() }
      );
    }

    // 4. Always 2xx — even if nothing was found
    res.json({ success: true });
  });
  ```

  ```python Python (Flask) theme={null}
  @app.route("/appstle/disconnect", methods=["POST"])
  def appstle_disconnect():
      shop_domain = request.json["shop_domain"]

      # 1. Find without filtering on status
      connection = Connection.query.filter_by(shop_domain=shop_domain).first()

      if connection:
          # 2. Idempotent token revoke
          AppstleToken.query.filter_by(shop_domain=shop_domain).delete()

          # 3. Mark inactive (upsert semantics)
          connection.status = "disconnected"
          connection.disconnected_at = datetime.utcnow()
          db.session.commit()

      # 4. Always 2xx
      return jsonify({"success": True})
  ```
</CodeGroup>

<Note>
  This is a best-effort notification — your app should also handle `401` responses from the Appstle API as a fallback signal that the connection was revoked.
</Note>

### Partner disconnects programmatically

Your app can disconnect a merchant using your partner authentication (Partner Secret or HMAC-SHA256):

<CodeGroup>
  ```bash Partner Secret theme={null}
  curl -X POST "https://subscription-admin.appstle.com/api/partner/your-partner-id/disconnect" \
    -H "X-Partner-Secret: YOUR_PARTNER_SECRET" \
    -H "Content-Type: application/json" \
    -d '{
      "shop_domain": "cool-store.myshopify.com"
    }'
  ```

  ```bash HMAC-SHA256 theme={null}
  TIMESTAMP=$(date +%s)
  BODY='{"shop_domain":"cool-store.myshopify.com"}'
  SIGNATURE=$(echo -n "${TIMESTAMP}${BODY}" | openssl dgst -sha256 -hmac "your-hmac-key" | awk '{print $2}')

  curl -X POST "https://subscription-admin.appstle.com/api/partner/your-partner-id/disconnect" \
    -H "X-Partner-Timestamp: $TIMESTAMP" \
    -H "X-Partner-Signature: $SIGNATURE" \
    -H "Content-Type: application/json" \
    -d "$BODY"
  ```
</CodeGroup>

**Response:**

```json theme={null}
{
  "success": true
}
```

### Check connection status

`GET /api/partner/{partnerId}/status?shop_domain=...` is the **authoritative source of truth** for whether a merchant is connected. If your UI shows a "Connected" badge, derive it from this endpoint — not from whether you happen to have a stored API key locally.

<Warning>
  **Why this matters:** Older integrations sometimes inferred "connected" from the presence of a per-app API key column in their own database. That column is now a deprecated fallback — it can be stale (key revoked on Appstle's side, your row never updated) and it can't represent `pending_merchant_approval` or `rejected`. Always call `/status` before showing connection state to the merchant or making business decisions based on it.
</Warning>

Partners can authenticate with their Partner Secret or HMAC signature:

```bash theme={null}
# With Partner Secret
curl -X GET "https://subscription-admin.appstle.com/api/partner/your-partner-id/status?shop_domain=cool-store.myshopify.com" \
  -H "X-Partner-Secret: your-partner-secret"

# With HMAC
curl -X GET "https://subscription-admin.appstle.com/api/partner/your-partner-id/status?shop_domain=cool-store.myshopify.com" \
  -H "X-Partner-Timestamp: $TIMESTAMP" \
  -H "X-Partner-Signature: $SIGNATURE"
```

**Response (active connection):**

```json theme={null}
{
  "partner_id": "your-partner-id",
  "shop_domain": "cool-store.myshopify.com",
  "status": "active",
  "connected_at": "2026-03-07T20:30:00Z"
}
```

**Response (pending merchant approval):**

```json theme={null}
{
  "partner_id": "your-partner-id",
  "shop_domain": "cool-store.myshopify.com",
  "status": "pending_merchant_approval"
}
```

**All possible status values:**

| Status                      | Meaning                                                                                                                                                                                                                               |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `active`                    | Connected and working — API token is valid                                                                                                                                                                                            |
| `pending_merchant_approval` | Partner-initiated connect is awaiting merchant approval                                                                                                                                                                               |
| `rejected`                  | Merchant rejected the connection request                                                                                                                                                                                              |
| `expired`                   | A pending request expired (30-day window) without merchant action — partner must initiate a new connection                                                                                                                            |
| `not_connected`             | No connection record exists for this partner + shop, or the connection was previously terminated (by merchant, by partner, or by uninstall). In both cases the token is revoked and your app would need to initiate a new connection. |

<Tip>
  Use the status endpoint to poll for approval if your app doesn't implement the `/appstle/approved` callback. Poll every 30–60 seconds after initiating a connect. Once the status changes from `pending_merchant_approval` to `active`, your token has been delivered via the approval callback (or you can request it again).
</Tip>

## Handling the approval callback

When a merchant approves a partner-initiated connection, Appstle delivers the API token by calling an endpoint on your server. This applies to **partner-initiated connections only** — Appstle-initiated connections (Flow B) return the token immediately.

### Endpoint: `POST /appstle/approved`

The path defaults to `/appstle/approved` but can be customized during onboarding (configured as `approval_callback_path`).

<Warning>
  Your `/appstle/approved` endpoint **must accept unauthenticated POST requests** from Appstle's servers. Do not put authentication middleware (e.g., JWT validation, API key checks) on this endpoint — Appstle will not send your app's auth credentials when calling this callback. If you need to verify the request is from Appstle, use HMAC-SHA256 authentication mode — when enabled, the callback includes signed headers (`X-Partner-Timestamp`, `X-Partner-Signature`) you can verify.
</Warning>

**Request body from Appstle (Nonce Handshake mode):**

```json theme={null}
{
  "shop_domain": "cool-store.myshopify.com",
  "access_token": "apst_AbCdEfGhIjKlMnOpQrStUvWxYz123456789012"
}
```

**Request body from Appstle (Simple Token Exchange mode):**

```json theme={null}
{
  "shop_domain": "cool-store.myshopify.com",
  "status": "approved"
}
```

<Note>
  In Simple Token Exchange mode, your app already provided its own token during the connect call. The approval callback simply confirms the connection is now active — Appstle will start using your token for API calls.
</Note>

**Expected response:** Return any `2xx` status code with a JSON body (e.g., `{ "success": true }`). If your endpoint returns a non-2xx status (e.g., `401 Unauthorized`), the connection is still approved on Appstle's side, but your app won't know — see [What if the callback fails?](#what-if-the-callback-fails) below.

If your partner uses HMAC-SHA256 auth, the callback includes signed headers (`X-Partner-Timestamp`, `X-Partner-Signature`) so you can verify it came from Appstle.

<CodeGroup>
  ```javascript Node.js theme={null}
  router.post('/appstle/approved', async (req, res) => {
    const { shop_domain, access_token, status } = req.body;

    // Optional: verify HMAC signature if using HMAC auth
    // if (!verifyAppstleSignature(req, HMAC_KEY)) {
    //   return res.status(401).json({ error: 'Invalid signature' });
    // }

    if (access_token) {
      // Nonce Handshake mode — store the Appstle API token
      await saveToken(shop_domain, access_token);
      console.log(`Connection approved for ${shop_domain} — token received`);
    } else if (status === 'approved') {
      // Simple Token Exchange mode — our token is now active
      await markConnectionActive(shop_domain);
      console.log(`Connection approved for ${shop_domain} — our token is now active`);
    }

    res.json({ success: true });
  });
  ```

  ```python Python theme={null}
  @app.route("/appstle/approved", methods=["POST"])
  def appstle_approved():
      body = request.json
      shop_domain = body["shop_domain"]
      access_token = body.get("access_token")
      status = body.get("status")

      if access_token:
          # Nonce Handshake mode — store the Appstle API token
          save_token(shop_domain, access_token)
      elif status == "approved":
          # Simple Token Exchange mode — our token is now active
          mark_connection_active(shop_domain)

      return jsonify({"success": True})
  ```
</CodeGroup>

### What if the callback fails?

If your endpoint is unreachable or returns an error, the connection is still approved on Appstle's side. The API token exists and is valid. Your app can:

1. **Poll the status endpoint** — check `GET /api/partner/{id}/status?shop_domain=...` until the status is `active`
2. **Retry from Appstle's side** — currently, Appstle does not automatically retry the callback. Contact support if you need the token re-delivered.

### What if the merchant rejects?

If the merchant clicks "Reject," the connection status changes to `rejected` and Appstle notifies your app via the disconnect webhook (if configured). Your app should handle this gracefully — show the merchant that the connection was not approved.

## Error handling

All partner endpoints return structured error responses:

```json theme={null}
{
  "type": "https://subscription-admin.appstle.com/problem",
  "title": "Bad Request",
  "status": 400,
  "detail": "UserGeneratedError:Active connection already exists. Disconnect first.",
  "errorKey": "ALREADY_CONNECTED"
}
```

### Error codes

| Error Code            | HTTP Status | When It Happens                                                                                                                                                                                                          | What To Do                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PARTNER_NOT_FOUND`   | 400         | Your Partner ID is wrong, or the partner has been deactivated                                                                                                                                                            | Double-check your Partner ID. Contact Appstle if unexpected.                                                                                                                                                                                                                                                                                                                                                                                          |
| `TOKEN_INVALID`       | 400         | Auth failed: `X-Partner-Secret` is wrong, or HMAC signature is invalid, or timestamp is >5 min off                                                                                                                       | Verify your secret or HMAC key. Check for trailing whitespace. For HMAC: ensure server clock is synced (NTP) and you're signing `timestamp + body` exactly.                                                                                                                                                                                                                                                                                           |
| `SHOP_NOT_FOUND`      | 400         | The shop doesn't have Appstle Subscriptions installed                                                                                                                                                                    | Tell the merchant to install Appstle Subscriptions first.                                                                                                                                                                                                                                                                                                                                                                                             |
| `ALREADY_CONNECTED`   | 400         | An active connection already exists for this partner + shop                                                                                                                                                              | Call disconnect first, then reconnect. Or skip — you're already connected.                                                                                                                                                                                                                                                                                                                                                                            |
| `VERIFICATION_FAILED` | 400         | Nonce didn't match, expired (>5 min), or your `/verify` endpoint returned `false`                                                                                                                                        | Generate a fresh nonce and try again. Check your nonce storage logic.                                                                                                                                                                                                                                                                                                                                                                                 |
| `NOT_CONNECTED`       | 400         | Trying to disconnect or check status, but no active connection exists                                                                                                                                                    | The merchant may have already disconnected from their side.                                                                                                                                                                                                                                                                                                                                                                                           |
| `PARTNER_UNREACHABLE` | 400         | Appstle couldn't reach your `/appstle/connect` or `/appstle/verify` endpoint                                                                                                                                             | Check your endpoint URL is correct, HTTPS, and publicly accessible. Check your server logs.                                                                                                                                                                                                                                                                                                                                                           |
| `UNEXPECTED_ROLLBACK` | 500         | Your endpoint returned success, but Appstle's transaction was silently rolled back. Manifests in logs as `UnexpectedRollbackException` / `Transaction silently rolled back because it has been marked as rollback-only`. | Common footgun for partners running on transactional frameworks: an inner write throws and gets caught by your handler, but the surrounding transaction has already been marked rollback-only — so the outer commit fails with no visible error from your business logic. Fix is in your code: either let the inner exception propagate, or perform the write in a fresh inner transaction. Don't swallow exceptions inside a transactional boundary. |

#### Idempotency requirements — recap

The integration framework relies on partners treating callbacks as **at-least-once**. Concretely:

* **Connect / approval callbacks:** upsert by `(shop_domain, partnerId)`. Two `/appstle/approved` calls for the same shop must produce the same end state, not two rows.
* **Disconnect callback:** find the connection without filtering on status; revoke tokens idempotently; return `2xx` even when there is nothing to do.
* **Status reads:** safe by definition — no side effects.

If your code is built on "this only ever fires once", expect bugs the first time the merchant flips connect/disconnect quickly or the first time a network blip triggers an Appstle retry.

## Security checklist

Before going live, verify all of these:

* [ ] **Partner Secret / HMAC Key** is stored in environment variables or a secrets manager — not hardcoded in source code
* [ ] **Nonces** are generated using a cryptographically secure random generator (`crypto.randomBytes`, `secrets.token_hex`, `SecureRandom`, etc.)
* [ ] **Nonces** are stored with a TTL (≤ 5 minutes) and deleted after verification
* [ ] **Nonces** are compared using a constant-time comparison to prevent timing attacks (most frameworks do this by default for string equality)
* [ ] **Endpoints** are served over **HTTPS** — Appstle will not call HTTP endpoints
* [ ] **`shop_domain`** is validated in your `/appstle/connect` and `/appstle/verify` endpoints — reject domains you don't recognize
* [ ] **Access tokens** are stored encrypted at rest (or in a secrets manager)
* [ ] **401 responses** are handled gracefully — show a "Reconnect" option, don't break silently
* [ ] **Error responses** from Appstle are logged for debugging
* [ ] **(HMAC only)** Server clock is synced via NTP — timestamps more than 5 minutes off will be rejected
* [ ] **(If using disconnect webhook)** Your `/appstle/disconnect` endpoint cleans up stored tokens and marks the connection as inactive

## Complete example: partner-initiated flow

<CodeGroup>
  ```javascript Node.js theme={null}
  // appstle-partner.js
  const express = require('express');
  const crypto = require('crypto');
  const axios = require('axios');
  const Redis = require('ioredis');

  const router = express.Router();
  const redis = new Redis(process.env.REDIS_URL);

  const PARTNER_ID = process.env.APPSTLE_PARTNER_ID;
  const PARTNER_SECRET = process.env.APPSTLE_PARTNER_SECRET;
  const APPSTLE_BASE = process.env.APPSTLE_BASE_URL || 'https://subscription-admin.appstle.com';
  const NONCE_TTL = 300; // 5 minutes in seconds

  // ──────────────────────────────────────────────
  // Nonce helpers
  // ──────────────────────────────────────────────

  async function storeNonce(shopDomain, nonce) {
    await redis.set(`appstle:nonce:${shopDomain}`, nonce, 'EX', NONCE_TTL);
  }

  async function verifyAndDeleteNonce(shopDomain, nonceToCheck) {
    const key = `appstle:nonce:${shopDomain}`;
    const stored = await redis.get(key);
    if (!stored || stored !== nonceToCheck) return false;
    await redis.del(key);
    return true;
  }

  // ──────────────────────────────────────────────
  // Token storage (use your database in production)
  // ──────────────────────────────────────────────

  async function saveToken(shopDomain, accessToken) {
    // In production: encrypt the token before storing
    await redis.set(`appstle:token:${shopDomain}`, accessToken);
  }

  async function getToken(shopDomain) {
    return redis.get(`appstle:token:${shopDomain}`);
  }

  // ──────────────────────────────────────────────
  // Flow A: Partner-initiated connect
  // Called when merchant clicks "Connect Appstle" in YOUR dashboard
  // ──────────────────────────────────────────────

  router.post('/connect-appstle', async (req, res) => {
    const { shopDomain } = req.body;

    try {
      // 1. Generate nonce
      const nonce = crypto.randomBytes(32).toString('hex');
      await storeNonce(shopDomain, nonce);

      // 2. Call Appstle
      const response = await axios.post(
        `${APPSTLE_BASE}/api/partner/${PARTNER_ID}/connect`,
        { shop_domain: shopDomain, callback_nonce: nonce },
        { headers: { 'X-Partner-Secret': PARTNER_SECRET, 'Content-Type': 'application/json' } }
      );

      // 3. Connection is now pending merchant approval
      if (response.data.status === 'pending_merchant_approval') {
        // Mark as pending in your system — show the merchant a "waiting for approval" state
        await redis.set(`appstle:pending:${shopDomain}`, 'true');
        return res.json({ pending: true, message: 'Waiting for merchant to approve in Appstle dashboard' });
      }

      res.status(400).json({ error: 'Connection failed' });
    } catch (err) {
      const detail = err.response?.data?.detail || err.message;
      console.error('Appstle connect failed:', detail);
      res.status(400).json({ error: detail });
    }
  });

  // ──────────────────────────────────────────────
  // Endpoint: POST /appstle/verify
  // Called BY Appstle during Flow A to verify your nonce
  // ──────────────────────────────────────────────

  router.post('/appstle/verify', async (req, res) => {
    const { shop_domain, callback_nonce } = req.body;
    const verified = await verifyAndDeleteNonce(shop_domain, callback_nonce);
    res.json({ verified });
  });

  // ──────────────────────────────────────────────
  // Endpoint: POST /appstle/approved
  // Called BY Appstle when merchant approves a partner-initiated connection
  // ──────────────────────────────────────────────

  router.post('/appstle/approved', async (req, res) => {
    const { shop_domain, access_token, status } = req.body;

    if (access_token) {
      // Nonce Handshake mode — Appstle is delivering our API token
      await saveToken(shop_domain, access_token);
      await redis.del(`appstle:pending:${shop_domain}`);
      console.log(`Approved! Token received for ${shop_domain}`);
    } else if (status === 'approved') {
      // Simple Token Exchange mode — our token is now active on Appstle's side
      await redis.del(`appstle:pending:${shop_domain}`);
      console.log(`Approved! Our token is now active for ${shop_domain}`);
    }

    res.json({ success: true });
  });

  // ──────────────────────────────────────────────
  // Endpoint: POST /appstle/connect
  // Called BY Appstle during Flow B (Appstle-initiated)
  // ──────────────────────────────────────────────

  router.post('/appstle/connect', async (req, res) => {
    const { shop_domain, callback_url, callback_nonce } = req.body;

    // Verify the shop exists in your system
    // const shop = await db.shops.findOne({ domain: shop_domain });
    // if (!shop) return res.status(400).json({ error: 'Unknown shop' });

    // Auto-approve: immediately call back to complete the handshake
    // (Flow B doesn't need merchant approval — merchant initiated it from Appstle)
    try {
      const response = await axios.post(callback_url, {
        shop_domain,
        callback_nonce,
      }, {
        headers: { 'X-Partner-Secret': PARTNER_SECRET, 'Content-Type': 'application/json' },
      });

      if (response.data.verified && response.data.access_token) {
        await saveToken(shop_domain, response.data.access_token);
      }
    } catch (err) {
      console.error('Failed to complete Appstle handshake:', err.message);
    }

    res.json({ success: true });
  });

  module.exports = router;
  ```

  ```python Python theme={null}
  # appstle_partner.py
  import os
  import secrets
  import redis
  import requests
  from flask import Flask, request, jsonify

  app = Flask(__name__)
  r = redis.Redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379"))

  PARTNER_ID = os.environ["APPSTLE_PARTNER_ID"]
  PARTNER_SECRET = os.environ["APPSTLE_PARTNER_SECRET"]
  APPSTLE_BASE = os.environ.get("APPSTLE_BASE_URL", "https://subscription-admin.appstle.com")
  NONCE_TTL = 300  # 5 minutes


  def store_nonce(shop_domain, nonce):
      r.set(f"appstle:nonce:{shop_domain}", nonce, ex=NONCE_TTL)


  def verify_and_delete_nonce(shop_domain, nonce_to_check):
      key = f"appstle:nonce:{shop_domain}"
      stored = r.get(key)
      if not stored or stored.decode() != nonce_to_check:
          return False
      r.delete(key)
      return True


  def save_token(shop_domain, access_token):
      r.set(f"appstle:token:{shop_domain}", access_token)


  # ── Flow A: Partner-initiated connect ──

  @app.route("/connect-appstle", methods=["POST"])
  def connect_appstle():
      shop_domain = request.json["shopDomain"]

      # 1. Generate nonce
      nonce = secrets.token_hex(32)
      store_nonce(shop_domain, nonce)

      # 2. Call Appstle
      resp = requests.post(
          f"{APPSTLE_BASE}/api/partner/{PARTNER_ID}/connect",
          json={"shop_domain": shop_domain, "callback_nonce": nonce},
          headers={"X-Partner-Secret": PARTNER_SECRET, "Content-Type": "application/json"},
      )
      resp.raise_for_status()
      data = resp.json()

      # 3. Connection is pending merchant approval
      if data.get("status") == "pending_merchant_approval":
          r.set(f"appstle:pending:{shop_domain}", "true")
          return jsonify({"pending": True, "message": "Waiting for merchant to approve in Appstle dashboard"})

      return jsonify({"error": "Connection failed"}), 400


  # ── Endpoint: POST /appstle/verify (called BY Appstle during Flow A) ──

  @app.route("/appstle/verify", methods=["POST"])
  def appstle_verify():
      body = request.json
      verified = verify_and_delete_nonce(body["shop_domain"], body["callback_nonce"])
      return jsonify({"verified": verified})


  # ── Endpoint: POST /appstle/approved (called BY Appstle when merchant approves) ──

  @app.route("/appstle/approved", methods=["POST"])
  def appstle_approved():
      body = request.json
      shop_domain = body["shop_domain"]
      access_token = body.get("access_token")
      status = body.get("status")

      if access_token:
          # Nonce Handshake mode — store the Appstle API token
          save_token(shop_domain, access_token)
      elif status == "approved":
          # Simple Token Exchange mode — our token is now active
          pass  # mark connection as active in your DB

      r.delete(f"appstle:pending:{shop_domain}")
      return jsonify({"success": True})


  # ── Endpoint: POST /appstle/connect (called BY Appstle during Flow B) ──

  @app.route("/appstle/connect", methods=["POST"])
  def appstle_connect():
      body = request.json
      shop_domain = body["shop_domain"]
      callback_url = body["callback_url"]
      callback_nonce = body["callback_nonce"]

      # Auto-approve: call back immediately
      # (Flow B doesn't need merchant approval — merchant initiated it from Appstle)
      try:
          resp = requests.post(
              callback_url,
              json={"shop_domain": shop_domain, "callback_nonce": callback_nonce},
              headers={"X-Partner-Secret": PARTNER_SECRET, "Content-Type": "application/json"},
          )
          data = resp.json()
          if data.get("verified") and data.get("access_token"):
              save_token(shop_domain, data["access_token"])
      except Exception as e:
          app.logger.error(f"Handshake failed: {e}")

      return jsonify({"success": True})
  ```
</CodeGroup>

## FAQ

<AccordionGroup>
  <Accordion title="Can a merchant have multiple partner connections?">
    Yes. Each partner gets its own scoped API token. Merchants can connect as many partners as they want. The tokens are completely independent.
  </Accordion>

  <Accordion title="What happens if a merchant uninstalls Appstle Subscriptions?">
    All active partner connections for that shop are automatically disconnected. Your tokens will stop working (401 responses).
  </Accordion>

  <Accordion title="Can I change my partner's permission level (READ_ONLY vs READ_WRITE) after onboarding?">
    Yes — contact the Appstle team. New connections will use the updated permission, but existing connections keep their original permission until reconnected.
  </Accordion>

  <Accordion title="What's the rate limit for partner API calls?">
    Partner tokens share the same rate limits as regular API keys. If you receive a `429 Too Many Requests`, implement exponential backoff.
  </Accordion>

  <Accordion title="How do I test the integration before going live?">
    Use a Shopify development store with Appstle Subscriptions installed. The partner integration works identically in development and production. You can use a tool like [ngrok](https://ngrok.com) to expose your local endpoints to the internet for testing.
  </Accordion>

  <Accordion title="What if the nonce expires before I can complete the handshake?">
    The merchant simply needs to click "Connect" again. A new nonce will be generated. Old nonces are automatically cleaned up.
  </Accordion>

  <Accordion title="Do I need to implement both Flow A and Flow B?">
    Yes. Flow A is for when the merchant connects from your dashboard. Flow B is for when they connect from Appstle's dashboard. Both are needed for a complete integration. You also need the `/appstle/approved` endpoint to receive API tokens after merchant approval (Flow A).
  </Accordion>

  <Accordion title="Can I reconnect a merchant without them doing anything?">
    No. A new connect handshake requires the merchant to initiate it from one of the dashboards. This is by design — merchants must explicitly authorize each connection.
  </Accordion>

  <Accordion title="Why does partner-initiated connect require merchant approval?">
    For security and trust. When your app initiates a connection, the merchant hasn't explicitly agreed on Appstle's side. The approval step ensures merchants consciously grant API access to partner apps. Appstle-initiated connections (Flow B) skip this step because the merchant is already clicking "Connect" in the Appstle dashboard.
  </Accordion>

  <Accordion title="How long does a pending approval last?">
    Pending connection requests expire after **30 days**. If the merchant doesn't approve or reject within that window, the request expires and your app will need to initiate a new connection.
  </Accordion>

  <Accordion title="What happens if the merchant rejects the connection?">
    The status changes to `rejected` and your app is notified via the disconnect webhook (if configured). The merchant can be asked to reconnect later if they change their mind — your app can initiate a new connection request.
  </Accordion>
</AccordionGroup>

## Need help?

* **Partner onboarding & technical support:** [support@appstle.com](mailto:support@appstle.com)
* **Integration guide:** [Third-party integration guide](/subscription/integration-guide) (for direct API key usage)
