# Partner Integration Framework

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

div
**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


## 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.

```
┌──────────────┐                          ┌────────────────────────┐
│  Partner App │                          │  Appstle Memberships   │
└──────┬───────┘                          └──────────┬─────────────┘
       │                                             │
       │  ── Flow A: Partner initiates ────────────► │
       │  POST /api/partner/{id}/connect             │
       │  (shop_domain, callback_nonce, secret)       │
       │                                             │
       │  ◄─── Appstle verifies nonce ────────────── │
       │  POST {your_base_url}/appstle/verify        │
       │                                             │
       │  ◄─── Returns pending_merchant_approval ─── │
       │                                             │
       │  ··· Merchant approves in Appstle UI ···    │
       │                                             │
       │  ◄─── Appstle delivers access_token ─────── │
       │  POST {your_base_url}/appstle/approved      │
       │                                             │
       │  ◄── Flow B: Appstle initiates ──────────── │
       │  POST {your_base_url}/appstle/connect       │
       │  (shop_domain, app, callback_url, nonce)     │
       │                                             │
       │  ── Partner verifies & calls back ────────► │
       │  POST /api/partner/{id}/verify              │
       │  (shop_domain, callback_nonce, secret)       │
       │                                             │
       │  ◄─── Returns access_token ───────────────  │
       └─────────────────────────────────────────────┘
```

> **ℹ️ 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."


## 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. See [Step 1b](#step-1b-choose-your-authentication-mode) for details. | `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. See [Step 1c](#step-1c-choose-your-connect-mode) for details. | `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 membership data to your app (e.g., when memberships are created, cancelled, or plans change), provide the path on your server where Appstle should send these payloads. See [Data Sync](#data-sync-push-model) for details. | `/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. | — |


> **💡 Not sure about some of these?** That's fine — 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.


> **💡 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).


#### 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://membership-admin.appstle.com` | Appstle's API base URL. Same for all partners. |


> **🔒 Important:** 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.


**Store your credentials as environment variables:**

```bash
# .env (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://membership-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`


> **⏱️ 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).


**Node.js example:**

```javascript
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 example:**

```python
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"])
```

**curl example:**

```bash
# 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://membership-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"
```

div
**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.

### 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 (membership contracts, plans, billing, 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.

> **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.


**How Simple Token Exchange works:**

*Partner-initiated:*

```bash
curl -X POST "https://membership-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
{
  "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)).


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

```json
{
  "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.


### Step 2: Understand the Callback Nonce

> **ℹ️ 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).


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. Here are examples:

**Node.js:**

```javascript
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
import secrets

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

**Ruby:**

```ruby
require 'securerandom'

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

**PHP:**

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

**Java:**

```java
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
import (
    "crypto/rand"
    "encoding/hex"
)

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

div
**⚠️ 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


#### How to store a nonce

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

**Node.js + Redis example:**

```javascript
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 + database example (if you don't have Redis):**

```python
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
```

### 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
{
  "shop_domain": "cool-store.myshopify.com",
  "app": "memberships",
  "callback_url": "https://membership-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 `"memberships"` — 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.


**Node.js (Express) example:**

```javascript
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
{
  "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 }`


**Node.js (Express) example:**

```javascript
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 Memberships" 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.

**Here's exactly what happens, step by step:**

```
Step 1: Merchant clicks "Connect Appstle" in your dashboard
            │
            ▼
Step 2: Your app generates a cryptographically random nonce
        and stores it (keyed by shop_domain, 5-min TTL)
            │
            ▼
Step 3: Your app calls POST /api/partner/{id}/connect
        with the shop_domain, nonce, and your Partner Secret
            │
            ▼
Step 4: Appstle validates your Partner Secret
        Appstle checks the shop has Appstle Memberships installed
            │
            ▼
Step 5: Appstle calls YOUR /appstle/verify endpoint
        with the shop_domain and the same nonce
            │
            ▼
Step 6: Your /appstle/verify checks the nonce matches,
        deletes it, and returns { "verified": true }
            │
            ▼
Step 7: Appstle returns { "status": "pending_merchant_approval" }
        The connection is now PENDING — no token yet
            │
            ▼
Step 8: The merchant sees the pending request in their
        Appstle dashboard → Partner Connections and clicks "Approve"
            │
            ▼
Step 9: Appstle creates a scoped API key and delivers it
        to YOUR /appstle/approved endpoint via POST
            │
            ▼
Step 10: Your app stores the access_token and shows
         "Connected!" to the merchant
```

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

```javascript
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://membership-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
curl -X POST "https://membership-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
{
  "status": "pending_merchant_approval"
}
```

> **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.


> **💡 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/appstle-memberships/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.


#### Appstle-Initiated Connect (Flow B) — Step by Step

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

**Here's exactly what happens, step by step:**

```
Step 1: Merchant clicks "Connect {YourApp}" in Appstle's dashboard
            │
            ▼
Step 2: Appstle calls POST /api/partner/{id}/initiate-connect
        (internally — not your call)
            │
            ▼
Step 3: Appstle generates a nonce (5-min TTL)
        and calls YOUR /appstle/connect endpoint
        with shop_domain, app, callback_url, and callback_nonce
            │
            ▼
Step 4: Your /appstle/connect stores the nonce and callback_url
            │
            ▼
Step 5: Your app calls the callback_url (Appstle's /verify endpoint)
        with the shop_domain, callback_nonce, and your Partner Secret
            │
            ▼
Step 6: Appstle verifies the nonce matches, creates a scoped API key,
        and returns it in the response
            │
            ▼
Step 7: Your app stores the access_token
```

##### 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
curl -X POST "https://membership-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
{
  "verified": true,
  "access_token": "apst_AbCdEfGhIjKlMnOpQrStUvWxYz123456789012"
}
```

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

```json
{
  "verified": false
}
```

> **⏱️ Important:** 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.


## 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
curl -X GET \
  "https://membership-admin.appstle.com/api/external/v2/subscription-contract-details?shop=cool-store.myshopify.com" \
  -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](/integration-guide) as merchant API keys:

- **Membership Contracts** — `GET /api/external/v2/subscription-contract-details` (list membership contracts, filter by customer, status, plan)
- **Cancel Membership** — `DELETE /api/external/v2/subscription-contracts/{id}` *(requires READ_WRITE)*
- **Update Payment Method** — `PUT /api/external/v2/subscription-contracts-update-payment-method` *(requires READ_WRITE)*
- **Apply Discount** — `PUT /api/external/v2/subscription-contracts-apply-discount` *(requires READ_WRITE)*
- **Add Discount** — `PUT /api/external/v2/subscription-contracts-add-discount` *(requires READ_WRITE)*
- **Remove Discount** — `PUT /api/external/v2/subscription-contracts-remove-discount` *(requires READ_WRITE)*
- **Add Line Item** — `PUT /api/external/v2/subscription-contracts-add-line-item` *(requires READ_WRITE)*


See the full [Integration Guide](/integration-guide) and [Admin API Reference](/admin-api-swagger) 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, an analytics platform might need Appstle to push membership data so it can be tracked alongside other store metrics.

### How It Works

During onboarding, you can configure a `sync_path` on your server (e.g., `/appstle/sync`). When membership events occur (memberships created, cancelled, billing attempts, plan changes, upgrades/downgrades), Appstle calls your endpoint with the relevant data.

| Config Field | Example | Description |
|  --- | --- | --- |
| `sync_path` | `/appstle/sync` | Your endpoint where Appstle pushes membership 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
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 membership data on demand |
| **Push** | Simple Token Exchange | ❌ No | ✅ Yes (via your token + sync_path) | Analytics tools, search platforms 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
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
{
  "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.


**Node.js (Express) example:**

```javascript
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 (Flask) example:**

```python
@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})
```

> **ℹ️ 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.


### Partner Disconnects Programmatically

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

**With Partner Secret:**

```bash
curl -X POST "https://membership-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"
  }'
```

**With HMAC-SHA256:**

```bash
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://membership-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"
```

**Response:**

```json
{
  "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.

> **⚠️ 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_APPROVAL` or `REJECTED`. Always call `/status` before showing connection state to the merchant or making business decisions based on it.


Partners can authenticate with their Partner Secret or HMAC signature:

```bash
# With Partner Secret
curl -X GET "https://membership-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://membership-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
{
  "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
{
  "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 |
| `disconnected` | Connection was previously active but has been terminated (by merchant, by partner, or by uninstall). The row is retained for audit but the token is revoked. Treat the same as `not_connected` for UI purposes — your app would need to initiate a new connection. |
| `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 |


> **💡 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).


## 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`).

> **⚠️ Important:** 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](#option-b-hmac-sha256) — when enabled, the callback includes signed headers (`X-Partner-Timestamp`, `X-Partner-Signature`) you can verify.


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

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

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

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

> 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.


> **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.

### Implementation (Node.js)

```javascript
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 });
});
```

### Implementation (Python)

```python
@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})
```

### 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
{
  "type": "https://membership-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 Memberships installed | Tell the merchant to install Appstle Memberships 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 (Node.js)

Here's a full, copy-pasteable implementation of Flow A in Express:

```javascript
// 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://membership-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;
```

## Complete Example: Partner-Initiated Flow (Python)

```python
# 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://membership-admin.appstle.com")
NONCE_TTL = 300  # 5 minutes


def store_nonce(shop_domain: str, nonce: str):
    r.setex(f"appstle:nonce:{shop_domain}", NONCE_TTL, nonce)


def verify_and_delete_nonce(shop_domain: str, nonce_to_check: str) -> bool:
    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: str, access_token: str):
    # In production: encrypt before storing
    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})
```

## FAQ

**Q: 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.

**Q: What happens if a merchant uninstalls Appstle Memberships?**
All active partner connections for that shop are automatically disconnected. Your tokens will stop working (401 responses).

**Q: 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.

**Q: 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.

**Q: How do I test the integration before going live?**
Use a Shopify development store with Appstle Memberships 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.

**Q: 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.

**Q: 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).

**Q: 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.

**Q: 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.

**Q: 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.

**Q: 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.

## Need Help?

- **Partner onboarding & technical support:** [support@appstle.com](mailto:support@appstle.com)
- **Full API Reference:** [Admin APIs](/admin-api-swagger) · [Customer Portal APIs](/storefront-api-swagger)
- **Integration Guide:** [Third-Party Integration Guide](/integration-guide) (for direct API key usage)